diff --git a/.env.example b/.env.example index 4f9a322..efbdd46 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,12 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_MODEL=deepseek-chat MYSQL_IMAGE=mysql:8.4 +INTERVIEW_MAX_FOLLOW_UP_ROUNDS=3 JWT_SECRET=replace-with-at-least-32-random-characters RESUME_TOKEN_SECRET=replace-with-a-different-32-character-secret MYSQL_DSN=mysql+asyncmy://interview:interview@localhost:3307/interview_agent?charset=utf8mb4 +SNOWFLAKE_NODE_ID=0 REDIS_URL=redis://localhost:6381/0 MILVUS_URI=http://localhost:19531 MILVUS_COLLECTION=interview_questions_v2 @@ -36,3 +38,30 @@ 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_REPOSITORIES_PER_TOPIC=2 +GITHUB_CANDIDATE_LIMIT=15 +GITHUB_ACTIVE_DAYS=365 +GITHUB_FALLBACK_ACTIVE_DAYS=548 +GITHUB_MINIMUM_RECOMMENDATION_SCORE=60 + +# Optional personal-memory semantic index. MySQL remains authoritative when disabled/unavailable. +MEM0_ENABLED=false +MEM0_MODE=cloud +MEM0_API_KEY= +MEM0_HOST=https://api.mem0.ai +MEM0_AGENT_ID=interview-coach +MEM0_TIMEOUT_SECONDS=10 +MEM0_OSS_CONFIG_PATH=data/mem0-oss-local.json +MEM0_OSS_OLLAMA_BASE_URL= +MEM0_OSS_QDRANT_PATH=data/mem0_qdrant +MEM0_OSS_COLLECTION_NAME=interview_personal_memory +MEM0_OSS_HISTORY_DB_PATH=data/mem0_history.db +PERSONAL_MEMORY_TOP_K=8 diff --git a/.gitignore b/.gitignore index 8885a4e..7571fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,7 @@ output/ dist/ volumes/ data/uploads/ +data/mem0_qdrant/ +data/mem0_history.db data/eval/reports/*.json data/eval/reports/*.md diff --git a/CONTEXT.md b/CONTEXT.md index 24e528c..2dc1ded 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,3 +35,11 @@ _Avoid_: 文档、向量集合 **候选人画像**: 根据历次面试表现形成的能力与薄弱点记录,用于后续面试的个性化选题。 _Avoid_: 简历、用户资料 + +**业务时间**: +面试记录、检查点和候选人画像中面向业务展示与持久化的北京时间(Asia/Shanghai,UTC+8)。 +_Avoid_: 服务器本地时间、UTC 存储时间 + +**业务标识**: +新建业务记录使用的 64 位 Snowflake 十进制 ID;历史 UUID 和自增 ID 仍是有效业务标识。 +_Avoid_: 请求 ID、连接 ID、JWT jti 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 3afc08a..dd6ceca 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ flowchart LR ## 关键能力 -- JD 分析、简历匹配、混合 RAG 出题、自适应难度、追问、评分、评估报告和复习计划。 +- JD 分析、简历匹配、混合 RAG 出题、自适应难度、受限多轮动态追问、评分、评估报告和复习计划;单题追问上限由 `INTERVIEW_MAX_FOLLOW_UP_ROUNDS` 配置。 - MySQL 权威检查点 + outbox、Redis Streams 事件补发、Milvus 用户隔离题库。 - `session_id + resume_token + last_event_id` 续面,`client_message_id` 写入幂等。 - 后建立连接接管同一面试会话;未完成会话默认保留 24 小时。 @@ -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 @@ -70,6 +71,28 @@ uv run interview-agent serve --reload 这个开发模式适合调试 Python;完整语音链路建议使用上面的 `--profile app` 全容器启动,因为中文 VITS 模型卷会直接挂载到应用容器。 +### 数据库时间与 ID + +- MySQL 业务时间列使用 `DATETIME(6)` 保存北京时间(`Asia/Shanghai`),API 返回的业务时间携带 `+08:00` 偏移。 +- 新建用户、面试会话、题目、候选人记忆、outbox 事件和内部记录统一使用 64 位 Snowflake ID;对外 ID 保持十进制字符串,避免浏览器 JavaScript 丢失整数精度。 +- `SNOWFLAKE_NODE_ID` 取值为 `0-1023`,默认 `0`。单实例本地开发可保留默认值;多副本部署必须为每个同时运行的实例分配不同节点号,否则可能产生重复 ID。 +- 既有 UUID 和自增 ID 不会重写,新旧 ID 可以同时查询和关联。 + +从 `0001` 或更早的现有数据库升级前先备份 MySQL,然后执行: + +```bash +uv run alembic upgrade head +``` + +`0002` 会把现有 UTC 时间列一次性增加 8 小时,并将原自增内部主键扩展为 `BIGINT`;`0003` 会把检查点和候选人画像 JSON 内的 UTC 时间同步转换为 `+08:00`。已执行过 `0002` 的环境继续运行 `upgrade head` 即可补迁,且不会重复转换。迁移完成后可用下面的查询确认北京时间: + +```sql +SELECT id, status, created_at, updated_at +FROM interview_sessions +ORDER BY created_at DESC +LIMIT 10; +``` + 服务地址: - 浏览器前端:http://localhost:9092/ @@ -88,11 +111,12 @@ 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` 续面。 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/data/mem0-oss-local.json b/data/mem0-oss-local.json new file mode 100644 index 0000000..52190ca --- /dev/null +++ b/data/mem0-oss-local.json @@ -0,0 +1,26 @@ +{ + "mode": "oss", + "oss": { + "llm": { + "provider": "ollama", + "config": { + "model": "qwen2.5-coder:14b", + "ollama_base_url": "http://127.0.0.1:11434" + } + }, + "embedder": { + "provider": "ollama", + "config": { + "model": "nomic-embed-text", + "ollama_base_url": "http://127.0.0.1:11434", + "embedding_dims": 768 + } + }, + "vector_store": { + "provider": "qdrant", + "config": { + "path": "data/mem0_qdrant" + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index e56620c..a59e27b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -231,6 +231,11 @@ services: RERANK_BASE_URL: http://reranker:80 SPEECH_BASE_URL: http://speech:8000 TTS_MODEL_DIRECTORY: /models/sherpa-onnx-vits-zh-ll + MEM0_OSS_OLLAMA_BASE_URL: http://host.docker.internal:11434 + MEM0_OSS_QDRANT_PATH: /mem0/qdrant + MEM0_OSS_HISTORY_DB_PATH: /mem0/history.db + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "${APP_PORT:-9092}:9090" depends_on: @@ -252,6 +257,7 @@ services: condition: service_completed_successfully volumes: - tts_models:/models:ro + - mem0_data:/mem0 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9090/health/ready"] interval: 10s @@ -269,3 +275,4 @@ volumes: speech_hf_cache: tts_models: reranker_models: + mem0_data: diff --git a/docs/adr/0006-use-isolated-qdrant-for-mem0-oss.md b/docs/adr/0006-use-isolated-qdrant-for-mem0-oss.md new file mode 100644 index 0000000..e3837dd --- /dev/null +++ b/docs/adr/0006-use-isolated-qdrant-for-mem0-oss.md @@ -0,0 +1,20 @@ +# ADR 0006:mem0 OSS 使用独立 Qdrant 派生索引 + +## 状态 + +已接受。 + +## 背景 + +InterviewAgent 已使用 MySQL 保存权威业务状态,使用 Milvus 保存可重建题库向量。现有 Hermes 配置提供了 mem0 OSS 所需的 Ollama LLM、Ollama Embedder 与本地 Qdrant 参数,但其 Qdrant 目录属于 Hermes,嵌入式 Qdrant 也不适合被两个进程并发打开。 + +## 决策 + +- 允许读取 Hermes 兼容的 mem0 JSON 配置,只复用 provider、model、嵌入维度和 Ollama 地址。 +- InterviewAgent 覆盖 Qdrant 路径、集合名和 mem0 history 数据库,使用自己的持久化目录。 +- MySQL 继续是个人画像的唯一权威存储;mem0/Qdrant 仅保存可删除、可重建的压缩学习信号。 +- mem0 仍为显式 opt-in,初始化或运行失败时回退 MySQL 确定性召回。 + +## 结果 + +该方案可以直接利用本机已有 Ollama 模型,又不会让 Hermes 与 InterviewAgent 共享用户命名空间、文件锁或向量生命周期。代价是本地额外保存一份小型派生索引;如果以后改用 mem0 的 Milvus provider,需要单独验证版本、过滤和集合隔离后再替换。 diff --git a/docs/adr/0007-use-beijing-business-time-and-snowflake-identifiers.md b/docs/adr/0007-use-beijing-business-time-and-snowflake-identifiers.md new file mode 100644 index 0000000..bea9960 --- /dev/null +++ b/docs/adr/0007-use-beijing-business-time-and-snowflake-identifiers.md @@ -0,0 +1,27 @@ +# ADR 0007:使用北京时间与 Snowflake 业务标识 + +## 状态 + +已接受。 + +## 背景 + +面试记录此前以 UTC 写入 MySQL,用户直接查询表时会看到比北京时间少 8 小时的值;新业务实体同时混用 UUID 与数据库自增 ID,不利于跨服务生成和按时间粗略排序。历史数据已被外键、检查点 JSON 和候选人画像 JSON 引用,不能整体重写标识。 + +## 决策 + +- 领域层持久化时间使用带 `+08:00` 的 `Asia/Shanghai` 时间;MySQL `DATETIME(6)` 保存去除时区后的北京时间墙上时间,读取时恢复 `+08:00`。 +- `0002` 升级把既有关系型时间列整体加 8 小时;`0003` 把会话检查点与候选人画像 JSON 中已有的 UTC 时间转换为等价的 `+08:00` 时间。拆分版本确保已执行 `0002` 的环境仍会得到 JSON 补迁。 +- 新建用户、会话、问题、记忆、事件及内部记录使用 64 位 Snowflake ID。历史 UUID 和自增 ID 原样保留,因此所有已有外键和恢复凭证继续有效。 +- 每个并行写入实例必须配置唯一的 `SNOWFLAKE_NODE_ID`(0–1023)。同一部署内复用节点号属于配置错误;数据库唯一约束是最终防线,碰撞时写事务失败,不静默改号或覆盖数据。 +- 连接 ID、客户端消息 ID 和 JWT `jti` 不是持久化业务主键,继续使用 UUID。 + +## 迁移与失败语义 + +升级前必须备份 MySQL。列类型变更由 MySQL DDL 执行;关系型时间更新在显式事务中完成,JSON 时间转换可重复执行且只转换带时区值。升级中断后先检查 `alembic_version` 和备份,再重跑升级;若数据库状态无法确认,从升级前备份恢复。 + +Snowflake 序列在同一毫秒耗尽时等待下一毫秒。短暂时钟回拨使用进程内最后时间戳继续递增,避免本实例重复;跨实例唯一性由不同节点号保证。产生新 Snowflake 值后不支持直接降级回 32 位自增列,回退应恢复升级前备份,而不是执行破坏性 ID 转换。 + +## 取舍 + +该方案让数据库中的时间符合当前主要用户习惯,并允许应用层生成趋势递增的业务 ID;代价是数据库值不再是 UTC,跨时区分析必须显式转换,并且部署需要管理节点号。保留历史标识避免高风险全量主外键重写,但同一列会长期兼容 UUID 与十进制 Snowflake 字符串。 diff --git a/docs/research/github-project-recommendation.md b/docs/research/github-project-recommendation.md new file mode 100644 index 0000000..841eef7 --- /dev/null +++ b/docs/research/github-project-recommendation.md @@ -0,0 +1,188 @@ +# 面向面试薄弱项的 GitHub 项目推荐方案调研 + +> 调研日期:2026-08-16 +> 目标:针对“面试薄弱主题”,推荐**直接相关、仍在维护、可以阅读或动手实践**的 GitHub 项目;避免高 Star 但无关的 `awesome` 清单、Prompt 合集、资源导航和多年未维护仓库。 + +## 结论 + +首选方案是**GitHub 多路候选召回 + 硬过滤 + 本地可解释规则打分**,暂不引入新的搜索引擎、全量仓库向量库或在线 LLM 重排。 + +推荐流程: + +1. 把评估里的自然语言薄弱项转换成结构化检索意图:核心概念、相邻概念、期望语言/生态、项目形态、排除类型。 +2. 每个主题只发 2~3 条受控 GitHub Search 查询,分别覆盖精确主题和名称/描述/Topic;保留 GitHub 默认 `best match`,不再按 Star 强行重排。 +3. 用 `pushed`、`archived`、`fork`、`mirror`、`template` 等限定词做第一层过滤;用返回的 `pushed_at`、topics、language、size、license、issues 等字段做第二层校验。 +4. 本地对“主题覆盖、GitHub 原始排名、活跃度、可实践性、社区信号”进行确定性打分,低于相关性阈值时宁可不展示。 +5. 推荐卡片展示“为什么相关”和“最近推送时间”,让结果可验证;Redis 缓存查询和 ETag,失败时保留无 GitHub 项目的复习计划。 + +这条路线直接解决当前故障,同时复用 FastAPI/httpx、Redis 和现有 GitHub 适配器,改动范围小、可测试、可回退。现有 BGE-M3 + Milvus 可保留为第二阶段候选重排实验,不应成为第一版的前置依赖。 + +## 当前问题的技术原因 + +当前实现从中文主题中抽取少量 ASCII 词,拼成 `in:name,description,readme` 查询,再用 `sort=stars` 取前两名。这个组合有三个结构性问题: + +- `in:readme` 会让只在 README 中罗列了关键词的资源清单进入候选;GitHub 官方说明 `in` 可以分别限制 name、description、topics 和 README,省略时只搜索 name、description、topics。[GitHub:Searching for repositories](https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories#search-by-repository-name-description-or-contents-of-the-readme-file) +- GitHub Search 默认按 `best match` 返回,并综合多种因素提升相关结果;显式 `sort=stars` 会把最终顺序改成 Star 数,热门但弱相关仓库因此压过真正相关的小项目。[GitHub:Ranking search results](https://docs.github.com/en/rest/search/search#ranking-search-results) [GitHub:Search repositories 参数](https://docs.github.com/en/rest/search/search#search-repositories) +- 只过滤 `archived:false` 不能代表仍在维护。GitHub 提供 `pushed:>=YYYY-MM-DD`,其含义是仓库任一分支最近一次提交时间;搜索结果本身也返回 `pushed_at`、`updated_at`、`archived`、language、size、license、open issues 等字段。[GitHub:按最近推送时间搜索](https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories#search-by-when-a-repository-was-created-or-last-updated) [GitHub:Search repositories 响应](https://docs.github.com/en/rest/search/search#search-repositories) + +因此,这不是单纯“关键词不够好”,而是候选召回范围过宽、热度成为主排序、活跃度没有硬门槛共同造成的。 + +## 业界常用方案比较 + +| 方案 | 典型做法 | 优点 | 主要代价/风险 | 本项目判断 | +|---|---|---|---|---| +| 纯 GitHub 查询 + 规则 | 单条关键词查询,使用 qualifiers 过滤,直接采用 GitHub 排名 | 最简单、数据新鲜、无需维护索引 | 自然语言主题到查询词的映射弱;单路召回容易漏项目 | 可作为最小修复,但不足以稳定解决“Planning/Action”这类抽象主题 | +| **多路候选召回 + 规则打分** | 2~3 条精确/Topic/生态查询合并去重,硬过滤后本地打分 | 可解释、低延迟、容易离线评测;无需新增基础设施 | 需要维护少量主题词典、阈值和负面类型规则 | **首选** | +| Embedding / 混合检索 | 对仓库名、描述、Topic、README 做 BM25 + dense embedding,以 RRF/加权融合 | 能处理同义词、中文薄弱项到英文项目描述的语义差异 | 必须维护可搜索的候选语料及更新任务;README 抓取、向量刷新和删除同步复杂 | 第二阶段;先只重排 GitHub 候选,不建全量 GitHub 索引 | +| Cross-encoder / LLM rerank | 先召回 20~50 个候选,再把 query 与候选文本成对打分 | 对复杂语义相关性通常更强 | 增加模型调用、超时、成本和降级路径;LLM 排名较难复现;描述质量决定上限 | 只有离线标注证明规则排序到瓶颈后再加 | +| 人工精选目录 | 按 Agent、RAG、FastAPI 等主题维护项目白名单 | 头部主题精度高,可人工保证实践价值 | 覆盖低、维护成本高,也会过时;无法适应长尾薄弱项 | 仅做冷启动种子/兜底,仍须实时活跃度校验 | + +GitHub 的仓库查询可以组合 name、description、topics、language、license、stars、pushed、archived、mirror、template,以及具有 `good first issue` / `help wanted` Issue 的仓库等限定词,因此第一阶段已有足够多的结构化信号。[GitHub:Repository search qualifiers](https://docs.github.com/en/search-github/searching-on-github/searching-for-repositories) + +混合检索是成熟的后续路线:OpenSearch 将其定义为关键词与语义检索的组合,并支持归一化加权或基于排名的融合;Elastic 官方推荐用 RRF 合并全文与向量排名;Milvus 也原生支持 BM25、dense vector、`WeightedRanker` 和 `RRFRanker`。[OpenSearch:Hybrid search](https://docs.opensearch.org/latest/vector-search/ai-search/hybrid-search/index/) [Elastic:Hybrid search](https://www.elastic.co/docs/solutions/search/hybrid-search) [Milvus:Full-text search](https://milvus.io/docs/full-text-search.md) [Milvus:Reranking](https://milvus.io/docs/reranking.md) + +但这些能力只有在拥有一个持续更新的“仓库文档集合”时才有明显收益。当前系统按需访问 GitHub,没有全量仓库语料;直接上 Milvus 反而先引入抓取、更新、去重和删除同步问题。在线 embedding 只重排 10~30 个 GitHub 候选是更轻量的第二阶段实验。 + +Cross-encoder/Rerank 服务适合最后一跳。以 Cohere 官方接口为例,它接收一条 query 和候选 documents,返回按语义相关性排序的索引及 relevance score,也支持中英文等多语言;这说明它应该放在“召回之后”,不能替代活跃度和项目类型硬过滤。[Cohere:Rerank overview](https://docs.cohere.com/docs/rerank-overview) + +## 推荐方案设计 + +### 1. 结构化检索意图 + +不要直接把评估文案当 GitHub 查询。让复习计划生成结构化字段,例如: + +```json +{ + "display_topic": "Agent Planning 与 Action", + "required_concepts": ["agent planning", "tool execution"], + "optional_concepts": ["task planning", "ReAct", "workflow"], + "github_topics": ["ai-agents", "agent-framework"], + "preferred_languages": ["Python"], + "project_kinds": ["framework", "application", "example"], + "excluded_kinds": ["awesome-list", "prompt-collection", "resource-list"] +} +``` + +LLM 只负责把薄弱项归一化成有限字段,不能直接提供仓库 URL,也不能决定最终候选。字段需要 Pydantic 枚举、长度和数量约束;无效输出回退到受控词典。 + +### 2. 多路 GitHub 召回 + +建议每个主题最多三路,每路取 10~20 个候选,随后合并去重: + +- 精确概念路:优先搜索 `in:name,description,topics`,避免 `in:readme` 的高噪声。 +- GitHub Topic 路:按已知 topics 分别查询,解决名称与概念词不一致的问题。 +- 生态/语言路:仅在用户岗位或项目语言明确时加 `language:`,避免把语言偏好误当硬需求。 + +所有查询带动态时间门槛,例如: + +```text +"agent planning" in:name,description,topics +pushed:>=2025-08-16 archived:false fork:false mirror:false template:false +``` + +第一阶段建议最近推送硬门槛为 **12 个月**,不足两个候选时可放宽到 **18 个月**,但 UI 明确标注;超过 18 个月不推荐。门槛必须配置化,并以运行时当前日期计算,不能写死年份。 + +不要设置 `sort=stars`。保留默认 `best match` 作为一条相关性信号;Star 只在本地作为弱的可信度特征。GitHub Search 每次最多返回 1,000 个结果、认证搜索请求为每分钟 30 次、未认证为每分钟 10 次,因此限制查询路数、认证访问和缓存是必要设计,而不是提前优化。[GitHub:Search limits and rate limit](https://docs.github.com/en/rest/search/search#about-search) + +### 3. 硬过滤 + +硬过滤先于打分,任何一项不满足就丢弃: + +- 非 archived、disabled、fork、mirror;默认排除 template。 +- `pushed_at` 在配置窗口内。 +- 仓库类型不是 awesome/list/roadmap/interview-questions/prompt collection/resource navigation;依据 name、description、topics 的标准化词表判断,不能只按仓库名包含 `awesome`。 +- 至少命中一个 required concept 或允许的 GitHub topic;防止“热度补偿不相关”。 +- 存在实际代码/项目迹象:language 非空,或 size 达到最低值并具有源码相关 Topic。纯文档学习资源应进入“文档资源”,而不是“实践项目”卡片。 +- 若产品文案承诺“可参与贡献”,再要求 has issues、许可证和可参与 Issue;如果只承诺“可阅读/可运行”,这些信号只能加分,不能一刀切。 + +GitHub 明确说明归档仓库是只读并表示不再积极维护,因此 `archived:false` 应是硬条件,但仍需 `pushed_at` 补足活跃度判断。[GitHub:Archiving repositories](https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories) + +硬过滤后只对 Top 5~10 获取 README,用安装、运行、示例、源码结构等证据补充“可实践性”,不要再把 README 用作全局宽泛召回字段。公开仓库的 README 可通过官方 Contents API 获取,接口支持条件请求与 `304 Not Modified`。[GitHub:Get a repository README](https://docs.github.com/en/rest/repos/contents#get-a-repository-readme) + +### 4. 可解释打分 + +建议先使用 100 分制,不训练模型: + +| 维度 | 建议权重 | 说明 | +|---|---:|---| +| 主题相关性 | 50 | required concept、GitHub Topic、名称/描述字段命中;设独立最低分 | +| 多路排名共识 | 15 | 对各召回路使用 RRF,多个检索路都靠前的候选优先 | +| 活跃度 | 15 | 按 `pushed_at` 时间衰减;最近 90 天最高,逐步下降 | +| 可实践性 | 15 | 有主要语言、合理代码体量、Issue、license、README/文档等 | +| 社区可信度 | 5 | 对 stars/forks 做 `log1p`,避免几十万 Star 完全支配排序 | + +主题相关性必须设闸门,例如低于 30/50 直接剔除;总分阈值以下返回空数组。“没有合格项目”优于错误推荐。 + +RRF 适合合并不同查询路的排名,因为它依赖名次而不是不可比的原始分数。OpenSearch 和 Milvus 的官方实现都采用这一模式,默认平滑常数通常为 60。[OpenSearch:Score ranker processor](https://docs.opensearch.org/latest/search-plugins/search-pipelines/score-ranker-processor/) [Milvus:RRF Ranker](https://milvus.io/docs/rrf-ranker.md) + +### 5. 缓存、降级与可观察性 + +- Redis key 使用“规范化检索意图 + 活跃窗口 + 算法版本”,TTL 建议 6~24 小时;不要只按截断后的 ASCII 字符串缓存。 +- 缓存 GitHub 的 ETag,并优先使用条件请求。GitHub 官方建议认证条件请求,未变化的 `304` 响应不会消耗主限额。[GitHub:REST API best practices](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#use-conditional-requests-if-appropriate) +- 遇到 403/429 时尊重 `retry-after` 或 `x-ratelimit-reset`,不要在用户请求链路里高频重试。[GitHub:Rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#exceeding-the-rate-limit) +- GitHub 超时、限流、无合格候选时,复习计划仍正常生成,只是不附项目;日志记录 query fingerprint、候选数、各过滤原因和算法版本,不记录用户完整回答。 +- 推荐快照随评估报告存入 MySQL,便于历史复现;Redis 只承担可丢失的搜索缓存。 + +## 为什么第一版不直接上 Milvus 或 LLM 重排 + +现有系统虽然已有 BGE-M3 与 Milvus,但它们目前服务于用户题库,而 GitHub 推荐是另一类数据生命周期:仓库元数据和 README 会持续变化、归档或删除。复用同一服务进程可行,复用同一 collection 和同步语义不可取。 + +第一版直接上向量/LLM 有四个风险: + +1. 候选源仍然宽泛时,重排模型不能稳定把未召回的正确项目“变出来”。 +2. 只用仓库 description 做 embedding 信息不足;抓 README 又会重新引入列表噪声和额外 API 调用。 +3. 新模型调用增加延迟、超时和成本,且仍需所有活跃度/项目类型规则。 +4. 没有人工标注集时,无法证明模型比确定性规则更好,也难以选择阈值。 + +因此先把候选集做对,并沉淀评测样本。若规则版在同义词和中英跨语言主题上仍有明显漏召回,再对 GitHub 候选做 on-demand embedding 或 cross-encoder 重排;无需先构建全量 GitHub 镜像。 + +## 分阶段落地与验收 + +### 阶段 0:建立离线基线(0.5~1 天) + +- 从真实评估中选 30~50 个薄弱主题,覆盖 Agent、RAG、FastAPI、Redis、数据库、并发和系统设计。 +- 每个主题人工标注 5~10 个 GitHub 候选为“直接相关/部分相关/无关”,同时标注是否活跃、是否为可实践项目。 +- 保存当前算法的 Precision@2、无关推荐率、活跃度通过率和空结果率。 + +验收:截图中的四个反例必须全部标记为无关;数据集和判定说明可重复运行。 + +### 阶段 1:多路召回 + 规则排序(2~4 天,推荐先做) + +- 规格先补充相关性、活跃度、项目类型、空结果和降级语义。 +- 引入结构化 `ProjectSearchIntent`、受控查询构造、动态 `pushed` 门槛、硬过滤、RRF 与可解释打分。 +- UI 展示最近推送日期和推荐理由;不合格时不显示 GitHub 卡片。 +- Redis 缓存、限流响应和超时降级均有隔离测试。 + +验收目标: + +- 标注集 Precision@2 ≥ 0.80;“无关项目进入 Top 2”比例 ≤ 10%。 +- 展示项目 100% 满足未归档、非 fork、最近 18 个月有推送;至少 90% 在最近 12 个月有推送。 +- awesome/list/prompt/resource 导航类误入率为 0。 +- 单主题最多 3 次 GitHub Search;缓存命中不发外部请求;GitHub 失败不影响报告生成。 +- 对截图中的 Planning/Action 场景,返回源码项目或空结果,不得返回四个已知反例。 + +### 阶段 2:轻量语义重排实验(2~3 天,可选) + +- 仅对阶段 1 的 10~30 个合格候选,将 topic、description、topics 和有限 README 摘要做 BGE-M3 embedding;与词法/规则排名用 RRF 融合。 +- 先在离线集跑 A/B,不改变线上默认;Milvus 使用独立 collection 和独立版本,不与用户题库混用。 + +启用门槛:相对阶段 1 的 NDCG@5 或 Precision@2 有至少 5 个百分点稳定提升,且 P95 额外延迟 ≤ 300 ms;否则保持规则版。 + +### 阶段 3:Cross-encoder/LLM rerank(仅在证据充分时) + +- 对阶段 2 仍失败的抽象/跨语言主题试验 cross-encoder;LLM 只输出候选 ID、相关性分和简短理由,不允许生成 URL。 +- 配置超时和规则排序降级,记录算法版本与模型版本。 + +启用门槛:在固定盲测集上显著优于阶段 2,且成本、P95 延迟和失败率满足产品预算。没有离线增益证据,不上线。 + +## 最终建议 + +当前应实施**阶段 0 + 阶段 1**。它比“改几个关键词”稳健,又比“抓取 GitHub 全量仓库 + Milvus + LLM rerank”更容易落地: + +- 相关性由结构化意图、字段限定、多路共识和硬阈值保证; +- 新鲜度由动态 `pushed` 窗口保证; +- 可实践性由项目类型和代码信号保证; +- Star 退回弱特征,不再主导排序; +- 所有决定都可解释、可单测、可离线评测,也能在 GitHub 不可用时安全降级。 + +在这一基线跑出真实指标后,再决定是否复用现有 BGE-M3/Milvus 做轻量语义重排。这样可以把“模型升级”变成有数据支持的增量优化,而不是用更多基础设施掩盖候选召回和产品定义问题。 diff --git a/migrations/versions/0002_beijing_time_snowflake_ids.py b/migrations/versions/0002_beijing_time_snowflake_ids.py new file mode 100644 index 0000000..6ebbec0 --- /dev/null +++ b/migrations/versions/0002_beijing_time_snowflake_ids.py @@ -0,0 +1,81 @@ +"""Use Beijing business time and Snowflake IDs for new records. + +Revision ID: 0002 +Revises: 0001 +""" + +from alembic import op +from sqlalchemy.dialects import mysql + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + +TIME_COLUMNS: dict[str, tuple[str, ...]] = { + "users": ("created_at",), + "interview_sessions": ("expires_at", "created_at", "updated_at"), + "session_outbox": ("created_at", "published_at"), + "processed_client_messages": ("created_at",), + "interview_records": ("created_at",), + "candidate_profiles": ("updated_at",), + "candidate_profile_updates": ("created_at",), + "question_bank_items": ("created_at",), + "chat_messages": ("created_at",), +} + +SNOWFLAKE_TABLES = ( + "processed_client_messages", + "interview_records", + "chat_messages", +) + + +def _shift_time_columns(hours: int) -> None: + operation = "DATE_ADD" if hours > 0 else "DATE_SUB" + amount = abs(hours) + op.execute("START TRANSACTION") + try: + for table, columns in TIME_COLUMNS.items(): + assignments = ", ".join( + f"`{column}` = {operation}(`{column}`, INTERVAL {amount} HOUR)" + for column in columns + ) + op.execute(f"UPDATE `{table}` SET {assignments}") + except Exception: + op.execute("ROLLBACK") + raise + op.execute("COMMIT") + + +def _set_datetime_precision() -> None: + for table, columns in TIME_COLUMNS.items(): + for column in columns: + op.alter_column( + table, + column, + existing_type=mysql.DATETIME(), + type_=mysql.DATETIME(fsp=6), + existing_nullable=column == "published_at", + ) + + +def upgrade() -> None: + _set_datetime_precision() + for table in SNOWFLAKE_TABLES: + op.alter_column( + table, + "id", + existing_type=mysql.INTEGER(), + type_=mysql.BIGINT(), + existing_nullable=False, + autoincrement=False, + ) + _shift_time_columns(8) + + +def downgrade() -> None: + raise RuntimeError( + "0002 cannot be downgraded after Snowflake IDs may have been written; " + "restore the pre-upgrade MySQL backup instead" + ) diff --git a/migrations/versions/0003_beijing_json_times.py b/migrations/versions/0003_beijing_json_times.py new file mode 100644 index 0000000..8193270 --- /dev/null +++ b/migrations/versions/0003_beijing_json_times.py @@ -0,0 +1,78 @@ +"""Convert embedded domain timestamps to Beijing time. + +Revision ID: 0003 +Revises: 0002 +""" + +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +from alembic import op +from sqlalchemy import text + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + +JSON_TIME_COLUMNS = ( + ("interview_sessions", "id", "state"), + ("candidate_profiles", "user_id", "profile"), +) +JSON_TIME_KEYS = {"created_at", "updated_at", "last_seen_at"} + + +def _convert_json_times(value: Any, target_offset_hours: int) -> Any: + if isinstance(value, list): + return [_convert_json_times(item, target_offset_hours) for item in value] + if not isinstance(value, dict): + return value + + converted: dict[str, Any] = {} + target_timezone = timezone(timedelta(hours=target_offset_hours)) + for key, item in value.items(): + if key in JSON_TIME_KEYS and isinstance(item, str): + try: + parsed = datetime.fromisoformat(item.replace("Z", "+00:00")) + except ValueError: + converted[key] = item + continue + if parsed.tzinfo is not None: + converted[key] = parsed.astimezone(target_timezone).isoformat() + continue + converted[key] = _convert_json_times(item, target_offset_hours) + return converted + + +def _shift_json_times(target_offset_hours: int) -> None: + connection = op.get_bind() + for table, primary_key, column in JSON_TIME_COLUMNS: + rows = connection.execute( + text(f"SELECT `{primary_key}`, `{column}` FROM `{table}`") + ).mappings() + for row in rows: + raw_value = row[column] + value = json.loads(raw_value) if isinstance(raw_value, str) else raw_value + converted = _convert_json_times(value, target_offset_hours) + if converted == value: + continue + connection.execute( + text( + f"UPDATE `{table}` SET `{column}` = :value WHERE `{primary_key}` = :primary_key" + ), + { + "value": json.dumps(converted, ensure_ascii=False), + "primary_key": row[primary_key], + }, + ) + + +def upgrade() -> None: + _shift_json_times(8) + + +def downgrade() -> None: + raise RuntimeError( + "0003 cannot be downgraded safely; restore the pre-upgrade MySQL backup instead" + ) diff --git a/pyproject.toml b/pyproject.toml index 4f72beb..94028aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,12 @@ dependencies = [ "fastapi>=0.116", "httpx>=0.28", "langgraph>=1.0", + "mem0ai>=2.0,<3", "numpy>=2.2", + "ollama>=0.5,<1", "openai>=2.0", "orjson>=3.10", + "playwright>=1.54", "pydantic-settings>=2.10", "pyjwt>=2.10", "pymilvus>=2.6", diff --git a/specs/001-python-rewrite/spec.md b/specs/001-python-rewrite/spec.md index 6980368..f97560b 100644 --- a/specs/001-python-rewrite/spec.md +++ b/specs/001-python-rewrite/spec.md @@ -163,7 +163,8 @@ created -> asking_question -> waiting_for_answer -> scoring_answer - -> [asking_follow_up -> waiting_for_follow_up -> scoring_follow_up] + -> [asking_follow_up -> waiting_for_follow_up -> scoring_follow_up + -> asking_follow_up | asking_question | reviewing_weak_answers] -> asking_question | reviewing_weak_answers -> evaluating -> planning_review @@ -177,6 +178,14 @@ waiting_for_follow_up(主问题已评分) -> evaluating 每次状态迁移必须同时定义:前置状态、输入消息、幂等键、写入检查点、产生的会话事件和失败重试策略。 +动态追问迁移遵循以下语义: + +- `scoring_answer -> asking_follow_up | asking_question | reviewing_weak_answers`:输入是已通过 `client_message_id` 幂等落库的主回答;以检查点版本作为并发写入条件,在同一检查点事务中保存主评分、决策动作及下一状态。进入追问时下一节点再产生问题事件;结束时保存完整问答历史。 +- `asking_follow_up -> waiting_for_follow_up`:输入是检查点中已保存的 `deepen` 或 `switch` 决策;同一事务追加追问轮次并产生带轮次和动作的 `question` 事件。恢复时缺少待执行决策可根据已保存轨迹重新决策,但达到配置上限必须直接结束本题。 +- `waiting_for_follow_up -> scoring_follow_up`:输入是追问回答消息;沿用客户端消息幂等键,在接受回答的事务中写入回答正文和 `answer` 事件,不在 API 层执行评分。 +- `scoring_follow_up -> asking_follow_up | asking_question | reviewing_weak_answers`:依据实际追问评分,把本轮回答、评分和下一决策一起写入检查点;继续追问时进入 `asking_follow_up`,模型结束或达到上限时写入最终问答历史。 +- 任一评分或决策调用超时、结构化输出无效或检查点上下文缺失时进入 `failed_retryable`,记录原阶段但不写入半轮追问;按现有最多三次恢复策略重试。连续失败后返回中文不可继续提示,已持久化的主回答和完成追问不得丢失。 + ## 数据一致性 - MySQL 检查点包含状态版本号,使用乐观锁防止并发推进。 @@ -184,12 +193,18 @@ waiting_for_follow_up(主问题已评分) -> evaluating - MySQL outbox 记录待发布事件;发布器将其写入 Redis Stream 后标记已发布,避免“状态已推进但事件丢失”。 - 消费方和客户端均以业务事件 ID 去重;Redis Stream ID 只承担传输排序游标。 - Redis 丢失时可根据 MySQL 检查点和 outbox 恢复;Milvus 不保存权威业务状态。 +- MySQL 业务时间列统一保存 `Asia/Shanghai` 北京时间,精度为微秒;迁移时把既有 UTC 时间列一次性转换为北京时间。领域模型中的持久化时间携带 `+08:00` 偏移,数据库 `DATETIME(6)` 列保存对应的本地墙钟时间,API 输出不得把北京时间误标为 UTC。 +- 新建用户、面试会话、题目、候选人记忆、outbox 事件以及数据库内部记录时,服务端统一生成 64 位 Snowflake ID。对外协议中的 ID 使用十进制字符串,避免 JavaScript 安全整数精度损失;既有 UUID 和自增 ID 保持可读、可关联且不做重写。 +- Snowflake 节点号由 `SNOWFLAKE_NODE_ID` 配置,取值 0–1023;同一部署中的并发节点必须使用不同节点号。发生毫秒内序列耗尽时等待下一毫秒,时钟短暂回拨时不得产生重复 ID。 ## DeepSeek 调用策略 - 默认模型由 `DEEPSEEK_MODEL` 配置,初始值为 `deepseek-chat`;基础地址默认 `https://api.deepseek.com`。 - API 密钥只读取 `DEEPSEEK_API_KEY`,示例文件仅放占位符。 - 结构化任务使用 JSON 输出并用 Pydantic 二次校验;无效输出最多修复重试一次。 +- 追问使用受限的多轮决策循环。每次主回答或追问回答评分后,决策模型根据原问题、主回答、主评分和完整追问轨迹选择 `deepen`(沿当前缺口深挖)、`switch`(切换到另一个未验证缺口)或 `finish`(结束本题追问);`deepen`、`switch` 必须同时给出下一条实际追问。 +- 追问回答必须依据实际追问文本评分,并同时提供前序轨迹作为上下文;不得把追问的局部回答当作原问题的完整回答重新评分。每轮问题、回答、评分和决策动作都写入检查点及最终问答历史。 +- 单道主问题最多追问 `INTERVIEW_MAX_FOLLOW_UP_ROUNDS` 轮,默认 3、允许 1–5;达到上限后 workflow 强制结束本题追问,不再请求下一动作。模型不能直接修改检查点或绕过状态迁移。 - 网络超时、限流和 5xx 使用带抖动的指数退避;业务校验错误不盲目重试。 - 每个调用设置总超时,并记录耗时、任务名、模型和 token 用量,不记录完整简历或密钥。 @@ -200,6 +215,15 @@ waiting_for_follow_up(主问题已评分) -> evaluating - 分隔条支持 Pointer Events、左右方向键、Home/End,暴露 `separator` role 与当前/最小/最大值;拖拽时临时禁用 iframe pointer events,避免 PDF 捕获指针。1160 px 以下切换现有两列/单列布局并禁用分隔条。 - 全屏“放大”预览继续保留,拖拽调整与全屏预览互不替代;不向前端展示后端抽取文本。 +## GitHub 实践项目推荐质量 + +- 高、中优先级复习项必须提供受约束的项目检索意图:1–3 个英文检索短语、0–3 个 GitHub Topic 与可选的偏好语言;模型只能生成检索意图,不能生成仓库 URL。历史计划缺少该字段时,系统可以从标准技术名生成确定性降级意图。 +- 每个薄弱主题使用最多三路 GitHub 仓库查询并合并去重。查询只在仓库名称、描述与 Topic 中召回,保留 GitHub 默认相关性排序;不得用 README 宽泛召回,也不得按 Star 覆盖相关性顺序。 +- 候选仓库必须未归档、未禁用、非 fork、非镜像、非模板,并具有实际代码信号。默认只接受最近 12 个月有推送的仓库;候选不足时可以放宽到 18 个月并在卡片中标明,超过 18 个月的仓库不得展示。 +- `awesome`/资源导航、Prompt 合集、项目列表、路线图与纯面试题集合不能作为实践项目。主题相关性是独立准入条件,Star 只能作为弱社区信号,不能补偿主题无关或长期未维护。 +- 系统按多路排名共识、主题匹配、活跃度、可实践性与弱社区信号生成可解释排序。最终得分未达到阈值时返回空项目列表,不能为了填满卡片推荐低质量仓库。 +- 项目卡片展示仓库名、语言、Star、最近推送日期和简短推荐理由。GitHub 超时、限流、响应异常或没有合格项目时,复习计划仍正常完成且不附加项目卡片。 + ## RAG - BGE-M3 提供统一维度的文档与查询嵌入。 @@ -211,6 +235,7 @@ waiting_for_follow_up(主问题已评分) -> evaluating - 旧 `dataset_v1.json` 仅作为 50 条流程冒烟基线,不再作为正式 A/B 结论来源。 - 用户更新同名题库时,先形成新版本,索引成功后切换可见版本,避免删除旧索引后导入失败。 - 带 Markdown 问题标题或 `Q/A` 标记的题库使用确定性解析;其他非结构化文档交给 DeepSeek 标准化,结构化输出无效时明确失败;所有题目分批写入嵌入服务。 +- 题库上传成功后,用户必须能在“我的题库”看到当前激活版本的来源文件、题目总数、难度与类型统计,并通过关键词、来源、难度和类型筛选题目。列表选中题目后展示原始问题、参考答案、技能标签、来源文件与索引版本;空题库、加载失败和无匹配结果均提供可操作状态,不展示其他用户的数据。 ## 非功能要求 @@ -244,6 +269,13 @@ waiting_for_follow_up(主问题已评分) -> evaluating 20. 人为让向量、BM25 或 rerank 任一通道超时,检索仍能从剩余结果返回且排序可解释;两路成功时确认它们在同一等待窗口内并行执行并按 RRF 融合。 21. 使用固定小语料分别运行四种检索模式,报告中的样本数、黄金文档 ID、Recall、MRR、nDCG 和分组统计可重复;正式测试集不会因调参集运行而被修改。 22. 桌面端向右拖动 Brief 与面试房间之间的分隔条后,左侧 JD/简历 PDF 预览显著变宽且中间面试房间不低于最小宽度;刷新后宽度恢复。键盘可调、双击复位,窄屏下分隔条不显示且页面无横向溢出。 +23. 用户上传题库后无需刷新即可看到激活题目列表;选择任意题目可读完整参考答案,搜索和来源/难度/类型筛选会更新结果数与分页。刷新或重新进入“我的题库”后详情仍来自 MySQL,空库与无结果状态明确,且用户 A 无法看到用户 B 的题目。 +34. 主回答评分后,决策模型可以结束追问或发起第一轮追问;每轮追问评分后可以继续深挖、切换未验证缺口或结束本题。最多连续追问配置上限轮数,刷新和断线恢复后已完成及当前等待回答的追问轨迹不丢失。 +35. 回答追问时,评分模型收到实际追问、追问回答以及对应的原问题、主回答、主评分和全部前序追问轨迹;只评价当前回答对当前追问的完成度。最终报告与个人记忆保留每一轮独立追问得分。 +36. “Agent Planning 与 Action”薄弱主题的 GitHub 项目卡片不得出现 `sindresorhus/awesome`、`public-apis/public-apis`、Prompt 合集或独立开发者项目清单;系统返回直接相关且可实践的 Agent 框架/应用,或在没有合格候选时不显示项目。 +37. 每张 GitHub 项目卡片对应的仓库均未归档、非 fork/镜像/模板且最近 18 个月内有推送;至少两个 12 个月内合格候选存在时不得选择更旧候选。卡片展示最近推送日期与推荐理由,GitHub 失败不影响报告和复习计划完成。 +38. 升级既有 MySQL 数据库后,升级前记录的业务时间整体增加 8 小时且记录数、主外键关系不变;升级后创建的会话、事件、报告和题库记录直接以北京时间写入,API 返回时间携带 `+08:00`。 +39. 升级后新建的用户、会话、事件、题目、候选人记忆和内部记录均使用唯一、趋势递增的 Snowflake ID;对外 ID 是十进制字符串且现有 UUID 会话仍可恢复、查询和生成报告。 ## 已确认决策 diff --git a/specs/002-personal-interview-memory/spec.md b/specs/002-personal-interview-memory/spec.md new file mode 100644 index 0000000..fff52f0 --- /dev/null +++ b/specs/002-personal-interview-memory/spec.md @@ -0,0 +1,186 @@ +# 个人面试助手与自进化记忆规格 + +## 状态 + +探索版,进入实现与验证。 + +## 背景 + +当前系统会在面试结束后更新候选人画像,并在下一场面试出题时读取画像,但画像只有强项、薄弱点分数和面试次数,缺少跨场证据、置信度、时间和可检索的长期记忆。因此系统能够“记住一个结果”,还不能可靠地区分单场波动和长期趋势,也无法让用户查看、纠正或删除系统记忆。 + +本版本参考 `waku-agent` 的记忆后端协议、按需检索和批量沉淀思想,以及 `pi` 在 Agent 调用前注入上下文、调用后持久化状态的扩展点设计,构建面向模拟面试的个人长期记忆。mem0 用于可选的语义检索实验,不取代本项目的权威业务数据。 + +## 目标 + +- 模拟面试次数越多,系统对稳定强项、持续薄弱项和发展中能力的判断越有证据。 +- 新一场面试优先验证与目标岗位相关、且历史上多次暴露的薄弱项,减少对稳定强项的重复验证。 +- 复习推荐同时解释本场证据与跨场趋势,避免把一次低分夸大为长期结论。 +- 用户可以查看并删除系统保存的个人学习信号。 +- mem0 可按配置启用;其故障、超时或未配置不能阻断核心面试流程。 + +## 非目标 + +- 本版本不让 Agent 自行修改代码、系统提示、评分标准或工作流拓扑。 +- 本版本不把完整对话历史、简历、JD、原始回答或录音保存到 mem0。 +- 本版本不使用跨用户数据训练或更新公共模型参数。 +- 本版本不以一次模拟面试结果自动推断性格、健康、年龄、家庭、政治倾向等敏感属性。 +- 本版本不新增前端记忆管理界面;先提供鉴权 API,为后续 UI 留出稳定协议。 + +## 术语 + +- **个人画像**:MySQL 中按用户保存的权威候选人能力状态。 +- **学习信号**:围绕一个技术主题沉淀的结构化跨场证据,包括水平、平均分、证据次数、来源会话和最近更新时间。 +- **权威记忆**:个人画像中的学习信号。后续 Agent 只能注入仍存在于权威记忆中的条目。 +- **语义索引**:mem0 中可重建的学习信号副本,只用于提高相关记忆召回顺序。 +- **自进化**:系统根据新的、可追溯的面试证据更新个人画像和后续策略,不表示模型参数自训练。 + +## 用户场景 + +### 场景一:跨场更新能力判断 + +用户在多场面试中回答 Redis 相关问题。系统把相同主题合并为一个学习信号,累计答题证据并更新加权平均分与水平。重复执行同一会话的完成回调不得重复累计。 + +### 场景二:个性化下一场出题 + +用户开始新的后端岗位模拟面试。系统结合 JD、简历匹配、题库召回和历史学习信号生成题目;只把与当前岗位相关的少量记忆注入 Agent,并优先验证多次出现的薄弱项。 + +### 场景三:个性化复习推荐 + +本场面试结束后,系统生成复习计划。计划必须区分“本场 Redis 得分较低”和“Redis 已连续多场表现薄弱”,并依据证据强度排序行动项。 + +### 场景四:查看和删除记忆 + +登录用户通过 API 查看自己的学习信号,并删除错误或不希望保留的条目。删除 MySQL 权威记录后,即使 mem0 删除暂时失败,该条目也不能再进入 Agent 上下文。 + +### 场景五:mem0 降级 + +mem0 未配置、超时、认证失败或返回未知结构时,系统使用 MySQL 学习信号进行确定性排序,面试的出题、评分、报告和复习计划继续完成。 + +## 领域模型 + +每条学习信号至少包含: + +- `id`:不透明且稳定的记忆 ID。 +- `topic`:标准化技术主题,同一用户内按不区分大小写的主题合并。 +- `level`:`strength`、`developing` 或 `weakness`。 +- `average_score`:该主题所有有效答题证据的加权平均分,范围 0–100。 +- `evidence_count`:参与聚合的有效答题数量。 +- 主回答与已评分追问分别作为一条有效答题证据计入 `evidence_count` 和平均分;未评分追问不计入。 +- `last_session_id`:最近贡献证据的面试会话。 +- `source_session_ids`:有限长度的近期来源会话列表,用于解释和幂等审计。 +- `last_seen_at`:最近更新时刻。 + +个人画像记录已处理的近期会话 ID。一个会话最多使 `interview_count` 增加一次,并最多向每条主题记忆贡献一次该会话内聚合后的证据集合。 + +删除学习信号时同时在个人画像中记录有限长度的主题抑制标记,避免正在并发完成的旧会话或后续自动回调重新创建用户已明确删除的主题记忆。恢复该主题的个性化学习需要未来提供显式的用户操作,不能由后台同步自行取消抑制。 + +水平的初始确定性阈值为:平均分低于 60 为 `weakness`,60–79.99 为 `developing`,80 及以上为 `strength`。后续若引入置信区间或时间衰减,必须另行更新规格和离线评测。 + +## 数据流与边界 + +```text +完成面试 + -> 基于已评分 Q/A 更新 MySQL 个人画像 + -> 按 session_id 幂等保存 + -> 尽力把压缩学习信号 upsert 到 mem0(infer=false) + +开始新面试 / 生成复习计划 + -> 从 MySQL 读取权威学习信号 + -> mem0 按 user_id + agent_id 语义排序(可选) + -> 用返回的外部 ID 与 MySQL 权威集合求交集 + -> 注入有限条记忆到 Agent + -> mem0 失败时使用 MySQL 确定性排序 +``` + +- MySQL 是个人画像和学习信号的权威存储。 +- mem0 是可关闭、可清空、可重建的派生索引,不参与事务提交,也不决定一条记忆是否仍然有效。 +- 领域状态更新仍由 workflow/coordinator 驱动;API 不直接拼 SQL。 +- mem0 客户端由 Service/Infra 边界封装,AgentService 不依赖 mem0 SDK 类型。Cloud 与 OSS SDK 的初始化都必须移出事件循环并受启动超时约束;运行期读写使用异步客户端。 + +## mem0 集成 + +- 支持 `cloud` 与 `oss` 两种显式模式。Cloud 使用 API Key 与 Host;OSS 可读取兼容的 mem0 JSON 配置,并允许由 `Settings` 覆盖 Ollama 地址、独立 Qdrant 路径、集合名和历史数据库路径。 +- 使用异步客户端和显式超时;模式、配置路径、API Key、Host、Agent ID、开关和召回数量均由 `Settings` 管理。 +- OSS 模式只复用 Hermes 配置中的 provider/model 信息,不复用其 `user_id`、`agent_id` 或 Qdrant 数据目录;InterviewAgent 使用独立集合和持久化目录,避免跨应用数据混用与本地 Qdrant 文件锁冲突。 +- 所有写入携带 `user_id`、固定用途的 `agent_id` 和权威记忆 `external_id`。 +- 所有搜索、列举、更新和删除均同时限定当前 `user_id` 与 `agent_id`。 +- 写入内容仅为类似“Redis|薄弱项|基于 3 次答题证据,平均 45 分”的压缩信号及必要元数据,明确使用 `infer=false`。 +- 同一 `external_id` 使用 upsert 语义,避免每场面试产生重复向量记忆。 +- 同一用户、同一 `external_id` 的 lookup/update/add 通过 Redis 分布式租约串行执行,避免多实例并发首次同步时创建重复副本。 +- mem0 返回结果只提供排序建议;不存在于当前 MySQL 画像的外部 ID 必须丢弃。 +- 日志不得记录 API Key、完整请求文本、用户原始答案或远端响应正文。 + +## Agent 行为 + +### 出题 Agent + +- 输入增加 `personal_memory`,最大数量由配置限制。 +- 题目仍需满足 JD、简历事实、阶段分布和题库来源要求。 +- 多次薄弱且与岗位相关的主题获得更高验证优先级。 +- 稳定强项可以提高题目难度或减少重复基础题,但不能未经验证直接跳过岗位硬性要求。 + +### 复习计划 Agent + +- 输入明确分为 `current_interview` 与 `historical_memory`。 +- 只有跨场证据足够时才使用“长期”“持续”等表述。 +- 当前表现与历史趋势冲突时,应给出“需要再次验证”的建议,而不是强行覆盖历史结论。 + +### 画像更新 + +- LLM 可继续总结强项和薄弱点文字,但学习信号的会话幂等、计数、平均分和阈值由确定性代码计算。 +- LLM 输出不得删除或伪造已有学习信号;服务端在合并前恢复权威记忆字段。 +- LLM 更新失败时仍使用确定性聚合完成学习信号更新。 + +## API + +### `GET /api/personal-memory` + +需要 Bearer 鉴权,返回当前用户的: + +- `interview_count` +- `semantic_index_enabled` +- `memories` + +不得返回其他用户的记录、mem0 API Key 或远端内部配置。 + +### `DELETE /api/personal-memory/{memory_id}` + +需要 Bearer 鉴权。先删除当前用户 MySQL 画像中的权威条目,再尽力删除 mem0 副本。不存在或不属于当前用户的 ID 统一返回 404,不泄露条目是否属于其他用户。 + +## 容量、失败与可观察性 + +- 每个用户最多保留 50 条学习信号,优先保留近期且证据更多的条目。 +- 每条记忆只保留有限数量的近期来源会话;幂等会话集合也有上限。 +- mem0 搜索、同步或删除失败记录不含个人内容的结构化告警,不向用户暴露 SDK 异常正文。 +- mem0 不加入核心 readiness 硬依赖;关闭或故障时服务仍可 ready,但 API 应能说明语义索引是否启用。 +- 首版同步采用尽力写入;若实测出现长期索引漂移,再增加 MySQL 同步任务表和后台重建命令。 + +## 安全与隐私 + +- mem0 为显式 opt-in,默认关闭。 +- 只同步派生学习信号,不同步完整简历、JD、原始回答、报告全文、录音、JWT 或续面凭证。 +- 用户删除权威记忆后立即停止 Agent 注入;远端删除失败不得恢复本地条目。 +- 所有本地读写和远端过滤使用服务端从 JWT 得到的用户 ID,不接受客户端传入替代用户 ID。 + +## 验收场景 + +1. 同一用户连续完成两场包含 Redis 题目的面试后,个人记忆中只有一个 Redis 学习信号,证据次数和平均分反映两场有效答题。 +2. 重复执行同一会话的完成回调,`interview_count`、`evidence_count` 和平均分均不再次变化。 +3. 一场内多道同主题题目正确聚合;没有技能标签的题目按题型降级为基础知识、项目经验或系统设计主题。 +4. 新面试规划题目时,Agent 输入包含与目标岗位相关的历史信号,且数量不超过配置上限。 +5. 复习计划输入把本场报告与历史记忆放在不同字段,提示词禁止把单场波动描述为长期趋势。 +6. mem0 未配置时,系统完全使用 MySQL 画像工作,所有现有面试测试继续通过。 +7. mem0 搜索超时、抛错或返回未知结构时使用 MySQL 信号降级,面试流程仍能完成。 +8. 启用 mem0 后只同步压缩学习信号并明确 `infer=false`;每次操作都限定当前 `user_id + agent_id`。 +9. mem0 返回已经从 MySQL 删除的外部 ID 时,该结果被过滤,不进入 Agent 上下文。 +10. 用户 A 的列表和删除操作不能读取或删除用户 B 的记忆;越权 ID 返回 404。 +11. 删除本地记忆后,即使 mem0 删除失败,后续出题和复习计划也不再包含该信号。 +12. 日志和仓库扫描不包含真实 mem0 密钥、原始回答或完整简历。 +13. 使用 Hermes 的 Ollama + Qdrant OSS 配置时,服务可完成真实 add/search/update/delete 往返,且不会读写 Hermes 的 Qdrant 数据目录。 + +## 后续演进 + +- 增加记忆管理前端,允许用户查看证据来源、纠正主题和关闭个性化。 +- 建立离线个性化评测集,对比无记忆、MySQL 确定性记忆、MySQL + mem0 三组的题目相关性、重复率和复习推荐命中率。 +- 在有足够数据后评估时间衰减、贝叶斯能力估计和探索/利用配比;这些策略不得在没有离线基线和用户可解释性的情况下直接上线。 +- 若未来需要统一 Milvus 与 mem0 OSS 的向量基础设施,先验证 mem0 Milvus provider 的隔离、过滤和升级语义,再通过 ADR 替换当前独立 Qdrant 索引。 diff --git a/src/interview_agent/api/http.py b/src/interview_agent/api/http.py index e3b906c..ecbc36a 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 ( @@ -69,6 +69,31 @@ async def history( return cast(list[dict[str, Any]], await container.repository.list_history(user.id)) +@router.get("/api/personal-memory") +async def personal_memory( + user: Annotated[CurrentUser, Depends(current_user)], + container: Annotated[Any, Depends(get_container)], +) -> dict[str, object]: + profile = await container.memory_coordinator.profile(user.id) + return { + "interview_count": profile.interview_count, + "semantic_index_enabled": container.memories.semantic_index_enabled, + "memories": [memory.model_dump(mode="json") for memory in profile.memories], + } + + +@router.delete("/api/personal-memory/{memory_id}") +async def delete_personal_memory( + memory_id: str, + user: Annotated[CurrentUser, Depends(current_user)], + container: Annotated[Any, Depends(get_container)], +) -> dict[str, bool]: + deleted = await container.memory_coordinator.delete(user.id, memory_id) + if not deleted: + raise HTTPException(status_code=404, detail="未找到这条个人记忆") + return {"deleted": True} + + @router.post("/api/upload") async def upload_question_bank( file: Annotated[UploadFile, File(...)], @@ -91,6 +116,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/api/websocket.py b/src/interview_agent/api/websocket.py index dd21619..d842b98 100644 --- a/src/interview_agent/api/websocket.py +++ b/src/interview_agent/api/websocket.py @@ -12,6 +12,7 @@ from pydantic import ValidationError from interview_agent.api.schemas import ClientMessage +from interview_agent.domain.identity import new_snowflake_id from interview_agent.infra.redis_store import ConnectionClaim from interview_agent.services.auth import InvalidToken @@ -94,7 +95,7 @@ async def _start_interview(self, message: ClientMessage) -> None: if len(jd.strip()) < 20 or len(resume.strip()) < 20: raise ValueError("JD 和简历内容不能为空或过短") - provisional_session_id = str(uuid4()) + provisional_session_id = new_snowflake_id() resume_token = self.container.auth.issue_resume_token(self.user_id, provisional_session_id) session_id = await self.container.coordinator.start_session( self.user_id, diff --git a/src/interview_agent/config.py b/src/interview_agent/config.py index 868e0bf..e4d42f1 100644 --- a/src/interview_agent/config.py +++ b/src/interview_agent/config.py @@ -1,6 +1,7 @@ from functools import lru_cache +from typing import Literal, Self -from pydantic import Field, SecretStr, field_validator +from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -24,6 +25,7 @@ class Settings(BaseSettings): deepseek_model: str = "deepseek-chat" deepseek_timeout_seconds: float = 90.0 deepseek_max_retries: int = 2 + interview_max_follow_up_rounds: int = Field(default=3, ge=1, le=5) jwt_secret: SecretStr resume_token_secret: SecretStr @@ -35,6 +37,7 @@ class Settings(BaseSettings): ) mysql_pool_size: int = 10 mysql_max_overflow: int = 20 + snowflake_node_id: int = Field(default=0, ge=0, le=1023) redis_url: str = "redis://localhost:6381/0" event_retention_seconds: int = 604800 @@ -81,6 +84,33 @@ 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_repositories_per_topic: int = Field(default=2, ge=1, le=5) + github_candidate_limit: int = Field(default=15, ge=5, le=50) + github_active_days: int = Field(default=365, ge=30, le=365) + github_fallback_active_days: int = Field(default=548, ge=30, le=548) + github_minimum_recommendation_score: float = Field(default=60.0, ge=0, le=100) + + mem0_enabled: bool = False + mem0_mode: Literal["cloud", "oss"] = "cloud" + mem0_api_key: SecretStr | None = None + mem0_host: str = "https://api.mem0.ai" + mem0_agent_id: str = "interview-coach" + mem0_timeout_seconds: float = Field(default=10.0, gt=0, le=60) + mem0_oss_config_path: str | None = None + mem0_oss_ollama_base_url: str | None = None + mem0_oss_qdrant_path: str = "data/mem0_qdrant" + mem0_oss_collection_name: str = "interview_personal_memory" + mem0_oss_history_db_path: str = "data/mem0_history.db" + personal_memory_top_k: int = Field(default=8, ge=1, le=20) + @field_validator("jwt_secret", "resume_token_secret") @classmethod def validate_secret_length(cls, value: SecretStr) -> SecretStr: @@ -95,6 +125,12 @@ def split_cors_origins(cls, value: object) -> object: return [item.strip() for item in value.split(",") if item.strip()] return value + @model_validator(mode="after") + def validate_github_activity_windows(self) -> Self: + if self.github_fallback_active_days < self.github_active_days: + raise ValueError("github_fallback_active_days must not be shorter than active days") + return self + database_echo: bool = Field(default=False, alias="SQL_ECHO") eval_user_id: str = "eval_user" diff --git a/src/interview_agent/container.py b/src/interview_agent/container.py index ddc4d42..66241bc 100644 --- a/src/interview_agent/container.py +++ b/src/interview_agent/container.py @@ -8,8 +8,12 @@ from interview_agent.api.websocket import ConnectionManager from interview_agent.config import Settings +from interview_agent.domain.identity import configure_snowflake 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 GitHubRepositoryClient +from interview_agent.infra.mem0_memory import create_mem0_memory_backend from interview_agent.infra.milvus_store import MilvusQuestionStore from interview_agent.infra.redis_store import OutboxPublisher, RedisStore from interview_agent.infra.repositories import Repository @@ -20,12 +24,51 @@ from interview_agent.services.auth import AuthService from interview_agent.services.coordinator import InterviewCoordinator from interview_agent.services.documents import DocumentService +from interview_agent.services.github_recommendations import GitHubProjectRecommender +from interview_agent.services.personal_memory import PersonalMemoryService +from interview_agent.services.personal_memory_coordinator import PersonalMemoryCoordinator from interview_agent.services.question_bank import QuestionBankService from interview_agent.services.skills import SkillService from interview_agent.services.voice import VoiceService from interview_agent.services.workflow import InterviewWorkflow +def _create_github_repository_search( + settings: Settings, +) -> GitHubProjectRecommender | None: + if not settings.github_recommendations_enabled: + return None + github = GitHubRepositoryClient( + token=settings.github_token.get_secret_value() if settings.github_token else None, + timeout_seconds=settings.github_search_timeout_seconds, + ) + return GitHubProjectRecommender( + github, + per_topic=settings.github_repositories_per_topic, + candidate_limit=settings.github_candidate_limit, + active_days=settings.github_active_days, + fallback_active_days=settings.github_fallback_active_days, + minimum_score=settings.github_minimum_recommendation_score, + ) + + +def _build_interview_workflow( + agents: AgentService, + repository: Repository, + retriever: HybridRetriever, + memories: PersonalMemoryService, + *, + max_follow_up_rounds: int, +) -> InterviewWorkflow: + return InterviewWorkflow( + agents, + repository, + retriever, + memories, + max_follow_up_rounds=max_follow_up_rounds, + ) + + @dataclass class AppContainer: settings: Settings @@ -37,6 +80,7 @@ class AppContainer: reranker: RerankClient milvus: MilvusQuestionStore llm: DeepSeekClient + github_repositories: GitHubProjectRecommender | None agents: AgentService retriever: HybridRetriever workflow: InterviewWorkflow @@ -44,6 +88,8 @@ class AppContainer: auth: AuthService documents: DocumentService question_banks: QuestionBankService + memories: PersonalMemoryService + memory_coordinator: PersonalMemoryCoordinator skills: SkillService voice: VoiceService connections: ConnectionManager @@ -52,6 +98,7 @@ class AppContainer: @classmethod async def create(cls, settings: Settings) -> AppContainer: + configure_snowflake(settings.snowflake_node_id) database = Database(settings) await database.create_schema() repository = Repository(database, settings) @@ -62,13 +109,38 @@ async def create(cls, settings: Settings) -> AppContainer: milvus = MilvusQuestionStore(settings) await milvus.ensure_collection() llm = DeepSeekClient(settings) - agents = AgentService(llm) + github_repositories = _create_github_repository_search(settings) + agents = AgentService(llm, github_repositories) + memory_backend = await create_mem0_memory_backend(settings) + memories = PersonalMemoryService( + backend=memory_backend, + agent_id=settings.mem0_agent_id, + top_k=settings.personal_memory_top_k, + sync_lease=redis.memory_sync_lease, + ) + memory_coordinator = PersonalMemoryCoordinator(repository, memories) retriever = HybridRetriever(repository, embeddings, milvus, reranker, settings) - workflow = InterviewWorkflow(agents, repository, retriever) + workflow = _build_interview_workflow( + agents, + repository, + retriever, + memories, + max_follow_up_rounds=settings.interview_max_follow_up_rounds, + ) publisher = OutboxPublisher(repository, redis) - coordinator = InterviewCoordinator(repository, redis, publisher, workflow, agents) + coordinator = InterviewCoordinator(repository, redis, publisher, workflow, agents, memories) 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 +154,7 @@ async def create(cls, settings: Settings) -> AppContainer: reranker=reranker, milvus=milvus, llm=llm, + github_repositories=github_repositories, agents=agents, retriever=retriever, workflow=workflow, @@ -89,6 +162,8 @@ async def create(cls, settings: Settings) -> AppContainer: auth=auth, documents=documents, question_banks=question_banks, + memories=memories, + memory_coordinator=memory_coordinator, skills=skills, voice=voice, connections=ConnectionManager(), @@ -119,8 +194,11 @@ async def close(self) -> None: if self.publisher_task is not None: await self.publisher_task await self.llm.close() + await self.memories.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/business_time.py b/src/interview_agent/domain/business_time.py new file mode 100644 index 0000000..1d8dcd2 --- /dev/null +++ b/src/interview_agent/domain/business_time.py @@ -0,0 +1,20 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai") + + +def beijing_now() -> datetime: + return datetime.now(BEIJING_TIMEZONE) + + +def to_beijing_naive(value: datetime) -> datetime: + if value.tzinfo is None: + return value + return value.astimezone(BEIJING_TIMEZONE).replace(tzinfo=None) + + +def from_beijing_naive(value: datetime) -> datetime: + if value.tzinfo is not None: + return value.astimezone(BEIJING_TIMEZONE) + return value.replace(tzinfo=BEIJING_TIMEZONE) diff --git a/src/interview_agent/domain/identity.py b/src/interview_agent/domain/identity.py new file mode 100644 index 0000000..1feff7d --- /dev/null +++ b/src/interview_agent/domain/identity.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import threading +import time +from collections.abc import Callable + +SNOWFLAKE_EPOCH_MS = 1_704_067_200_000 # 2024-01-01T00:00:00Z +MAX_NODE_ID = 1023 +MAX_SEQUENCE = 4095 + + +class SnowflakeIdGenerator: + """Generate signed-64-bit-safe, time-ordered decimal identifiers.""" + + def __init__( + self, + node_id: int = 0, + *, + clock_ms: Callable[[], int] | None = None, + ) -> None: + if not 0 <= node_id <= MAX_NODE_ID: + raise ValueError(f"node_id must be between 0 and {MAX_NODE_ID}") + self.node_id = node_id + self._clock_ms = clock_ms or (lambda: time.time_ns() // 1_000_000) + self._last_timestamp = -1 + self._sequence = 0 + self._lock = threading.Lock() + + def next_id(self) -> str: + with self._lock: + timestamp = max(self._clock_ms(), self._last_timestamp) + if timestamp < SNOWFLAKE_EPOCH_MS: + raise RuntimeError("system clock is before the Snowflake epoch") + + if timestamp == self._last_timestamp: + self._sequence = (self._sequence + 1) & MAX_SEQUENCE + if self._sequence == 0: + timestamp = self._next_millisecond(self._last_timestamp) + else: + self._sequence = 0 + + self._last_timestamp = timestamp + value = ( + ((timestamp - SNOWFLAKE_EPOCH_MS) << 22) + | (self.node_id << 12) + | self._sequence + ) + return str(value) + + def _next_millisecond(self, previous: int) -> int: + timestamp = self._clock_ms() + while timestamp <= previous: + time.sleep(0.0001) + timestamp = self._clock_ms() + return timestamp + + +_configured_generators = {0: SnowflakeIdGenerator()} +_default_generator = _configured_generators[0] +_configuration_lock = threading.Lock() + + +def configure_snowflake(node_id: int) -> None: + global _default_generator + with _configuration_lock: + generator = _configured_generators.get(node_id) + if generator is None: + generator = SnowflakeIdGenerator(node_id) + _configured_generators[node_id] = generator + _default_generator = generator + + +def new_snowflake_id() -> str: + return _default_generator.next_id() + + +def new_snowflake_int() -> int: + return int(new_snowflake_id()) diff --git a/src/interview_agent/domain/models.py b/src/interview_agent/domain/models.py index eb62bb0..812885f 100644 --- a/src/interview_agent/domain/models.py +++ b/src/interview_agent/domain/models.py @@ -1,15 +1,19 @@ from __future__ import annotations -from datetime import UTC, datetime +import re +from datetime import datetime from enum import StrEnum from typing import Any, Literal -from uuid import uuid4 +from urllib.parse import urlparse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator +from interview_agent.domain.business_time import beijing_now +from interview_agent.domain.identity import new_snowflake_id -def utc_now() -> datetime: - return datetime.now(UTC) +PROJECT_QUERY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._+#-]*$") +GITHUB_TOPIC_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*$") +LANGUAGE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._+#-]*$") class SessionStatus(StrEnum): @@ -71,7 +75,7 @@ class ResumeMatchResult(BaseModel): class InterviewQuestion(BaseModel): - id: str = Field(default_factory=lambda: str(uuid4())) + id: str = Field(default_factory=new_snowflake_id) content: str reference: str = "" type: Literal["basic", "experience", "design"] = "basic" @@ -88,6 +92,36 @@ class AnswerScore(BaseModel): should_follow_up: bool = False +class FollowUpAction(StrEnum): + DEEPEN = "deepen" + SWITCH = "switch" + FINISH = "finish" + + +class FollowUpDecision(BaseModel): + action: FollowUpAction + question: str | None = Field(default=None, max_length=1000) + basis: str = Field(default="", max_length=500) + + @model_validator(mode="after") + def validate_question_for_action(self) -> FollowUpDecision: + if self.action in {FollowUpAction.DEEPEN, FollowUpAction.SWITCH}: + if self.question is None or not self.question.strip(): + raise ValueError("follow-up question is required for deepen or switch") + self.question = self.question.strip() + else: + self.question = None + return self + + +class FollowUpTurn(BaseModel): + action: FollowUpAction + question: str = Field(min_length=1, max_length=1000) + answer: str | None = None + score: AnswerScore | None = None + basis: str = Field(default="", max_length=500) + + class QAPair(BaseModel): question: InterviewQuestion user_answer: str @@ -96,12 +130,45 @@ class QAPair(BaseModel): follow_up_question: str | None = None follow_up_answer: str | None = None follow_up_score: float | None = None + follow_up_turns: list[FollowUpTurn] = Field(default_factory=list) + follow_up_decisions: list[FollowUpDecision] = Field(default_factory=list) + + +class FollowUpScoreEvidence(BaseModel): + question_id: str + round: int = Field(ge=1) + action: FollowUpAction + question: str + score: float = Field(ge=0, le=100) + + +class CandidateScoreEvidence(BaseModel): + session_id: str + question_id: str + kind: Literal["main", "follow_up"] + follow_up_round: int | None = Field(default=None, ge=1) + score: float = Field(ge=0, le=100) + + +class CandidateMemory(BaseModel): + id: str = Field(default_factory=new_snowflake_id) + topic: str = Field(min_length=1, max_length=120) + level: Literal["strength", "developing", "weakness"] + average_score: float = Field(ge=0, le=100) + evidence_count: int = Field(ge=1) + last_session_id: str + source_session_ids: list[str] = Field(default_factory=list) + score_evidence: list[CandidateScoreEvidence] = Field(default_factory=list) + last_seen_at: datetime = Field(default_factory=beijing_now) class CandidateProfile(BaseModel): strengths: list[str] = Field(default_factory=list) weak_points: dict[str, float] = Field(default_factory=dict) interview_count: int = 0 + memories: list[CandidateMemory] = Field(default_factory=list) + processed_session_ids: list[str] = Field(default_factory=list) + suppressed_memory_topics: list[str] = Field(default_factory=list) class EvaluationReport(BaseModel): @@ -111,15 +178,86 @@ class EvaluationReport(BaseModel): weaknesses: list[str] = Field(default_factory=list) dimension_scores: dict[str, float] = Field(default_factory=dict) recommendations: list[str] = Field(default_factory=list) + follow_up_scores: list[FollowUpScoreEvidence] = Field(default_factory=list) terminated_early: bool = False +class ReviewResource(BaseModel): + title: str + url: str + description: str = "" + stars: int = 0 + language: str | None = None + pushed_at: datetime | None = None + recommendation_reason: str = "" + 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 ProjectSearchIntent(BaseModel): + queries: list[str] = Field(min_length=1, max_length=3) + github_topics: list[str] = Field(default_factory=list, max_length=3) + preferred_languages: list[str] = Field(default_factory=list, max_length=2) + + @field_validator("queries") + @classmethod + def validate_queries(cls, values: list[str]) -> list[str]: + normalized = cls._normalize_terms(values) + if not normalized: + raise ValueError("at least one project search query is required") + if any(PROJECT_QUERY_PATTERN.fullmatch(value) is None for value in normalized): + raise ValueError("project search queries contain unsupported characters") + return normalized + + @field_validator("github_topics") + @classmethod + def validate_github_topics(cls, values: list[str]) -> list[str]: + normalized = cls._normalize_terms(values) + if any(GITHUB_TOPIC_PATTERN.fullmatch(value) is None for value in normalized): + raise ValueError("GitHub topics contain unsupported characters") + return normalized + + @field_validator("preferred_languages") + @classmethod + def validate_languages(cls, values: list[str]) -> list[str]: + normalized = cls._normalize_terms(values) + if any(LANGUAGE_PATTERN.fullmatch(value) is None for value in normalized): + raise ValueError("preferred languages contain unsupported characters") + return normalized + + @staticmethod + def _normalize_terms(values: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw_value in values: + value = " ".join(raw_value.strip().split())[:60] + if not value or value.casefold() in seen: + continue + seen.add(value.casefold()) + normalized.append(value) + return normalized + + 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) + project_search: ProjectSearchIntent | None = None class ReviewPlan(BaseModel): @@ -143,6 +281,9 @@ class InterviewCheckpoint(BaseModel): pending_answer: str | None = None pending_follow_up_answer: str | None = None current_score: AnswerScore | None = None + pending_follow_up_decision: FollowUpDecision | None = None + follow_up_turns: list[FollowUpTurn] = Field(default_factory=list) + follow_up_decisions: list[FollowUpDecision] = Field(default_factory=list) qa_history: list[QAPair] = Field(default_factory=list) consecutive_right: int = 0 consecutive_wrong: int = 0 @@ -152,8 +293,8 @@ class InterviewCheckpoint(BaseModel): terminated_early: bool = False retry_from: SessionStatus | None = None last_error: str | None = None - created_at: datetime = Field(default_factory=utc_now) - updated_at: datetime = Field(default_factory=utc_now) + created_at: datetime = Field(default_factory=beijing_now) + updated_at: datetime = Field(default_factory=beijing_now) metadata: dict[str, Any] = Field(default_factory=dict) @property @@ -173,16 +314,16 @@ def waiting_for_input(self) -> bool: class SessionEvent(BaseModel): - event_uid: str = Field(default_factory=lambda: str(uuid4())) + event_uid: str = Field(default_factory=new_snowflake_id) session_id: str user_id: str type: str payload: dict[str, Any] = Field(default_factory=dict) - created_at: datetime = Field(default_factory=utc_now) + created_at: datetime = Field(default_factory=beijing_now) class QuestionBankItem(BaseModel): - id: str = Field(default_factory=lambda: str(uuid4())) + id: str = Field(default_factory=new_snowflake_id) content: str reference: str type: Literal["basic", "experience", "design"] = "basic" diff --git a/src/interview_agent/domain/state_machine.py b/src/interview_agent/domain/state_machine.py index 5dbe940..9fa464d 100644 --- a/src/interview_agent/domain/state_machine.py +++ b/src/interview_agent/domain/state_machine.py @@ -34,6 +34,8 @@ class InvalidTransition(ValueError): }, SessionStatus.ASKING_FOLLOW_UP: { SessionStatus.WAITING_FOR_FOLLOW_UP, + SessionStatus.ASKING_QUESTION, + SessionStatus.REVIEWING_WEAK_ANSWERS, SessionStatus.FAILED_RETRYABLE, }, SessionStatus.WAITING_FOR_FOLLOW_UP: { @@ -42,6 +44,7 @@ class InvalidTransition(ValueError): SessionStatus.TERMINATED, }, SessionStatus.SCORING_FOLLOW_UP: { + SessionStatus.ASKING_FOLLOW_UP, SessionStatus.ASKING_QUESTION, SessionStatus.REVIEWING_WEAK_ANSWERS, SessionStatus.FAILED_RETRYABLE, 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..d17fdf7 --- /dev/null +++ b/src/interview_agent/infra/github_repositories.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + + +class GitHubRepositoryClient: + """Thin asynchronous transport for GitHub repository and README APIs.""" + + def __init__( + self, + *, + token: str | None = None, + timeout_seconds: float = 15.0, + 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, + ) + + async def search( + self, route: str, *, pushed_after: datetime, limit: int + ) -> list[dict[str, Any]]: + response = await self.client.get( + "/search/repositories", + params={ + "q": ( + f"{route} in:name,description,topics " + f"pushed:>={pushed_after.date().isoformat()} " + "archived:false fork:false mirror:false template:false" + ), + "per_page": limit, + }, + ) + response.raise_for_status() + payload: dict[str, Any] = response.json() + return [item for item in payload.get("items", []) if isinstance(item, dict)] + + async def readme(self, full_name: str) -> str | None: + try: + response = await self.client.get( + f"/repos/{full_name}/readme", + headers={"Accept": "application/vnd.github.raw+json"}, + ) + response.raise_for_status() + except Exception as exc: + logger.info( + "GitHub README request unavailable for %s: %s", + full_name, + type(exc).__name__, + ) + return None + return response.text[:120_000] + + async def close(self) -> None: + await self.client.aclose() diff --git a/src/interview_agent/infra/mem0_memory.py b/src/interview_agent/infra/mem0_memory.py new file mode 100644 index 0000000..cc68580 --- /dev/null +++ b/src/interview_agent/infra/mem0_memory.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +from copy import deepcopy +from pathlib import Path +from typing import Any, cast + +import httpx + +from interview_agent.config import Settings +from interview_agent.services.personal_memory import MemoryHit, SemanticMemoryBackend + +logger = logging.getLogger(__name__) + + +def _rows(payload: object) -> list[dict[str, Any]]: + if isinstance(payload, dict): + payload = payload.get("results") or payload.get("memories") or [] + if not isinstance(payload, list): + return [] + return [row for row in payload if isinstance(row, dict)] + + +def _filters(user_id: str, agent_id: str, external_id: str | None = None) -> dict[str, object]: + conditions: list[dict[str, object]] = [ + {"user_id": user_id}, + {"agent_id": agent_id}, + ] + if external_id is not None: + conditions.append({"external_id": external_id}) + return {"AND": conditions} + + +def _oss_filters( + user_id: str, agent_id: str, external_id: str | None = None +) -> dict[str, object]: + filters: dict[str, object] = {"user_id": user_id, "agent_id": agent_id} + if external_id is not None: + filters["external_id"] = external_id + return filters + + +class Mem0MemoryBackend: + def __init__(self, *, api_key: str, host: str, http_client: httpx.AsyncClient) -> None: + try: + from mem0 import AsyncMemoryClient # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - installation error path + raise RuntimeError("启用 mem0 需要安装 mem0ai 依赖") from exc + self.http_client = http_client + self.client: Any = AsyncMemoryClient( + api_key=api_key, + host=host, + client=http_client, + ) + + async def search( + self, *, user_id: str, agent_id: str, query: str, limit: int + ) -> list[MemoryHit]: + payload = await self.client.search( + query=query, + filters=_filters(user_id, agent_id), + top_k=limit, + ) + hits: list[MemoryHit] = [] + for row in _rows(payload): + raw_metadata = row.get("metadata") + metadata = cast(dict[str, Any], raw_metadata) if isinstance(raw_metadata, dict) else {} + external_id = str(metadata.get("external_id", "")) + if not external_id: + continue + hits.append( + MemoryHit( + external_id=external_id, + text=str(row.get("memory") or row.get("text") or ""), + ) + ) + return hits + + async def _matching_rows( + self, *, user_id: str, agent_id: str, external_id: str + ) -> list[dict[str, Any]]: + payload = await self.client.get_all( + filters=_filters(user_id, agent_id, external_id), + page_size=100, + ) + return _rows(payload) + + async def upsert( + self, + *, + user_id: str, + agent_id: str, + external_id: str, + text: str, + metadata: dict[str, object], + infer: bool, + ) -> None: + rows = await self._matching_rows( + user_id=user_id, agent_id=agent_id, external_id=external_id + ) + if rows: + await self.client.update( + memory_id=str(rows[0]["id"]), + text=text, + metadata=metadata, + ) + return + await self.client.add( + [{"role": "user", "content": text}], + filters={ + "user_id": user_id, + "agent_id": agent_id, + "run_id": external_id, + }, + metadata=metadata, + infer=infer, + ) + + async def delete(self, *, user_id: str, agent_id: str, external_id: str) -> bool: + rows = await self._matching_rows( + user_id=user_id, agent_id=agent_id, external_id=external_id + ) + for row in rows: + await self.client.delete(memory_id=str(row["id"])) + return bool(rows) + + async def close(self) -> None: + await self.http_client.aclose() + + +def _load_oss_config(settings: Settings) -> dict[str, object]: + if settings.mem0_oss_config_path is None: + raise ValueError("MEM0_OSS_CONFIG_PATH is required in oss mode") + config_path = Path(settings.mem0_oss_config_path).expanduser() + payload = json.loads(config_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("mem0 OSS config must be a JSON object") + if payload.get("mode") not in {None, "oss"}: + raise ValueError("mem0 OSS config mode must be oss") + source = payload.get("oss", payload) + if not isinstance(source, dict): + raise ValueError("mem0 OSS provider config is missing") + + blocks: dict[str, dict[str, Any]] = {} + for name in ("llm", "embedder", "vector_store"): + block = source.get(name) + if not isinstance(block, dict): + raise ValueError(f"mem0 OSS config is missing {name}") + blocks[name] = deepcopy(block) + + vector_config = blocks["vector_store"].setdefault("config", {}) + embedder_config = blocks["embedder"].setdefault("config", {}) + if not isinstance(vector_config, dict) or not isinstance(embedder_config, dict): + raise ValueError("mem0 OSS provider config must contain objects") + vector_config.clear() + vector_config.update( + { + "path": str(Path(settings.mem0_oss_qdrant_path).expanduser()), + "collection_name": settings.mem0_oss_collection_name, + } + ) + embedding_dims = embedder_config.get("embedding_dims") + if embedding_dims is not None: + vector_config["embedding_model_dims"] = embedding_dims + + if settings.mem0_oss_ollama_base_url: + for name in ("llm", "embedder"): + provider_config = blocks[name].setdefault("config", {}) + if not isinstance(provider_config, dict): + raise ValueError(f"mem0 OSS {name} config must be an object") + provider_config["ollama_base_url"] = settings.mem0_oss_ollama_base_url + + history_path = Path(settings.mem0_oss_history_db_path).expanduser() + Path(vector_config["path"]).mkdir(parents=True, exist_ok=True) + history_path.parent.mkdir(parents=True, exist_ok=True) + return { + **blocks, + "history_db_path": str(history_path), + "version": "v1.1", + } + + +class Mem0OssMemoryBackend: + def __init__(self, *, config: dict[str, object], timeout_seconds: float) -> None: + try: + from mem0 import AsyncMemory + from mem0.configs.base import MemoryConfig # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - installation error path + raise RuntimeError("启用 mem0 OSS 需要安装 mem0ai 与对应 provider 依赖") from exc + self.client: Any = AsyncMemory(config=MemoryConfig.model_validate(config)) + self.timeout_seconds = timeout_seconds + + async def _wait(self, operation: Any) -> Any: + return await asyncio.wait_for(operation, timeout=self.timeout_seconds) + + async def search( + self, *, user_id: str, agent_id: str, query: str, limit: int + ) -> list[MemoryHit]: + payload = await self._wait( + self.client.search( + query=query, + filters=_oss_filters(user_id, agent_id), + top_k=limit, + ) + ) + hits: list[MemoryHit] = [] + for row in _rows(payload): + raw_metadata = row.get("metadata") + metadata = cast(dict[str, Any], raw_metadata) if isinstance(raw_metadata, dict) else {} + external_id = str(metadata.get("external_id", "")) + if external_id: + hits.append( + MemoryHit( + external_id=external_id, + text=str(row.get("memory") or row.get("text") or ""), + ) + ) + return hits + + async def _matching_rows( + self, *, user_id: str, agent_id: str, external_id: str + ) -> list[dict[str, Any]]: + payload = await self._wait( + self.client.get_all( + filters=_oss_filters(user_id, agent_id, external_id), + top_k=100, + ) + ) + return _rows(payload) + + async def upsert( + self, + *, + user_id: str, + agent_id: str, + external_id: str, + text: str, + metadata: dict[str, object], + infer: bool, + ) -> None: + rows = await self._matching_rows( + user_id=user_id, agent_id=agent_id, external_id=external_id + ) + if rows: + await self._wait( + self.client.update( + memory_id=str(rows[0]["id"]), + text=text, + metadata=metadata, + ) + ) + return + await self._wait( + self.client.add( + [{"role": "user", "content": text}], + user_id=user_id, + agent_id=agent_id, + run_id=external_id, + metadata=metadata, + infer=infer, + ) + ) + + async def delete(self, *, user_id: str, agent_id: str, external_id: str) -> bool: + rows = await self._matching_rows( + user_id=user_id, agent_id=agent_id, external_id=external_id + ) + for row in rows: + await self._wait(self.client.delete(memory_id=str(row["id"]))) + return bool(rows) + + async def close(self) -> None: + vector_store = getattr(self.client, "vector_store", None) + targets = [vector_store, getattr(vector_store, "client", None)] + seen: set[int] = set() + for target in targets: + close = getattr(target, "close", None) + if target is None or close is None or id(target) in seen: + continue + seen.add(id(target)) + if inspect.iscoroutinefunction(close): + await close() + else: + await asyncio.to_thread(close) + + +async def create_mem0_memory_backend(settings: Settings) -> SemanticMemoryBackend | None: + if not settings.mem0_enabled: + return None + if settings.mem0_mode == "oss": + try: + config = await asyncio.to_thread(_load_oss_config, settings) + return await asyncio.wait_for( + asyncio.to_thread( + Mem0OssMemoryBackend, + config=config, + timeout_seconds=settings.mem0_timeout_seconds, + ), + timeout=settings.mem0_timeout_seconds, + ) + except Exception as exc: + logger.warning( + "mem0_oss_initialization_failed_falling_back_to_mysql error_type=%s", + type(exc).__name__, + ) + return None + if settings.mem0_api_key is None: + logger.warning("mem0_disabled_missing_api_key") + return None + + http_client = httpx.AsyncClient(timeout=settings.mem0_timeout_seconds) + try: + # The current SDK performs key validation synchronously in __init__. + # Keep it off the event loop and bound startup time explicitly. + return await asyncio.wait_for( + asyncio.to_thread( + Mem0MemoryBackend, + api_key=settings.mem0_api_key.get_secret_value(), + host=settings.mem0_host, + http_client=http_client, + ), + timeout=settings.mem0_timeout_seconds, + ) + except Exception as exc: + await http_client.aclose() + logger.warning( + "mem0_initialization_failed_falling_back_to_mysql error_type=%s", + type(exc).__name__, + ) + return None diff --git a/src/interview_agent/infra/redis_store.py b/src/interview_agent/infra/redis_store.py index 985ea61..5839d2e 100644 --- a/src/interview_agent/infra/redis_store.py +++ b/src/interview_agent/infra/redis_store.py @@ -192,6 +192,24 @@ async def renew() -> None: with suppress(LockError): await lock.release() + @asynccontextmanager + async def memory_sync_lease(self, user_id: str, memory_id: str) -> AsyncIterator[bool]: + lock = self.client.lock( + f"interview:memory-sync:{user_id}:{memory_id}", + timeout=max( + self.settings.worker_lease_seconds, int(self.settings.mem0_timeout_seconds * 3) + ), + blocking_timeout=self.settings.mem0_timeout_seconds, + thread_local=False, + ) + acquired = await lock.acquire(blocking=True) + try: + yield bool(acquired) + finally: + if acquired: + with suppress(LockError): + await lock.release() + class OutboxPublisher: def __init__(self, repository: Repository, redis: RedisStore) -> None: diff --git a/src/interview_agent/infra/repositories.py b/src/interview_agent/infra/repositories.py index 3be34fa..05e746d 100644 --- a/src/interview_agent/infra/repositories.py +++ b/src/interview_agent/infra/repositories.py @@ -1,22 +1,26 @@ from __future__ import annotations from collections.abc import Callable -from datetime import UTC, timedelta +from datetime import timedelta 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 from interview_agent.config import Settings +from interview_agent.domain.business_time import ( + beijing_now, + from_beijing_naive, + to_beijing_naive, +) +from interview_agent.domain.identity import new_snowflake_id, new_snowflake_int from interview_agent.domain.models import ( CandidateProfile, InterviewCheckpoint, QuestionBankItem, SessionEvent, SessionStatus, - utc_now, ) from interview_agent.domain.state_machine import ensure_transition from interview_agent.infra.database import Database @@ -59,14 +63,14 @@ def __init__(self, database: Database, settings: Settings) -> None: self.settings = settings async def create_user(self, username: str, password_hash: str) -> str: - user_id = str(uuid4()) + user_id = new_snowflake_id() async with self.database.sessions.begin() as db: db.add( UserRow( id=user_id, username=username, password_hash=password_hash, - created_at=utc_now().replace(tzinfo=None), + created_at=to_beijing_naive(beijing_now()), ) ) try: @@ -97,7 +101,7 @@ async def ensure_evaluation_user(self, user_id: str) -> None: id=user_id, username=reserved_username, password_hash="disabled-evaluation-account", - created_at=utc_now().replace(tzinfo=None), + created_at=to_beijing_naive(beijing_now()), ) ) elif row.username != reserved_username: @@ -108,7 +112,7 @@ async def ensure_evaluation_user(self, user_id: str) -> None: async def create_session( self, checkpoint: InterviewCheckpoint, events: list[SessionEvent] ) -> None: - now = utc_now().replace(tzinfo=None) + now = to_beijing_naive(beijing_now()) expires = now + timedelta(seconds=self.settings.session_resume_ttl_seconds) async with self.database.sessions.begin() as db: db.add( @@ -149,8 +153,8 @@ async def save_checkpoint( ensure_transition(checkpoint.status, target_status) checkpoint.status = target_status checkpoint.version = expected_version + 1 - checkpoint.updated_at = utc_now() - now = checkpoint.updated_at.replace(tzinfo=None) + checkpoint.updated_at = beijing_now() + now = to_beijing_naive(checkpoint.updated_at) expires = now + timedelta(seconds=self.settings.session_resume_ttl_seconds) state = checkpoint.model_dump(mode="json") @@ -213,21 +217,22 @@ async def apply_client_message( checkpoint.status = target checkpoint.version += 1 - checkpoint.updated_at = utc_now() + checkpoint.updated_at = beijing_now() row.status = target.value row.version = checkpoint.version row.state = checkpoint.model_dump(mode="json") - row.updated_at = checkpoint.updated_at.replace(tzinfo=None) + row.updated_at = to_beijing_naive(checkpoint.updated_at) row.expires_at = row.updated_at + timedelta( seconds=self.settings.session_resume_ttl_seconds ) db.add( ProcessedMessageRow( + id=new_snowflake_int(), session_id=session_id, client_message_id=client_message_id, message_type=message_type, result=result, - created_at=checkpoint.updated_at.replace(tzinfo=None), + created_at=to_beijing_naive(checkpoint.updated_at), ) ) return result, False @@ -249,7 +254,7 @@ async def fetch_unpublished_events(self, limit: int = 100) -> list[SessionEvent] user_id=row.user_id, type=row.type, payload=row.payload, - created_at=row.created_at.replace(tzinfo=UTC), + created_at=from_beijing_naive(row.created_at), ) for row in rows ] @@ -268,7 +273,7 @@ async def mark_event_published(self, event_uid: str, stream_id: str) -> None: ) .values( published_stream_id=stream_id, - published_at=utc_now().replace(tzinfo=None), + published_at=to_beijing_naive(beijing_now()), ) ) @@ -289,10 +294,10 @@ async def save_interview_record(self, checkpoint: InterviewCheckpoint) -> None: "overall_score": checkpoint.report.overall_score, "report": checkpoint.report.model_dump(mode="json"), "review_plan": checkpoint.review_plan.model_dump(mode="json"), - "created_at": utc_now().replace(tzinfo=None), + "created_at": to_beijing_naive(beijing_now()), } if existing is None: - db.add(InterviewRecordRow(**values)) + db.add(InterviewRecordRow(id=new_snowflake_int(), **values)) else: for key, value in values.items(): setattr(existing, key, value) @@ -311,7 +316,7 @@ async def recoverable_session_ids(self, limit: int = 100) -> list[str]: select(InterviewSessionRow.id) .where( InterviewSessionRow.status.not_in(paused_or_terminal), - InterviewSessionRow.expires_at > utc_now().replace(tzinfo=None), + InterviewSessionRow.expires_at > to_beijing_naive(beijing_now()), ) .order_by(InterviewSessionRow.updated_at) .limit(limit) @@ -336,7 +341,7 @@ async def list_history(self, user_id: str, limit: int = 50) -> list[dict[str, An "overall_score": row.overall_score, "report": row.report, "review_plan": row.review_plan, - "created_at": row.created_at.isoformat(), + "created_at": from_beijing_naive(row.created_at).isoformat(), } for row in rows ] @@ -354,22 +359,30 @@ async def save_profile(self, user_id: str, profile: CandidateProfile) -> None: CandidateProfileRow( user_id=user_id, profile=profile.model_dump(mode="json"), - updated_at=utc_now().replace(tzinfo=None), + updated_at=to_beijing_naive(beijing_now()), ) ) else: row.profile = profile.model_dump(mode="json") - row.updated_at = utc_now().replace(tzinfo=None) + row.updated_at = to_beijing_naive(beijing_now()) - async def save_profile_once( - self, user_id: str, session_id: str, profile: CandidateProfile - ) -> bool: + async def update_profile_once( + self, + user_id: str, + session_id: str, + mutate: Callable[[CandidateProfile], CandidateProfile], + ) -> tuple[CandidateProfile, bool]: async with self.database.sessions.begin() as db: + user = await db.get(UserRow, user_id, with_for_update=True) + if user is None: + raise RepositoryError("profile user does not exist") applied = await db.get(ProfileUpdateRow, session_id) + row = await db.get(CandidateProfileRow, user_id) + current = CandidateProfile.model_validate(row.profile) if row else CandidateProfile() if applied is not None: - return False - row = await db.get(CandidateProfileRow, user_id, with_for_update=True) - now = utc_now().replace(tzinfo=None) + return current, False + profile = mutate(current) + now = to_beijing_naive(beijing_now()) if row is None: db.add( CandidateProfileRow( @@ -382,6 +395,27 @@ async def save_profile_once( row.profile = profile.model_dump(mode="json") row.updated_at = now db.add(ProfileUpdateRow(session_id=session_id, user_id=user_id, created_at=now)) + return profile, True + + async def delete_profile_memory(self, user_id: str, memory_id: str) -> bool: + async with self.database.sessions.begin() as db: + user = await db.get(UserRow, user_id, with_for_update=True) + if user is None: + return False + row = await db.get(CandidateProfileRow, user_id) + if row is None: + return False + profile = CandidateProfile.model_validate(row.profile) + deleted = next((memory for memory in profile.memories if memory.id == memory_id), None) + if deleted is None: + return False + remaining = [memory for memory in profile.memories if memory.id != memory_id] + profile.memories = remaining + profile.suppressed_memory_topics = list( + dict.fromkeys([*profile.suppressed_memory_topics, deleted.topic.casefold()]) + )[-50:] + row.profile = profile.model_dump(mode="json") + row.updated_at = to_beijing_naive(beijing_now()) return True async def replace_question_bank( @@ -391,7 +425,7 @@ async def replace_question_bank( source_hash: str, items: list[QuestionBankItem], ) -> int: - now = utc_now().replace(tzinfo=None) + now = to_beijing_naive(beijing_now()) async with self.database.sessions.begin() as db: current_versions = ( await db.scalars( @@ -482,14 +516,126 @@ 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": from_beijing_naive(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": from_beijing_naive(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( ChatMessageRow( + id=new_snowflake_int(), user_id=user_id, role=role, content=content, - created_at=utc_now().replace(tzinfo=None), + created_at=to_beijing_naive(beijing_now()), ) ) @@ -515,6 +661,6 @@ def _add_events(db: Any, events: list[SessionEvent]) -> None: user_id=event.user_id, type=event.type, payload=event.payload, - created_at=event.created_at.replace(tzinfo=None), + created_at=to_beijing_naive(event.created_at), ) ) diff --git a/src/interview_agent/infra/tables.py b/src/interview_agent/infra/tables.py index 61533b8..7e86adc 100644 --- a/src/interview_agent/infra/tables.py +++ b/src/interview_agent/infra/tables.py @@ -3,8 +3,8 @@ from sqlalchemy import ( JSON, + BigInteger, Boolean, - DateTime, Float, ForeignKey, Index, @@ -13,8 +13,11 @@ Text, UniqueConstraint, ) +from sqlalchemy.dialects.mysql import DATETIME from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from interview_agent.domain.identity import new_snowflake_int + class Base(DeclarativeBase): type_annotation_map: ClassVar[dict[Any, Any]] = {dict[str, Any]: JSON, list[str]: JSON} @@ -26,7 +29,7 @@ class UserRow(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True) username: Mapped[str] = mapped_column(String(128), unique=True, index=True) password_hash: Mapped[str] = mapped_column(String(512)) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) class InterviewSessionRow(Base): @@ -37,9 +40,9 @@ class InterviewSessionRow(Base): status: Mapped[str] = mapped_column(String(48), index=True) version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) state: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) - expires_at: Mapped[datetime] = mapped_column(DateTime, index=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), index=True) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) __table_args__ = (Index("idx_session_user_status", "user_id", "status"),) @@ -54,22 +57,24 @@ class SessionOutboxRow(Base): user_id: Mapped[str] = mapped_column(String(36), index=True) type: Mapped[str] = mapped_column(String(64), index=True) payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False, index=True) published_stream_id: Mapped[str | None] = mapped_column(String(32), nullable=True) - published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DATETIME(fsp=6), nullable=True) class ProcessedMessageRow(Base): __tablename__ = "processed_client_messages" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=False, default=new_snowflake_int + ) session_id: Mapped[str] = mapped_column( String(36), ForeignKey("interview_sessions.id"), index=True ) client_message_id: Mapped[str] = mapped_column(String(64)) message_type: Mapped[str] = mapped_column(String(64)) result: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) __table_args__ = ( UniqueConstraint("session_id", "client_message_id", name="uq_session_client_message"), @@ -79,14 +84,16 @@ class ProcessedMessageRow(Base): class InterviewRecordRow(Base): __tablename__ = "interview_records" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=False, default=new_snowflake_int + ) user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True) session_id: Mapped[str] = mapped_column(String(36), unique=True, index=True) position: Mapped[str] = mapped_column(String(255)) overall_score: Mapped[float] = mapped_column(Float) report: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) review_plan: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False, index=True) class CandidateProfileRow(Base): @@ -94,7 +101,7 @@ class CandidateProfileRow(Base): user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), primary_key=True) profile: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) class ProfileUpdateRow(Base): @@ -102,7 +109,7 @@ class ProfileUpdateRow(Base): session_id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) class QuestionBankRow(Base): @@ -119,7 +126,7 @@ class QuestionBankRow(Base): question_type: Mapped[str] = mapped_column(String(32)) difficulty: Mapped[str] = mapped_column(String(16)) skills: Mapped[list[str]] = mapped_column(JSON, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False) __table_args__ = (Index("idx_question_owner_file_active", "user_id", "source_file", "active"),) @@ -127,8 +134,10 @@ class QuestionBankRow(Base): class ChatMessageRow(Base): __tablename__ = "chat_messages" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=False, default=new_snowflake_int + ) user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True) role: Mapped[str] = mapped_column(String(16)) content: Mapped[str] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6), nullable=False, index=True) diff --git a/src/interview_agent/services/agents.py b/src/interview_agent/services/agents.py index 2c85f1a..a32daa9 100644 --- a/src/interview_agent/services/agents.py +++ b/src/interview_agent/services/agents.py @@ -2,20 +2,27 @@ import json import logging +from typing import Protocol from pydantic import BaseModel, Field +from interview_agent.domain.identity import new_snowflake_id from interview_agent.domain.models import ( AnswerScore, CandidateProfile, EvaluationReport, + FollowUpDecision, + FollowUpScoreEvidence, + FollowUpTurn, InterviewQuestion, JDAnalysis, QAPair, QuestionBankItem, ResumeAnalysis, ResumeMatchResult, + ReviewItem, ReviewPlan, + ReviewResource, ) from interview_agent.llm.deepseek import DeepSeekClient, StructuredOutputError from interview_agent.services.question_bank_parser import parse_question_bank_fallback @@ -23,6 +30,10 @@ logger = logging.getLogger(__name__) +class RepositorySearch(Protocol): + async def search(self, items: list[ReviewItem]) -> dict[str, list[ReviewResource]]: ... + + class ResumeBundle(BaseModel): resume: ResumeAnalysis match: ResumeMatchResult @@ -41,8 +52,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( @@ -85,6 +99,8 @@ async def plan_questions( retrieved: list[InterviewQuestion], profile: CandidateProfile, total: int = 9, + *, + memory_context: list[str] | None = None, ) -> list[InterviewQuestion]: output = await self.llm.structured( [ @@ -93,7 +109,9 @@ async def plan_questions( "content": ( "你是技术面试出题专家。生成覆盖 basic、experience、design 三阶段的题目。" "题库原题应尽量原样保留 content/reference/source;不足部分可由 LLM 补充。" - "题目应结合岗位、简历差距与历史薄弱点,禁止询问受保护个人信息。" + "题目应结合岗位、简历差距与历史薄弱点,优先验证多次出现且与岗位相关的" + "薄弱项;稳定强项可减少重复基础题并提高难度,但岗位硬性要求未经本场" + "验证不得直接跳过。禁止询问受保护个人信息。" ), }, { @@ -104,7 +122,11 @@ async def plan_questions( "distribution": {"basic": 3, "experience": 3, "design": 3}, "jd": jd.model_dump(mode="json"), "match": match.model_dump(mode="json"), - "candidate_profile": profile.model_dump(mode="json"), + "candidate_profile": profile.model_dump( + mode="json", + exclude={"memories", "processed_session_ids"}, + ), + "personal_memory": memory_context or [], "retrieved_questions": [ item.model_dump(mode="json") for item in retrieved ], @@ -117,7 +139,10 @@ async def plan_questions( task="question_plan", max_tokens=7000, ) - return output.questions[:total] + return [ + question.model_copy(update={"id": new_snowflake_id()}) + for question in output.questions[:total] + ] async def phrase_question( self, question: InterviewQuestion, position: str, question_number: int @@ -153,6 +178,96 @@ async def score_answer(self, question: InterviewQuestion, answer: str) -> Answer task="score_answer", ) + async def score_follow_up( + self, + question: InterviewQuestion, + main_answer: str, + main_score: AnswerScore, + follow_up_turns: list[FollowUpTurn], + follow_up_answer: str, + ) -> AnswerScore: + if not follow_up_turns: + raise ValueError("follow-up turn is required") + current_turn = follow_up_turns[-1] + return await self.llm.structured( + [ + { + "role": "system", + "content": ( + "你是严格且建设性的技术面试官。只评价候选人的追问回答是否完成了" + "实际追问,不要把局部追问回答当作原问题的完整回答重新评分。" + "原问题、主回答、主评分和遗漏要点只用于理解追问背景。" + "按技术正确性、针对性、完整性和工程判断评分;本次不再继续追问," + "should_follow_up 必须为 false。" + ), + }, + { + "role": "user", + "content": json.dumps( + { + "original_question": question.model_dump(mode="json"), + "main_answer": main_answer, + "main_score": main_score.model_dump(mode="json"), + "previous_follow_up_turns": [ + turn.model_dump(mode="json") for turn in follow_up_turns[:-1] + ], + "current_follow_up_question": current_turn.question, + "current_follow_up_action": current_turn.action.value, + "current_follow_up_answer": follow_up_answer, + }, + ensure_ascii=False, + ), + }, + ], + AnswerScore, + task="score_follow_up", + ) + + async def decide_follow_up( + self, + question: InterviewQuestion, + main_answer: str, + main_score: AnswerScore, + follow_up_turns: list[FollowUpTurn], + *, + max_rounds: int, + ) -> FollowUpDecision: + remaining_rounds = max(0, max_rounds - len(follow_up_turns)) + return await self.llm.structured( + [ + { + "role": "system", + "content": ( + "你是技术面试的动态追问决策器。根据可观察的评分和完整追问轨迹选择" + "下一动作:deepen 沿当前缺口深入,switch 切换到原问题中另一个尚未验证" + "的关键点,finish 结束本题追问。只有新增问题能显著增加面试证据时才继续;" + "避免重复已经回答的内容。deepen 或 switch 必须给出一条简洁且可独立回答" + "的问题,finish 的 question 必须为空。basis 只写简短、可审计的决策依据," + "不要输出隐藏思维过程。" + ), + }, + { + "role": "user", + "content": json.dumps( + { + "original_question": question.model_dump(mode="json"), + "main_answer": main_answer, + "main_score": main_score.model_dump(mode="json"), + "follow_up_turns": [ + turn.model_dump(mode="json") for turn in follow_up_turns + ], + "max_rounds": max_rounds, + "remaining_rounds": remaining_rounds, + }, + ensure_ascii=False, + ), + }, + ], + FollowUpDecision, + task="follow_up_decision", + max_tokens=1200, + ) + async def follow_up(self, question: InterviewQuestion, answer: str, score: AnswerScore) -> str: return await self.llm.complete( [ @@ -206,22 +321,81 @@ async def evaluate( task="evaluation", max_tokens=6000, ) + report.follow_up_scores = [ + FollowUpScoreEvidence( + question_id=item.question.id, + round=round_number, + action=turn.action, + question=turn.question, + score=turn.score.score, + ) + for item in history + for round_number, turn in enumerate(item.follow_up_turns, start=1) + if turn.score is not None + ] report.terminated_early = terminated_early return report - async def review_plan(self, report: EvaluationReport) -> ReviewPlan: - return await self.llm.structured( + async def review_plan( + self, report: EvaluationReport, memory_context: list[str] | None = None + ) -> ReviewPlan: + plan = await self.llm.structured( [ { "role": "system", - "content": "将面试评估转成按优先级排序、可执行的复习计划。", + "content": ( + "将面试评估转成按优先级排序、可执行的复习计划。" + "resources 只写书籍、文档等通用资源名称,不要编造 GitHub 链接;" + "topic 应包含可用于检索的标准英文技术名(例如 FastAPI、Redis、RAG);" + "每个 high/medium 项必须填写 project_search:queries 提供 1–3 个简短、" + "具体的英文 GitHub 项目检索短语,github_topics 提供最多 3 个标准 " + "Topic,preferred_languages 只在岗位技术栈明确时填写。对 Agent " + "Planning/Action " + "应使用 agent framework、agent orchestration 等项目概念,不能使用宽泛的" + " Agent/Planning/Action 单词组合。不得在这些字段中生成 URL 或仓库名。" + "真实开源项目将由系统检索、过滤和排序后补充。必须区分本场表现和跨场趋势,只有多次" + "证据支持时才称为长期薄弱项;按证据强度排序行动项。若本场表现与历史" + "趋势冲突,明确建议再次验证,不强行覆盖任一结论。" + ), + }, + { + "role": "user", + "content": json.dumps( + { + "current_interview": report.model_dump(mode="json"), + "historical_memory": memory_context or [], + }, + ensure_ascii=False, + ), }, - {"role": "user", "content": report.model_dump_json()}, ], ReviewPlan, task="review_plan", max_tokens=5000, ) + if self.github_repositories is None: + return plan + if any( + item.priority in {"high", "medium"} and item.project_search is None + for item in plan.items + ): + logger.warning("review plan omitted required project search intent") + searchable_items = [ + item + for item in plan.items + if item.priority in {"high", "medium"} and item.project_search is not None + ] + if not searchable_items: + return plan + try: + repositories = await self.github_repositories.search(searchable_items) + 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] @@ -236,7 +410,10 @@ async def update_profile( "role": "user", "content": json.dumps( { - "current": current.model_dump(mode="json"), + "current": current.model_dump( + mode="json", + exclude={"memories", "processed_session_ids"}, + ), "qa_history": [item.model_dump(mode="json") for item in history], }, ensure_ascii=False, @@ -246,7 +423,10 @@ async def update_profile( CandidateProfileOutput, task="update_profile", ) - output.profile.interview_count = current.interview_count + 1 + output.profile.interview_count = current.interview_count + output.profile.memories = [] + output.profile.processed_session_ids = [] + output.profile.suppressed_memory_topics = [] return output.profile async def chat(self, history: list[dict[str, str]], content: str) -> str: @@ -284,7 +464,11 @@ async def parse_question_bank(self, content: str) -> list[QuestionBankItem]: task="parse_question_bank", max_tokens=8000, ) - return [item for item in output.questions if len(item.content.strip()) >= 5] + return [ + item.model_copy(update={"id": new_snowflake_id()}) + for item in output.questions + if len(item.content.strip()) >= 5 + ] except StructuredOutputError: logger.warning("question_bank_structured_output_invalid") return [] diff --git a/src/interview_agent/services/coordinator.py b/src/interview_agent/services/coordinator.py index f24e5cc..87ec8a0 100644 --- a/src/interview_agent/services/coordinator.py +++ b/src/interview_agent/services/coordinator.py @@ -3,9 +3,10 @@ import logging from copy import deepcopy from typing import Any -from uuid import uuid4 +from interview_agent.domain.identity import new_snowflake_id from interview_agent.domain.models import ( + CandidateProfile, InterviewCheckpoint, SessionEvent, SessionStatus, @@ -13,6 +14,7 @@ from interview_agent.infra.redis_store import OutboxPublisher, RedisStore from interview_agent.infra.repositories import Repository, SessionConflict from interview_agent.services.agents import AgentService +from interview_agent.services.personal_memory import PersonalMemoryService, merge_profile_update from interview_agent.services.workflow import InterviewWorkflow logger = logging.getLogger(__name__) @@ -30,12 +32,14 @@ def __init__( publisher: OutboxPublisher, workflow: InterviewWorkflow, agents: AgentService, + memories: PersonalMemoryService, ) -> None: self.repository = repository self.redis = redis self.publisher = publisher self.workflow = workflow self.agents = agents + self.memories = memories async def start_session( self, @@ -45,7 +49,7 @@ async def start_session( resume_token: str, session_id: str | None = None, ) -> str: - session_id = session_id or str(uuid4()) + session_id = session_id or new_snowflake_id() checkpoint = InterviewCheckpoint( session_id=session_id, user_id=user_id, @@ -195,14 +199,21 @@ async def retry(self, session_id: str, user_id: str) -> None: async def _finalize(self, checkpoint: InterviewCheckpoint) -> None: await self.repository.save_interview_record(checkpoint) - current = await self.repository.load_profile(checkpoint.user_id) try: - profile = await self.agents.update_profile(current, checkpoint.qa_history) + current = await self.repository.load_profile(checkpoint.user_id) + proposed = await self.agents.update_profile(current, checkpoint.qa_history) except Exception: - profile = current - profile.interview_count += 1 - for qa in checkpoint.qa_history: - for skill in qa.question.skills: - previous = profile.weak_points.get(skill, 100.0) - profile.weak_points[skill] = (previous + qa.score) / 2 - await self.repository.save_profile_once(checkpoint.user_id, checkpoint.session_id, profile) + proposed = CandidateProfile() + authoritative, _applied = await self.repository.update_profile_once( + checkpoint.user_id, + checkpoint.session_id, + lambda latest: merge_profile_update( + latest, + proposed, + checkpoint.qa_history, + checkpoint.session_id, + ), + ) + await self.memories.sync_profile( + checkpoint.user_id, authoritative, session_id=checkpoint.session_id + ) 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/github_recommendations.py b/src/interview_agent/services/github_recommendations.py new file mode 100644 index 0000000..fedfe05 --- /dev/null +++ b/src/interview_agent/services/github_recommendations.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import asyncio +import logging +import math +import re +import time +from collections import OrderedDict +from collections.abc import Iterable +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any, Protocol + +from interview_agent.domain.models import ProjectSearchIntent, ReviewItem, ReviewResource + +logger = logging.getLogger(__name__) +ASCII_TECH_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_.+#/-]{1,30}") +NON_PROJECT_PATTERNS = ( + re.compile(r"(?:^|[/_.-])awesome(?:$|[/_.-])", re.IGNORECASE), + re.compile(r"\bcurated\s+list\b", re.IGNORECASE), + re.compile(r"\b(?:curated\s+|collective\s+|comprehensive\s+)?list\s+of\b", re.IGNORECASE), + re.compile(r"\b(?:resource|project|developer)s?\s+list\b", re.IGNORECASE), + re.compile(r"\b(?:prompt|prompts)\s+(?:collection|list|guide)\b", re.IGNORECASE), + re.compile(r"\broadmap\b", re.IGNORECASE), + re.compile(r"\binterview[- ]questions?\b", re.IGNORECASE), + re.compile(r"合集|资源导航|项目列表|调教指南", re.IGNORECASE), +) +README_COLLECTION_PATTERNS = ( + re.compile(r"(?im)^#*\s*awesome(?:\s|$)"), + re.compile(r"\b(?:curated|collective)\s+list\s+of\b", re.IGNORECASE), + re.compile(r"\b(?:prompt|prompts)\s+(?:collection|list|guide)\b", re.IGNORECASE), + re.compile(r"合集|资源导航|项目列表|调教指南", re.IGNORECASE), +) +SEARCH_STOP_WORDS = {"a", "an", "and", "for", "in", "of", "on", "the", "to", "with"} + + +@dataclass +class _Candidate: + payload: dict[str, Any] + ranks: list[int] = field(default_factory=list) + matched_query: str = "" + relevance_score: float = 0.0 + total_score: float = 0.0 + readme_score: float = 0.0 + readme_rejected: bool = False + + +class GitHubRepositoryGateway(Protocol): + async def search( + self, route: str, *, pushed_after: datetime, limit: int + ) -> list[dict[str, Any]]: ... + + async def readme(self, full_name: str) -> str | None: ... + + async def close(self) -> None: ... + + +class GitHubProjectRecommender: + """Returns recent, relevant, runnable repositories for review-plan items.""" + + def __init__( + self, + github: GitHubRepositoryGateway, + *, + per_topic: int = 2, + candidate_limit: int = 15, + active_days: int = 365, + fallback_active_days: int = 548, + minimum_score: float = 60.0, + ) -> None: + if not 30 <= active_days <= 365: + raise ValueError("active_days must be between 30 and 365") + if not active_days <= fallback_active_days <= 548: + raise ValueError( + "fallback_active_days must be between active_days and 548" + ) + self.github = github + self.per_topic = per_topic + self.candidate_limit = candidate_limit + self.active_days = active_days + self.fallback_active_days = fallback_active_days + self.minimum_score = minimum_score + self._cache: OrderedDict[str, tuple[float, list[ReviewResource]]] = OrderedDict() + self._inflight: dict[str, asyncio.Task[list[ReviewResource]]] = {} + + async def search(self, items: Iterable[ReviewItem]) -> dict[str, list[ReviewResource]]: + selected: list[ReviewItem] = [] + seen: set[str] = set() + for item in items: + key = item.topic.strip().casefold() + if not key or key in seen: + continue + seen.add(key) + selected.append(item) + + searches = await asyncio.gather( + *(self._search_item(item) for item in selected), return_exceptions=True + ) + results: dict[str, list[ReviewResource]] = {} + for item, resources in zip(selected, searches, strict=True): + if isinstance(resources, BaseException): + logger.warning( + "GitHub search failed for review topic %s: %s", item.topic, resources + ) + continue + results[item.topic] = resources + return results + + async def _search_item(self, item: ReviewItem) -> list[ReviewResource]: + intent = item.project_search or self._fallback_intent(item) + cache_key = self._cache_key(intent) + 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_item(intent, cache_key)) + self._inflight[cache_key] = task + task.add_done_callback( + lambda completed: self._remove_inflight(cache_key, completed) + ) + 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_item( + self, intent: ProjectSearchIntent, cache_key: str + ) -> list[ReviewResource]: + routes = self._search_routes(intent) + responses = await asyncio.gather( + *(self._request_route(route) for route in routes), return_exceptions=True + ) + candidates: dict[str, _Candidate] = {} + for route, response in zip(routes, responses, strict=True): + if isinstance(response, BaseException): + logger.warning("GitHub search route failed: %s", type(response).__name__) + continue + for rank, payload in enumerate(response, start=1): + full_name = str(payload.get("full_name") or "") + if not full_name: + continue + candidate = candidates.setdefault(full_name.casefold(), _Candidate(payload)) + candidate.ranks.append(rank) + relevance = self._relevance(payload, intent) + if relevance > candidate.relevance_score: + candidate.relevance_score = relevance + candidate.matched_query = route + + eligible = [ + candidate + for candidate in candidates.values() + if self._is_eligible(candidate.payload) and candidate.relevance_score >= 30.0 + ] + for candidate in eligible: + candidate.total_score = self._score(candidate, intent) + eligible.sort( + key=lambda candidate: ( + -candidate.total_score, + -int(candidate.payload.get("stargazers_count") or 0), + str(candidate.payload.get("full_name") or "").casefold(), + ) + ) + active_cutoff = datetime.now(UTC) - timedelta(days=self.active_days) + recent_pool = [ + candidate + for candidate in eligible + if self._pushed_at(candidate.payload) >= active_cutoff + ] + older_pool = [ + candidate + for candidate in eligible + if self._pushed_at(candidate.payload) < active_cutoff + ] + selected = await self._inspect_until(recent_pool, self.per_topic) + minimum_recent = min(2, self.per_topic) + if len(selected) < minimum_recent: + selected.extend( + await self._inspect_until(older_pool, self.per_topic - len(selected)) + ) + resources = [self._to_resource(candidate) for candidate in selected[: self.per_topic]] + self._store_cache(cache_key, resources) + return resources + + async def _request_route(self, route: str) -> list[dict[str, Any]]: + return await self.github.search( + route, + pushed_after=datetime.now(UTC) + - timedelta(days=self.fallback_active_days), + limit=self.candidate_limit, + ) + + async def _inspect_until( + self, candidates: list[_Candidate], limit: int + ) -> list[_Candidate]: + selected: list[_Candidate] = [] + for candidate in candidates: + await self._inspect_readme(candidate) + if candidate.readme_rejected: + continue + candidate.total_score += candidate.readme_score + if candidate.total_score < self.minimum_score: + continue + selected.append(candidate) + if len(selected) >= limit: + break + return selected + + async def _inspect_readme(self, candidate: _Candidate) -> None: + full_name = str(candidate.payload.get("full_name") or "") + if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", full_name) is None: + candidate.readme_rejected = True + return + readme = await self.github.readme(full_name) + if readme is None: + candidate.readme_rejected = True + return + if any(pattern.search(readme) for pattern in README_COLLECTION_PATTERNS): + candidate.readme_rejected = True + return + normalized = readme.casefold() + practice_markers = ( + "quickstart", + "getting started", + "installation", + "pip install", + "uv run", + "npm install", + "docker compose", + "usage", + "example", + ) + candidate.readme_score = min( + 5.0, + sum(marker in normalized for marker in practice_markers) + + (1.0 if "```" in readme else 0.0), + ) + + @staticmethod + def _search_routes(intent: ProjectSearchIntent) -> list[str]: + routes = [f'"{query.replace(chr(34), "")}"' for query in intent.queries] + routes.extend(f"topic:{topic}" for topic in intent.github_topics) + deduplicated: list[str] = [] + seen: set[str] = set() + for route in routes: + if route.casefold() in seen: + continue + seen.add(route.casefold()) + deduplicated.append(route) + if len(deduplicated) >= 3: + break + return deduplicated + + @staticmethod + def _fallback_intent(item: ReviewItem) -> ProjectSearchIntent: + combined = " ".join([item.topic, item.reason, *item.actions]) + keywords: list[str] = [] + seen: set[str] = set() + for keyword in ASCII_TECH_PATTERN.findall(combined): + if keyword.casefold() in seen: + continue + seen.add(keyword.casefold()) + keywords.append(keyword) + normalized = {keyword.casefold() for keyword in keywords} + if "agent" in normalized and normalized.intersection({"planning", "action"}): + return ProjectSearchIntent( + queries=["agent framework", "agent orchestration"], + github_topics=["ai-agents", "agent-framework"], + ) + query = " ".join(keywords[:3]) if keywords else item.topic[:60] + return ProjectSearchIntent(queries=[query]) + + @staticmethod + def _cache_key(intent: ProjectSearchIntent) -> str: + return "|".join( + [ + *(query.casefold() for query in intent.queries), + *(f"topic:{topic.casefold()}" for topic in intent.github_topics), + *(f"language:{language.casefold()}" for language in intent.preferred_languages), + ] + ) + + def _is_eligible(self, payload: dict[str, Any]) -> bool: + if any( + bool(payload.get(field)) + for field in ("archived", "disabled", "fork", "is_template", "mirror_url") + ): + return False + if self._pushed_at(payload) < datetime.now(UTC) - timedelta( + days=self.fallback_active_days + ): + return False + searchable = " ".join( + [ + str(payload.get("full_name") or ""), + str(payload.get("description") or ""), + " ".join(str(topic) for topic in payload.get("topics") or []), + ] + ) + if any(pattern.search(searchable) for pattern in NON_PROJECT_PATTERNS): + return False + language = payload.get("language") + size = int(payload.get("size") or 0) + return bool(language) or (size >= 100 and bool(payload.get("topics"))) + + @staticmethod + def _pushed_at(payload: dict[str, Any]) -> datetime: + raw_value = payload.get("pushed_at") + if not isinstance(raw_value, str): + return datetime.min.replace(tzinfo=UTC) + try: + parsed = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError: + return datetime.min.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + @classmethod + def _relevance(cls, payload: dict[str, Any], intent: ProjectSearchIntent) -> float: + searchable = cls._normalized_text( + " ".join( + [ + str(payload.get("full_name") or ""), + str(payload.get("description") or ""), + " ".join(str(topic) for topic in payload.get("topics") or []), + ] + ) + ) + searchable_tokens = set(searchable.split()) + best = 0.0 + for query in intent.queries: + normalized_query = cls._normalized_text(query) + tokens = [ + token + for token in normalized_query.split() + if token not in SEARCH_STOP_WORDS + ] + if not tokens: + continue + coverage = sum(token in searchable_tokens for token in tokens) / len(tokens) + score = coverage * 40.0 + if normalized_query in searchable: + score += 10.0 + best = max(best, score) + normalized_topics = { + cls._normalized_text(str(topic)) for topic in payload.get("topics") or [] + } + requested_topics = {cls._normalized_text(topic) for topic in intent.github_topics} + if normalized_topics.intersection(requested_topics): + best = max(best, 40.0) + return min(best, 50.0) + + def _score(self, candidate: _Candidate, intent: ProjectSearchIntent) -> float: + age_days = (datetime.now(UTC) - self._pushed_at(candidate.payload)).days + freshness = 15.0 if age_days <= 90 else 12.0 if age_days <= 365 else 5.0 + rank_consensus = min( + 15.0, sum(1.0 / (60 + rank) for rank in candidate.ranks) * 61 * 5 + ) + practice = 0.0 + if candidate.payload.get("language"): + practice += 4.0 + if int(candidate.payload.get("size") or 0) >= 100: + practice += 2.0 + if candidate.payload.get("license"): + practice += 1.0 + if candidate.payload.get("has_issues"): + practice += 1.0 + if candidate.payload.get("topics"): + practice += 1.0 + preferred = {language.casefold() for language in intent.preferred_languages} + language = str(candidate.payload.get("language") or "").casefold() + if preferred and language in preferred: + practice += 1.0 + stars = max(0, int(candidate.payload.get("stargazers_count") or 0)) + popularity = min(5.0, math.log1p(stars) / math.log1p(100_000) * 5.0) + return candidate.relevance_score + rank_consensus + freshness + practice + popularity + + @staticmethod + def _normalized_text(value: str) -> str: + return " ".join(re.findall(r"[a-z0-9+#.]+", value.casefold().replace("_", "-"))) + + def _to_resource(self, candidate: _Candidate) -> ReviewResource: + pushed_at = self._pushed_at(candidate.payload) + used_fallback_window = pushed_at < datetime.now(UTC) - timedelta( + days=self.active_days + ) + activity_reason = ( + "近 12 个月候选不足,放宽到 18 个月" + if used_fallback_window + else "通过近 12 个月活跃度检查" + ) + return ReviewResource( + title=str(candidate.payload["full_name"]), + url=str(candidate.payload["html_url"]), + description=str(candidate.payload.get("description") or ""), + stars=int(candidate.payload.get("stargazers_count") or 0), + language=str(candidate.payload.get("language") or "未识别"), + pushed_at=pushed_at, + recommendation_reason=( + f"匹配 {candidate.matched_query.strip(chr(34))}," + f"{activity_reason},通过可实践性检查" + ), + ) + + def _store_cache(self, cache_key: str, resources: list[ReviewResource]) -> None: + 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] = (now + 21_600, resources) + self._cache.move_to_end(cache_key) + while len(self._cache) > 128: + self._cache.popitem(last=False) + + 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.github.close() diff --git a/src/interview_agent/services/personal_memory.py b/src/interview_agent/services/personal_memory.py new file mode 100644 index 0000000..3b6238b --- /dev/null +++ b/src/interview_agent/services/personal_memory.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import asyncio +import logging +from collections import defaultdict +from collections.abc import Callable, Iterable +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from typing import Literal, Protocol + +from interview_agent.domain.business_time import beijing_now +from interview_agent.domain.models import ( + CandidateMemory, + CandidateProfile, + CandidateScoreEvidence, + QAPair, +) + +logger = logging.getLogger(__name__) + +MAX_PROFILE_MEMORIES = 50 +MAX_PROCESSED_SESSIONS = 200 +MAX_SCORE_EVIDENCE_PER_MEMORY = 100 + + +@dataclass(frozen=True) +class MemoryHit: + external_id: str + text: str + + +class SemanticMemoryBackend(Protocol): + async def search( + self, *, user_id: str, agent_id: str, query: str, limit: int + ) -> list[MemoryHit]: ... + + async def upsert( + self, + *, + user_id: str, + agent_id: str, + external_id: str, + text: str, + metadata: dict[str, object], + infer: bool, + ) -> None: ... + + async def delete(self, *, user_id: str, agent_id: str, external_id: str) -> bool: ... + + async def close(self) -> None: ... + + +MemorySyncLease = Callable[[str, str], AbstractAsyncContextManager[bool]] + + +def _memory_level(score: float) -> Literal["strength", "developing", "weakness"]: + if score >= 80: + return "strength" + if score < 60: + return "weakness" + return "developing" + + +def _canonical_topic(topic: str) -> str: + return " ".join(topic.strip().split())[:120] + + +def _question_topics(qa: QAPair) -> list[str]: + topics = [_canonical_topic(topic) for topic in qa.question.skills] + topics = [topic for topic in topics if topic] + if topics: + unique: dict[str, str] = {} + for topic in topics: + unique.setdefault(topic.casefold(), topic) + return list(unique.values()) + fallback = { + "basic": "基础知识", + "experience": "项目经验", + "design": "系统设计", + } + return [fallback[qa.question.type]] + + +def _answer_scores(qa: QAPair) -> list[float]: + scores = [qa.score] + if qa.follow_up_turns: + scores.extend(turn.score.score for turn in qa.follow_up_turns if turn.score is not None) + elif qa.follow_up_score is not None: + scores.append(qa.follow_up_score) + return scores + + +def _score_evidence(qa: QAPair, session_id: str) -> list[CandidateScoreEvidence]: + evidence = [ + CandidateScoreEvidence( + session_id=session_id, + question_id=qa.question.id, + kind="main", + score=qa.score, + ) + ] + if qa.follow_up_turns: + evidence.extend( + CandidateScoreEvidence( + session_id=session_id, + question_id=qa.question.id, + kind="follow_up", + follow_up_round=round_number, + score=turn.score.score, + ) + for round_number, turn in enumerate(qa.follow_up_turns, start=1) + if turn.score is not None + ) + elif qa.follow_up_score is not None: + evidence.append( + CandidateScoreEvidence( + session_id=session_id, + question_id=qa.question.id, + kind="follow_up", + follow_up_round=1, + score=qa.follow_up_score, + ) + ) + return evidence + + +def merge_interview_evidence( + current: CandidateProfile, + history: list[QAPair], + session_id: str, +) -> CandidateProfile: + """Merge scored evidence without allowing one completion callback to count twice.""" + profile = current.model_copy(deep=True) + if session_id in profile.processed_session_ids: + return profile + + profile.interview_count += 1 + profile.processed_session_ids = [ + *profile.processed_session_ids[-(MAX_PROCESSED_SESSIONS - 1) :], + session_id, + ] + + session_scores: dict[str, list[float]] = defaultdict(list) + session_evidence: dict[str, list[CandidateScoreEvidence]] = defaultdict(list) + display_topics: dict[str, str] = {} + suppressed = set(profile.suppressed_memory_topics) + for item in history: + for topic in _question_topics(item): + key = topic.casefold() + if key in suppressed: + continue + display_topics.setdefault(key, topic) + session_scores[key].extend(_answer_scores(item)) + session_evidence[key].extend(_score_evidence(item, session_id)) + + by_topic = {memory.topic.casefold(): memory for memory in profile.memories} + now = beijing_now() + for key, scores in session_scores.items(): + existing = by_topic.get(key) + new_count = len(scores) + if existing is None: + average = sum(scores) / new_count + memory = CandidateMemory( + topic=display_topics[key], + level=_memory_level(average), + average_score=round(average, 2), + evidence_count=new_count, + last_session_id=session_id, + source_session_ids=[session_id], + score_evidence=session_evidence[key][-MAX_SCORE_EVIDENCE_PER_MEMORY:], + last_seen_at=now, + ) + profile.memories.append(memory) + by_topic[key] = memory + continue + + total = existing.average_score * existing.evidence_count + sum(scores) + existing.evidence_count += new_count + existing.average_score = round(total / existing.evidence_count, 2) + existing.level = _memory_level(existing.average_score) + existing.last_session_id = session_id + existing.last_seen_at = now + if session_id not in existing.source_session_ids: + existing.source_session_ids.append(session_id) + existing.source_session_ids = existing.source_session_ids[-20:] + existing.score_evidence = [ + *existing.score_evidence, + *session_evidence[key], + ][-MAX_SCORE_EVIDENCE_PER_MEMORY:] + + profile.memories.sort( + key=lambda memory: (memory.last_seen_at, memory.evidence_count), reverse=True + ) + profile.memories = profile.memories[:MAX_PROFILE_MEMORIES] + return profile + + +def merge_profile_update( + current: CandidateProfile, + proposed: CandidateProfile, + history: list[QAPair], + session_id: str, +) -> CandidateProfile: + """Apply an LLM proposal to the latest locked profile without replacing durable fields.""" + profile = merge_interview_evidence(current, history, session_id) + profile.strengths = list(dict.fromkeys([*current.strengths, *proposed.strengths]))[:30] + weak_points = dict(current.weak_points) + for topic, score in proposed.weak_points.items(): + weak_points.setdefault(topic, score) + for item in history: + for topic in _question_topics(item): + if topic.casefold() in set(profile.suppressed_memory_topics): + continue + scores = _answer_scores(item) + previous = weak_points.get(topic, sum(scores) / len(scores)) + weak_points[topic] = round((previous + sum(scores)) / (1 + len(scores)), 2) + profile.weak_points = weak_points + return profile + + +def memory_prompt_line(memory: CandidateMemory) -> str: + labels = {"strength": "强项", "developing": "发展中", "weakness": "薄弱项"} + return ( + f"{memory.topic}|{labels[memory.level]}|" + f"基于 {memory.evidence_count} 次答题证据,平均 {memory.average_score:.1f} 分" + ) + + +def _local_rank(query: str, memories: Iterable[CandidateMemory]) -> list[CandidateMemory]: + normalized_query = query.casefold() + + def score(memory: CandidateMemory) -> tuple[int, int, int, float]: + topic = memory.topic.casefold() + relevant = int(topic in normalized_query or normalized_query in topic) + weakness = int(memory.level == "weakness") + return (relevant, weakness, memory.evidence_count, memory.last_seen_at.timestamp()) + + relevant = [memory for memory in memories if memory.topic.casefold() in normalized_query] + return sorted(relevant, key=score, reverse=True) + + +class PersonalMemoryService: + def __init__( + self, + *, + backend: SemanticMemoryBackend | None, + agent_id: str, + top_k: int, + sync_lease: MemorySyncLease | None = None, + ) -> None: + self.backend = backend + self.agent_id = agent_id + self.top_k = top_k + self.sync_lease = sync_lease + + @property + def semantic_index_enabled(self) -> bool: + return self.backend is not None + + async def recall(self, user_id: str, query: str, profile: CandidateProfile) -> list[str]: + local = _local_rank(query, profile.memories) + if self.backend is None or not profile.memories: + return [memory_prompt_line(item) for item in local[: self.top_k]] + + by_id = {memory.id: memory for memory in profile.memories} + ordered: list[CandidateMemory] = [] + try: + hits = await self.backend.search( + user_id=user_id, + agent_id=self.agent_id, + query=query, + limit=self.top_k, + ) + for hit in hits: + memory = by_id.get(hit.external_id) + if memory is not None and memory not in ordered: + ordered.append(memory) + except Exception as exc: + logger.warning("personal_memory_search_failed error_type=%s", type(exc).__name__) + + selected = ordered or local + return [memory_prompt_line(item) for item in selected[: self.top_k]] + + async def sync_profile( + self, user_id: str, profile: CandidateProfile, *, session_id: str | None = None + ) -> None: + backend = self.backend + if backend is None: + return + + candidates = [ + memory + for memory in profile.memories + if session_id is None or memory.last_session_id == session_id + ] + + async def sync_one(memory: CandidateMemory) -> None: + async def upsert() -> None: + try: + await backend.upsert( + user_id=user_id, + agent_id=self.agent_id, + external_id=memory.id, + text=memory_prompt_line(memory), + metadata={ + "external_id": memory.id, + "topic": memory.topic, + "level": memory.level, + "evidence_count": memory.evidence_count, + "last_session_id": memory.last_session_id, + }, + infer=False, + ) + except Exception as exc: + logger.warning("personal_memory_sync_failed error_type=%s", type(exc).__name__) + + if self.sync_lease is None: + await upsert() + return + async with self.sync_lease(user_id, memory.id) as acquired: + if acquired: + await upsert() + + await asyncio.gather(*(sync_one(memory) for memory in candidates)) + + async def forget(self, user_id: str, memory_id: str) -> bool: + if self.backend is None: + return True + try: + return await self.backend.delete( + user_id=user_id, + agent_id=self.agent_id, + external_id=memory_id, + ) + except Exception as exc: + logger.warning("personal_memory_delete_failed error_type=%s", type(exc).__name__) + return False + + async def close(self) -> None: + if self.backend is not None: + await self.backend.close() diff --git a/src/interview_agent/services/personal_memory_coordinator.py b/src/interview_agent/services/personal_memory_coordinator.py new file mode 100644 index 0000000..b71d50e --- /dev/null +++ b/src/interview_agent/services/personal_memory_coordinator.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Protocol + +from interview_agent.domain.models import CandidateProfile +from interview_agent.services.personal_memory import PersonalMemoryService + + +class ProfileMemoryRepository(Protocol): + async def load_profile(self, user_id: str) -> CandidateProfile: ... + + async def delete_profile_memory(self, user_id: str, memory_id: str) -> bool: ... + + +class PersonalMemoryCoordinator: + def __init__( + self, + repository: ProfileMemoryRepository, + memories: PersonalMemoryService, + ) -> None: + self.repository = repository + self.memories = memories + + async def profile(self, user_id: str) -> CandidateProfile: + return await self.repository.load_profile(user_id) + + async def delete(self, user_id: str, memory_id: str) -> bool: + deleted = await self.repository.delete_profile_memory(user_id, memory_id) + if deleted: + await self.memories.forget(user_id, memory_id) + return deleted 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/services/workflow.py b/src/interview_agent/services/workflow.py index 12739a8..e2ea9c9 100644 --- a/src/interview_agent/services/workflow.py +++ b/src/interview_agent/services/workflow.py @@ -8,6 +8,9 @@ from interview_agent.domain.models import ( Difficulty, + FollowUpAction, + FollowUpDecision, + FollowUpTurn, InterviewCheckpoint, QAPair, SessionEvent, @@ -16,6 +19,7 @@ from interview_agent.infra.repositories import Repository from interview_agent.rag.hybrid import HybridRetriever from interview_agent.services.agents import AgentService +from interview_agent.services.personal_memory import PersonalMemoryService @dataclass @@ -36,10 +40,15 @@ def __init__( agents: AgentService, repository: Repository, retriever: HybridRetriever, + memories: PersonalMemoryService, + *, + max_follow_up_rounds: int, ) -> None: self.agents = agents self.repository = repository self.retriever = retriever + self.memories = memories + self.max_follow_up_rounds = max_follow_up_rounds self.graph = self._build_graph() def _build_graph(self) -> Any: @@ -174,11 +183,13 @@ async def _plan_questions(self, state: WorkflowState) -> WorkflowState: except Exception: retrieved = [] profile = await self.repository.load_profile(checkpoint.user_id) + memory_context = await self.memories.recall(checkpoint.user_id, query, profile) checkpoint.questions = await self.agents.plan_questions( checkpoint.jd_analysis, checkpoint.match_result, retrieved, profile, + memory_context=memory_context, ) return { "result": StepResult( @@ -225,6 +236,10 @@ async def _ask_question(self, state: WorkflowState) -> WorkflowState: checkpoint.pending_answer = None checkpoint.pending_follow_up_answer = None checkpoint.current_score = None + checkpoint.pending_follow_up_decision = None + checkpoint.follow_up_turns = [] + checkpoint.follow_up_decisions = [] + checkpoint.metadata.pop("follow_up_question", None) content = await self.agents.phrase_question( question, checkpoint.jd_analysis.position if checkpoint.jd_analysis else "目标岗位", @@ -263,10 +278,16 @@ async def _score_answer(self, state: WorkflowState) -> WorkflowState: key_points_missed=score.key_points_missed, ) ] - should_follow_up = ( - score.should_follow_up and 30 <= score.score < 80 and bool(score.key_points_missed) + decision = await self.agents.decide_follow_up( + checkpoint.current_question, + checkpoint.pending_answer, + score, + checkpoint.follow_up_turns, + max_rounds=self.max_follow_up_rounds, ) - if should_follow_up: + checkpoint.follow_up_decisions.append(decision) + if decision.action != FollowUpAction.FINISH: + checkpoint.pending_follow_up_decision = decision target = SessionStatus.ASKING_FOLLOW_UP else: self.record_main_answer(checkpoint) @@ -281,11 +302,37 @@ async def _ask_follow_up(self, state: WorkflowState) -> WorkflowState: or checkpoint.current_score is None ): raise ValueError("follow-up context missing") - content = await self.agents.follow_up( - checkpoint.current_question, - checkpoint.pending_answer, - checkpoint.current_score, + decision = checkpoint.pending_follow_up_decision + if len(checkpoint.follow_up_turns) >= self.max_follow_up_rounds: + decision = FollowUpDecision( + action=FollowUpAction.FINISH, + basis="恢复时发现已达到单题追问轮数上限", + ) + checkpoint.follow_up_decisions.append(decision) + checkpoint.pending_follow_up_decision = None + self.record_main_answer(checkpoint) + return {"result": StepResult(checkpoint, self._next_after_answer(checkpoint))} + if decision is None: + decision = await self.agents.decide_follow_up( + checkpoint.current_question, + checkpoint.pending_answer, + checkpoint.current_score, + checkpoint.follow_up_turns, + max_rounds=self.max_follow_up_rounds, + ) + checkpoint.follow_up_decisions.append(decision) + if decision.action == FollowUpAction.FINISH or decision.question is None: + self.record_main_answer(checkpoint) + return {"result": StepResult(checkpoint, self._next_after_answer(checkpoint))} + turn = FollowUpTurn( + action=decision.action, + question=decision.question, + basis=decision.basis, ) + checkpoint.follow_up_turns.append(turn) + checkpoint.pending_follow_up_decision = None + checkpoint.pending_follow_up_answer = None + content = turn.question checkpoint.metadata["follow_up_question"] = content return { "result": StepResult( @@ -297,6 +344,8 @@ async def _ask_follow_up(self, state: WorkflowState) -> WorkflowState: "question", question_num=checkpoint.question_index + 1, content="[追问] " + content, + follow_up_round=len(checkpoint.follow_up_turns), + follow_up_action=turn.action.value, ) ], ) @@ -304,13 +353,46 @@ async def _ask_follow_up(self, state: WorkflowState) -> WorkflowState: async def _score_follow_up(self, state: WorkflowState) -> WorkflowState: checkpoint = state["checkpoint"] - if checkpoint.current_question is None or checkpoint.pending_follow_up_answer is None: - raise ValueError("follow-up answer missing") - follow_up_score = await self.agents.score_answer( - checkpoint.current_question, checkpoint.pending_follow_up_answer + self._restore_legacy_follow_up_turn(checkpoint) + if ( + checkpoint.current_question is None + or checkpoint.pending_answer is None + or checkpoint.current_score is None + or not checkpoint.follow_up_turns + or checkpoint.pending_follow_up_answer is None + ): + raise ValueError("follow-up scoring context missing") + current_turn = checkpoint.follow_up_turns[-1] + follow_up_score = await self.agents.score_follow_up( + checkpoint.current_question, + checkpoint.pending_answer, + checkpoint.current_score, + checkpoint.follow_up_turns, + checkpoint.pending_follow_up_answer, ) - self.record_main_answer(checkpoint, follow_up_score.score) - target = self._next_after_answer(checkpoint) + current_turn.answer = checkpoint.pending_follow_up_answer + current_turn.score = follow_up_score + checkpoint.pending_follow_up_answer = None + if len(checkpoint.follow_up_turns) < self.max_follow_up_rounds: + decision = await self.agents.decide_follow_up( + checkpoint.current_question, + checkpoint.pending_answer, + checkpoint.current_score, + checkpoint.follow_up_turns, + max_rounds=self.max_follow_up_rounds, + ) + else: + decision = FollowUpDecision( + action=FollowUpAction.FINISH, + basis="已达到单题追问轮数上限", + ) + checkpoint.follow_up_decisions.append(decision) + if decision.action == FollowUpAction.FINISH: + self.record_main_answer(checkpoint) + target = self._next_after_answer(checkpoint) + else: + checkpoint.pending_follow_up_decision = decision + target = SessionStatus.ASKING_FOLLOW_UP return { "result": StepResult( checkpoint, @@ -323,6 +405,8 @@ async def _score_follow_up(self, state: WorkflowState) -> WorkflowState: feedback=follow_up_score.feedback, key_points_hit=follow_up_score.key_points_hit, key_points_missed=follow_up_score.key_points_missed, + follow_up_round=len(checkpoint.follow_up_turns), + follow_up_action=current_turn.action.value, ) ], ) @@ -386,7 +470,18 @@ async def _plan_review(self, state: WorkflowState) -> WorkflowState: checkpoint = state["checkpoint"] if checkpoint.report is None: raise ValueError("evaluation report missing") - checkpoint.review_plan = await self.agents.review_plan(checkpoint.report) + profile = await self.repository.load_profile(checkpoint.user_id) + query = " ".join( + [ + checkpoint.report.summary, + *checkpoint.report.weaknesses, + *checkpoint.report.dimension_scores, + ] + ) + memory_context = await self.memories.recall(checkpoint.user_id, query, profile) + checkpoint.review_plan = await self.agents.review_plan( + checkpoint.report, memory_context=memory_context + ) events = [ self._event( checkpoint, @@ -439,29 +534,50 @@ def preserve_scored_answer_for_early_exit(self, checkpoint: InterviewCheckpoint) item.question.id == checkpoint.current_question.id for item in checkpoint.qa_history ): return False + self._restore_legacy_follow_up_turn(checkpoint) self.record_main_answer(checkpoint) return True - def record_main_answer( - self, checkpoint: InterviewCheckpoint, follow_up_score: float | None = None - ) -> None: + @staticmethod + def _restore_legacy_follow_up_turn(checkpoint: InterviewCheckpoint) -> None: + if checkpoint.follow_up_turns: + return + question = checkpoint.metadata.get("follow_up_question") + if isinstance(question, str) and question.strip(): + checkpoint.follow_up_turns.append( + FollowUpTurn(action=FollowUpAction.DEEPEN, question=question.strip()) + ) + + def record_main_answer(self, checkpoint: InterviewCheckpoint) -> None: if ( checkpoint.current_question is None or checkpoint.pending_answer is None or checkpoint.current_score is None ): raise ValueError("answer context missing") + turns = [turn.model_copy(deep=True) for turn in checkpoint.follow_up_turns] + first_follow_up = turns[0] if turns else None checkpoint.qa_history.append( QAPair( question=checkpoint.current_question, user_answer=checkpoint.pending_answer, score=checkpoint.current_score.score, feedback=checkpoint.current_score.feedback, - follow_up_question=checkpoint.metadata.pop("follow_up_question", None), - follow_up_answer=checkpoint.pending_follow_up_answer, - follow_up_score=follow_up_score, + follow_up_question=first_follow_up.question if first_follow_up else None, + follow_up_answer=first_follow_up.answer if first_follow_up else None, + follow_up_score=( + first_follow_up.score.score + if first_follow_up is not None and first_follow_up.score is not None + else None + ), + follow_up_turns=turns, + follow_up_decisions=[ + decision.model_copy(deep=True) for decision in checkpoint.follow_up_decisions + ], ) ) + checkpoint.metadata.pop("follow_up_question", None) + checkpoint.pending_follow_up_decision = None if checkpoint.current_score.score >= 80: checkpoint.consecutive_right += 1 checkpoint.consecutive_wrong = 0 @@ -508,12 +624,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 a45dc3e..f5e0007 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), @@ -33,6 +34,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 +55,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; } @@ -85,6 +98,7 @@ if (state.auth?.token) enterApp(); else showAuth(); updateCounts(); + syncEnhancementStatuses(); restoreDocumentPreviews(); syncVoiceModeUi(); updateVoiceControls(); @@ -190,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; @@ -199,6 +214,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])); @@ -214,10 +230,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 +330,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() { @@ -541,6 +585,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") { @@ -572,12 +619,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) { @@ -601,6 +655,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"); } @@ -614,6 +670,122 @@ 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 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); + 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) => { + 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"); + let pushedDate = ""; + if (resource.pushed_at) { + const parsedPushedAt = new Date(resource.pushed_at); + if (!Number.isNaN(parsedPushedAt.getTime())) pushedDate = `更新 ${parsedPushedAt.toISOString().slice(0, 10)}`; + } + const language = resource.language || "未识别"; + const stars = Number.isFinite(Number(resource.stars)) ? Number(resource.stars) : 0; + meta.textContent = [language, `★ ${stars.toLocaleString()}`, pushedDate].filter(Boolean).join(" · "); + const description = document.createElement("small"); + description.textContent = resource.description || "在 GitHub 查看项目"; + link.append(title, meta, description); + if (resource.recommendation_reason) { + const reason = document.createElement("small"); + reason.className = "repository-reason"; + reason.textContent = resource.recommendation_reason; + link.append(reason); + } + 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)); @@ -652,6 +824,10 @@ 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 网页;普通抓取无正文时会自动启用动态渲染…"; + setEnhancementStatus("dynamic-fetch", "running", "读取中", "正在抓取网页,必要时启动 Chromium 动态渲染…"); + } $("#room-title").textContent = "正在创建面试"; $("#quit-button").disabled = false; setVoiceStage("thinking", "正在分析岗位与简历", "DeepSeek 正在为你规划这场专属语音面试。"); @@ -711,6 +887,7 @@ resetDocumentUi(kind); }); updateCounts(); + syncEnhancementStatuses(); feed.innerHTML = '
AI

