# CareerBot 项目设计文档 ## 1. 项目概述 CareerBot 是一个个人职业展示网站,集成了 AI 智能对话助手。主要面向两类用户: - **访问者(招聘方)**:浏览候选人的职业背景,通过 AI 对话深入了解候选人,上传 JD 进行匹配分析 - **管理员(候选人)**:管理个人资料、教育/工作经历、技能等内容,配置 LLM,管理访问令牌,查看招聘意向消息,生成定制简历 ### 技术栈 | 层级 | 技术 | |------|------| | 后端框架 | Python / FastAPI | | 模板引擎 | Jinja2 | | 数据库 | SQLite + SQLAlchemy ORM | | LLM 集成 | OpenAI 兼容 API (httpx) | | 实时通信 | Server-Sent Events (SSE) | | 认证 | JWT (管理员) / Cookie (访问者) | | 密码加密 | bcrypt | | 文件处理 | python-docx, PyPDF2, Pillow | --- ## 2. 项目结构 ``` CareerBot/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 应用入口,启动初始化 │ ├── config.py # Pydantic Settings 配置 │ ├── database.py # SQLAlchemy 引擎、Session、init_db │ ├── models.py # 全部 ORM 模型定义 │ ├── routers/ │ │ ├── auth.py # 认证:管理员登录、访问令牌验证、匿名访问 │ │ ├── public.py # 公开页面路由和数据 API │ │ ├── admin.py # 管理后台 CRUD、简历生成、消息管理 │ │ └── chat.py # 聊天 API:对话、历史、招聘意图检测 │ ├── services/ │ │ ├── chat_service.py # 聊天业务逻辑:构建 prompt、处理消息 │ │ ├── llm_service.py # LLM 调用封装:流式/非流式、视觉降级 │ │ └── file_parser.py # 文件解析:txt/docx/pdf/image │ └── static/ │ ├── css/style.css # 全局样式 │ └── js/ │ ├── main.js # 主页数据加载(个人信息、技能、经历) │ ├── chat.js # 聊天组件(SSE 流式、提问限制、匿名模式) │ └── admin.js # 管理后台 CRUD 操作、消息管理 ├── templates/ │ ├── index.html # 主页(职业展示 + 聊天组件) │ ├── login.html # 访问者登录/匿名入口 │ └── admin/ │ ├── login.html # 管理员登录 │ ├── dashboard.html # 仪表盘 │ ├── profile.html # 个人资料管理 │ ├── education.html # 教育经历管理 │ ├── experience.html # 工作经历管理 │ ├── skills.html # 技能管理 │ ├── tokens.html # 访问令牌管理 │ ├── messages.html # 招聘消息查看 │ ├── llm-config.html # LLM 配置 │ └── resume.html # 简历生成 ├── init_data.py # 数据库初始化种子数据 ├── requirements.txt # Python 依赖 ├── start.bat # 启动脚本 └── stop.bat # 停止脚本 ``` --- ## 3. 系统架构图 ``` ┌─────────────────────────────────────────────────────────────────┐ │ Browser │ │ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ index.html │ │ login.html │ │ admin/*.html │ │ │ │ main.js │ │ (Token/匿名入口) │ │ admin.js │ │ │ │ chat.js │ │ │ │ │ │ │ └──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘ │ └─────────┼──────────────────┼──────────────────────┼─────────────┘ │ HTTP/SSE │ HTTP │ HTTP ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ FastAPI Application │ │ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌──────────┐ │ │ │ public.py │ │ auth.py │ │ chat.py │ │ admin.py │ │ │ │ 页面路由 │ │ 认证鉴权 │ │ 聊天API │ │ 管理CRUD │ │ │ │ 数据API │ │ JWT/Cookie │ │ SSE流式 │ │ 简历生成 │ │ │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └────┬─────┘ │ │ │ │ │ │ │ │ │ ┌──────┴──────────┐ │ │ │ │ │ │ get_current_ │ │ │ │ │ │ │ admin/visitor │◄───┤ │ │ │ │ └─────────────────┘ │ │ │ │ │ │ │ │ │ ┌─────┴───────────────────────────────┴──────────────┴─────┐ │ │ │ Services Layer │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │chat_service │ │ llm_service │ │ file_parser │ │ │ │ │ │System Prompt │ │ OpenAI API │ │ txt/docx/pdf │ │ │ │ │ │消息处理/历史 │─▶│ 流式/非流式 │ │ image/base64 │ │ │ │ │ └──────────────┘ └──────┬───────┘ └──────────────┘ │ │ │ └───────────────────────────┼──────────────────────────────┘ │ │ │ │ │ ┌───────────────────────────┼──────────────────────────────┐ │ │ │ SQLAlchemy ORM │ │ │ │ Profile │ Skill │ Education │ WorkExperience │ AdminUser │ │ │ │ AccessToken │ LLMConfig │ ChatHistory │ RecruiterMessage │ │ │ └───────────────────────────┼──────────────────────────────┘ │ └──────────────────────────────┼──────────────────────────────────┘ │ ┌──────────▼──────────┐ │ SQLite (WAL模式) │ │ careerbot.db │ └─────────────────────┘ │ httpx (异步) ┌──────────▼──────────┐ │ LLM API Provider │ │ (DeepSeek/Qwen/ │ │ OpenAI/Zhipu) │ └─────────────────────┘ ``` --- ## 4. 数据模型(类图) ``` ┌─────────────────────┐ ┌─────────────────────────┐ │ Profile │ │ AdminUser │ ├─────────────────────┤ ├─────────────────────────┤ │ id: int (PK) │ │ id: int (PK) │ │ name: str │ │ email: str (unique) │ │ phone: str │ │ password_hash: str │ │ location: str │ └─────────────────────────┘ │ birthday: str │ │ party: str │ ┌─────────────────────────┐ │ education_level: str│ │ AccessToken │ │ email: str │ ├─────────────────────────┤ │ photo_url: str? │ │ id: int (PK) │ │ self_summary: text │ │ token: str (unique) │ └─────────────────────┘ │ created_at: datetime │ │ is_active: bool │ ┌─────────────────────┐ │ note: str? │ │ Skill │ │ max_questions: int [=9] │ ├─────────────────────┤ │ used_questions: int [=0] │ │ id: int (PK) │ └─────────────────────────┘ │ category: str │ │ content: text │ ┌─────────────────────────┐ │ sort_order: int │ │ LLMConfig │ └─────────────────────┘ ├─────────────────────────┤ │ id: int (PK) │ ┌─────────────────────┐ │ api_url: str │ │ Education │ │ api_key: str │ ├─────────────────────┤ │ model_name: str │ │ id: int (PK) │ │ max_tokens: int [=2048] │ │ start_date: str │ │ temperature: float [=0.7]│ │ end_date: str │ │ system_prompt: text? │ │ school: str │ │ is_active: bool │ │ major: str │ │ max_questions_per_session│ │ degree: str │ └─────────────────────────┘ │ details: text │ │ sort_order: int │ ┌─────────────────────────┐ └─────────────────────┘ │ ChatHistory │ ├─────────────────────────┤ ┌─────────────────────┐ │ id: int (PK) │ │ WorkExperience │ │ session_id: str (索引) │ ├─────────────────────┤ │ role: str │ │ id: int (PK) │ │ content: text │ │ start_date: str │ │ created_at: datetime │ │ end_date: str │ └─────────────────────────┘ │ company: str │ │ position: str │ ┌─────────────────────────┐ │ company_intro: text │ │ RecruiterMessage │ │ responsibilities: t │ ├─────────────────────────┤ │ achievements: text │ │ id: int (PK) │ │ sort_order: int │ │ session_id: str (索引) │ └─────────────────────┘ │ visitor_label: str │ │ company: str │ │ contact: str │ │ intent: str │ │ summary: text │ │ is_read: bool │ │ created_at: datetime │ └─────────────────────────┘ ``` **模型分组关系:** ``` 候选人内容数据: Profile ──┬── Skill (1:N) ├── Education (1:N) └── WorkExperience (1:N) 访问控制: AdminUser (管理员) AccessToken (访问者令牌) ── 关联 max_questions / used_questions AI 配置: LLMConfig (LLM连接配置 + 默认提问次数) 对话数据: ChatHistory (按 session_id 分组) RecruiterMessage (招聘意图检测结果) ``` --- ## 5. 认证与访问控制 ### 5.1 三种访问身份 ``` ┌───────────────┬───────────────────┬──────────────┬──────────────┐ │ 身份 │ 认证方式 │ 浏览内容 │ 对话限制 │ ├───────────────┼───────────────────┼──────────────┼──────────────┤ │ 管理员 │ JWT (Bearer Token)│ 管理后台 │ 无限制 │ │ Token 访问者 │ Cookie (visitor_ │ 主页+对话 │ 按token配额 │ │ │ token = 实际token)│ │ (默认9次) │ │ 匿名访问者 │ Cookie (visitor_ │ 主页+对话 │ 3次 │ │ │ token=__anonymous__)│ │ │ └───────────────┴───────────────────┴──────────────┴──────────────┘ ``` ### 5.2 访问者认证流程 ``` 访问 / (主页) │ ▼ 有 visitor_token Cookie? │ ┌────┴────┐ │ No │ Yes ▼ ▼ 重定向 token == "__anonymous__"? /login │ │ ┌────┴────┐ ▼ │ Yes │ No 登录页 │ ▼ │ │ DB查询token有效? │ │ │ │ │ ┌────┴────┐ │ │ │ Yes │ No │ │ │ ▼ │ │ │ 重定向 /login │ ▼ ▼ │ 渲染 index.html │ ├─ 输入Token → POST /api/verify-token → set cookie → 跳转 / └─ 匿名访问 → POST /api/anonymous-entry → set cookie(__anonymous__) → 跳转 / ``` ### 5.3 管理员认证 - **登录**: `POST /api/admin/login` → 验证 email + bcrypt 密码 → 返回 JWT - **鉴权**: 所有 `/api/admin/*` 接口通过 `get_current_admin` 依赖校验 JWT - **JWT 存储**: 前端 `localStorage.admin_token`,请求时放入 `Authorization: Bearer` Header - **有效期**: 480 分钟(8 小时) --- ## 6. 核心流程 ### 6.1 智能对话流程 ``` 用户发送消息 (chat.js) │ │ POST /api/chat (FormData: session_id, message, file?) ▼ ┌─── chat.py ─────────────────────────────────────────┐ │ │ │ 1. 身份识别: get_current_visitor │ │ ├─ AccessToken记录 → 检查 used_questions < max │ │ └─ None (匿名) → 查 ChatHistory 计数 < 3 │ │ │ │ 2. 超限 → 返回限制提示的 SSE │ │ 未超限 → 继续 │ │ │ │ 3. Token用户: used_questions += 1, db.commit() │ │ │ │ 4. 调用 chat_service.process_message() │ │ │ │ │ ┌──▼── chat_service.py ──────────────────────┐ │ │ │ │ │ │ │ a. 解析上传文件 (file_parser) │ │ │ │ ├─ txt/docx/pdf → 提取文本 │ │ │ │ └─ 图片 → base64编码 │ │ │ │ │ │ │ │ b. 查询最近20条 ChatHistory │ │ │ │ │ │ │ │ c. 构建 messages: │ │ │ │ [system_prompt, ...history, user_msg] │ │ │ │ │ │ │ │ d. 调用 llm_service.chat_completion_stream │ │ │ │ │ │ │ │ e. 保存 user/assistant 消息到 ChatHistory │ │ │ │ (错误消息不保存) │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ 5. SSE 流式返回 → chat.js 逐块渲染 │ │ │ │ 6. BackgroundTask: 招聘意图检测 │ │ (独立DB Session, 不影响主流程) │ └──────────────────────────────────────────────────────┘ ``` ### 6.2 LLM 调用流程(含视觉降级) ``` chat_completion_stream(messages, db) │ ▼ 读取 LLMConfig (is_active=True) │ ├─ 无配置 → yield "AI助手尚未配置" │ ▼ 构建请求: URL: {api_url}/v1/chat/completions Headers: Authorization: Bearer {api_key} Body: { model, messages, max_tokens, temperature, stream: true } │ ▼ 发送流式请求 (httpx.AsyncClient.stream) │ ┌────┴────────────────┐ │ 成功 │ 400 错误 且 消息含图片 │ │ ▼ ▼ 逐块 yield 视觉降级处理: content _strip_images(messages) ├─ 移除 image_url 内容 ├─ 保留文本部分 └─ 追加提示: "模型不支持图片, 请改用txt/docx/pdf" │ ▼ 重试 (不含图片的messages) │ ┌────┴────┐ │ 成功 │ 失败 ▼ ▼ 逐块 yield 错误信息 yield ``` ### 6.3 招聘意图检测流程 ``` 聊天 SSE 流结束 │ ▼ BackgroundTask (独立DB Session) _check_recruiter_intent() │ ▼ 构建检测 prompt: System: "你是一个精确的信息提取助手,只输出JSON格式。" User: "分析以下对话,判断访问者是否表达了: 1.招聘意愿 2.面试意愿 3.公司/联系方式" + 用户消息(截取500字) + AI回复(截取500字) │ ▼ 调用 chat_completion (非流式) → LLM 返回 JSON │ ▼ 解析 JSON: {"detected": true/false, "intent": "...", "company": "...", "contact": "...", "summary": "..."} │ ┌────┴────┐ │detected │ not detected │= true │ ▼ ▼ 创建 忽略 RecruiterMessage 记录到DB │ ▼ 管理后台 Messages 页面显示 侧边栏红色未读数徽章 ``` ### 6.4 简历生成流程 ``` 管理员上传 JD (文件或文本) │ ▼ POST /api/admin/resume/generate 解析 JD 内容 (file_parser) │ ▼ 从 DB 读取候选人全部资料: Profile + Skills + Education + WorkExperience │ ▼ 构建 messages: System: "你是专业简历撰写顾问..." (详见 §7.3) User: "=== JD === ... === 候选人背景 === ..." │ ▼ SSE 流式生成 Markdown 格式简历 │ ▼ 前端渲染 Markdown 预览 │ ▼ POST /api/admin/resume/download Markdown → Word 文档 (python-docx) - 解析 #/##/### 标题 - 解析 -/* 列表 - 解析 **粗体** - 设置页边距、对齐 │ ▼ 下载 resume.docx ``` --- ## 7. LLM 集成详细设计 ### 7.1 架构设计 CareerBot 采用 **OpenAI 兼容 API 格式** 调用 LLM,通过 `httpx` 发送 HTTP 请求,**不使用官方 SDK**。这样可以灵活切换国内外各种兼容 OpenAI 格式的 LLM 提供商。 ``` ┌──────────────────────────────────────────────────┐ │ llm_service.py │ │ │ │ get_active_config(db) ← 从DB读取LLM配置 │ │ │ │ │ ▼ │ │ _build_url_and_headers() │ │ URL自动补全: {base_url}/v1/chat/completions │ │ Headers: Authorization: Bearer {api_key} │ │ │ │ │ _build_body() │ │ { model, messages, max_tokens, temperature } │ │ │ │ │ ┌────┴────────────────┐ │ │ │ │ │ │ ▼ ▼ │ │ chat_completion chat_completion_stream │ │ (非流式,同步返回) (流式,AsyncGenerator) │ │ │ │ │ │ │ ┌────────────────┤ │ │ │ │ 视觉降级逻辑 │ │ │ │ │ _has_image_content() 检测 │ │ │ │ _strip_images() 移除图片 │ │ │ │ 自动重试 │ │ │ └────────────────┘ │ └──────┴──────────────────────────────────────────┘ ``` **支持的 LLM 提供商(管理后台预设按钮):** | 提供商 | Base URL | 默认模型 | |--------|----------|----------| | 通义千问 | `https://dashscope.aliyuncs.com/compatible-mode` | qwen-plus | | DeepSeek | `https://api.deepseek.com` | deepseek-chat | | 智谱 AI | `https://open.bigmodel.cn/api/paas` | glm-4-flash | | OpenAI | `https://api.openai.com` | gpt-4o | ### 7.2 LLM 配置参数 在管理后台 **LLM Config** 页面配置,存储在 `llm_config` 表: | 参数 | 说明 | 默认值 | |------|------|--------| | `api_url` | LLM API 基础地址 | - | | `api_key` | API 密钥 | - | | `model_name` | 模型名称 | - | | `max_tokens` | 单次最大生成 token 数 | 2048 | | `temperature` | 生成随机性 (0~1) | 0.7 | | `system_prompt` | 自定义额外系统指令 | 空 | | `max_questions_per_session` | 新Token默认提问次数 | 9 | ### 7.3 Prompt 设计 #### 7.3.1 智能对话 System Prompt 由 `chat_service.build_system_prompt()` 动态构建,**每次对话请求实时从数据库读取**,确保内容始终最新。 **结构:** ``` 你是{候选人姓名}的个人AI职业助手,专门帮助招聘者了解{姓名}的职业背景、技能和工作经验。 === 个人基本信息 === 姓名: ... 学历: ... 所在地: ... 邮箱: ... 个人总结: ... === 技能特长 === - 类别1: 内容... - 类别2: 内容... === 教育经历 === 时间 | 学校 | 专业 | 学位 详情: ... === 工作经历 === 时间 | 公司 | 职位 公司简介: ... 工作职责: ... 工作成就: ... === 回答规则 === 1. 只回答与招聘、职业、工作能力、技术背景相关的问题 2. 对于无关问题,礼貌拒绝并引导回职业话题 3. 严格基于以上信息回答,不编造经历 4. 使用专业、友好的语气 5. 如果招聘者上传了JD,详细分析岗位匹配度,突出亮点/优势,客观指出不足 6. 根据招聘者使用的语言选择中文或英文回答 === 额外指令 === ← 仅当管理员配置了自定义 system_prompt 时追加 {自定义内容} ``` #### 7.3.2 招聘意图检测 Prompt 由 `chat.py._check_recruiter_intent()` 使用,**非流式调用**,后台静默执行: ``` System: "你是一个精确的信息提取助手,只输出JSON格式。" User: 你是一个信息提取助手。分析以下对话,判断访问者是否表达了以下任何意愿: 1. 招聘意愿(想要招聘候选人) 2. 面试意愿(想要邀请候选人面试) 3. 留下了公司信息或联系方式 如果检测到以上任何一项,请用以下JSON格式回复: {"detected": true, "intent": "...", "company": "...", "contact": "...", "summary": "..."} 如果没有检测到,回复: {"detected": false} 访问者消息: {截取前500字} AI助手回复: {截取前500字} ``` #### 7.3.3 简历生成 Prompt 由 `admin.py.generate_resume()` 使用,**流式调用**: ``` System: 你是一位专业的简历撰写顾问。请根据提供的JD和候选人的完整背景信息, 生成一份针对该职位量身定制的中文简历。 要求: 1. 必须包含候选人的全部教育经历和工作经历,不得遗漏 2. "个人优势"和"自我评价"部分要针对JD定制 3. 每段工作经历重点突出与JD相关的职责和成就 4. 技能部分优先展示JD要求的技能 5. 使用专业、简洁的语言 6. 输出格式使用Markdown 简历结构: # 个人信息 # 求职意向(根据JD推断) # 个人优势(3-5条,针对JD定制) # 核心技能 # 工作经历(全部,按时间倒序) # 教育背景(全部,按时间倒序) User: === 目标职位描述(JD) === {JD内容} === 候选人完整背景 === {从DB读取的全部资料} ``` ### 7.4 LLM 能力说明 > **当前版本没有为 LLM 配置 Function Calling / Tool Use 能力。** LLM 在本项目中扮演的角色是**纯文本生成**,有三个使用场景: | 场景 | 调用方式 | 输入 | 输出 | |------|---------|------|------| | 智能对话 | 流式 (SSE) | system prompt + 对话历史 + 用户消息(可含文件文本) | 自然语言回复 | | 招聘意图检测 | 非流式 | 当轮对话(用户+AI各500字) | JSON 结构化数据 | | 简历生成 | 流式 (SSE) | JD + 候选人全部资料 | Markdown 格式简历 | **没有使用的 LLM 高级能力:** - Function Calling / Tool Use - RAG (检索增强生成) - Agent 多步推理 - 图片生成 **已实现的降级策略:** - 视觉模型不可用时自动降级:检测到 400 错误 + 消息含图片 → 移除图片内容 → 用文本重试 → 提示用户改用 txt/docx/pdf 格式 --- ## 8. 提问次数限制机制 ``` ┌─────────────────────────────────────────────────┐ │ 提问次数限制 │ ├─────────────────────────────────────────────────┤ │ │ │ Token 用户: │ │ 限制存储: AccessToken.max_questions (默认9) │ │ 已用计数: AccessToken.used_questions │ │ 检查方式: 服务端每次请求 check + increment │ │ 持久性: 跨会话、跨页面刷新持久有效 │ │ 管理: 管理后台可修改 max/used,可重置 │ │ │ │ 匿名用户: │ │ 限制: 固定 3 次 (ANONYMOUS_MAX_QUESTIONS) │ │ 计数: 服务端查 ChatHistory 中 session_id 的 │ │ user 消息数量 │ │ 持久性: 绑定 session_id (sessionStorage) │ │ │ │ 默认值来源: │ │ 新Token的 max_questions │ │ ← LLMConfig.max_questions_per_session │ │ ← 若未配置则 fallback 到 9 │ │ │ └─────────────────────────────────────────────────┘ ``` --- ## 9. SSE 流式通信协议 前后端通过 **Server-Sent Events** 实现流式对话,使用 `POST` + `fetch ReadableStream`(非 EventSource,因为需要 POST 发送 FormData)。 ### 数据格式 ``` data: {"content": "回复文本块..."} ← 正常内容块 data: {"content": "...", "limit_reached": true} ← 超限提示 data: {"content": "", "done": true} ← 结束标记 ``` ### 前端处理逻辑 (chat.js) ```javascript // 1. POST FormData (session_id + message + file?) const response = await fetch('/api/chat', { method: 'POST', body: formData }); // 2. 读取 ReadableStream const reader = response.body.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; // 3. 解析 SSE 行,提取 data: JSON // 4. 逐块拼接到气泡元素的 textContent } ``` --- ## 10. 文件处理能力 `file_parser.py` 支持以下格式: | 格式 | 处理方式 | 返回 | 大小限制 | |------|---------|------|---------| | `.txt` | 多编码尝试 (utf-8 → gbk → gb2312 → utf-16) | `{"text": "..."}` | 10MB | | `.docx` | python-docx 提取段落文本 | `{"text": "..."}` | 10MB | | `.pdf` | PyPDF2 逐页提取文本 | `{"text": "..."}` | 10MB | | `.png/.jpg/.gif/.webp` | base64 编码 | `{"base64_image": "...", "mime_type": "..."}` | 10MB | | 其他 | - | `{"error": "不支持的文件格式"}` | - | --- ## 11. 运维脚本 ### start.bat 1. 检查端口 8000 是否被占用 2. 首次运行自动执行 `init_data.py` 初始化数据库 3. 启动 `uvicorn --host 0.0.0.0 --port 8000 --reload` ### stop.bat 1. 查找占用端口 8000 的所有进程 2. 逐个终止进程 --- ## 12. 关键设计决策 | 决策 | 原因 | |------|------| | 直接使用 bcrypt 而非 passlib | passlib 与 bcrypt 5.0 存在不兼容问题 | | httpx 而非 openai SDK | 灵活支持国内 LLM 厂商,无需依赖特定 SDK | | SQLite + WAL | 轻量部署,WAL 模式支持并发读取 | | 无 Alembic 迁移 | 项目初期用 `create_all` 自动建表,新增列手动 `ALTER TABLE` | | Jinja2 < 3.1.6 | 3.1.6 存在模板缓存 `unhashable dict` bug | | POST + ReadableStream 而非 EventSource | EventSource 仅支持 GET,无法发送 FormData 附件 | | BackgroundTask 做意图检测 | 避免阻塞 SSE 流或导致连接异常 | | 意图检测用独立 DB Session | BackgroundTask 执行时请求级 Session 已关闭 | | FAB 按钮用内层 span flex | Chrome 对 button 元素的 flex 布局有渲染 bug |