面试官还没有入场

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

'; $("#start-button").disabled = false; $("#new-session-button").classList.add("hidden"); @@ -769,11 +946,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" : "简历"; @@ -950,7 +1375,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 690f9c3..0b191f6 100644 --- a/src/interview_agent/web/index.html +++ b/src/interview_agent/web/index.html @@ -59,7 +59,13 @@

定义这场
面试的边界

+
+
+ SMART ENHANCEMENTS + 智能增强 +
+
+ +
动态网页抓取输入 JD 链接后,提交时即时触发
+ 按需 +
+
+ +
GitHub 项目推荐生成复习计划时自动搜索真实仓库
+ 自动 +
+
-

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

+

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

准备助手
-
-
-
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..31e4a53 100644 --- a/src/interview_agent/web/styles.css +++ b/src/interview_agent/web/styles.css @@ -211,7 +211,12 @@ body.panel-resizing iframe { pointer-events: none; } .editor-label > span { margin-bottom: 8px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; font-weight: 650; } .editor-label b { color: var(--tertiary); font-family: var(--font-mono); font-weight: 500; } .editor-label textarea { width: 100%; min-height: 112px; border: 1px solid transparent; border-radius: 13px; padding: 12px; outline: none; background: var(--surface-secondary); color: var(--ink); line-height: 1.5; transition: border-color .16s, box-shadow .16s, background .16s; } +.editor-label textarea::placeholder { color: #a1a1a7; opacity: 1; } .editor-label textarea:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 4px rgba(0, 113, 227, .1); } +.completion-hint { width: max-content; margin: -30px 9px 8px auto; position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; color: #8e8e93; pointer-events: none; font-family: var(--font-mono); font-size: 8px; opacity: 0; transform: translateY(3px); transition: opacity .16s, transform .16s; } +.completion-hint kbd { min-width: 19px; padding: 2px 5px; border-color: #d1d1d6; color: #6e6e73; background: rgba(255, 255, 255, .88); box-shadow: 0 1px 0 #c7c7cc; font-size: 8px; text-align: center; } +.editor-label:focus-within textarea:placeholder-shown + .completion-hint { opacity: 1; transform: translateY(0); } +.editor-label textarea.suggestion-accepted { animation: suggestion-accepted .32s ease both; } .document-field { min-width: 0; } .document-upload-row { margin-top: 9px; display: flex; align-items: center; gap: 9px; min-width: 0; } .document-upload-row input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } @@ -239,6 +244,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; } @@ -365,6 +387,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; } @@ -377,6 +482,7 @@ kbd { min-width: 18px; padding: 2px 4px; border: 1px solid var(--line-strong); b .toast { max-width: 390px; padding: 12px 16px; position: fixed; right: 22px; bottom: 22px; z-index: 100; border: 1px solid rgba(255, 255, 255, .15); border-radius: 12px; color: #fff; background: rgba(29, 29, 31, .9); box-shadow: 0 12px 36px rgba(0, 0, 0, .22); backdrop-filter: blur(18px); transform: translateY(100px); opacity: 0; transition: .25s ease; font-size: 12px; } .toast.show { transform: translateY(0); opacity: 1; } @keyframes reveal { from { opacity: 0; transform: translateY(7px); } } +@keyframes suggestion-accepted { 50% { background: rgba(0, 113, 227, .055); } } @keyframes rise { from { opacity: 0; transform: translateY(9px); } } @keyframes pulse { 50% { opacity: .35; } } @keyframes avatar-breathe { 50% { transform: translateY(-2px) scale(1.006); } } @@ -391,6 +497,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 +527,47 @@ 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; } } +.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); } +.repository-card .repository-reason { margin-top: 3px; color: #35628f; font-size: .72rem; } diff --git a/tests/browser/github_recommendation_card.sh b/tests/browser/github_recommendation_card.sh new file mode 100755 index 0000000..9323caa --- /dev/null +++ b/tests/browser/github_recommendation_card.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="${BASE_URL:-http://localhost:9092/}" +session="github-recommendation-card-$$" +pw=(npx --yes --package @playwright/cli playwright-cli "-s=${session}") + +cleanup() { + "${pw[@]}" close >/dev/null 2>&1 || true +} +trap cleanup EXIT + +"${pw[@]}" open "$base_url" +"${pw[@]}" run-code 'async page => { + await page.route("**/api/history", async route => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify([{ + created_at: "2026-08-16T08:00:00Z", + position: "后端 Agent 工程师", + overall_score: 72, + report: { summary: "需要补强规划与执行", strengths: [], weaknesses: ["Agent Planning"] }, + review_plan: { + summary: "优先完成一个可运行项目", + items: [{ + topic: "Agent Planning", + priority: "high", + reason: "规划职责解释不完整", + actions: ["运行并修改项目"], + resources: [ + { + title: "", + url: "https://github.com/microsoft/agent-framework", + description: "Agent runtime", + stars: 12827, + language: "Python", + pushed_at: "2026-08-15T12:00:00Z", + recommendation_reason: "匹配 agent framework,通过近 12 个月活跃度检查", + }, + { + title: "newcomer/agent-runtime", + url: "https://github.com/newcomer/agent-runtime", + description: "New Agent runtime", + stars: 0, + language: null, + pushed_at: "2026-08-14T12:00:00Z", + recommendation_reason: "匹配 agent runtime,通过近 12 个月活跃度检查", + }, + { title: "unsafe", url: "javascript:alert(1)" }, + { title: "lookalike", url: "https://github.com.evil.test/repo" }, + ], + }], + }, + }]), + }); + }); + await page.addInitScript(() => { + localStorage.setItem("interview-room.auth", JSON.stringify({ + token: "browser-test-token", username: "browser-test", user_id: "browser-test-id", + })); + }); + await page.reload(); + await page.locator("[data-nav=history]").click(); + await page.locator(".history-item").click(); + + const cards = page.locator(".repository-card"); + if (await cards.count() !== 2) throw new Error("safe or unsafe repository URL filtering failed"); + const card = cards.first(); + if (await card.getAttribute("href") !== "https://github.com/microsoft/agent-framework") throw new Error("repository URL changed"); + if (await card.getAttribute("target") !== "_blank") throw new Error("repository does not open separately"); + if (await card.getAttribute("rel") !== "noopener noreferrer") throw new Error("repository link lacks opener isolation"); + if (await card.locator("strong").textContent() !== "") throw new Error("title was not rendered as text"); + const meta = await card.locator("span").textContent(); + if (!meta.includes("Python") || !meta.includes("12,827") || !meta.includes("更新 2026-08-15")) throw new Error(`metadata missing: ${meta}`); + const reason = await card.locator(".repository-reason").textContent(); + if (!reason.includes("近 12 个月活跃度检查")) throw new Error("recommendation reason missing"); + const newcomerMeta = await cards.nth(1).locator("span").textContent(); + if (!newcomerMeta.includes("未识别") || !newcomerMeta.includes("★ 0") || !newcomerMeta.includes("更新 2026-08-14")) throw new Error(`zero-star metadata missing: ${newcomerMeta}`); +}' diff --git a/tests/integration/test_beijing_snowflake_migration.py b/tests/integration/test_beijing_snowflake_migration.py new file mode 100644 index 0000000..8ee0a0c --- /dev/null +++ b/tests/integration/test_beijing_snowflake_migration.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import asyncio +import json +import os +from datetime import UTC, datetime + +import pytest +from alembic import command +from alembic.config import Config +from pydantic import SecretStr +from sqlalchemy import text +from sqlalchemy.engine import make_url +from sqlalchemy.ext.asyncio import create_async_engine + +from interview_agent.config import Settings, get_settings +from interview_agent.domain.models import ( + CandidateMemory, + CandidateProfile, + EvaluationReport, + InterviewCheckpoint, + ReviewPlan, + SessionStatus, +) +from interview_agent.infra.database import Database +from interview_agent.infra.repositories import Repository +from interview_agent.infra.tables import Base + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION") != "1" or not os.getenv("MIGRATION_MYSQL_DSN"), + reason="set RUN_INTEGRATION=1 and a disposable MIGRATION_MYSQL_DSN", +) + + +async def _reset_schema(dsn: str) -> None: + engine = create_async_engine(dsn) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.drop_all) + await connection.execute(text("DROP TABLE IF EXISTS alembic_version")) + await engine.dispose() + + +async def _insert_legacy_data(dsn: str) -> None: + checkpoint = InterviewCheckpoint( + session_id="legacy-session-uuid", + user_id="legacy-user-uuid", + jd_text="jd", + resume_text="resume", + status=SessionStatus.COMPLETED, + report=EvaluationReport(overall_score=88, summary="legacy report"), + review_plan=ReviewPlan(summary="legacy review"), + ) + state = checkpoint.model_dump(mode="json") + state["created_at"] = "2026-08-16T03:00:00+00:00" + state["updated_at"] = "2026-08-16T03:30:00+00:00" + profile = CandidateProfile( + memories=[ + CandidateMemory( + id="legacy-memory-uuid", + topic="Python", + level="strength", + average_score=88, + evidence_count=1, + last_session_id="legacy-session-uuid", + last_seen_at=datetime(2026, 8, 16, 3, 20, tzinfo=UTC), + ) + ] + ).model_dump(mode="json") + + engine = create_async_engine(dsn) + async with engine.begin() as connection: + await connection.execute( + text( + "INSERT INTO users (id, username, password_hash, created_at) " + "VALUES ('legacy-user-uuid', 'legacy-user', 'hash', '2026-08-16 03:00:00')" + ) + ) + await connection.execute( + text( + "INSERT INTO interview_sessions " + "(id, user_id, status, version, state, expires_at, created_at, updated_at) " + "VALUES ('legacy-session-uuid', 'legacy-user-uuid', 'completed', 1, " + ":state, '2026-08-17 03:00:00', '2026-08-16 03:00:00', " + "'2026-08-16 03:30:00')" + ), + {"state": json.dumps(state, ensure_ascii=False)}, + ) + await connection.execute( + text( + "INSERT INTO candidate_profiles (user_id, profile, updated_at) " + "VALUES ('legacy-user-uuid', :profile, '2026-08-16 03:20:00')" + ), + {"profile": json.dumps(profile, ensure_ascii=False)}, + ) + await connection.execute( + text( + "INSERT INTO interview_records " + "(id, user_id, session_id, position, overall_score, report, " + "review_plan, created_at) VALUES " + "(7, 'legacy-user-uuid', 'legacy-session-uuid', '', 88, " + ":report, :review_plan, '2026-08-16 03:40:00')" + ), + { + "report": checkpoint.report.model_dump_json(), + "review_plan": checkpoint.review_plan.model_dump_json(), + }, + ) + await engine.dispose() + + +async def _prepare_legacy_column_shapes(dsn: str) -> None: + engine = create_async_engine(dsn) + async with engine.begin() as connection: + for table_name in ( + "processed_client_messages", + "interview_records", + "chat_messages", + ): + await connection.execute( + text(f"ALTER TABLE `{table_name}` MODIFY `id` INTEGER NOT NULL AUTO_INCREMENT") + ) + for table_name, column_names in { + "users": ("created_at",), + "interview_sessions": ("expires_at", "created_at", "updated_at"), + "interview_records": ("created_at",), + "candidate_profiles": ("updated_at",), + }.items(): + for column_name in column_names: + await connection.execute( + text(f"ALTER TABLE `{table_name}` MODIFY `{column_name}` DATETIME NOT NULL") + ) + await engine.dispose() + + +async def _verify_upgrade(dsn: str) -> None: + settings = Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + mysql_dsn=dsn, + ) + database = Database(settings) + repository = Repository(database, settings) + loaded = await repository.load_session("legacy-session-uuid", "legacy-user-uuid") + profile = await repository.load_profile("legacy-user-uuid") + + async with database.session() as session: + values = ( + await session.execute( + text( + "SELECT created_at, (SELECT COUNT(*) FROM users), " + "(SELECT COUNT(*) FROM interview_sessions), id " + "FROM interview_records WHERE session_id = 'legacy-session-uuid'" + ) + ) + ).one() + relation_count = await session.scalar( + text( + "SELECT COUNT(*) FROM interview_records r " + "JOIN users u ON u.id = r.user_id " + "JOIN interview_sessions s ON s.id = r.session_id" + ) + ) + id_shape = ( + await session.execute( + text( + "SELECT COLUMN_TYPE, EXTRA FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = 'interview_records' AND COLUMN_NAME = 'id'" + ) + ) + ).one() + + assert loaded.session_id == "legacy-session-uuid" + assert loaded.report is not None and loaded.report.summary == "legacy report" + assert loaded.created_at.isoformat() == "2026-08-16T11:00:00+08:00" + assert profile.memories[0].id == "legacy-memory-uuid" + assert profile.memories[0].last_seen_at.isoformat() == "2026-08-16T11:20:00+08:00" + assert values[0] == datetime(2026, 8, 16, 11, 40) + assert values[1:] == (1, 1, 7) + assert relation_count == 1 + assert id_shape == ("bigint", "") + await database.close() + + +def test_upgrade_from_0001_preserves_legacy_ids_relations_and_reports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dsn = os.environ["MIGRATION_MYSQL_DSN"] + database_name = make_url(dsn).database or "" + assert database_name.endswith("_migration_test"), "migration test DSN must be disposable" + asyncio.run(_reset_schema(dsn)) + + monkeypatch.setenv("MYSQL_DSN", dsn) + monkeypatch.setenv("DEEPSEEK_API_KEY", "dummy-key") + monkeypatch.setenv("JWT_SECRET", "j" * 32) + monkeypatch.setenv("RESUME_TOKEN_SECRET", "r" * 32) + get_settings.cache_clear() + configuration = Config("alembic.ini") + command.upgrade(configuration, "0001") + asyncio.run(_prepare_legacy_column_shapes(dsn)) + asyncio.run(_insert_legacy_data(dsn)) + command.upgrade(configuration, "head") + asyncio.run(_verify_upgrade(dsn)) + + asyncio.run(_reset_schema(dsn)) + get_settings.cache_clear() diff --git a/tests/integration/test_database_identifiers.py b/tests/integration/test_database_identifiers.py new file mode 100644 index 0000000..ce69ad4 --- /dev/null +++ b/tests/integration/test_database_identifiers.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +from datetime import timedelta + +import pytest +from pydantic import SecretStr +from sqlalchemy import select + +from interview_agent.config import Settings +from interview_agent.domain.business_time import beijing_now, from_beijing_naive +from interview_agent.domain.identity import configure_snowflake, new_snowflake_id +from interview_agent.domain.models import ( + EvaluationReport, + InterviewCheckpoint, + QuestionBankItem, + ReviewPlan, + SessionEvent, + SessionStatus, +) +from interview_agent.infra.database import Database +from interview_agent.infra.repositories import Repository +from interview_agent.infra.tables import ( + ChatMessageRow, + InterviewRecordRow, + InterviewSessionRow, + ProcessedMessageRow, +) + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION") != "1", reason="set RUN_INTEGRATION=1" +) + + +def integration_settings() -> Settings: + return Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("0123456789abcdef0123456789abcdef"), + resume_token_secret=SecretStr("abcdef0123456789abcdef0123456789"), + snowflake_node_id=23, + ) + + +@pytest.mark.asyncio +async def test_new_database_entities_use_snowflake_ids_and_beijing_time() -> None: + settings = integration_settings() + configure_snowflake(settings.snowflake_node_id) + database = Database(settings) + await database.create_schema() + repository = Repository(database, settings) + + user_id = await repository.create_user(f"snowflake-{new_snowflake_id()}", "hash") + session_id = new_snowflake_id() + checkpoint = InterviewCheckpoint( + session_id=session_id, + user_id=user_id, + jd_text="jd", + resume_text="resume", + status=SessionStatus.WAITING_FOR_ANSWER, + ) + event = SessionEvent(session_id=session_id, user_id=user_id, type="session_created") + await repository.create_session(checkpoint, []) + + def accept(value: InterviewCheckpoint) -> tuple[SessionStatus, dict[str, bool]]: + value.pending_answer = "answer" + return SessionStatus.SCORING_ANSWER, {"accepted": True} + + await repository.apply_client_message( + session_id, + user_id, + new_snowflake_id(), + "answer", + accept, + ) + checkpoint.report = EvaluationReport(overall_score=80, summary="ok") + checkpoint.review_plan = ReviewPlan(summary="review") + await repository.save_interview_record(checkpoint) + await repository.append_chat(user_id, "user", "hello") + await repository.replace_question_bank( + user_id, + "questions.md", + "source-hash", + [QuestionBankItem(content="question", reference="reference")], + ) + + async with database.session() as db: + session_row = await db.get(InterviewSessionRow, session_id) + processed_id = await db.scalar( + select(ProcessedMessageRow.id).where(ProcessedMessageRow.session_id == session_id) + ) + record_id = await db.scalar( + select(InterviewRecordRow.id).where(InterviewRecordRow.session_id == session_id) + ) + chat_id = await db.scalar( + select(ChatMessageRow.id).where(ChatMessageRow.user_id == user_id) + ) + + assert session_row is not None + assert all(value.isdecimal() for value in (user_id, session_id, event.event_uid)) + assert all(value is not None and value > 2**31 for value in (processed_id, record_id, chat_id)) + assert abs(from_beijing_naive(session_row.created_at) - beijing_now()) < timedelta(minutes=1) + await database.close() diff --git a/tests/integration/test_durable_session.py b/tests/integration/test_durable_session.py index 93a1173..776ba0b 100644 --- a/tests/integration/test_durable_session.py +++ b/tests/integration/test_durable_session.py @@ -10,6 +10,10 @@ from interview_agent.config import Settings from interview_agent.domain.models import ( + AnswerScore, + FollowUpAction, + FollowUpDecision, + FollowUpTurn, InterviewCheckpoint, InterviewQuestion, SessionEvent, @@ -105,6 +109,97 @@ def mutate(value: InterviewCheckpoint) -> tuple[SessionStatus, dict[str, Any]]: await database.close() +@pytest.mark.asyncio +async def test_dynamic_follow_up_trace_survives_service_restart_and_outbox_replay() -> None: + settings = integration_settings() + database = Database(settings) + repository = Repository(database, settings) + redis = RedisStore(settings) + user_id = await repository.create_user(f"u-{uuid4()}", "hash") + session_id = str(uuid4()) + question = InterviewQuestion(id="q-dynamic", content="解释 Redis 持久化") + deepen = FollowUpDecision( + action=FollowUpAction.DEEPEN, + question="如何降低数据丢失?", + basis="验证持久化取舍", + ) + switch = FollowUpDecision( + action=FollowUpAction.SWITCH, + question="如何提高恢复速度?", + basis="切换验证恢复目标", + ) + checkpoint = InterviewCheckpoint( + session_id=session_id, + user_id=user_id, + jd_text="jd", + resume_text="resume", + status=SessionStatus.WAITING_FOR_FOLLOW_UP, + questions=[question], + current_question=question, + pending_answer="RDB 是快照,AOF 记录写命令。", + current_score=AnswerScore(score=65, feedback="需要补充取舍"), + pending_follow_up_decision=None, + follow_up_decisions=[deepen, switch], + follow_up_turns=[ + FollowUpTurn( + action=FollowUpAction.DEEPEN, + question=deepen.question or "", + answer="使用 AOF everysec。", + score=AnswerScore(score=80, feedback="正确"), + basis=deepen.basis, + ), + FollowUpTurn( + action=FollowUpAction.SWITCH, + question=switch.question or "", + basis=switch.basis, + ), + ], + ) + question_event = SessionEvent( + session_id=session_id, + user_id=user_id, + type="question", + payload={ + "content": "[追问] " + (switch.question or ""), + "follow_up_round": 2, + "follow_up_action": switch.action.value, + }, + ) + await repository.create_session(checkpoint, [question_event]) + await redis.client.delete( + redis.stream_key(session_id), + redis.event_map_key(session_id), + ) + assert await redis.read_events(session_id) == [] + await redis.close() + await database.close() + + restarted_database = Database(settings) + restarted_repository = Repository(restarted_database, settings) + restarted_redis = RedisStore(settings) + restarted_publisher = OutboxPublisher(restarted_repository, restarted_redis) + assert await restarted_publisher.flush_once() >= 1 + restored = await restarted_repository.load_session(session_id, user_id) + replayed = await restarted_redis.read_events(session_id) + + assert restored.status == SessionStatus.WAITING_FOR_FOLLOW_UP + assert [turn.question for turn in restored.follow_up_turns] == [ + "如何降低数据丢失?", + "如何提高恢复速度?", + ] + assert restored.follow_up_turns[0].score is not None + assert restored.follow_up_turns[0].score.score == 80 + assert restored.follow_up_turns[1].answer is None + assert [decision.action for decision in restored.follow_up_decisions] == [ + FollowUpAction.DEEPEN, + FollowUpAction.SWITCH, + ] + assert replayed[-1].payload["follow_up_round"] == 2 + assert replayed[-1].payload["follow_up_action"] == "switch" + await restarted_database.close() + await restarted_redis.close() + + @pytest.mark.asyncio async def test_concurrent_duplicate_answer_returns_the_original_ack() -> None: settings = integration_settings() diff --git a/tests/integration/test_mem0_oss.py b/tests/integration/test_mem0_oss.py new file mode 100644 index 0000000..73bda6b --- /dev/null +++ b/tests/integration/test_mem0_oss.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from interview_agent.config import Settings +from interview_agent.infra.mem0_memory import create_mem0_memory_backend + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_MEM0_OSS_INTEGRATION") != "1", + reason="set RUN_MEM0_OSS_INTEGRATION=1 with MEM0_OSS_CONFIG_PATH", +) + + +@pytest.mark.asyncio +async def test_real_mem0_oss_round_trip_with_isolated_qdrant(tmp_path: Path) -> None: + source_config = os.environ["MEM0_OSS_CONFIG_PATH"] + settings = Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + mem0_enabled=True, + mem0_mode="oss", + mem0_timeout_seconds=60, + mem0_oss_config_path=source_config, + mem0_oss_qdrant_path=str(tmp_path / "qdrant"), + mem0_oss_collection_name="interview_memory_test", + mem0_oss_history_db_path=str(tmp_path / "history.db"), + ) + backend = await create_mem0_memory_backend(settings) + assert backend is not None + user_id = f"integration-{uuid4()}" + external_id = str(uuid4()) + + try: + await backend.upsert( + user_id=user_id, + agent_id="interview-coach", + external_id=external_id, + text="Redis 管道与事务边界需要继续练习", + metadata={"external_id": external_id, "topic": "Redis"}, + infer=False, + ) + hits = await backend.search( + user_id=user_id, + agent_id="interview-coach", + query="Redis 事务", + limit=4, + ) + deleted = await backend.delete( + user_id=user_id, + agent_id="interview-coach", + external_id=external_id, + ) + missing = await backend.delete( + user_id=user_id, + agent_id="interview-coach", + external_id=external_id, + ) + + assert any(hit.external_id == external_id for hit in hits) + assert deleted is True + assert missing is False + finally: + await backend.close() diff --git a/tests/integration/test_personal_memory_http.py b/tests/integration/test_personal_memory_http.py new file mode 100644 index 0000000..5d02495 --- /dev/null +++ b/tests/integration/test_personal_memory_http.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace +from uuid import uuid4 + +import httpx +import pytest +from fastapi import FastAPI +from pydantic import SecretStr + +from interview_agent.api.http import router +from interview_agent.config import Settings +from interview_agent.container import AppContainer +from interview_agent.domain.models import CandidateMemory, CandidateProfile +from interview_agent.infra.database import Database +from interview_agent.infra.repositories import Repository +from interview_agent.main import create_app +from interview_agent.services.auth import AuthService +from interview_agent.services.personal_memory import PersonalMemoryService +from interview_agent.services.personal_memory_coordinator import PersonalMemoryCoordinator + +pytestmark = pytest.mark.skipif(os.getenv("RUN_INTEGRATION") != "1", reason="set RUN_INTEGRATION=1") + + +@pytest.mark.asyncio +async def test_real_application_lifespan_wires_personal_memory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + auto_recover_sessions=False, + dynamic_page_fetch_enabled=False, + github_recommendations_enabled=False, + mem0_enabled=False, + ) + monkeypatch.setattr("interview_agent.main.get_settings", lambda: settings) + app = create_app() + + async with app.router.lifespan_context(app): + container = app.state.container + assert isinstance(container, AppContainer) + assert container.memory_coordinator.repository is container.repository + assert container.memory_coordinator.memories is container.memories + assert container.workflow.memories is container.memories + assert container.coordinator.memories is container.memories + assert container.memories.sync_lease.__self__ is container.redis + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + readiness = await client.get("/health/ready") + + assert readiness.status_code == 200 + dependencies = readiness.json()["dependencies"] + assert all( + dependencies[name] + for name in ("mysql", "redis", "milvus", "embeddings", "reranker") + ) + + +@pytest.mark.asyncio +async def test_authenticated_personal_memory_http_round_trip() -> None: + settings = Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + ) + database = Database(settings) + await database.create_schema() + repository = Repository(database, settings) + memories = PersonalMemoryService(backend=None, agent_id="test", top_k=4) + memory_coordinator = PersonalMemoryCoordinator(repository, memories) + app = FastAPI() + app.state.container = SimpleNamespace( + auth=AuthService(repository, settings), + repository=repository, + memories=memories, + memory_coordinator=memory_coordinator, + ) + app.include_router(router) + + try: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test" + ) as client: + auth = await client.post( + "/api/register", + json={"username": f"mem-{uuid4().hex[:20]}", "password": "password123"}, + ) + assert auth.status_code == 200 + body = auth.json() + user_id = body["user_id"] + token = body["token"] + memory = CandidateMemory( + id="memory-1", + topic="Redis", + level="weakness", + average_score=50, + evidence_count=2, + last_session_id="session-1", + ) + await repository.update_profile_once( + user_id, + str(uuid4()), + lambda _current: CandidateProfile(interview_count=1, memories=[memory]), + ) + + headers = {"Authorization": f"Bearer {token}"} + listed = await client.get("/api/personal-memory", headers=headers) + deleted = await client.delete("/api/personal-memory/memory-1", headers=headers) + empty = await client.get("/api/personal-memory", headers=headers) + + assert listed.status_code == 200 + assert listed.json()["memories"][0]["topic"] == "Redis" + assert deleted.status_code == 200 + assert deleted.json() == {"deleted": True} + assert empty.json()["memories"] == [] + finally: + await database.close() diff --git a/tests/integration/test_personal_memory_profile.py b/tests/integration/test_personal_memory_profile.py new file mode 100644 index 0000000..90f48d4 --- /dev/null +++ b/tests/integration/test_personal_memory_profile.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import asyncio +import os +from uuid import uuid4 + +import pytest +from pydantic import SecretStr + +from interview_agent.config import Settings +from interview_agent.domain.models import CandidateProfile, InterviewQuestion, QAPair +from interview_agent.infra.database import Database +from interview_agent.infra.redis_store import RedisStore +from interview_agent.infra.repositories import Repository +from interview_agent.services.personal_memory import merge_profile_update + +pytestmark = pytest.mark.skipif(os.getenv("RUN_INTEGRATION") != "1", reason="set RUN_INTEGRATION=1") + + +def qa(score: float) -> QAPair: + return QAPair( + question=InterviewQuestion(content="Redis persistence", skills=["Redis"]), + user_answer="answer", + score=score, + feedback="feedback", + ) + + +@pytest.mark.asyncio +async def test_concurrent_profile_updates_accumulate_and_delete_is_not_resurrected() -> 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) + user_id = await repository.create_user(f"memory-owner-{uuid4().hex}", "hash") + + async def apply(session_id: str, score: float) -> None: + await repository.update_profile_once( + user_id, + session_id, + lambda latest: merge_profile_update( + latest, CandidateProfile(), [qa(score)], session_id + ), + ) + + try: + await asyncio.gather(apply(str(uuid4()), 40), apply(str(uuid4()), 80)) + profile = await repository.load_profile(user_id) + assert profile.interview_count == 2 + assert len(profile.memories) == 1 + assert profile.memories[0].evidence_count == 2 + assert profile.memories[0].average_score == 60 + + memory_id = profile.memories[0].id + assert await repository.delete_profile_memory(user_id, memory_id) is True + await apply(str(uuid4()), 100) + + forgotten = await repository.load_profile(user_id) + assert forgotten.memories == [] + assert forgotten.suppressed_memory_topics == ["redis"] + finally: + await database.close() + + +@pytest.mark.asyncio +async def test_memory_sync_lease_serializes_same_user_memory() -> None: + settings = Settings( + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + mem0_timeout_seconds=2, + ) + redis = RedisStore(settings) + second_entered = asyncio.Event() + + async def acquire_second() -> bool: + async with redis.memory_sync_lease("user-1", "memory-1") as acquired: + second_entered.set() + return acquired + + try: + async with redis.memory_sync_lease("user-1", "memory-1") as first: + task = asyncio.create_task(acquire_second()) + await asyncio.sleep(0.05) + assert first is True + assert second_entered.is_set() is False + assert await task is True + finally: + await redis.close() 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_agent_personalization.py b/tests/unit/test_agent_personalization.py new file mode 100644 index 0000000..bda069f --- /dev/null +++ b/tests/unit/test_agent_personalization.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from interview_agent.domain.models import ( + CandidateProfile, + EvaluationReport, + InterviewQuestion, + JDAnalysis, + ResumeMatchResult, + ReviewPlan, +) +from interview_agent.services.agents import AgentService, QuestionPlanOutput + + +class CapturingLlm: + def __init__(self) -> None: + self.calls: list[tuple[Any, Any]] = [] + + async def structured(self, messages: Any, output_type: Any, **_kwargs: Any) -> Any: + self.calls.append((messages, output_type)) + if output_type is QuestionPlanOutput: + return QuestionPlanOutput( + questions=[InterviewQuestion(content=f"question-{index}") for index in range(3)] + ) + if output_type is ReviewPlan: + return ReviewPlan(summary="plan") + raise AssertionError(output_type) + + +@pytest.mark.asyncio +async def test_question_planner_receives_recalled_cross_session_memory() -> None: + llm = CapturingLlm() + service = AgentService(llm) # type: ignore[arg-type] + + await service.plan_questions( + JDAnalysis(position="Python backend"), + ResumeMatchResult(overall_score=70), + [], + CandidateProfile(), + memory_context=["Redis|薄弱项|跨 3 场平均 45 分"], + total=3, + ) + + payload = json.loads(llm.calls[0][0][1]["content"]) + assert payload["personal_memory"] == ["Redis|薄弱项|跨 3 场平均 45 分"] + assert "memories" not in payload["candidate_profile"] + system_prompt = llm.calls[0][0][0]["content"] + assert "岗位硬性要求" in system_prompt + assert "提高难度" in system_prompt + + +@pytest.mark.asyncio +async def test_question_planner_replaces_model_ids_with_snowflake_ids() -> None: + class LegacyIdLlm: + async def structured(self, *_args: Any, **_kwargs: Any) -> QuestionPlanOutput: + return QuestionPlanOutput( + questions=[ + InterviewQuestion(id=f"model-controlled-id-{index}", content="question") + for index in range(3) + ] + ) + + service = AgentService(LegacyIdLlm()) # type: ignore[arg-type] + + questions = await service.plan_questions( + JDAnalysis(position="Python backend"), + ResumeMatchResult(overall_score=70), + [], + CandidateProfile(), + total=1, + ) + + assert questions[0].id.isdecimal() + assert questions[0].id != "model-controlled-id" + + +@pytest.mark.asyncio +async def test_review_planner_distinguishes_current_report_from_history() -> None: + llm = CapturingLlm() + service = AgentService(llm) # type: ignore[arg-type] + + await service.review_plan( + EvaluationReport(overall_score=75, summary="本场 Redis 表现一般"), + memory_context=["MySQL|薄弱项|跨 4 场平均 50 分"], + ) + + payload = json.loads(llm.calls[0][0][1]["content"]) + assert payload["current_interview"]["overall_score"] == 75 + assert payload["historical_memory"] == ["MySQL|薄弱项|跨 4 场平均 50 分"] + system_prompt = llm.calls[0][0][0]["content"] + assert "再次验证" in system_prompt + assert "证据强度" in system_prompt diff --git a/tests/unit/test_beijing_json_migration.py b/tests/unit/test_beijing_json_migration.py new file mode 100644 index 0000000..e0ded31 --- /dev/null +++ b/tests/unit/test_beijing_json_migration.py @@ -0,0 +1,54 @@ +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from typing import Any + + +def load_migration(filename: str = "0003_beijing_json_times.py") -> Any: + path = Path(__file__).parents[2] / "migrations/versions" / filename + spec = spec_from_file_location("beijing_snowflake_migration", path) + assert spec is not None and spec.loader is not None + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_migration_converts_nested_domain_times_to_beijing() -> None: + migration = load_migration() + value = { + "created_at": "2026-08-16T04:00:00+00:00", + "updated_at": "2026-08-16T04:30:00Z", + "memories": [ + {"last_seen_at": "2026-08-16T05:00:00+00:00"}, + {"last_seen_at": "not-a-time"}, + ], + "metadata": {"created_at": "free-form"}, + } + + converted = migration._convert_json_times(value, 8) + + assert converted["created_at"] == "2026-08-16T12:00:00+08:00" + assert converted["updated_at"] == "2026-08-16T12:30:00+08:00" + assert converted["memories"][0]["last_seen_at"] == "2026-08-16T13:00:00+08:00" + assert converted["memories"][1]["last_seen_at"] == "not-a-time" + assert converted["metadata"]["created_at"] == "free-form" + + +def test_migration_json_conversion_is_idempotent() -> None: + migration = load_migration() + value = {"created_at": "2026-08-16T12:00:00+08:00"} + + assert migration._convert_json_times(value, 8) == value + + +def test_migration_refuses_destructive_downgrade() -> None: + for filename in ( + "0002_beijing_time_snowflake_ids.py", + "0003_beijing_json_times.py", + ): + migration = load_migration(filename) + try: + migration.downgrade() + except RuntimeError as exc: + assert "backup" in str(exc) + else: + raise AssertionError("downgrade must reject destructive ID rollback") diff --git a/tests/unit/test_business_time.py b/tests/unit/test_business_time.py new file mode 100644 index 0000000..8429ded --- /dev/null +++ b/tests/unit/test_business_time.py @@ -0,0 +1,31 @@ +from datetime import timedelta + +from interview_agent.domain.business_time import beijing_now, from_beijing_naive, to_beijing_naive +from interview_agent.domain.models import InterviewCheckpoint, SessionEvent + + +def test_business_time_round_trips_as_beijing_wall_clock() -> None: + current = beijing_now() + stored = to_beijing_naive(current) + restored = from_beijing_naive(stored) + + assert current.utcoffset() == timedelta(hours=8) + assert stored.tzinfo is None + assert restored.utcoffset() == timedelta(hours=8) + assert restored == current + + +def test_new_interview_timestamps_carry_beijing_offset() -> None: + checkpoint = InterviewCheckpoint( + session_id="legacy-session", + user_id="legacy-user", + jd_text="jd", + resume_text="resume", + ) + event = SessionEvent( + session_id=checkpoint.session_id, user_id=checkpoint.user_id, type="created" + ) + + assert checkpoint.created_at.utcoffset() == timedelta(hours=8) + assert checkpoint.updated_at.utcoffset() == timedelta(hours=8) + assert event.created_at.utcoffset() == timedelta(hours=8) diff --git a/tests/unit/test_coordinator_identity.py b/tests/unit/test_coordinator_identity.py new file mode 100644 index 0000000..b9b5eef --- /dev/null +++ b/tests/unit/test_coordinator_identity.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest + +from interview_agent.domain.models import InterviewCheckpoint, SessionEvent +from interview_agent.services.coordinator import InterviewCoordinator + + +class RecordingRepository: + def __init__(self) -> None: + self.created: list[tuple[InterviewCheckpoint, list[SessionEvent]]] = [] + + async def create_session( + self, checkpoint: InterviewCheckpoint, events: list[SessionEvent] + ) -> None: + self.created.append((checkpoint, events)) + + +class RecordingPublisher: + async def flush_once(self) -> int: + return 1 + + +@pytest.mark.asyncio +async def test_coordinator_generates_snowflake_session_id_and_accepts_legacy_id() -> None: + repository = RecordingRepository() + coordinator = InterviewCoordinator( + repository, # type: ignore[arg-type] + None, # type: ignore[arg-type] + RecordingPublisher(), # type: ignore[arg-type] + None, # type: ignore[arg-type] + None, # type: ignore[arg-type] + None, # type: ignore[arg-type] + ) + + generated = await coordinator.start_session("user", "jd", "resume", "token") + legacy = await coordinator.start_session( + "user", "jd", "resume", "token", session_id="legacy-uuid" + ) + + assert generated.isdecimal() + assert legacy == "legacy-uuid" + assert [item[0].session_id for item in repository.created] == [generated, legacy] + assert all(item[1][0].session_id == item[0].session_id for item in repository.created) 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_early_quit.py b/tests/unit/test_early_quit.py index b57bdf4..4c269c7 100644 --- a/tests/unit/test_early_quit.py +++ b/tests/unit/test_early_quit.py @@ -6,11 +6,14 @@ from interview_agent.domain.models import ( AnswerScore, + FollowUpAction, + FollowUpTurn, InterviewCheckpoint, InterviewQuestion, SessionStatus, ) from interview_agent.services.coordinator import InterviewCoordinator +from interview_agent.services.personal_memory import PersonalMemoryService from interview_agent.services.workflow import InterviewWorkflow @@ -68,6 +71,42 @@ def test_early_quit_does_not_duplicate_an_already_recorded_answer() -> None: assert len(checkpoint.qa_history) == 1 +def test_early_quit_preserves_completed_and_unanswered_dynamic_follow_ups() -> None: + question = InterviewQuestion(id="q-1", content="解释 Redis 持久化") + checkpoint = InterviewCheckpoint( + session_id="session-1", + user_id="user-1", + jd_text="Backend engineer", + resume_text="Redis experience", + status=SessionStatus.WAITING_FOR_FOLLOW_UP, + current_question=question, + pending_answer="RDB 是快照,AOF 记录写命令。", + current_score=AnswerScore(score=65, feedback="需要补充取舍"), + follow_up_turns=[ + FollowUpTurn( + action=FollowUpAction.DEEPEN, + question="如何降低数据丢失?", + answer="使用 AOF everysec。", + score=AnswerScore(score=80, feedback="正确"), + ), + FollowUpTurn( + action=FollowUpAction.SWITCH, + question="如何提高恢复速度?", + ), + ], + ) + + preserved = _workflow().preserve_scored_answer_for_early_exit(checkpoint) + + assert preserved is True + saved = checkpoint.qa_history[0] + assert len(saved.follow_up_turns) == 2 + assert saved.follow_up_turns[0].score is not None + assert saved.follow_up_turns[0].score.score == 80 + assert saved.follow_up_turns[1].answer is None + assert saved.follow_up_turns[1].score is None + + def test_early_quit_without_a_scored_answer_keeps_history_empty() -> None: checkpoint = InterviewCheckpoint( session_id="session-1", @@ -117,6 +156,7 @@ async def apply_client_message(*args: Any) -> tuple[dict[str, Any], bool]: cast(Any, publisher), _workflow(), cast(Any, SimpleNamespace()), + PersonalMemoryService(backend=None, agent_id="test", top_k=1), ) result, duplicate = await coordinator.quit_session( diff --git a/tests/unit/test_follow_up_config.py b/tests/unit/test_follow_up_config.py new file mode 100644 index 0000000..02c2b47 --- /dev/null +++ b/tests/unit/test_follow_up_config.py @@ -0,0 +1,22 @@ +import pytest +from pydantic import SecretStr, ValidationError + +from interview_agent.config import Settings + + +def settings(**overrides: object) -> Settings: + return Settings( + deepseek_api_key=SecretStr("test-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + **overrides, + ) + + +def test_dynamic_follow_up_defaults_to_three_rounds() -> None: + assert settings().interview_max_follow_up_rounds == 3 + + +def test_dynamic_follow_up_rejects_more_than_five_rounds() -> None: + with pytest.raises(ValidationError): + settings(interview_max_follow_up_rounds=6) diff --git a/tests/unit/test_follow_up_scoring.py b/tests/unit/test_follow_up_scoring.py new file mode 100644 index 0000000..b5e3d16 --- /dev/null +++ b/tests/unit/test_follow_up_scoring.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from pydantic import ValidationError + +from interview_agent.domain.models import ( + AnswerScore, + EvaluationReport, + FollowUpAction, + FollowUpDecision, + FollowUpTurn, + InterviewCheckpoint, + InterviewQuestion, + QAPair, + SessionStatus, +) +from interview_agent.services.agents import AgentService +from interview_agent.services.workflow import InterviewWorkflow + + +class FollowUpLlm: + def __init__(self, decision: FollowUpDecision) -> None: + self.decision = decision + self.calls: list[tuple[list[dict[str, str]], type[Any]]] = [] + + async def structured( + self, + messages: list[dict[str, str]], + output_model: type[Any], + **_kwargs: Any, + ) -> Any: + self.calls.append((messages, output_model)) + if output_model is AnswerScore: + return AnswerScore(score=85, feedback="补充说明准确") + if output_model is FollowUpDecision: + return self.decision + if output_model is EvaluationReport: + return EvaluationReport(overall_score=75, summary="整体表现良好") + raise AssertionError(output_model) + + +def _follow_up_context() -> tuple[InterviewQuestion, AnswerScore]: + question = InterviewQuestion( + id="q-1", + content="解释 Redis 的 RDB 与 AOF", + reference="说明机制、优缺点和选择依据", + skills=["Redis"], + ) + main_score = AnswerScore( + score=65, + feedback="机制正确,但没有说明选择依据", + key_points_hit=["RDB 快照", "AOF 追加日志"], + key_points_missed=["恢复速度", "数据安全性取舍"], + should_follow_up=True, + ) + return question, main_score + + +def test_follow_up_decision_requires_a_question_for_non_terminal_actions() -> None: + with pytest.raises(ValidationError): + FollowUpDecision(action=FollowUpAction.DEEPEN, basis="需要继续验证") + + +def test_finish_decision_discards_an_accidental_question() -> None: + decision = FollowUpDecision( + action=FollowUpAction.FINISH, + question="不应继续提出的问题", + basis="证据充分", + ) + + assert decision.question is None + + +@pytest.mark.asyncio +async def test_follow_up_agent_observes_full_trajectory_before_choosing_action() -> None: + decision = FollowUpDecision( + action=FollowUpAction.SWITCH, + question="如果更关注恢复速度,你会怎样调整?", + basis="数据安全性已验证,切换验证恢复速度", + ) + llm = FollowUpLlm(decision) + service = AgentService(llm) # type: ignore[arg-type] + question, main_score = _follow_up_context() + turns = [ + FollowUpTurn( + action=FollowUpAction.DEEPEN, + question="如果要求最多丢失一秒数据,你会怎样配置?", + answer="开启 AOF everysec。", + score=AnswerScore(score=80, feedback="配置合理"), + ) + ] + + result = await service.decide_follow_up( + question, + "RDB 是快照,AOF 记录写命令。", + main_score, + turns, + max_rounds=3, + ) + + messages, output_model = llm.calls[0] + payload = json.loads(messages[1]["content"]) + assert output_model is FollowUpDecision + assert payload["follow_up_turns"][0]["question"] == turns[0].question + assert payload["follow_up_turns"][0]["score"]["score"] == 80 + assert payload["remaining_rounds"] == 2 + assert result.action == FollowUpAction.SWITCH + + +@pytest.mark.asyncio +async def test_follow_up_scoring_uses_actual_question_and_previous_observations() -> None: + llm = FollowUpLlm(FollowUpDecision(action=FollowUpAction.FINISH, basis="证据充分")) + service = AgentService(llm) # type: ignore[arg-type] + question, main_score = _follow_up_context() + previous = FollowUpTurn( + action=FollowUpAction.DEEPEN, + question="先说明 AOF 刷盘策略。", + answer="有 always、everysec 和 no。", + score=AnswerScore(score=75, feedback="还缺少取舍"), + ) + current = FollowUpTurn( + action=FollowUpAction.SWITCH, + question="如果更关注恢复速度,你会怎样调整?", + ) + + await service.score_follow_up( + question, + "RDB 是快照,AOF 记录写命令。", + main_score, + [previous, current], + "保留 RDB 快照,并控制 AOF 重写后的体积。", + ) + + messages, output_model = llm.calls[0] + payload = json.loads(messages[1]["content"]) + assert output_model is AnswerScore + assert payload["current_follow_up_question"] == current.question + assert payload["previous_follow_up_turns"][0]["question"] == previous.question + assert payload["current_follow_up_answer"].startswith("保留 RDB") + assert "实际追问" in messages[0]["content"] + + +@pytest.mark.asyncio +async def test_workflow_can_deepen_then_switch_and_stops_at_round_limit() -> None: + question, main_score = _follow_up_context() + next_question = InterviewQuestion(id="q-2", content="解释缓存穿透") + deepen = FollowUpDecision( + action=FollowUpAction.DEEPEN, + question="如果要求最多丢失一秒数据,你会怎样配置?", + basis="继续验证持久化取舍", + ) + switch = FollowUpDecision( + action=FollowUpAction.SWITCH, + question="如果更关注恢复速度,你会怎样调整?", + basis="切换验证另一个遗漏点", + ) + agents = SimpleNamespace( + score_answer=AsyncMock(return_value=main_score), + score_follow_up=AsyncMock( + side_effect=[ + AnswerScore(score=80, feedback="数据安全策略正确"), + AnswerScore(score=85, feedback="恢复策略正确"), + ] + ), + decide_follow_up=AsyncMock(side_effect=[deepen, switch]), + ) + workflow = InterviewWorkflow.__new__(InterviewWorkflow) + workflow.agents = agents + workflow.max_follow_up_rounds = 2 + checkpoint = InterviewCheckpoint( + session_id="session-1", + user_id="user-1", + jd_text="Backend engineer", + resume_text="Redis experience", + status=SessionStatus.SCORING_ANSWER, + questions=[question, next_question], + current_question=question, + pending_answer="RDB 是快照,AOF 记录写命令。", + ) + + scored_main = await workflow._score_answer({"checkpoint": checkpoint}) + assert scored_main["result"].target == SessionStatus.ASKING_FOLLOW_UP + + first_question = await workflow._ask_follow_up({"checkpoint": checkpoint}) + assert first_question["result"].target == SessionStatus.WAITING_FOR_FOLLOW_UP + assert checkpoint.follow_up_turns[0].action == FollowUpAction.DEEPEN + checkpoint.pending_follow_up_answer = "开启 AOF everysec。" + + scored_first = await workflow._score_follow_up({"checkpoint": checkpoint}) + assert scored_first["result"].target == SessionStatus.ASKING_FOLLOW_UP + assert checkpoint.follow_up_turns[0].score is not None + assert checkpoint.follow_up_turns[0].score.score == 80 + + second_question = await workflow._ask_follow_up({"checkpoint": checkpoint}) + assert second_question["result"].target == SessionStatus.WAITING_FOR_FOLLOW_UP + assert checkpoint.follow_up_turns[1].action == FollowUpAction.SWITCH + checkpoint.pending_follow_up_answer = "保留 RDB,并控制 AOF 重写体积。" + + scored_second = await workflow._score_follow_up({"checkpoint": checkpoint}) + + assert scored_second["result"].target == SessionStatus.ASKING_QUESTION + assert len(checkpoint.qa_history[0].follow_up_turns) == 2 + scores = [turn.score.score for turn in checkpoint.qa_history[0].follow_up_turns if turn.score] + assert scores == [80, 85] + assert [decision.action for decision in checkpoint.qa_history[0].follow_up_decisions] == [ + FollowUpAction.DEEPEN, + FollowUpAction.SWITCH, + FollowUpAction.FINISH, + ] + assert agents.decide_follow_up.await_count == 2 + + +@pytest.mark.asyncio +async def test_workflow_accepts_agent_finish_without_asking_a_follow_up() -> None: + question, main_score = _follow_up_context() + finish = FollowUpDecision(action=FollowUpAction.FINISH, basis="主回答证据已经充分") + agents = SimpleNamespace( + score_answer=AsyncMock(return_value=main_score), + decide_follow_up=AsyncMock(return_value=finish), + ) + workflow = InterviewWorkflow.__new__(InterviewWorkflow) + workflow.agents = agents + workflow.max_follow_up_rounds = 3 + checkpoint = InterviewCheckpoint( + session_id="session-1", + user_id="user-1", + jd_text="Backend engineer", + resume_text="Redis experience", + status=SessionStatus.SCORING_ANSWER, + questions=[question], + current_question=question, + pending_answer="RDB 是快照,AOF 记录写命令。", + ) + + state = await workflow._score_answer({"checkpoint": checkpoint}) + + assert state["result"].target == SessionStatus.REVIEWING_WEAK_ANSWERS + assert len(checkpoint.qa_history) == 1 + assert checkpoint.qa_history[0].follow_up_turns == [] + assert checkpoint.qa_history[0].follow_up_decisions == [finish] + + +@pytest.mark.asyncio +async def test_recovery_cannot_append_a_follow_up_past_the_round_limit() -> None: + question, main_score = _follow_up_context() + turns = [ + FollowUpTurn( + action=FollowUpAction.DEEPEN, + question=f"追问 {round_number}", + answer=f"回答 {round_number}", + score=AnswerScore(score=70 + round_number, feedback="已评分"), + ) + for round_number in range(1, 3) + ] + checkpoint = InterviewCheckpoint( + session_id="session-1", + user_id="user-1", + jd_text="Backend engineer", + resume_text="Redis experience", + status=SessionStatus.ASKING_FOLLOW_UP, + questions=[question], + current_question=question, + pending_answer="主回答", + current_score=main_score, + follow_up_turns=turns, + pending_follow_up_decision=FollowUpDecision( + action=FollowUpAction.SWITCH, + question="不应出现的第三轮追问", + basis="恢复前保存的动作", + ), + ) + workflow = InterviewWorkflow.__new__(InterviewWorkflow) + workflow.agents = SimpleNamespace() + workflow.max_follow_up_rounds = 2 + + state = await workflow._ask_follow_up({"checkpoint": checkpoint}) + + assert state["result"].target == SessionStatus.REVIEWING_WEAK_ANSWERS + assert len(checkpoint.qa_history[0].follow_up_turns) == 2 + assert checkpoint.qa_history[0].follow_up_decisions[-1].action == FollowUpAction.FINISH + + +@pytest.mark.asyncio +async def test_evaluation_report_keeps_each_follow_up_score() -> None: + decision = FollowUpDecision(action=FollowUpAction.FINISH, basis="证据充分") + service = AgentService(FollowUpLlm(decision)) # type: ignore[arg-type] + question, _main_score = _follow_up_context() + history = [ + QAPair( + question=question, + user_answer="主回答", + score=65, + feedback="需要追问", + follow_up_turns=[ + FollowUpTurn( + action=FollowUpAction.DEEPEN, + question="如何配置 AOF?", + answer="everysec", + score=AnswerScore(score=80, feedback="正确"), + ), + FollowUpTurn( + action=FollowUpAction.SWITCH, + question="如何提升恢复速度?", + answer="结合 RDB", + score=AnswerScore(score=90, feedback="正确"), + ), + ], + ) + ] + + report = await service.evaluate("Backend", "候选人", history, False) + + assert [item.score for item in report.follow_up_scores] == [80, 90] + assert [item.action for item in report.follow_up_scores] == [ + FollowUpAction.DEEPEN, + FollowUpAction.SWITCH, + ] diff --git a/tests/unit/test_github_recommendation_config.py b/tests/unit/test_github_recommendation_config.py new file mode 100644 index 0000000..da8ef55 --- /dev/null +++ b/tests/unit/test_github_recommendation_config.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import SecretStr, ValidationError + +import interview_agent.container as container_module +from interview_agent.config import Settings + + +def _settings(**overrides: object) -> Settings: + return Settings( + deepseek_api_key=SecretStr("test-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + **overrides, + ) + + +def test_github_recommendation_defaults_use_twelve_and_eighteen_month_windows() -> None: + settings = _settings() + + assert settings.github_active_days == 365 + assert settings.github_fallback_active_days == 548 + + +@pytest.mark.parametrize( + "overrides", + [ + {"github_active_days": 366}, + {"github_fallback_active_days": 549}, + {"github_active_days": 365, "github_fallback_active_days": 364}, + ], +) +def test_github_recommendation_rejects_invalid_activity_windows( + overrides: dict[str, int], +) -> None: + with pytest.raises(ValidationError): + _settings(**overrides) + + +def test_container_maps_all_github_recommendation_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + gateway = object() + sentinel = object() + + def fake_client(**kwargs: object) -> object: + assert kwargs == {"token": "github-token", "timeout_seconds": 9} + return gateway + + def fake_recommender(received_gateway: object, **kwargs: object) -> object: + assert received_gateway is gateway + captured.update(kwargs) + return sentinel + + monkeypatch.setattr(container_module, "GitHubRepositoryClient", fake_client) + monkeypatch.setattr(container_module, "GitHubProjectRecommender", fake_recommender) + settings = _settings( + github_token=SecretStr("github-token"), + github_search_timeout_seconds=9, + github_repositories_per_topic=3, + github_candidate_limit=22, + github_active_days=300, + github_fallback_active_days=500, + github_minimum_recommendation_score=67, + ) + + result = container_module._create_github_repository_search(settings) + + assert result is sentinel + assert captured == { + "per_topic": 3, + "candidate_limit": 22, + "active_days": 300, + "fallback_active_days": 500, + "minimum_score": 67, + } + + +def test_container_disables_github_recommendations_without_constructing_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_if_called(**_kwargs: object) -> object: + raise AssertionError("adapter should not be constructed") + + monkeypatch.setattr(container_module, "GitHubRepositoryClient", fail_if_called) + + assert ( + container_module._create_github_repository_search( + _settings(github_recommendations_enabled=False) + ) + is None + ) diff --git a/tests/unit/test_github_recommendation_web.py b/tests/unit/test_github_recommendation_web.py new file mode 100644 index 0000000..4062666 --- /dev/null +++ b/tests/unit/test_github_recommendation_web.py @@ -0,0 +1,12 @@ +from pathlib import Path + +WEB_DIRECTORY = Path(__file__).parents[2] / "src" / "interview_agent" / "web" + + +def test_repository_cards_render_freshness_and_recommendation_reason_safely() -> None: + javascript = (WEB_DIRECTORY / "app.js").read_text(encoding="utf-8") + + assert "resource.pushed_at" in javascript + assert "resource.recommendation_reason" in javascript + assert "repository-reason" in javascript + assert "reason.textContent = resource.recommendation_reason" in javascript diff --git a/tests/unit/test_github_repositories.py b/tests/unit/test_github_repositories.py new file mode 100644 index 0000000..1902182 --- /dev/null +++ b/tests/unit/test_github_repositories.py @@ -0,0 +1,656 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest + +from interview_agent.domain.models import ProjectSearchIntent, ReviewItem +from interview_agent.infra.github_repositories import GitHubRepositoryClient +from interview_agent.services.github_recommendations import GitHubProjectRecommender + + +def _search( + *, client: httpx.AsyncClient | None = None, **kwargs: Any +) -> GitHubProjectRecommender: + return GitHubProjectRecommender( + GitHubRepositoryClient(client=client), + **kwargs, + ) + + +def _repository( + full_name: str, + *, + description: str, + stars: int, + topics: list[str], + language: str | None = "Python", + pushed_at: datetime | None = None, +) -> dict[str, object]: + return { + "full_name": full_name, + "html_url": f"https://github.com/{full_name}", + "description": description, + "stargazers_count": stars, + "language": language, + "topics": topics, + "archived": False, + "disabled": False, + "fork": False, + "is_template": False, + "mirror_url": None, + "size": 1000, + "open_issues_count": 12, + "has_issues": True, + "license": {"spdx_id": "MIT"}, + "pushed_at": (pushed_at or datetime.now(UTC)).isoformat(), + } + + +def _review_item(topic: str) -> ReviewItem: + return ReviewItem( + topic=topic, + reason=f"需要补强 {topic}", + actions=[f"阅读 {topic} 源码并完成实践"], + ) + + +@pytest.mark.asyncio +async def test_search_returns_normalized_repositories_grouped_by_topic() -> None: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/readme"): + return httpx.Response(200, text="# FastAPI\n## Quickstart\n```bash\nuv run app\n```") + assert request.url.path == "/search/repositories" + assert "fastapi" in request.url.params["q"].lower() + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "fastapi/fastapi", + description="FastAPI framework", + stars=90_000, + topics=["fastapi", "python"], + ) + ] + }, + ) + + client = httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + search = _search(client=client, per_topic=2) + try: + resources = await search.search([_review_item("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_rejects_popular_lists_and_keeps_relevant_agent_project() -> None: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/readme"): + return httpx.Response( + 200, text="# Agent Framework\n## Quickstart\n```bash\nuv run app\n```" + ) + assert request.url.path == "/search/repositories" + assert "in:name,description,topics" in request.url.params["q"] + assert "in:readme" not in request.url.params["q"] + assert "stars:" not in request.url.params["q"] + assert "sort" not in request.url.params + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "sindresorhus/awesome", + description="Awesome lists about all kinds of interesting topics", + stars=496_168, + topics=["awesome", "lists"], + ), + _repository( + "public-apis/public-apis", + description="A collective list of free APIs", + stars=460_236, + topics=["api", "list"], + ), + _repository( + "PlexPt/awesome-chatgpt-prompts-zh", + description="ChatGPT 中文调教指南和 Prompt 合集", + stars=61_585, + topics=["prompts"], + ), + _repository( + "microsoft/agent-framework", + description="Build and orchestrate AI agents and workflows", + stars=12_827, + topics=["agent-framework", "ai-agents", "orchestration"], + ), + ] + }, + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + item = ReviewItem( + topic="Agent Planning 与 Action", + priority="high", + reason="规划与工具执行职责解释不完整", + actions=["阅读 Agent 框架的规划与执行循环源码"], + project_search=ProjectSearchIntent( + queries=["agent framework"], + github_topics=["agent-framework"], + preferred_languages=["Python"], + ), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert [resource.title for resource in resources[item.topic]] == [ + "microsoft/agent-framework" + ] + + +@pytest.mark.asyncio +async def test_search_rejects_a_matching_repository_that_is_only_a_list() -> None: + def respond(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "public-apis/public-apis", + description="A collective list of free public APIs", + stars=460_236, + topics=["public-api", "api"], + ) + ] + }, + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + item = ReviewItem( + topic="Public API 设计", + reason="需要通过真实项目理解 API 设计", + actions=["运行并修改一个 API 项目"], + project_search=ProjectSearchIntent(queries=["public api"]), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert resources[item.topic] == [] + + +@pytest.mark.asyncio +async def test_search_prefers_two_recent_projects_and_excludes_stale_candidates() -> None: + now = datetime.now(UTC) + + def respond(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "example/old-agent-framework", + description="Agent framework and orchestration runtime", + stars=100_000, + topics=["agent-framework"], + pushed_at=now - timedelta(days=400), + ), + _repository( + "example/recent-agent-framework", + description="Agent framework and orchestration runtime", + stars=500, + topics=["agent-framework"], + pushed_at=now - timedelta(days=20), + ), + _repository( + "example/active-agent-framework", + description="Agent framework and orchestration runtime", + stars=300, + topics=["agent-framework"], + pushed_at=now - timedelta(days=80), + ), + _repository( + "example/stale-agent-framework", + description="Agent framework and orchestration runtime", + stars=500_000, + topics=["agent-framework"], + pushed_at=now - timedelta(days=700), + ), + ] + }, + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ), + per_topic=2, + ) + item = ReviewItem( + topic="Agent framework", + reason="需要理解规划和执行", + actions=["运行 Agent 框架"], + project_search=ProjectSearchIntent( + queries=["agent framework"], github_topics=["agent-framework"] + ), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert [resource.title for resource in resources[item.topic]] == [ + "example/recent-agent-framework", + "example/active-agent-framework", + ] + assert all(resource.pushed_at is not None for resource in resources[item.topic]) + assert all("匹配" in resource.recommendation_reason for resource in resources[item.topic]) + + +@pytest.mark.asyncio +async def test_search_uses_readme_to_reject_a_disguised_resource_list() -> None: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/search/repositories": + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "example/agent-resources", + description="Agent framework examples", + stars=20_000, + topics=["agent-framework"], + ), + _repository( + "example/runnable-agent", + description="Runnable agent framework with tool execution", + stars=1_000, + topics=["agent-framework"], + ), + ] + }, + ) + if request.url.path == "/repos/example/agent-resources/readme": + return httpx.Response(200, text="# Agent resources\nA curated list of agent frameworks") + if request.url.path == "/repos/example/runnable-agent/readme": + return httpx.Response( + 200, + text=( + "# Runnable Agent\n## Quickstart\n```bash\n" + "pip install runnable-agent\n```\n## Roadmap\nA list of planned features" + ), + ) + raise AssertionError(f"unexpected request: {request.url}") + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ), + per_topic=2, + ) + item = ReviewItem( + topic="Agent framework", + reason="需要阅读规划与执行源码", + actions=["安装并运行一个 Agent 框架"], + project_search=ProjectSearchIntent( + queries=["agent framework"], github_topics=["agent-framework"] + ), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert [resource.title for resource in resources[item.topic]] == [ + "example/runnable-agent" + ] + + +@pytest.mark.asyncio +async def test_readme_validation_continues_past_five_rejected_candidates() -> None: + inspected: list[str] = [] + + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/search/repositories": + return httpx.Response( + 200, + json={ + "items": [ + _repository( + f"example/agent-framework-{index}", + description="Agent framework runtime", + stars=10_000 - index, + topics=["agent-framework"], + ) + for index in range(1, 7) + ] + }, + ) + inspected.append(request.url.path) + if request.url.path.endswith("agent-framework-6/readme"): + return httpx.Response( + 200, text="# Runtime\n## Quickstart\n```bash\nuv run app\n```" + ) + return httpx.Response( + 200, text="# Awesome agents\nA curated list of agent frameworks" + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ), + per_topic=1, + ) + item = ReviewItem( + topic="Agent framework", + reason="需要理解运行时", + actions=["运行项目"], + project_search=ProjectSearchIntent(queries=["agent framework"]), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert [resource.title for resource in resources[item.topic]] == [ + "example/agent-framework-6" + ] + assert len(inspected) == 6 + + +@pytest.mark.asyncio +async def test_search_processes_every_review_topic_not_only_the_first_three() -> None: + requests = 0 + + def respond(request: httpx.Request) -> httpx.Response: + nonlocal requests + if request.url.path == "/search/repositories": + requests += 1 + return httpx.Response(200, json={"items": []}) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + try: + await search.search([_review_item(f"Technology{index}") for index in range(4)]) + finally: + await search.close() + + assert requests == 4 + + +@pytest.mark.asyncio +async def test_fallback_activity_window_is_explicitly_labeled() -> None: + pushed_at = datetime.now(UTC) - timedelta(days=400) + + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/search/repositories": + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "example/agent-framework", + description="Agent framework runtime", + stars=1, + topics=["agent-framework"], + pushed_at=pushed_at, + ) + ] + }, + ) + return httpx.Response( + 200, text="# Runtime\n## Quickstart\n```bash\nuv run app\n```" + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ), + per_topic=1, + ) + item = ReviewItem( + topic="Agent framework", + reason="需要理解运行时", + actions=["运行项目"], + project_search=ProjectSearchIntent(queries=["agent framework"]), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert "放宽到 18 个月" in resources[item.topic][0].recommendation_reason + + +@pytest.mark.asyncio +async def test_readme_timeout_fails_closed_without_a_repository_card() -> None: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path == "/search/repositories": + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "example/agent-framework", + description="Agent framework runtime", + stars=1000, + topics=["agent-framework"], + ) + ] + }, + ) + raise httpx.ReadTimeout("README timed out", request=request) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + item = ReviewItem( + topic="Agent framework", + reason="需要理解运行时", + actions=["运行项目"], + project_search=ProjectSearchIntent(queries=["agent framework"]), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert resources[item.topic] == [] + + +@pytest.mark.asyncio +async def test_zero_star_repository_has_a_complete_card_payload() -> None: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/readme"): + return httpx.Response(200, text="# Runtime\n## Quickstart\n```bash\nuv run app\n```") + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "newcomer/agent-runtime", + description="Agent framework runtime", + stars=0, + topics=["agent-framework"], + language=None, + ) + ] + }, + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + item = ReviewItem( + topic="Agent framework", + reason="需要理解运行时", + actions=["运行项目"], + project_search=ProjectSearchIntent(queries=["agent framework"]), + ) + try: + resource = (await search.search([item]))[item.topic][0] + finally: + await search.close() + + assert resource.stars == 0 + assert resource.language == "未识别" + + +@pytest.mark.asyncio +async def test_two_recent_projects_prevent_older_projects_from_filling_extra_slots() -> None: + now = datetime.now(UTC) + + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/readme"): + return httpx.Response(200, text="# Runtime\n## Quickstart\n```bash\nuv run app\n```") + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "example/recent-agent-one", + description="Agent framework runtime", + stars=100, + topics=["agent-framework"], + pushed_at=now - timedelta(days=20), + ), + _repository( + "example/recent-agent-two", + description="Agent framework runtime", + stars=90, + topics=["agent-framework"], + pushed_at=now - timedelta(days=40), + ), + _repository( + "example/older-agent", + description="Agent framework runtime", + stars=10_000, + topics=["agent-framework"], + pushed_at=now - timedelta(days=400), + ), + ] + }, + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ), + per_topic=3, + ) + item = ReviewItem( + topic="Agent framework", + reason="需要理解运行时", + actions=["运行项目"], + project_search=ProjectSearchIntent(queries=["agent framework"]), + ) + try: + resources = await search.search([item]) + finally: + await search.close() + + assert [resource.title for resource in resources[item.topic]] == [ + "example/recent-agent-one", + "example/recent-agent-two", + ] + + +@pytest.mark.parametrize( + ("active_days", "fallback_days"), [(366, 548), (365, 549), (365, 364)] +) +def test_search_rejects_activity_windows_outside_the_product_limit( + active_days: int, fallback_days: int +) -> None: + with pytest.raises(ValueError): + _search( + active_days=active_days, fallback_active_days=fallback_days + ) + + +@pytest.mark.asyncio +async def test_search_uses_technical_keyword_and_keeps_partial_results() -> None: + def respond(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/readme"): + return httpx.Response(200, text="# FastAPI\n## Quickstart\n```bash\nuv run app\n```") + query = request.url.params["q"] + if "Redis" in query: + return httpx.Response(503) + assert query.startswith('"FastAPI" ') + return httpx.Response( + 200, + json={ + "items": [ + _repository( + "fastapi/fastapi", + description="FastAPI framework", + stars=90_000, + topics=["fastapi", "python"], + ) + ] + }, + ) + + search = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + try: + resources = await search.search( + [_review_item("FastAPI 异步依赖与生命周期"), _review_item("Redis 持久化")] + ) + finally: + await search.close() + + assert resources["FastAPI 异步依赖与生命周期"][0].title == "fastapi/fastapi" + assert resources["Redis 持久化"] == [] + + +@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 = _search( + client=httpx.AsyncClient( + base_url="https://api.github.com", transport=httpx.MockTransport(respond) + ) + ) + try: + item = _review_item("FastAPI") + await asyncio.gather(search.search([item]), search.search([item])) + finally: + await search.close() + + assert request_count == 1 diff --git a/tests/unit/test_identity.py b/tests/unit/test_identity.py new file mode 100644 index 0000000..8f27f88 --- /dev/null +++ b/tests/unit/test_identity.py @@ -0,0 +1,74 @@ +import pytest +from pydantic import SecretStr, ValidationError + +from interview_agent.config import Settings +from interview_agent.domain.identity import SnowflakeIdGenerator +from interview_agent.domain.models import ( + CandidateMemory, + InterviewQuestion, + QuestionBankItem, + SessionEvent, +) + + +def test_snowflake_ids_are_unique_ordered_decimal_strings() -> None: + generator = SnowflakeIdGenerator(node_id=17, clock_ms=lambda: 1_800_000_000_000) + + values = [generator.next_id() for _ in range(3)] + + assert all(value.isdecimal() for value in values) + assert len(set(values)) == len(values) + assert [int(value) for value in values] == sorted(int(value) for value in values) + assert all(int(value) < 2**63 for value in values) + + +def test_snowflake_waits_for_next_millisecond_after_sequence_exhaustion() -> None: + values = iter([1_800_000_000_000] * 2 + [1_800_000_000_001]) + generator = SnowflakeIdGenerator(node_id=17, clock_ms=lambda: next(values)) + generator._last_timestamp = 1_800_000_000_000 + generator._sequence = 4095 + + generated = int(generator.next_id()) + + assert generated >> 22 == 1_800_000_000_001 - 1_704_067_200_000 + + +def test_snowflake_short_clock_rollback_does_not_repeat_ids() -> None: + clock_values = iter([1_800_000_000_010, 1_800_000_000_009]) + generator = SnowflakeIdGenerator(node_id=17, clock_ms=lambda: next(clock_values)) + + first = generator.next_id() + second = generator.next_id() + + assert first != second + assert int(second) > int(first) + + +def test_snowflake_node_id_is_validated_by_settings() -> None: + base = { + "deepseek_api_key": SecretStr("test-key"), + "jwt_secret": SecretStr("j" * 32), + "resume_token_secret": SecretStr("r" * 32), + } + + assert Settings(**base).snowflake_node_id == 0 + with pytest.raises(ValidationError): + Settings(**base, snowflake_node_id=1024) + + +def test_new_domain_entities_use_decimal_snowflake_ids() -> None: + values = [ + InterviewQuestion(content="question").id, + CandidateMemory( + topic="Python", + level="strength", + average_score=80, + evidence_count=1, + last_session_id="legacy-session", + ).id, + SessionEvent(session_id="legacy-session", user_id="legacy-user", type="created").event_uid, + QuestionBankItem(content="question", reference="reference").id, + ] + + assert all(value.isdecimal() for value in values) + assert len(values) == len(set(values)) diff --git a/tests/unit/test_personal_memory.py b/tests/unit/test_personal_memory.py new file mode 100644 index 0000000..aa3a5c4 --- /dev/null +++ b/tests/unit/test_personal_memory.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from interview_agent.domain.models import ( + AnswerScore, + CandidateMemory, + CandidateProfile, + FollowUpAction, + FollowUpTurn, + InterviewQuestion, + QAPair, +) +from interview_agent.infra.mem0_memory import Mem0MemoryBackend +from interview_agent.services.personal_memory import ( + MemoryHit, + PersonalMemoryService, + merge_interview_evidence, + merge_profile_update, +) +from interview_agent.services.personal_memory_coordinator import PersonalMemoryCoordinator + + +def qa(topic: str, score: float) -> QAPair: + return QAPair( + question=InterviewQuestion(content=f"{topic} question", skills=[topic]), + user_answer="answer", + score=score, + feedback="feedback", + ) + + +def test_merge_interview_evidence_accumulates_one_signal_per_topic() -> None: + first = merge_interview_evidence(CandidateProfile(), [qa("Redis", 40)], "session-1") + second = merge_interview_evidence(first, [qa("redis", 80)], "session-2") + + assert second.interview_count == 2 + assert len(second.memories) == 1 + memory = second.memories[0] + assert memory.topic == "Redis" + assert memory.evidence_count == 2 + assert memory.average_score == 60 + assert memory.level == "developing" + assert memory.source_session_ids == ["session-1", "session-2"] + assert memory.id.isdecimal() + assert memory.last_seen_at.isoformat().endswith("+08:00") + + +def test_one_answer_does_not_duplicate_case_insensitive_skill_labels() -> None: + item = qa("Redis", 40) + item.question.skills = ["Redis", "redis"] + + profile = merge_interview_evidence(CandidateProfile(), [item], "session-1") + + assert len(profile.memories) == 1 + assert profile.memories[0].evidence_count == 1 + assert profile.memories[0].average_score == 40 + + +def test_merge_interview_evidence_is_idempotent_per_session() -> None: + profile = merge_interview_evidence(CandidateProfile(), [qa("MySQL", 50)], "session-1") + + duplicate = merge_interview_evidence(profile, [qa("MySQL", 10)], "session-1") + + assert duplicate == profile + + +def test_deleted_topic_is_suppressed_from_future_evidence() -> None: + current = CandidateProfile(suppressed_memory_topics=["redis"]) + + updated = merge_profile_update( + current, + CandidateProfile(strengths=["Redis"]), + [qa("Redis", 90)], + "session-2", + ) + + assert updated.memories == [] + assert "Redis" not in updated.weak_points + + +def test_follow_up_score_counts_as_separate_evidence() -> None: + item = qa("Redis", 20) + item.follow_up_score = 100 + + profile = merge_interview_evidence(CandidateProfile(), [item], "session-1") + + assert profile.memories[0].evidence_count == 2 + assert profile.memories[0].average_score == 60 + assert profile.memories[0].level == "developing" + + +def test_each_dynamic_follow_up_score_counts_as_separate_evidence() -> None: + item = qa("Redis", 20) + item.follow_up_turns = [ + FollowUpTurn( + action=FollowUpAction.DEEPEN, + question="如何配置 AOF?", + answer="everysec", + score=AnswerScore(score=70, feedback="基本正确"), + ), + FollowUpTurn( + action=FollowUpAction.SWITCH, + question="如何提升恢复速度?", + answer="结合 RDB", + score=AnswerScore(score=90, feedback="正确"), + ), + ] + item.follow_up_score = 70 + + profile = merge_interview_evidence(CandidateProfile(), [item], "session-1") + + assert profile.memories[0].evidence_count == 3 + assert profile.memories[0].average_score == 60 + assert [evidence.score for evidence in profile.memories[0].score_evidence] == [20, 70, 90] + assert [evidence.follow_up_round for evidence in profile.memories[0].score_evidence] == [ + None, + 1, + 2, + ] + + +@pytest.mark.asyncio +async def test_recall_falls_back_to_authoritative_profile_when_mem0_fails() -> None: + backend = SimpleNamespace(search=AsyncMock(side_effect=TimeoutError("mem0 unavailable"))) + service = PersonalMemoryService(backend=backend, agent_id="interview-coach", top_k=4) + profile = CandidateProfile( + memories=[ + CandidateMemory( + id="memory-1", + topic="Redis", + level="weakness", + average_score=45, + evidence_count=3, + last_session_id="session-3", + source_session_ids=["session-1", "session-2", "session-3"], + ) + ] + ) + + recalled = await service.recall("user-1", "Redis backend", profile) + + assert len(recalled) == 1 + assert "Redis" in recalled[0] + backend.search.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_sync_uses_user_scope_and_disables_mem0_inference() -> None: + backend = SimpleNamespace(upsert=AsyncMock()) + service = PersonalMemoryService(backend=backend, agent_id="interview-coach", top_k=4) + memory = CandidateMemory( + id="memory-1", + topic="FastAPI", + level="strength", + average_score=88, + evidence_count=2, + last_session_id="session-2", + source_session_ids=["session-1", "session-2"], + ) + + await service.sync_profile("user-1", CandidateProfile(memories=[memory])) + + backend.upsert.assert_awaited_once() + call = backend.upsert.await_args + assert call.kwargs["user_id"] == "user-1" + assert call.kwargs["agent_id"] == "interview-coach" + assert call.kwargs["infer"] is False + assert "answer" not in call.kwargs["text"].lower() + + +@pytest.mark.asyncio +async def test_sync_only_upserts_memories_changed_by_completed_session() -> None: + backend = SimpleNamespace(upsert=AsyncMock()) + service = PersonalMemoryService(backend=backend, agent_id="interview-coach", top_k=4) + profile = CandidateProfile( + memories=[ + CandidateMemory( + id="old", + topic="Redis", + level="weakness", + average_score=50, + evidence_count=1, + last_session_id="session-1", + ), + CandidateMemory( + id="changed", + topic="FastAPI", + level="strength", + average_score=88, + evidence_count=2, + last_session_id="session-2", + ), + ] + ) + + await service.sync_profile("user-1", profile, session_id="session-2") + + backend.upsert.assert_awaited_once() + assert backend.upsert.await_args.kwargs["external_id"] == "changed" + + +@pytest.mark.asyncio +async def test_forget_only_deletes_memory_in_current_user_scope() -> None: + backend = SimpleNamespace(delete=AsyncMock(return_value=True)) + service = PersonalMemoryService(backend=backend, agent_id="interview-coach", top_k=4) + + await service.forget("user-a", "memory-1") + + backend.delete.assert_awaited_once_with( + user_id="user-a", agent_id="interview-coach", external_id="memory-1" + ) + + +@pytest.mark.asyncio +async def test_delete_orchestrates_authoritative_then_remote_deletion() -> None: + repository = SimpleNamespace(delete_profile_memory=AsyncMock(return_value=True)) + backend = SimpleNamespace(delete=AsyncMock(return_value=True)) + service = PersonalMemoryService(backend=backend, agent_id="interview-coach", top_k=4) + coordinator = PersonalMemoryCoordinator(repository, service) + + deleted = await coordinator.delete("user-a", "memory-1") + + assert deleted is True + repository.delete_profile_memory.assert_awaited_once_with("user-a", "memory-1") + backend.delete.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_recall_ignores_mem0_ids_missing_from_authoritative_profile() -> None: + backend = SimpleNamespace( + search=AsyncMock( + return_value=[ + MemoryHit(external_id="deleted-memory", text="stale"), + MemoryHit(external_id="memory-1", text="current"), + ] + ) + ) + service = PersonalMemoryService(backend=backend, agent_id="interview-coach", top_k=4) + profile = CandidateProfile( + memories=[ + CandidateMemory( + id="memory-1", + topic="Redis", + level="weakness", + average_score=45, + evidence_count=3, + last_session_id="session-3", + ) + ] + ) + + recalled = await service.recall("user-1", "Redis", profile) + + assert recalled == ["Redis|薄弱项|基于 3 次答题证据,平均 45.0 分"] + assert all("stale" not in item for item in recalled) + + +@pytest.mark.asyncio +async def test_recall_does_not_inject_unrelated_local_memory() -> None: + service = PersonalMemoryService(backend=None, agent_id="interview-coach", top_k=4) + profile = CandidateProfile( + memories=[ + CandidateMemory( + topic="Redis", + level="weakness", + average_score=45, + evidence_count=3, + last_session_id="session-3", + ) + ] + ) + + recalled = await service.recall("user-1", "FastAPI async dependency injection", profile) + + assert recalled == [] + + +@pytest.mark.asyncio +async def test_mem0_backend_upserts_with_scoped_metadata_filter() -> None: + client = SimpleNamespace( + get_all=AsyncMock( + return_value={"results": [{"id": "mem0-id", "metadata": {"external_id": "memory-1"}}]} + ), + update=AsyncMock(), + add=AsyncMock(), + ) + backend = object.__new__(Mem0MemoryBackend) + backend.client = client + + await backend.upsert( + user_id="user-1", + agent_id="interview-coach", + external_id="memory-1", + text="Redis memory", + metadata={"external_id": "memory-1"}, + infer=False, + ) + + filters = client.get_all.await_args.kwargs["filters"] + assert {"user_id": "user-1"} in filters["AND"] + assert {"agent_id": "interview-coach"} in filters["AND"] + assert {"external_id": "memory-1"} in filters["AND"] + client.update.assert_awaited_once_with( + memory_id="mem0-id", + text="Redis memory", + metadata={"external_id": "memory-1"}, + ) + client.add.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_mem0_backend_adds_new_memory_with_inference_disabled() -> None: + client = SimpleNamespace( + get_all=AsyncMock(return_value={"results": []}), + update=AsyncMock(), + add=AsyncMock(), + ) + backend = object.__new__(Mem0MemoryBackend) + backend.client = client + + await backend.upsert( + user_id="user-1", + agent_id="interview-coach", + external_id="memory-1", + text="Redis memory", + metadata={"external_id": "memory-1"}, + infer=False, + ) + + client.add.assert_awaited_once_with( + [{"role": "user", "content": "Redis memory"}], + filters={ + "user_id": "user-1", + "agent_id": "interview-coach", + "run_id": "memory-1", + }, + metadata={"external_id": "memory-1"}, + infer=False, + ) + client.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_mem0_backend_search_and_delete_are_user_and_agent_scoped() -> None: + client = SimpleNamespace( + search=AsyncMock( + return_value={ + "results": [ + { + "id": "mem0-id", + "memory": "Redis memory", + "metadata": {"external_id": "memory-1"}, + } + ] + } + ), + get_all=AsyncMock(return_value={"results": [{"id": "mem0-id"}]}), + delete=AsyncMock(), + ) + backend = object.__new__(Mem0MemoryBackend) + backend.client = client + + hits = await backend.search( + user_id="user-1", agent_id="interview-coach", query="Redis", limit=3 + ) + deleted = await backend.delete( + user_id="user-1", agent_id="interview-coach", external_id="memory-1" + ) + + assert hits == [MemoryHit(external_id="memory-1", text="Redis memory")] + search_filters = client.search.await_args.kwargs["filters"]["AND"] + delete_filters = client.get_all.await_args.kwargs["filters"]["AND"] + assert {"user_id": "user-1"} in search_filters + assert {"agent_id": "interview-coach"} in search_filters + assert {"external_id": "memory-1"} in delete_filters + assert deleted is True + client.delete.assert_awaited_once_with(memory_id="mem0-id") diff --git a/tests/unit/test_personal_memory_api.py b/tests/unit/test_personal_memory_api.py new file mode 100644 index 0000000..9bf1931 --- /dev/null +++ b/tests/unit/test_personal_memory_api.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from interview_agent.api.dependencies import CurrentUser +from interview_agent.api.http import delete_personal_memory, personal_memory +from interview_agent.domain.models import CandidateMemory, CandidateProfile + + +@pytest.mark.asyncio +async def test_personal_memory_api_uses_authenticated_user() -> None: + profile = CandidateProfile( + interview_count=2, + memories=[ + CandidateMemory( + id="memory-1", + topic="Redis", + level="weakness", + average_score=50, + evidence_count=2, + last_session_id="session-2", + ) + ], + ) + memory_coordinator = SimpleNamespace( + profile=AsyncMock(return_value=profile), + ) + container = SimpleNamespace( + memory_coordinator=memory_coordinator, + memories=SimpleNamespace(semantic_index_enabled=True), + ) + + result = await personal_memory(CurrentUser(id="user-a", username="alice"), container) + + memory_coordinator.profile.assert_awaited_once_with("user-a") + assert result["interview_count"] == 2 + memory_rows = result["memories"] + assert isinstance(memory_rows, list) + assert memory_rows[0]["id"] == "memory-1" + + +@pytest.mark.asyncio +async def test_delete_memory_uses_authenticated_user_and_forgets_remote_copy() -> None: + memory_coordinator = SimpleNamespace(delete=AsyncMock(return_value=True)) + container = SimpleNamespace(memory_coordinator=memory_coordinator) + + result = await delete_personal_memory( + "memory-1", CurrentUser(id="user-a", username="alice"), container + ) + + assert result == {"deleted": True} + memory_coordinator.delete.assert_awaited_once_with("user-a", "memory-1") + + +@pytest.mark.asyncio +async def test_delete_memory_returns_same_not_found_for_unknown_or_foreign_id() -> None: + memory_coordinator = SimpleNamespace(delete=AsyncMock(return_value=False)) + container = SimpleNamespace(memory_coordinator=memory_coordinator) + + with pytest.raises(HTTPException) as error: + await delete_personal_memory( + "foreign-memory", CurrentUser(id="user-a", username="alice"), container + ) + + assert error.value.status_code == 404 + memory_coordinator.delete.assert_awaited_once_with("user-a", "foreign-memory") diff --git a/tests/unit/test_personal_memory_config.py b/tests/unit/test_personal_memory_config.py new file mode 100644 index 0000000..0dae61e --- /dev/null +++ b/tests/unit/test_personal_memory_config.py @@ -0,0 +1,26 @@ +from pydantic import SecretStr + +from interview_agent.config import Settings + + +def test_personal_memory_defaults_to_local_mysql_profile() -> None: + assert Settings.model_fields["mem0_enabled"].default is False + settings = Settings( + _env_file=None, + deepseek_api_key=SecretStr("dummy-key"), + jwt_secret=SecretStr("j" * 32), + resume_token_secret=SecretStr("r" * 32), + mem0_enabled=False, + mem0_mode="cloud", + mem0_oss_config_path=None, + ) + + assert settings.mem0_enabled is False + assert settings.mem0_mode == "cloud" + assert settings.mem0_api_key is None + assert settings.mem0_agent_id == "interview-coach" + assert settings.mem0_timeout_seconds == 10 + assert settings.mem0_oss_config_path is None + assert settings.mem0_oss_qdrant_path == "data/mem0_qdrant" + assert settings.mem0_oss_collection_name == "interview_personal_memory" + assert settings.personal_memory_top_k == 8 diff --git a/tests/unit/test_personal_memory_container.py b/tests/unit/test_personal_memory_container.py new file mode 100644 index 0000000..505be44 --- /dev/null +++ b/tests/unit/test_personal_memory_container.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from interview_agent.container import AppContainer, _build_interview_workflow + + +def closable() -> SimpleNamespace: + return SimpleNamespace(close=AsyncMock()) + + +def test_container_injects_dynamic_follow_up_limit_into_workflow() -> None: + workflow = _build_interview_workflow( + SimpleNamespace(), # type: ignore[arg-type] + SimpleNamespace(), # type: ignore[arg-type] + SimpleNamespace(), # type: ignore[arg-type] + SimpleNamespace(), # type: ignore[arg-type] + max_follow_up_rounds=4, + ) + + assert workflow.max_follow_up_rounds == 4 + + +@pytest.mark.asyncio +async def test_container_closes_personal_memory_backend() -> None: + container = AppContainer( + settings=SimpleNamespace(), # type: ignore[arg-type] + database=closable(), # type: ignore[arg-type] + repository=SimpleNamespace(), # type: ignore[arg-type] + redis=closable(), # type: ignore[arg-type] + publisher=SimpleNamespace(stop=Mock()), # type: ignore[arg-type] + embeddings=closable(), # type: ignore[arg-type] + reranker=closable(), # type: ignore[arg-type] + milvus=closable(), # type: ignore[arg-type] + llm=closable(), # type: ignore[arg-type] + github_repositories=None, + agents=SimpleNamespace(), # type: ignore[arg-type] + retriever=SimpleNamespace(), # type: ignore[arg-type] + workflow=SimpleNamespace(), # type: ignore[arg-type] + coordinator=SimpleNamespace(), # type: ignore[arg-type] + auth=SimpleNamespace(), # type: ignore[arg-type] + documents=closable(), # type: ignore[arg-type] + question_banks=SimpleNamespace(), # type: ignore[arg-type] + memories=closable(), # type: ignore[arg-type] + memory_coordinator=SimpleNamespace(), # type: ignore[arg-type] + skills=SimpleNamespace(), # type: ignore[arg-type] + voice=closable(), # type: ignore[arg-type] + connections=SimpleNamespace(close_all=AsyncMock()), # type: ignore[arg-type] + ) + + await container.close() + + container.memories.close.assert_awaited_once() # type: ignore[attr-defined] diff --git a/tests/unit/test_personal_memory_flow.py b/tests/unit/test_personal_memory_flow.py new file mode 100644 index 0000000..e6acfc5 --- /dev/null +++ b/tests/unit/test_personal_memory_flow.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from interview_agent.domain.models import ( + CandidateProfile, + EvaluationReport, + InterviewCheckpoint, + InterviewQuestion, + JDAnalysis, + QAPair, + ResumeMatchResult, +) +from interview_agent.services.coordinator import InterviewCoordinator +from interview_agent.services.workflow import InterviewWorkflow + + +def checkpoint() -> InterviewCheckpoint: + return InterviewCheckpoint( + session_id="session-1", + user_id="user-1", + jd_text="Redis backend", + resume_text="Python", + jd_analysis=JDAnalysis(position="Backend", key_topics=["Redis"]), + match_result=ResumeMatchResult(overall_score=70, interview_focus=["Redis"]), + qa_history=[ + QAPair( + question=InterviewQuestion(content="Redis", skills=["Redis"]), + user_answer="answer", + score=50, + feedback="feedback", + ) + ], + report=EvaluationReport(overall_score=50, summary="Redis needs work"), + ) + + +@pytest.mark.asyncio +async def test_workflow_recalls_memory_before_question_planning() -> None: + value = checkpoint() + profile = CandidateProfile() + repository = SimpleNamespace(load_profile=AsyncMock(return_value=profile)) + memories = SimpleNamespace(recall=AsyncMock(return_value=["Redis|薄弱项|基于 2 次答题证据"])) + agents = SimpleNamespace( + plan_questions=AsyncMock( + return_value=[InterviewQuestion(content=f"q-{index}") for index in range(3)] + ) + ) + workflow = InterviewWorkflow.__new__(InterviewWorkflow) + workflow.repository = repository + workflow.memories = memories + workflow.agents = agents + workflow.retriever = SimpleNamespace(retrieve=AsyncMock(return_value=[])) + + await workflow._plan_questions({"checkpoint": value}) + + memories.recall.assert_awaited_once() + assert "Redis" in memories.recall.await_args.args[1] + assert agents.plan_questions.await_args.kwargs["memory_context"] == [ + "Redis|薄弱项|基于 2 次答题证据" + ] + + +@pytest.mark.asyncio +async def test_finalize_atomically_updates_profile_then_syncs_authoritative_result() -> None: + value = checkpoint() + proposed = CandidateProfile(strengths=["Python"]) + repository = SimpleNamespace( + save_interview_record=AsyncMock(), + load_profile=AsyncMock(return_value=CandidateProfile()), + ) + + async def update_profile_once( + _user_id: str, _session_id: str, mutate: object + ) -> tuple[CandidateProfile, bool]: + updated = mutate(CandidateProfile()) # type: ignore[operator] + return updated, True + + repository.update_profile_once = AsyncMock(side_effect=update_profile_once) + memories = SimpleNamespace(sync_profile=AsyncMock()) + coordinator = InterviewCoordinator.__new__(InterviewCoordinator) + coordinator.repository = repository + coordinator.agents = SimpleNamespace(update_profile=AsyncMock(return_value=proposed)) + coordinator.memories = memories + + await coordinator._finalize(value) + + repository.update_profile_once.assert_awaited_once() + synced = memories.sync_profile.await_args.args[1] + assert synced.interview_count == 1 + assert synced.memories[0].topic == "Redis" + assert synced.strengths == ["Python"] + assert memories.sync_profile.await_args.kwargs["session_id"] == "session-1" diff --git a/tests/unit/test_personal_memory_infra.py b/tests/unit/test_personal_memory_infra.py new file mode 100644 index 0000000..792d621 --- /dev/null +++ b/tests/unit/test_personal_memory_infra.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from pydantic import SecretStr + +from interview_agent.config import Settings +from interview_agent.domain.models import CandidateMemory, CandidateProfile +from interview_agent.infra.mem0_memory import ( + Mem0OssMemoryBackend, + _load_oss_config, + create_mem0_memory_backend, +) +from interview_agent.infra.redis_store import RedisStore +from interview_agent.infra.repositories import Repository +from interview_agent.infra.tables import CandidateProfileRow, ProfileUpdateRow, UserRow + + +def settings(**overrides: object) -> Settings: + values: dict[str, object] = { + "deepseek_api_key": SecretStr("dummy-key"), + "jwt_secret": SecretStr("j" * 32), + "resume_token_secret": SecretStr("r" * 32), + "mem0_enabled": False, + "mem0_mode": "cloud", + } + values.update(overrides) + return Settings.model_validate(values) + + +def write_hermes_oss_config(path: Path) -> None: + path.write_text( + json.dumps( + { + "mode": "oss", + "user_id": "hermes-user", + "agent_id": "hermes-agent", + "oss": { + "llm": { + "provider": "ollama", + "config": { + "model": "qwen2.5-coder:14b", + "ollama_base_url": "http://127.0.0.1:11434", + }, + }, + "embedder": { + "provider": "ollama", + "config": { + "model": "nomic-embed-text", + "ollama_base_url": "http://127.0.0.1:11434", + "embedding_dims": 768, + }, + }, + "vector_store": { + "provider": "qdrant", + "config": {"path": "/must/not/be/reused"}, + }, + }, + } + ), + encoding="utf-8", + ) + + +def test_oss_config_reuses_providers_but_isolates_storage_and_identity(tmp_path: Path) -> None: + source = tmp_path / "hermes-mem0.json" + write_hermes_oss_config(source) + qdrant_path = tmp_path / "interview-qdrant" + history_path = tmp_path / "interview-history.db" + + config = _load_oss_config( + settings( + mem0_mode="oss", + mem0_oss_config_path=str(source), + mem0_oss_ollama_base_url="http://ollama:11434", + mem0_oss_qdrant_path=str(qdrant_path), + mem0_oss_collection_name="interview-memory", + mem0_oss_history_db_path=str(history_path), + ) + ) + + assert config["llm"]["config"]["model"] == "qwen2.5-coder:14b" # type: ignore[index] + assert config["llm"]["config"]["ollama_base_url"] == "http://ollama:11434" # type: ignore[index] + vector = config["vector_store"]["config"] # type: ignore[index] + assert vector == { + "path": str(qdrant_path), + "collection_name": "interview-memory", + "embedding_model_dims": 768, + } + assert config["history_db_path"] == str(history_path) + assert "user_id" not in config + assert "agent_id" not in config + + +@pytest.mark.asyncio +async def test_oss_backend_uses_scoped_async_crud_and_disables_inference() -> None: + client = SimpleNamespace( + get_all=AsyncMock(return_value=[]), + add=AsyncMock(return_value={"results": []}), + search=AsyncMock( + return_value={ + "results": [ + { + "id": "row-1", + "memory": "Redis weakness", + "metadata": {"external_id": "memory-1"}, + } + ] + } + ), + delete=AsyncMock(), + ) + backend = object.__new__(Mem0OssMemoryBackend) + backend.client = client + backend.timeout_seconds = 1 + + await backend.upsert( + user_id="user-1", + agent_id="interview-coach", + external_id="memory-1", + text="Redis weakness", + metadata={"external_id": "memory-1"}, + infer=False, + ) + hits = await backend.search( + user_id="user-1", agent_id="interview-coach", query="Redis", limit=4 + ) + + assert hits[0].external_id == "memory-1" + client.add.assert_awaited_once() + add_call = client.add.await_args + assert add_call.kwargs["user_id"] == "user-1" + assert add_call.kwargs["agent_id"] == "interview-coach" + assert add_call.kwargs["run_id"] == "memory-1" + assert add_call.kwargs["infer"] is False + search_filters = client.search.await_args.kwargs["filters"] + assert search_filters == {"user_id": "user-1", "agent_id": "interview-coach"} + + +@pytest.mark.asyncio +async def test_mem0_factory_falls_back_and_closes_client_on_initialization_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + http_client = SimpleNamespace(aclose=AsyncMock()) + + def fail_backend(**_kwargs: object) -> None: + raise RuntimeError("invalid key") + + monkeypatch.setattr( + "interview_agent.infra.mem0_memory.httpx.AsyncClient", Mock(return_value=http_client) + ) + monkeypatch.setattr("interview_agent.infra.mem0_memory.Mem0MemoryBackend", fail_backend) + + backend = await create_mem0_memory_backend( + settings(mem0_enabled=True, mem0_api_key=SecretStr("mem0-key")) + ) + + assert backend is None + http_client.aclose.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_memory_sync_lease_uses_scoped_redis_lock() -> None: + lock = SimpleNamespace(acquire=AsyncMock(return_value=True), release=AsyncMock()) + redis = object.__new__(RedisStore) + redis.settings = settings(mem0_timeout_seconds=2) + redis.client = SimpleNamespace(lock=Mock(return_value=lock)) + + async with redis.memory_sync_lease("user-1", "memory-1") as acquired: + assert acquired is True + + key = redis.client.lock.call_args.args[0] + assert key == "interview:memory-sync:user-1:memory-1" + lock.acquire.assert_awaited_once_with(blocking=True) + lock.release.assert_awaited_once() + + +class FakeTransaction: + def __init__(self, db: object) -> None: + self.db = db + + async def __aenter__(self) -> object: + return self.db + + async def __aexit__(self, *_args: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_repository_profile_update_and_delete_are_serialized_by_user_row() -> None: + row = SimpleNamespace( + profile=CandidateProfile().model_dump(mode="json"), + updated_at=None, + ) + db = SimpleNamespace(add=Mock()) + + async def get(model: object, _identity: str, **kwargs: object) -> object | None: + if model is UserRow: + assert kwargs == {"with_for_update": True} + return SimpleNamespace(id="user-1") + if model is ProfileUpdateRow: + return None + if model is CandidateProfileRow: + return row + raise AssertionError(model) + + db.get = AsyncMock(side_effect=get) + repository = Repository.__new__(Repository) + repository.database = SimpleNamespace( + sessions=SimpleNamespace(begin=Mock(return_value=FakeTransaction(db))) + ) + + profile, applied = await repository.update_profile_once( + "user-1", + "session-1", + lambda current: current.model_copy( + update={ + "memories": [ + CandidateMemory( + id="memory-1", + topic="Redis", + level="weakness", + average_score=50, + evidence_count=1, + last_session_id="session-1", + ) + ] + } + ), + ) + row.profile = profile.model_dump(mode="json") + deleted = await repository.delete_profile_memory("user-1", "memory-1") + + assert applied is True + assert deleted is True + assert CandidateProfile.model_validate(row.profile).suppressed_memory_topics == ["redis"] + user_lock_calls = [call for call in db.get.await_args_list if call.args[0] is UserRow] + assert len(user_lock_calls) == 2 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_parser.py b/tests/unit/test_question_bank_parser.py index 53fd392..f9329a4 100644 --- a/tests/unit/test_question_bank_parser.py +++ b/tests/unit/test_question_bank_parser.py @@ -5,7 +5,7 @@ import pytest from interview_agent.llm.deepseek import StructuredOutputError -from interview_agent.services.agents import AgentService +from interview_agent.services.agents import AgentService, QuestionBankOutput from interview_agent.services.question_bank_parser import parse_question_bank_fallback MARKDOWN = """ @@ -39,8 +39,7 @@ def test_markdown_fallback_extracts_questions_and_answers() -> None: def test_qa_fallback_deduplicates_questions() -> None: items = parse_question_bank_fallback( - "Q: 什么是缓存穿透?\nA: 查询不存在的数据。\n\n" - "问题:什么是缓存穿透?\n答案:重复问题。" + "Q: 什么是缓存穿透?\nA: 查询不存在的数据。\n\n问题:什么是缓存穿透?\n答案:重复问题。" ) assert len(items) == 1 @@ -70,3 +69,27 @@ async def structured(self, *_args: Any, **_kwargs: Any) -> Any: service = AgentService(InvalidStructuredLlm()) # type: ignore[arg-type] assert await service.parse_question_bank("一段没有问题标记的非结构化文档内容。") == [] + + +@pytest.mark.asyncio +async def test_agent_replaces_question_bank_model_ids_with_snowflake_ids() -> None: + class LegacyIdLlm: + async def structured(self, *_args: Any, **_kwargs: Any) -> QuestionBankOutput: + from interview_agent.domain.models import QuestionBankItem + + return QuestionBankOutput( + questions=[ + QuestionBankItem( + id="model-controlled-id", + content="请解释 Redis 持久化机制", + reference="RDB 与 AOF", + ) + ] + ) + + service = AgentService(LegacyIdLlm()) # type: ignore[arg-type] + + items = await service.parse_question_bank("没有可由确定性解析器识别的长文内容") + + assert items[0].id.isdecimal() + assert items[0].id != "model-controlled-id" 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, + }, + ) + ] diff --git a/tests/unit/test_review_recommendations.py b/tests/unit/test_review_recommendations.py new file mode 100644 index 0000000..cbf9f24 --- /dev/null +++ b/tests/unit/test_review_recommendations.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError + +from interview_agent.domain.models import ( + EvaluationReport, + ProjectSearchIntent, + ReviewItem, + 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": ["阅读源码"], + "project_search": { + "queries": ["FastAPI framework"], + "github_topics": ["fastapi"], + "preferred_languages": ["Python"], + }, + } + ], + } + ) + + +class RepositorySearchStub: + async def search(self, items: list[ReviewItem]) -> dict[str, list[ReviewResource]]: + assert [item.topic for item in items] == ["FastAPI"] + assert items[0].project_search is not None + assert items[0].project_search.queries == ["FastAPI framework"] + return { + "FastAPI": [ + ReviewResource( + title="fastapi/fastapi", + url="https://github.com/fastapi/fastapi", + stars=90000, + language="Python", + ) + ] + } + + +class MissingSearchIntentLlmStub: + async def structured(self, *_args: Any, **_kwargs: Any) -> ReviewPlan: + return ReviewPlan( + summary="plan", + items=[ + ReviewItem( + topic="Agent Planning", + priority="high", + reason="需要补强", + actions=["运行项目"], + ) + ], + ) + + + +class PartialSearchIntentLlmStub: + async def structured(self, *_args: Any, **_kwargs: Any) -> ReviewPlan: + return ReviewPlan( + summary="plan", + items=[ + ReviewItem( + topic="FastAPI", + priority="high", + reason="需要补强", + actions=["阅读源码"], + project_search=ProjectSearchIntent( + queries=["FastAPI framework"] + ), + ), + ReviewItem( + topic="Agent Planning", + priority="medium", + reason="需要补强", + actions=["实现规划循环"], + ), + ], + ) + +class NeverSearchRepositoryStub: + async def search(self, _items: list[ReviewItem]) -> dict[str, list[ReviewResource]]: + raise AssertionError("repository search must not use a generated fallback intent") + + +@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", + ) + + +@pytest.mark.parametrize( + "query", + [ + "", + " ", + "https://github.com/example/repo", + "智能体规划", + "agent stars:>0", + "microsoft/agent-framework", + ], +) +def test_project_search_intent_rejects_unsafe_or_unsearchable_queries(query: str) -> None: + with pytest.raises(ValidationError): + ProjectSearchIntent(queries=[query]) + + +@pytest.mark.asyncio +async def test_generated_high_priority_item_without_search_intent_is_not_searched() -> None: + service = AgentService( # type: ignore[arg-type] + MissingSearchIntentLlmStub(), NeverSearchRepositoryStub() # type: ignore[arg-type] + ) + report = EvaluationReport( + overall_score=45, + summary="Agent 基础需要加强", + weaknesses=["Agent Planning"], + recommendations=["实现一个规划与执行循环"], + ) + + plan = await service.review_plan(report) + + assert plan.items[0].project_search is None + assert plan.items[0].resources == [] + + +@pytest.mark.asyncio +async def test_missing_intent_does_not_suppress_other_valid_recommendations() -> None: + service = AgentService( # type: ignore[arg-type] + PartialSearchIntentLlmStub(), RepositorySearchStub() # type: ignore[arg-type] + ) + report = EvaluationReport(overall_score=60, summary="部分主题需要补强") + + plan = await service.review_plan(report) + + assert isinstance(plan.items[0].resources[0], ReviewResource) + assert plan.items[1].resources == [] diff --git a/tests/unit/test_state_machine.py b/tests/unit/test_state_machine.py index 29e5a67..33601ea 100644 --- a/tests/unit/test_state_machine.py +++ b/tests/unit/test_state_machine.py @@ -13,6 +13,10 @@ def test_waiting_answer_cannot_skip_to_completed() -> None: ensure_transition(SessionStatus.WAITING_FOR_ANSWER, SessionStatus.COMPLETED) +def test_scored_follow_up_can_request_another_follow_up() -> None: + ensure_transition(SessionStatus.SCORING_FOLLOW_UP, SessionStatus.ASKING_FOLLOW_UP) + + def test_terminal_session_cannot_advance() -> None: with pytest.raises(InvalidTransition): ensure_transition(SessionStatus.COMPLETED, SessionStatus.ASKING_QUESTION) diff --git a/tests/unit/test_websocket_forwarding.py b/tests/unit/test_websocket_forwarding.py index 5c6fdf1..05e7637 100644 --- a/tests/unit/test_websocket_forwarding.py +++ b/tests/unit/test_websocket_forwarding.py @@ -3,9 +3,11 @@ import asyncio from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock import pytest +from interview_agent.api.schemas import ClientMessage from interview_agent.api.websocket import WebSocketConnection from interview_agent.infra.redis_store import ConnectionClaim, StreamEvent @@ -58,3 +60,39 @@ async def test_forward_events_delivers_events_created_after_attach() -> None: assert payload["type"] == "question" assert payload["event_id"] == "2-0" assert payload["content"] == "请解释状态机" + + +@pytest.mark.asyncio +async def test_start_interview_uses_same_snowflake_id_for_token_and_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + coordinator = SimpleNamespace( + start_session=AsyncMock(return_value="347238131814105088"), + advance=AsyncMock(), + ) + auth = SimpleNamespace(issue_resume_token=lambda user_id, session_id: f"token:{session_id}") + container = SimpleNamespace( + settings=SimpleNamespace(websocket_send_queue_size=8), + documents=SimpleNamespace(resolve=AsyncMock(side_effect=["J" * 20, "R" * 20])), + auth=auth, + coordinator=coordinator, + spawn=lambda coroutine, **_kwargs: coroutine.close(), + ) + connection = WebSocketConnection(SimpleNamespace(), container, "user-1") + monkeypatch.setattr( + "interview_agent.api.websocket.new_snowflake_id", + lambda: "347238131814105088", + ) + monkeypatch.setattr(connection, "attach", AsyncMock()) + + await connection._start_interview( + ClientMessage(type="start_interview", jd="jd", resume="resume") + ) + + coordinator.start_session.assert_awaited_once_with( + "user-1", + "J" * 20, + "R" * 20, + resume_token="token:347238131814105088", + session_id="347238131814105088", + ) diff --git a/uv.lock b/uv.lock index 4f630cd..208c160 100644 --- a/uv.lock +++ b/uv.lock @@ -2,9 +2,12 @@ version = 1 revision = 3 requires-python = ">=3.12, <3.14" resolution-markers = [ - "sys_platform == 'win32'", - "sys_platform == 'emscripten'", - "sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -170,6 +173,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -443,6 +455,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + [[package]] name = "htmldate" version = "1.10.0" @@ -522,6 +556,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx2" version = "2.10.0" @@ -548,6 +587,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -578,9 +626,12 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "langgraph" }, + { name = "mem0ai" }, { name = "numpy" }, + { name = "ollama" }, { name = "openai" }, { name = "orjson" }, + { name = "playwright" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "pymilvus" }, @@ -614,9 +665,12 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.116" }, { name = "httpx", specifier = ">=0.28" }, { name = "langgraph", specifier = ">=1.0" }, + { name = "mem0ai", specifier = ">=2.0,<3" }, { name = "numpy", specifier = ">=2.2" }, + { name = "ollama", specifier = ">=0.5,<1" }, { 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" }, @@ -984,6 +1038,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mem0ai" +version = "2.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "openai" }, + { name = "posthog" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/00de525fc6a4ec4a6bddb180356e7a1587179cfb12e37953075bc7751328/mem0ai-2.0.18.tar.gz", hash = "sha256:7dfd23b119448fe631f2a20c025eeaee2bb2fb28b155e18b791486d2aa01896f", size = 248611, upload-time = "2026-08-11T18:58:00.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/eb/733a2c405f2890bbdbb32294f9fa9a4b248f0b7e91378821482caf02c5c4/mem0ai-2.0.18-py3-none-any.whl", hash = "sha256:2c20c78dec21c512e1daf6b7c2cacc306cb34eaab7dd24c2edc81b71386b0a10", size = 344386, upload-time = "2026-08-11T18:57:58.616Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -1051,6 +1124,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, ] +[[package]] +name = "ollama" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/72/5f12423b6b39ca8430fbe56f77fcf4ef60f63067c7c4a2e30e200ed9ec16/ollama-0.6.2.tar.gz", hash = "sha256:936d55daa684f474364c098611c933626f8d6c7d67065c5b7ae0c477b508b07f", size = 53145, upload-time = "2026-04-29T21:21:15.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/d6722beeb2d10f7a3b9ff49375708904fde18f82b5609a0bc4aeb5996a4d/ollama-0.6.2-py3-none-any.whl", hash = "sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d", size = 15115, upload-time = "2026-04-29T21:21:13.794Z" }, +] + [[package]] name = "openai" version = "3.1.0" @@ -1174,6 +1260,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" @@ -1183,19 +1288,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "posthog" +version = "7.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/77/3737f60571995ba07677b058bb1523b7c26f28570806b8ffaf83a66df18c/posthog-7.39.1.tar.gz", hash = "sha256:0d184596e35057457fc1094883646fd23de2d6338db8b9c3ea770643fb55d8a2", size = 428586, upload-time = "2026-08-14T13:50:24.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/79/ee5c01937bfb0c80415929e25aa1e8296c48e26fc9a10fe1d9f665f0f478/posthog-7.39.1-py3-none-any.whl", hash = "sha256:e76e82fe571314a0a9bc11d039fd1a1a8d210cd0f737899d41b31f61b73bf08c", size = 504259, upload-time = "2026-08-14T13:50:22.896Z" }, +] + [[package]] name = "protobuf" -version = "7.35.1" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, - { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, - { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, - { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -1281,6 +1413,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" @@ -1407,6 +1551,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1435,6 +1592,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "qdrant-client" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/33/c6e4ec45b4fca5a0b808e8804e60be54a6d7505b68b53c0b1d0d62ba86c1/qdrant_client-1.19.0.tar.gz", hash = "sha256:365395a04b0a26c309b25b7d8b1c99ef2071ec9a2b74bc8a5fd3b7a3642fe963", size = 350953, upload-time = "2026-08-04T14:32:56.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3c/480c61cc8d5a3e76bb44e86231f408c93643e5498beadbbeb381ab55d02b/qdrant_client-1.19.0-py3-none-any.whl", hash = "sha256:13602a2b3478a95ecdf42f97b93d7f703b63a3361cd912a04495a33a5ac14121", size = 396157, upload-time = "2026-08-04T14:32:55.734Z" }, +] + [[package]] name = "rank-bm25" version = "0.2.2"