diff --git a/.github/workflows/build-windows-portable.yml b/.github/workflows/build-windows-portable.yml new file mode 100644 index 0000000..bc2f572 --- /dev/null +++ b/.github/workflows/build-windows-portable.yml @@ -0,0 +1,165 @@ +name: Build Windows Packages + +on: + workflow_dispatch: + inputs: + bundle_local_model: + description: "Build a full offline installer with the Qwen GGUF model bundled" + required: false + type: boolean + default: false + push: + branches: + - resume-screening-cli + paths: + - ".github/workflows/build-windows-portable.yml" + - "packaging/**" + - "scripts/build_windows_app.ps1" + - "src/**" + - "tests/**" + - "web/**" + - "pyproject.toml" + +jobs: + build-windows-portable: + runs-on: windows-latest + timeout-minutes: 90 + env: + DEFAULT_QWEN_MODEL_FILENAME: Qwen3.5-9B-Q4_K_M.gguf + DEFAULT_QWEN_MODEL_URLS: | + https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf?download=true + https://huggingface.co/lmstudio-community/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf?download=true + https://modelscope.cn/models/diodel/Qwen3.5-9B-Q4_K_M-GGUF/resolve/master/Qwen3.5-9B-Q4_K_M.gguf + BUNDLE_LOCAL_MODEL: ${{ github.event_name == 'workflow_dispatch' && inputs.bundle_local_model && '1' || '0' }} + WINDOWS_ARTIFACT_NAME: ${{ github.event_name == 'workflow_dispatch' && inputs.bundle_local_model && 'xiaoa-resume-screening-windows-x64-offline' || 'xiaoa-resume-screening-windows-x64' }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: "web/package-lock.json" + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup --no-progress --yes + + - name: Build llama.cpp server runtime + shell: pwsh + run: | + git clone --depth 1 https://github.com/ggml-org/llama.cpp.git "$env:RUNNER_TEMP\llama.cpp" + cmake -S "$env:RUNNER_TEMP\llama.cpp" -B "$env:RUNNER_TEMP\llama-build" -DGGML_NATIVE=OFF -DGGML_OPENMP=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=ON + cmake --build "$env:RUNNER_TEMP\llama-build" --config Release --target llama-server --parallel 2 + New-Item -ItemType Directory -Force packaging\runtime\windows-x64 | Out-Null + Copy-Item "$env:RUNNER_TEMP\llama-build\bin\Release\llama-server.exe" packaging\runtime\windows-x64\ + Copy-Item "$env:RUNNER_TEMP\llama-build\bin\Release\*.dll" packaging\runtime\windows-x64\ -ErrorAction SilentlyContinue + + - name: Download bundled Qwen model + if: env.BUNDLE_LOCAL_MODEL == '1' + shell: pwsh + run: | + $targetDir = "packaging\models\qwen" + New-Item -ItemType Directory -Force $targetDir | Out-Null + $target = Join-Path $targetDir $env:DEFAULT_QWEN_MODEL_FILENAME + if (-not (Test-Path $target)) { + $urls = $env:DEFAULT_QWEN_MODEL_URLS -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $errors = @() + foreach ($url in $urls) { + Write-Host "Downloading bundled Qwen model from $url" + if (Test-Path $target) { + Remove-Item $target -Force + } + curl.exe -L --fail --retry 5 --retry-delay 10 --retry-all-errors --connect-timeout 30 --max-time 2400 --output $target $url + if ($LASTEXITCODE -ne 0) { + $errors += "$url failed with curl exit code $LASTEXITCODE" + if (Test-Path $target) { + Remove-Item $target -Force + } + continue + } + $size = (Get-Item $target).Length + if ($size -ge 1000000000) { + break + } + $errors += "$url downloaded a too-small file: $size bytes" + Remove-Item $target -Force + } + if (-not (Test-Path $target)) { + throw "Failed to download bundled model. Attempts: $($errors -join '; ')" + } + } + $size = (Get-Item $target).Length + if ($size -lt 1000000000) { + throw "Downloaded model looks too small: $size bytes" + } + Write-Host "Bundled model ready: $target ($size bytes)" + + - name: Run backend tests + run: python -m pip install ".[test,desktop]" && python -m pytest -q + + - name: Build Windows portable and installer packages + shell: pwsh + run: | + $arguments = @{ Python = "python"; Installer = $true } + if ($env:BUNDLE_LOCAL_MODEL -eq "1") { + $arguments.BundleLocalModel = $true + } + .\scripts\build_windows_app.ps1 @arguments + + - name: Verify Windows portable package + shell: pwsh + run: | + $runtime = "dist\小A简历筛选\_internal\packaging\runtime\windows-x64\llama-server.exe" + if (-not (Test-Path $runtime)) { + throw "Windows portable package is missing $runtime" + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zipPath = "dist\小A简历筛选-windows-x64.zip" + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $match = $zip.Entries | Where-Object { $_.FullName -like "*packaging/runtime/windows-x64/llama-server.exe" } | Select-Object -First 1 + if (-not $match) { + throw "Windows portable zip is missing packaging/runtime/windows-x64/llama-server.exe" + } + if ($env:BUNDLE_LOCAL_MODEL -eq "1") { + $modelEntry = $zip.Entries | Where-Object { $_.FullName -like "*packaging/models/qwen/$env:DEFAULT_QWEN_MODEL_FILENAME" } | Select-Object -First 1 + if (-not $modelEntry) { + throw "Windows portable zip is missing bundled local model" + } + } + } finally { + $zip.Dispose() + } + $setup = "dist\小A简历筛选-windows-x64-setup.exe" + if (-not (Test-Path $setup)) { + throw "Windows installer package is missing $setup" + } + if ($env:BUNDLE_LOCAL_MODEL -eq "1") { + $setupBins = Get-ChildItem -Path dist -Filter "小A简历筛选-windows-x64-setup*.bin" + if (-not $setupBins) { + throw "Windows offline installer is missing setup bin slices" + } + $model = "dist\小A简历筛选\_internal\packaging\models\qwen\$env:DEFAULT_QWEN_MODEL_FILENAME" + if (-not (Test-Path $model)) { + throw "Windows package is missing bundled local model $model" + } + } + + - name: Upload Windows packages + uses: actions/upload-artifact@v4 + with: + name: ${{ env.WINDOWS_ARTIFACT_NAME }} + path: | + dist/小A简历筛选-windows-x64.zip + dist/小A简历筛选-windows-x64-setup.exe + dist/小A简历筛选-windows-x64-setup*.bin + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6007d99..55c3a58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,16 @@ .scratch/ .worktrees/ +.superpowers/ node_modules +__pycache__/ +*.egg-info/ +.DS_Store data/ logs/ outputs/ +build/ +dist/ +packaging/models/ +*.spec.bak .env config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa57063 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Resume Screening CLI + +## Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install ".[test]" +cp config.example.yaml config.yaml +``` + +Edit `config.yaml` and set `model.base_url`, `model.api_key`, and `model.model`. + +If the resume filename job name differs from the job workbook sheet name, add aliases: + +```yaml +job_aliases: + 后端开发工程师: "全栈" + 后端开发实习岗: "全栈" +``` + +## Run + +```bash +resume-screening run --config config.yaml +``` + +The tool appends rows to `/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx`. +It rereads `/Users/mac/Downloads/小A自动化岗位说明书_副本.xlsx` on every run. +Historical rows are preserved. +Duplicate resume content is still appended and marked red. +The resume folder is scanned recursively. All visible regular files are processed; unsupported formats are appended as `待人工二筛` with an extraction note. +Supported filename formats include `【岗位_地点 薪资】姓名 工龄.pdf` and `岗位_地点_薪资_姓名_工龄.pdf`. +The job workbook can use the original one-sheet-per-job layout or a tabular layout where each row contains a `岗位名称`. + +## Web Interface + +Double-click `启动简历筛选网页版.command` from this project folder. + +On first launch, the script creates or reuses `.venv`, installs the tool, creates `config.yaml` from `config.example.yaml` if needed, builds the React page if needed, and opens `http://127.0.0.1:8765`. + +The web page has four steps: + +1. Save model settings. +2. Confirm resume folder, job requirements workbook, result workbook paths, and optional job aliases. +3. Run a lightweight precheck. +4. Start screening and watch live progress, logs, and final statistics. + +## Local Desktop App + +Build a portable macOS app bundle: + +```bash +source .venv/bin/activate +bash scripts/build_macos_app.sh +``` + +The outputs are `dist/小A简历筛选.app` and `dist/小A简历筛选-mac.zip`. + +The app stores its config at `~/Library/Application Support/小A简历筛选/config.yaml`. +When copied to a new Mac, copy `小A简历筛选-mac.zip`, unzip it, launch the app, and reselect the local resume folder, job workbook, result workbook, and model API settings. +The app bundles the Python runtime and project dependencies. OCR still depends on a local `tesseract` installation if image or scanned-PDF recognition is needed. +The app is built for the architecture of the Mac that runs the build script. The current generated package is for Apple Silicon; build again on Intel Mac if an Intel-only Mac needs to run it. + +Build Windows packages on Windows: + +```powershell +.\scripts\build_windows_app.ps1 -Python python -Installer +``` + +The outputs are `dist\小A简历筛选-windows-x64.zip` and `dist\小A简历筛选-windows-x64-setup.exe`. +The installer checks WebView2 and can download it after the user confirms the selected installer task. +Qwen model weights are not bundled; the app checks the local model environment on first launch and downloads the GGUF model only after the user confirms in the app. + +## Manual-Review Mode + +By default, `model.fallback_to_local_when_unavailable: true` lets the web app switch from an unavailable custom OpenAI-compatible model to the bundled local Qwen model during precheck. + +Set `model.allow_without_model: true` to process files without model calls. +Rows that require model judgment are classified as `待人工二筛`. +When `base_url`, `api_key`, and `model` are all set, the tool uses the model first even if fallback mode is enabled. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..d0daa9f --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,51 @@ +resume_dir: "/Users/mac/Downloads" +job_book: "/Users/mac/Downloads/小A自动化岗位说明书_副本.xlsx" +result_book: "/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx" + +job_aliases: {} +# 示例: +# job_aliases: +# 后端开发工程师: "全栈" +# 后端开发实习岗: "全栈" + +job_profile_overrides: {} +# 预检页确认岗位画像后会自动写入: +# job_profile_overrides: +# 全栈: "自定义岗位核心画像..." + +default_source_channel: "" +default_interviewer: "" +index_path: "data/processed_index.json" +ocr_command: "tesseract" + +model: + provider: "openai-compatible" + base_url: "" + api_key: "" + model: "" + timeout_seconds: 60 + temperature: 0.1 + allow_without_model: false + fallback_to_local_when_unavailable: true + local: + runtime: "llama.cpp" + model_family: "qwen3.5" + model_display_name: "Qwen3.5 本地模型" + model_path: "" + manifest_path: "models/qwen/manifest.json" + host: "127.0.0.1" + port: 18080 + context_size: 8192 + threads: 0 + gpu_layers: "auto" + auto_start: true + auto_download: false + +screening: + score_pass: 7 + score_excellent: 9 + categories: + recommend: "推荐初试" + consider: "可考虑" + manual: "待人工二筛" + reject: "未通过" diff --git a/docs/superpowers/plans/2026-05-19-resume-screening-web.md b/docs/superpowers/plans/2026-05-19-resume-screening-web.md new file mode 100644 index 0000000..9d4e381 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-resume-screening-web.md @@ -0,0 +1,1786 @@ +# Resume Screening Web Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a double-click local web interface that lets the user configure, precheck, run, and monitor the existing resume screening automation from a React wizard. + +**Architecture:** Keep the existing CLI screening pipeline as the core engine. Add a FastAPI backend that reads and writes `config.yaml`, runs lightweight prechecks, starts one background screening run at a time, and serves the React frontend. The React app only talks to the local backend; all local file access stays in Python. + +**Tech Stack:** Python 3.11+, FastAPI, Uvicorn, Pydantic, PyYAML, OpenPyXL, Typer, pytest, React, Vite. + +--- + +## File Structure + +- Modify `pyproject.toml`: add FastAPI/Uvicorn dependencies and package static assets. +- Modify `src/resume_screening/cli.py`: add a `web` command for the local server. +- Modify `src/resume_screening/models.py`: add pipeline event dataclasses. +- Modify `src/resume_screening/pipeline.py`: accept an optional event handler and emit progress events. +- Create `src/resume_screening/web/__init__.py`: package marker for the web layer. +- Create `src/resume_screening/web/config_store.py`: read, merge, validate, and save local configuration. +- Create `src/resume_screening/web/precheck.py`: run lightweight local file and workbook checks. +- Create `src/resume_screening/web/runs.py`: manage background run state, logs, progress, statistics, and single-run locking. +- Create `src/resume_screening/web/app.py`: FastAPI app factory, JSON endpoints, and static React serving. +- Create `web/package.json`: frontend scripts and dependencies. +- Create `web/vite.config.js`: Vite build config that outputs static files into the Python package. +- Create `web/index.html`: React app shell. +- Create `web/src/main.jsx`: React entry point. +- Create `web/src/App.jsx`: wizard UI and API calls. +- Create `web/src/styles.css`: local app styling. +- Create `启动简历筛选网页版.command`: double-click launcher. +- Modify `README.md` and `使用说明.md`: add web startup and API configuration instructions. +- Create tests under `tests/` for web config, precheck, run manager, API endpoints, CLI help, and pipeline event emission. + +## Task 1: Add Web Dependencies and Config Store + +**Files:** +- Modify: `pyproject.toml` +- Create: `src/resume_screening/web/__init__.py` +- Create: `src/resume_screening/web/config_store.py` +- Test: `tests/test_web_config_store.py` + +- [ ] **Step 1: Write failing config-store tests** + +```python +# tests/test_web_config_store.py +from pathlib import Path + +import yaml + +from resume_screening.web.config_store import config_to_public_dict, load_config_for_web, save_config_for_web + + +def test_load_config_for_web_uses_example_defaults_when_missing(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + + data = load_config_for_web(config_path) + + assert data["resume_dir"] == "/Users/mac/Downloads" + assert data["job_book"].endswith("小A自动化岗位说明书.xlsx") + assert data["result_book"].endswith("小A科技(北京)组织招聘.xlsx") + assert data["model"]["allow_without_model"] is True + + +def test_save_config_for_web_round_trips_paths_and_model(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + saved = save_config_for_web( + config_path, + { + "resume_dir": str(tmp_path / "resumes"), + "job_book": str(tmp_path / "jobs.xlsx"), + "result_book": str(tmp_path / "result.xlsx"), + "default_source_channel": "Boss直聘", + "default_interviewer": "小A", + "index_path": str(tmp_path / "processed_index.json"), + "ocr_command": "tesseract", + "model": { + "provider": "openai-compatible", + "base_url": "https://api.example.com/v1", + "api_key": "sk-test", + "model": "gpt-test", + "timeout_seconds": 30, + "temperature": 0.1, + "allow_without_model": False, + }, + "screening": { + "score_pass": 8, + "score_excellent": 9, + "categories": { + "recommend": "推荐初试", + "consider": "可考虑", + "manual": "待人工二筛", + "reject": "未通过", + }, + }, + }, + ) + + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + + assert saved.resume_dir == tmp_path / "resumes" + assert raw["model"]["api_key"] == "sk-test" + assert raw["default_source_channel"] == "Boss直聘" + + +def test_config_to_public_dict_serializes_paths() -> None: + data = load_config_for_web(Path("config.example.yaml")) + + public = config_to_public_dict(data) + + assert isinstance(public["resume_dir"], str) + assert isinstance(public["model"]["timeout_seconds"], int) +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest tests/test_web_config_store.py -v` + +Expected: FAIL with `ModuleNotFoundError: No module named 'resume_screening.web'`. + +- [ ] **Step 3: Add dependencies and package data** + +```toml +# pyproject.toml +dependencies = [ + "typer>=0.12", + "pydantic>=2.7", + "pyyaml>=6.0", + "openpyxl>=3.1", + "pypdf>=4.2", + "python-docx>=1.1", + "python-pptx>=0.6", + "httpx>=0.27", + "fastapi>=0.111", + "uvicorn>=0.30", +] + +[tool.setuptools.package-data] +"resume_screening.web" = ["static/**/*"] +``` + +- [ ] **Step 4: Create the web package marker** + +```python +# src/resume_screening/web/__init__.py +"""Local web interface for resume screening.""" +``` + +- [ ] **Step 5: Implement config store helpers** + +```python +# src/resume_screening/web/config_store.py +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +from typing import Any + +import yaml + +from resume_screening.config import AppConfig + + +DEFAULT_CONFIG: dict[str, Any] = { + "resume_dir": "/Users/mac/Downloads", + "job_book": "/Users/mac/Downloads/小A自动化岗位说明书.xlsx", + "result_book": "/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx", + "default_source_channel": "", + "default_interviewer": "", + "index_path": "data/processed_index.json", + "ocr_command": "tesseract", + "model": { + "provider": "openai-compatible", + "base_url": "", + "api_key": "", + "model": "", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": True, + }, + "screening": { + "score_pass": 8, + "score_excellent": 9, + "categories": { + "recommend": "推荐初试", + "consider": "可考虑", + "manual": "待人工二筛", + "reject": "未通过", + }, + }, +} + + +def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: + merged = deepcopy(base) + for key, value in overlay.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _read_yaml_mapping(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError("config file must contain a YAML mapping") + return raw + + +def load_config_for_web(path: Path) -> dict[str, Any]: + return _deep_merge(DEFAULT_CONFIG, _read_yaml_mapping(path)) + + +def save_config_for_web(path: Path, data: dict[str, Any]) -> AppConfig: + merged = _deep_merge(DEFAULT_CONFIG, data) + validated = AppConfig.model_validate(merged) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(merged, allow_unicode=True, sort_keys=False), encoding="utf-8") + return validated + + +def config_to_public_dict(data: dict[str, Any]) -> dict[str, Any]: + public = deepcopy(data) + for key in ("resume_dir", "job_book", "result_book", "index_path"): + if key in public: + public[key] = str(public[key]) + return public +``` + +- [ ] **Step 6: Run tests and verify pass** + +Run: `pytest tests/test_web_config_store.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml src/resume_screening/web/__init__.py src/resume_screening/web/config_store.py tests/test_web_config_store.py +git commit -m "feat: add web config store" +``` + +## Task 2: Add Lightweight Precheck Service + +**Files:** +- Create: `src/resume_screening/web/precheck.py` +- Test: `tests/test_web_precheck.py` + +- [ ] **Step 1: Write failing precheck tests** + +```python +# tests/test_web_precheck.py +from pathlib import Path + +from openpyxl import Workbook + +from resume_screening.config import AppConfig +from resume_screening.web.precheck import run_precheck + + +def create_result_book(path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "多维表格" + sheet.append(["ID", "当前阶段", "候选人", "附件", "岗位方向", "来源渠道", "候选人摘要(妙记)", "量化打分", "可投入周期", "面试负责人", "下次动作日", "不推进原因", "推荐/触达人"]) + workbook.save(path) + + +def create_job_book(path: Path) -> None: + workbook = Workbook() + template = workbook.active + template.title = "模板" + job = workbook.create_sheet("财务总监") + job.append(["岗位名称", "财务总监", "学历", "本科"]) + job.append(["工作经验", "8年以上", "具体描述", "负责公司财务管理"]) + job.append(["1. 核心职责", "预算、核算、风控"]) + workbook.save(path) + + +def make_config(tmp_path: Path) -> AppConfig: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + (resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.pdf").write_text("fake", encoding="utf-8") + job_book = tmp_path / "jobs.xlsx" + result_book = tmp_path / "result.xlsx" + create_job_book(job_book) + create_result_book(result_book) + return AppConfig.model_validate( + { + "resume_dir": resume_dir, + "job_book": job_book, + "result_book": result_book, + "index_path": tmp_path / "processed_index.json", + "model": {"provider": "openai-compatible", "base_url": "", "api_key": "", "model": "", "allow_without_model": True}, + } + ) + + +def test_precheck_passes_for_valid_local_files(tmp_path: Path) -> None: + result = run_precheck(make_config(tmp_path)) + + assert result.status == "pass" + assert result.resume_file_count == 1 + assert any(item.name == "岗位说明书" and item.status == "pass" for item in result.items) + + +def test_precheck_fails_when_result_book_is_missing(tmp_path: Path) -> None: + config = make_config(tmp_path) + config.result_book.unlink() + + result = run_precheck(config) + + assert result.status == "fail" + assert any(item.name == "招聘结果表" and item.status == "fail" for item in result.items) +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest tests/test_web_precheck.py -v` + +Expected: FAIL because `resume_screening.web.precheck` does not exist. + +- [ ] **Step 3: Implement precheck service** + +```python +# src/resume_screening/web/precheck.py +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from openpyxl import load_workbook + +from resume_screening.config import AppConfig +from resume_screening.extractors import SUPPORTED_EXTENSIONS +from resume_screening.job_requirements import load_job_requirements + +CheckStatus = Literal["pass", "warning", "fail"] + + +@dataclass(frozen=True) +class PrecheckItem: + name: str + status: CheckStatus + message: str + + +@dataclass(frozen=True) +class PrecheckResult: + status: CheckStatus + resume_file_count: int + usable_job_sheet_count: int + items: list[PrecheckItem] = field(default_factory=list) + + +def _supported_resume_count(resume_dir: Path, job_book: Path, result_book: Path) -> int: + if not resume_dir.exists() or not resume_dir.is_dir(): + return 0 + skip = {job_book.resolve(), result_book.resolve()} + count = 0 + for path in resume_dir.iterdir(): + if not path.is_file(): + continue + if path.resolve() in skip: + continue + if path.suffix.lower() in SUPPORTED_EXTENSIONS: + count += 1 + return count + + +def _is_writable_file(path: Path) -> bool: + if not path.exists() or not path.is_file(): + return False + try: + with path.open("ab"): + return True + except OSError: + return False + + +def _overall_status(items: list[PrecheckItem]) -> CheckStatus: + statuses = [item.status for item in items] + if "fail" in statuses: + return "fail" + if "warning" in statuses: + return "warning" + return "pass" + + +def run_precheck(config: AppConfig) -> PrecheckResult: + items: list[PrecheckItem] = [] + + resume_count = _supported_resume_count(config.resume_dir, config.job_book, config.result_book) + if not config.resume_dir.exists() or not config.resume_dir.is_dir(): + items.append(PrecheckItem("简历文件夹", "fail", f"文件夹不存在:{config.resume_dir}")) + elif resume_count == 0: + items.append(PrecheckItem("简历文件夹", "warning", "没有找到支持格式的简历文件")) + else: + items.append(PrecheckItem("简历文件夹", "pass", f"找到 {resume_count} 个支持格式文件")) + + usable_jobs = 0 + if not config.job_book.exists(): + items.append(PrecheckItem("岗位说明书", "fail", f"文件不存在:{config.job_book}")) + else: + try: + jobs = load_job_requirements(config.job_book) + usable_jobs = sum(1 for job in jobs.values() if job.is_complete) + if usable_jobs == 0: + items.append(PrecheckItem("岗位说明书", "warning", "未找到完整岗位说明书 sheet")) + else: + items.append(PrecheckItem("岗位说明书", "pass", f"找到 {usable_jobs} 个完整岗位 sheet")) + except Exception as exc: + items.append(PrecheckItem("岗位说明书", "fail", f"读取失败:{exc}")) + + if not config.result_book.exists(): + items.append(PrecheckItem("招聘结果表", "fail", f"文件不存在:{config.result_book}")) + else: + try: + workbook = load_workbook(config.result_book) + workbook.close() + if _is_writable_file(config.result_book): + items.append(PrecheckItem("招聘结果表", "pass", "结果表存在且可写")) + else: + items.append(PrecheckItem("招聘结果表", "fail", "结果表不可写,可能已被 Excel 锁定")) + except Exception as exc: + items.append(PrecheckItem("招聘结果表", "fail", f"打开失败:{exc}")) + + model = config.model + missing_model = [name for name in ("base_url", "api_key", "model") if not getattr(model, name)] + if model.allow_without_model: + items.append(PrecheckItem("模型配置", "warning", "当前允许无模型运行,简历会进入待人工二筛")) + elif missing_model: + items.append(PrecheckItem("模型配置", "fail", f"缺少模型配置:{', '.join(missing_model)}")) + else: + items.append(PrecheckItem("模型配置", "pass", f"模型已配置:{model.model}")) + + return PrecheckResult( + status=_overall_status(items), + resume_file_count=resume_count, + usable_job_sheet_count=usable_jobs, + items=items, + ) +``` + +- [ ] **Step 4: Run tests and verify pass** + +Run: `pytest tests/test_web_precheck.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/resume_screening/web/precheck.py tests/test_web_precheck.py +git commit -m "feat: add web precheck service" +``` + +## Task 3: Emit Pipeline Progress Events + +**Files:** +- Modify: `src/resume_screening/models.py` +- Modify: `src/resume_screening/pipeline.py` +- Test: `tests/test_pipeline_events.py` + +- [ ] **Step 1: Write failing pipeline event test** + +```python +# tests/test_pipeline_events.py +from pathlib import Path + +from docx import Document +from openpyxl import Workbook + +from resume_screening.config import AppConfig +from resume_screening.models import PipelineEvent +from resume_screening.pipeline import ScreeningPipeline + + +def create_result_book(path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "多维表格" + sheet.append(["ID", "当前阶段", "候选人", "附件", "岗位方向", "来源渠道", "候选人摘要(妙记)", "量化打分", "可投入周期", "面试负责人", "下次动作日", "不推进原因", "推荐/触达人"]) + workbook.save(path) + + +def create_incomplete_job_book(path: Path) -> None: + workbook = Workbook() + template = workbook.active + template.title = "模板" + job = workbook.create_sheet("财务总监") + job.append(["岗位说明书"]) + job.append(["一、基本信息"]) + workbook.save(path) + + +def test_pipeline_emits_run_and_file_events(tmp_path: Path) -> None: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + resume = resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.docx" + document = Document() + document.add_paragraph("郭燕婷,10年以上财务经验。") + document.save(resume) + job_book = tmp_path / "jobs.xlsx" + result_book = tmp_path / "result.xlsx" + create_incomplete_job_book(job_book) + create_result_book(result_book) + events: list[PipelineEvent] = [] + config = AppConfig.model_validate( + { + "resume_dir": resume_dir, + "job_book": job_book, + "result_book": result_book, + "index_path": tmp_path / "processed_index.json", + "model": {"provider": "openai-compatible", "base_url": "", "api_key": "", "model": "", "allow_without_model": True}, + } + ) + + stats = ScreeningPipeline(config, event_handler=events.append).run() + + assert stats.processed == 1 + assert [event.type for event in events] == ["run_started", "file_started", "file_completed", "run_completed"] + assert events[1].filename == resume.name + assert events[-1].stats is stats +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest tests/test_pipeline_events.py -v` + +Expected: FAIL because `PipelineEvent` and the `event_handler` argument do not exist. + +- [ ] **Step 3: Add pipeline event model** + +```python +# src/resume_screening/models.py +from typing import Any, Literal + + +@dataclass(frozen=True) +class PipelineEvent: + type: Literal["run_started", "file_started", "file_completed", "run_completed", "warning"] + message: str + current: int = 0 + total: int = 0 + filename: str = "" + category: str = "" + stats: PipelineStats | None = None +``` + +- [ ] **Step 4: Emit events from the pipeline** + +```python +# src/resume_screening/pipeline.py +from collections.abc import Callable + +from resume_screening.models import PipelineEvent + + +class ScreeningPipeline: + def __init__(self, config: AppConfig, event_handler: Callable[[PipelineEvent], None] | None = None) -> None: + self.config = config + self.event_handler = event_handler + + def _emit(self, event: PipelineEvent) -> None: + if self.event_handler is not None: + self.event_handler(event) + + def run(self) -> PipelineStats: + jobs = load_job_requirements(self.config.job_book) + writer = ResultWorkbookWriter(self.config.result_book) + index = DuplicateIndex.load(self.config.index_path) + client = None if self.config.model.allow_without_model else ModelClient(self.config.model) + stats = PipelineStats() + files = self._resume_files() + self._emit(PipelineEvent("run_started", f"开始处理 {len(files)} 个文件", total=len(files))) + + for current, path in enumerate(files, start=1): + self._emit(PipelineEvent("file_started", f"正在处理:{path.name}", current=current, total=len(files), filename=path.name)) + parsed = parse_resume_filename(path) + extraction = extract_text(path, self.config.ocr_command) + duplicate = index.find(extraction.text) if extraction.text else DuplicateMatch(False) + result = self._screen(parsed, extraction, jobs, client) + row_id = writer.append_result( + parsed=parsed, + result=result, + duplicate=duplicate, + extraction_errors=extraction.errors, + default_source_channel=self.config.default_source_channel, + default_interviewer=self.config.default_interviewer, + ) + if extraction.text: + index.add(extraction.text, path.name, row_id, parsed.candidate_name, parsed.job_name) + stats.processed += 1 + stats.count_category(result.category) + if duplicate.is_duplicate: + stats.duplicate += 1 + if not parsed.is_standard: + stats.non_standard_names += 1 + if extraction.errors: + stats.extraction_failures += 1 + if any(item.startswith("模型评估失败") for item in result.missing_information): + stats.model_failures += 1 + self._emit(PipelineEvent("file_completed", f"完成:{path.name} -> {result.category}", current=current, total=len(files), filename=path.name, category=result.category, stats=stats)) + + writer.save() + index.save() + self._emit(PipelineEvent("run_completed", "处理完成", current=len(files), total=len(files), stats=stats)) + return stats +``` + +- [ ] **Step 5: Run focused tests** + +Run: `pytest tests/test_pipeline_events.py tests/test_pipeline.py tests/test_cli.py -v` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/resume_screening/models.py src/resume_screening/pipeline.py tests/test_pipeline_events.py +git commit -m "feat: emit pipeline progress events" +``` + +## Task 4: Add Background Run Manager + +**Files:** +- Create: `src/resume_screening/web/runs.py` +- Test: `tests/test_web_runs.py` + +- [ ] **Step 1: Write failing run-manager tests** + +```python +# tests/test_web_runs.py +from pathlib import Path +from typing import Callable + +import yaml + +from resume_screening.models import PipelineEvent, PipelineStats +from resume_screening.web.runs import RunAlreadyActive, RunManager + + +def write_config(path: Path, tmp_path: Path) -> None: + path.write_text( + yaml.safe_dump( + { + "resume_dir": str(tmp_path), + "job_book": str(tmp_path / "jobs.xlsx"), + "result_book": str(tmp_path / "result.xlsx"), + "index_path": str(tmp_path / "processed_index.json"), + "model": {"provider": "openai-compatible", "base_url": "", "api_key": "", "model": "", "allow_without_model": True}, + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + +class FakePipeline: + def __init__(self, event_handler: Callable[[PipelineEvent], None]) -> None: + self.event_handler = event_handler + + def run(self) -> PipelineStats: + stats = PipelineStats(processed=1, manual=1) + self.event_handler(PipelineEvent("run_started", "开始处理 1 个文件", total=1)) + self.event_handler(PipelineEvent("file_started", "正在处理:a.pdf", current=1, total=1, filename="a.pdf")) + self.event_handler(PipelineEvent("run_completed", "处理完成", current=1, total=1, stats=stats)) + return stats + + +def test_run_manager_records_completed_status(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + write_config(config_path, tmp_path) + manager = RunManager(config_path, pipeline_factory=lambda config, handler: FakePipeline(handler)) + + run = manager.start() + manager.wait(run.run_id, timeout_seconds=2) + snapshot = manager.get(run.run_id) + + assert snapshot.state == "completed" + assert snapshot.stats["processed"] == 1 + assert snapshot.logs[-1]["message"] == "处理完成" + + +def test_run_manager_rejects_second_active_run(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + write_config(config_path, tmp_path) + + class BlockingPipeline: + def __init__(self, event_handler: Callable[[PipelineEvent], None]) -> None: + self.event_handler = event_handler + + def run(self) -> PipelineStats: + self.event_handler(PipelineEvent("run_started", "开始处理", total=1)) + import time + + time.sleep(0.5) + return PipelineStats() + + manager = RunManager(config_path, pipeline_factory=lambda config, handler: BlockingPipeline(handler)) + first = manager.start() + + try: + try: + manager.start() + raise AssertionError("second run should fail") + except RunAlreadyActive as exc: + assert exc.run_id == first.run_id + finally: + manager.wait(first.run_id, timeout_seconds=2) +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest tests/test_web_runs.py -v` + +Expected: FAIL because `resume_screening.web.runs` does not exist. + +- [ ] **Step 3: Implement run manager** + +```python +# src/resume_screening/web/runs.py +from __future__ import annotations + +from collections.abc import Callable +from copy import deepcopy +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from threading import Lock, Thread +from typing import Literal +from uuid import uuid4 + +from resume_screening.config import AppConfig, load_config +from resume_screening.models import PipelineEvent, PipelineStats +from resume_screening.pipeline import ScreeningPipeline + +RunState = Literal["queued", "running", "completed", "failed"] + + +class RunAlreadyActive(RuntimeError): + def __init__(self, run_id: str) -> None: + super().__init__(f"run already active: {run_id}") + self.run_id = run_id + + +@dataclass(frozen=True) +class RunLog: + timestamp: str + level: str + message: str + + +@dataclass +class RunSnapshot: + run_id: str + state: RunState + current: int = 0 + total: int = 0 + current_file: str = "" + logs: list[dict[str, str]] = field(default_factory=list) + stats: dict[str, int] = field(default_factory=dict) + error: str = "" + + +def _stats_dict(stats: PipelineStats | None) -> dict[str, int]: + if stats is None: + return {} + return { + "processed": stats.processed, + "recommend": stats.recommend, + "consider": stats.consider, + "manual": stats.manual, + "reject": stats.reject, + "duplicate": stats.duplicate, + "model_failures": stats.model_failures, + "extraction_failures": stats.extraction_failures, + "non_standard_names": stats.non_standard_names, + } + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class RunManager: + def __init__( + self, + config_path: Path, + pipeline_factory: Callable[[AppConfig, Callable[[PipelineEvent], None]], object] | None = None, + ) -> None: + self.config_path = config_path + self.pipeline_factory = pipeline_factory or (lambda config, handler: ScreeningPipeline(config, event_handler=handler)) + self._lock = Lock() + self._runs: dict[str, RunSnapshot] = {} + self._threads: dict[str, Thread] = {} + self._active_run_id: str | None = None + + def start(self) -> RunSnapshot: + with self._lock: + if self._active_run_id: + active = self._runs[self._active_run_id] + if active.state in {"queued", "running"}: + raise RunAlreadyActive(self._active_run_id) + run_id = uuid4().hex + snapshot = RunSnapshot(run_id=run_id, state="queued") + self._runs[run_id] = snapshot + self._active_run_id = run_id + thread = Thread(target=self._run_worker, args=(run_id,), daemon=True) + self._threads[run_id] = thread + thread.start() + return deepcopy(snapshot) + + def get(self, run_id: str) -> RunSnapshot: + with self._lock: + if run_id not in self._runs: + raise KeyError(run_id) + return deepcopy(self._runs[run_id]) + + def wait(self, run_id: str, timeout_seconds: float) -> None: + thread = self._threads[run_id] + thread.join(timeout=timeout_seconds) + + def _append_log(self, run_id: str, level: str, message: str) -> None: + snapshot = self._runs[run_id] + snapshot.logs.append(asdict(RunLog(timestamp=_now(), level=level, message=message))) + snapshot.logs = snapshot.logs[-200:] + + def _handle_event(self, run_id: str, event: PipelineEvent) -> None: + with self._lock: + snapshot = self._runs[run_id] + snapshot.current = event.current + snapshot.total = event.total + snapshot.current_file = event.filename + if event.stats is not None: + snapshot.stats = _stats_dict(event.stats) + self._append_log(run_id, "info", event.message) + + def _run_worker(self, run_id: str) -> None: + try: + with self._lock: + self._runs[run_id].state = "running" + self._append_log(run_id, "info", "筛选任务已启动") + config = load_config(self.config_path) + pipeline = self.pipeline_factory(config, lambda event: self._handle_event(run_id, event)) + stats = pipeline.run() + with self._lock: + self._runs[run_id].state = "completed" + self._runs[run_id].stats = _stats_dict(stats) + self._append_log(run_id, "info", "筛选任务已完成") + self._active_run_id = None + except Exception as exc: + with self._lock: + self._runs[run_id].state = "failed" + self._runs[run_id].error = str(exc) + self._append_log(run_id, "error", f"筛选任务失败:{exc}") + self._active_run_id = None +``` + +- [ ] **Step 4: Run focused tests** + +Run: `pytest tests/test_web_runs.py tests/test_pipeline_events.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/resume_screening/web/runs.py tests/test_web_runs.py +git commit -m "feat: add web run manager" +``` + +## Task 5: Add FastAPI Backend + +**Files:** +- Create: `src/resume_screening/web/app.py` +- Test: `tests/test_web_api.py` + +- [ ] **Step 1: Write failing API tests** + +```python +# tests/test_web_api.py +from pathlib import Path + +from fastapi.testclient import TestClient +from openpyxl import Workbook + +from resume_screening.web.app import create_app + + +def create_result_book(path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "多维表格" + sheet.append(["ID", "当前阶段", "候选人", "附件", "岗位方向", "来源渠道", "候选人摘要(妙记)", "量化打分", "可投入周期", "面试负责人", "下次动作日", "不推进原因", "推荐/触达人"]) + workbook.save(path) + + +def create_job_book(path: Path) -> None: + workbook = Workbook() + workbook.active.title = "模板" + job = workbook.create_sheet("财务总监") + job.append(["岗位名称", "财务总监", "学历", "本科"]) + job.append(["工作经验", "8年以上", "具体描述", "负责公司财务管理"]) + job.append(["1. 核心职责", "预算、核算、风控"]) + workbook.save(path) + + +def valid_payload(tmp_path: Path) -> dict: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + (resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.pdf").write_text("fake", encoding="utf-8") + job_book = tmp_path / "jobs.xlsx" + result_book = tmp_path / "result.xlsx" + create_job_book(job_book) + create_result_book(result_book) + return { + "resume_dir": str(resume_dir), + "job_book": str(job_book), + "result_book": str(result_book), + "index_path": str(tmp_path / "processed_index.json"), + "ocr_command": "tesseract", + "model": {"provider": "openai-compatible", "base_url": "", "api_key": "", "model": "", "timeout_seconds": 60, "temperature": 0.1, "allow_without_model": True}, + } + + +def test_config_endpoints_round_trip(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + client = TestClient(create_app(config_path=config_path)) + + response = client.post("/api/config", json=valid_payload(tmp_path)) + assert response.status_code == 200 + assert response.json()["config"]["resume_dir"].endswith("resumes") + + response = client.get("/api/config") + assert response.status_code == 200 + assert response.json()["config"]["result_book"].endswith("result.xlsx") + + +def test_precheck_endpoint_returns_items(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + client = TestClient(create_app(config_path=config_path)) + client.post("/api/config", json=valid_payload(tmp_path)) + + response = client.get("/api/precheck") + + assert response.status_code == 200 + body = response.json() + assert body["status"] in {"pass", "warning"} + assert body["resume_file_count"] == 1 + assert body["items"] +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `pytest tests/test_web_api.py -v` + +Expected: FAIL because `resume_screening.web.app` does not exist. + +- [ ] **Step 3: Implement FastAPI app factory** + +```python +# src/resume_screening/web/app.py +from __future__ import annotations + +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import ValidationError + +from resume_screening.config import AppConfig +from resume_screening.web.config_store import config_to_public_dict, load_config_for_web, save_config_for_web +from resume_screening.web.precheck import run_precheck +from resume_screening.web.runs import RunAlreadyActive, RunManager + + +def _jsonable(value: Any) -> Any: + if is_dataclass(value): + return asdict(value) + if isinstance(value, Path): + return str(value) + if isinstance(value, list): + return [_jsonable(item) for item in value] + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + return value + + +def create_app(config_path: Path = Path("config.yaml"), static_dir: Path | None = None) -> FastAPI: + app = FastAPI(title="Resume Screening Web") + manager = RunManager(config_path) + static_root = static_dir or Path(__file__).parent / "static" + + @app.get("/api/config") + def get_config() -> dict[str, Any]: + data = load_config_for_web(config_path) + return {"config": config_to_public_dict(data)} + + @app.post("/api/config") + def post_config(payload: dict[str, Any]) -> dict[str, Any]: + try: + saved = save_config_for_web(config_path, payload) + except (ValueError, ValidationError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"config": saved.model_dump(mode="json")} + + @app.get("/api/precheck") + def get_precheck() -> dict[str, Any]: + try: + config = AppConfig.model_validate(load_config_for_web(config_path)) + except (ValueError, ValidationError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return _jsonable(run_precheck(config)) + + @app.post("/api/runs") + def start_run() -> dict[str, Any]: + try: + return {"run": _jsonable(manager.start())} + except RunAlreadyActive as exc: + raise HTTPException(status_code=409, detail={"message": "已有筛选任务运行中", "run_id": exc.run_id}) from exc + + @app.get("/api/runs/{run_id}") + def get_run(run_id: str) -> dict[str, Any]: + try: + return {"run": _jsonable(manager.get(run_id))} + except KeyError as exc: + raise HTTPException(status_code=404, detail="run not found") from exc + + if static_root.exists(): + assets = static_root / "assets" + if assets.exists(): + app.mount("/assets", StaticFiles(directory=assets), name="assets") + + @app.get("/{path:path}") + def frontend(path: str) -> FileResponse: + target = static_root / path + if path and target.exists() and target.is_file(): + return FileResponse(target) + return FileResponse(static_root / "index.html") + + return app +``` + +- [ ] **Step 4: Run API tests** + +Run: `pytest tests/test_web_api.py tests/test_web_config_store.py tests/test_web_precheck.py -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/resume_screening/web/app.py tests/test_web_api.py +git commit -m "feat: add local web api" +``` + +## Task 6: Add Web CLI Command and Double-Click Launcher + +**Files:** +- Modify: `src/resume_screening/cli.py` +- Create: `启动简历筛选网页版.command` +- Modify: `tests/test_cli.py` + +- [ ] **Step 1: Extend CLI tests** + +```python +# tests/test_cli.py +def test_cli_module_help_includes_web_command() -> None: + result = subprocess.run( + [sys.executable, "-m", "resume_screening.cli", "--help"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "web" in result.stdout +``` + +- [ ] **Step 2: Run CLI test and verify failure** + +Run: `pytest tests/test_cli.py::test_cli_module_help_includes_web_command -v` + +Expected: FAIL because the `web` command is not registered. + +- [ ] **Step 3: Add the web command** + +```python +# src/resume_screening/cli.py +@app.command() +def web( + config: Path = typer.Option(Path("config.yaml"), "--config", "-c"), + host: str = typer.Option("127.0.0.1", "--host"), + port: int = typer.Option(8765, "--port"), +) -> None: + """Start the local web interface.""" + import uvicorn + + from resume_screening.web.app import create_app + + typer.echo(f"启动本地网页:http://{host}:{port}") + uvicorn.run(create_app(config_path=config), host=host, port=port) +``` + +- [ ] **Step 4: Create the double-click launcher** + +```bash +# 启动简历筛选网页版.command +#!/bin/zsh +set -e + +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$PROJECT_DIR" + +if [ ! -d ".venv" ]; then + python3 -m venv .venv +fi + +source ".venv/bin/activate" +python -m pip install -e . + +if [ ! -f "config.yaml" ]; then + cp "config.example.yaml" "config.yaml" +fi + +if [ ! -f "src/resume_screening/web/static/index.html" ]; then + if command -v npm >/dev/null 2>&1; then + (cd web && npm install && npm run build) + else + echo "没有找到 npm,无法构建 React 页面。请先安装 Node.js,或联系 Codex 帮你构建一次。" + exit 1 + fi +fi + +open "http://127.0.0.1:8765" +resume-screening web --config config.yaml --host 127.0.0.1 --port 8765 +``` + +- [ ] **Step 5: Mark launcher executable** + +Run: `chmod +x 启动简历筛选网页版.command` + +Expected: command exits with code 0. + +- [ ] **Step 6: Run CLI tests** + +Run: `pytest tests/test_cli.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/resume_screening/cli.py tests/test_cli.py 启动简历筛选网页版.command +git commit -m "feat: add web launcher command" +``` + +## Task 7: Build the React Wizard + +**Files:** +- Create: `web/package.json` +- Create: `web/vite.config.js` +- Create: `web/index.html` +- Create: `web/src/main.jsx` +- Create: `web/src/App.jsx` +- Create: `web/src/styles.css` + +- [ ] **Step 1: Create frontend package files** + +```json +{ + "name": "resume-screening-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.4.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "lucide-react": "^0.468.0" + }, + "devDependencies": {} +} +``` + +```javascript +// web/vite.config.js +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "../src/resume_screening/web/static", + emptyOutDir: true, + rollupOptions: { + output: { + entryFileNames: "assets/app.js", + chunkFileNames: "assets/[name].js", + assetFileNames: "assets/[name][extname]" + } + } + } +}); +``` + +```html + + + + + + + 简历初筛工具 + + +
+ + + +``` + +- [ ] **Step 2: Create React entry point** + +```javascript +// web/src/main.jsx +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.jsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")).render( + + + +); +``` + +- [ ] **Step 3: Create the wizard component** + +```javascript +// web/src/App.jsx +import { useEffect, useMemo, useState } from "react"; +import { CheckCircle2, CircleAlert, FileSpreadsheet, KeyRound, Loader2, Play, Save } from "lucide-react"; + +const steps = ["文件路径", "模型配置", "运行前预检", "开始筛选"]; + +const emptyConfig = { + resume_dir: "", + job_book: "", + result_book: "", + default_source_channel: "", + default_interviewer: "", + index_path: "data/processed_index.json", + ocr_command: "tesseract", + model: { + provider: "openai-compatible", + base_url: "", + api_key: "", + model: "", + timeout_seconds: 60, + temperature: 0.1, + allow_without_model: true + } +}; + +async function requestJson(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...options + }); + const body = await response.json(); + if (!response.ok) { + throw new Error(typeof body.detail === "string" ? body.detail : body.detail?.message || "请求失败"); + } + return body; +} + +export function App() { + const [step, setStep] = useState(0); + const [config, setConfig] = useState(emptyConfig); + const [notice, setNotice] = useState(""); + const [error, setError] = useState(""); + const [precheck, setPrecheck] = useState(null); + const [run, setRun] = useState(null); + const [runId, setRunId] = useState(""); + + useEffect(() => { + requestJson("/api/config") + .then((body) => setConfig({ ...emptyConfig, ...body.config, model: { ...emptyConfig.model, ...body.config.model } })) + .catch((exc) => setError(exc.message)); + }, []); + + useEffect(() => { + if (!runId || run?.state === "completed" || run?.state === "failed") return; + const timer = window.setInterval(() => { + requestJson(`/api/runs/${runId}`) + .then((body) => setRun(body.run)) + .catch((exc) => setError(exc.message)); + }, 1200); + return () => window.clearInterval(timer); + }, [runId, run?.state]); + + const progress = useMemo(() => { + if (!run || !run.total) return 0; + return Math.round((run.current / run.total) * 100); + }, [run]); + + function updateField(name, value) { + setConfig((current) => ({ ...current, [name]: value })); + } + + function updateModel(name, value) { + setConfig((current) => ({ ...current, model: { ...current.model, [name]: value } })); + } + + async function saveConfig() { + setError(""); + setNotice(""); + const body = await requestJson("/api/config", { method: "POST", body: JSON.stringify(config) }); + setConfig({ ...emptyConfig, ...body.config, model: { ...emptyConfig.model, ...body.config.model } }); + setNotice("配置已保存到本地 config.yaml"); + } + + async function runPrecheck() { + setError(""); + const body = await requestJson("/api/precheck"); + setPrecheck(body); + setStep(2); + } + + async function startRun() { + setError(""); + const body = await requestJson("/api/runs", { method: "POST", body: "{}" }); + setRun(body.run); + setRunId(body.run.run_id); + setStep(3); + } + + return ( +
+
+
+

本地运行

+

简历初筛工具

+
+
+ {steps.map((label, index) => ( + + ))} +
+
+ + {notice &&
{notice}
} + {error &&
{error}
} + + {step === 0 && ( +
+

文件路径

+ + + +
+ + +
+
+ )} + + {step === 1 && ( +
+

模型配置

+ + + + + +
+ + +
+
+ )} + + {step === 2 && ( +
+

运行前预检

+ {!precheck && } + {precheck && ( +
+
支持格式简历:{precheck.resume_file_count} 个
+ {precheck.items.map((item) =>
{item.name}{item.message}
)} + +
+ )} +
+ )} + + {step === 3 && ( +
+

{run?.state === "running" ? : }开始筛选

+ {!run && } + {run && ( + <> +
+

{run.current_file || "等待任务更新"} {run.total ? `${run.current}/${run.total}` : ""}

+
+ {Object.entries(run.stats || {}).map(([key, value]) =>
{value}{key}
)} +
+
+ {(run.logs || []).map((log, index) =>

{log.message}

)} +
+ {run.error &&
{run.error}
} + + )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Add responsive styling** + +```css +/* web/src/styles.css */ +:root { + color: #172033; + background: #f5f7fb; + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +body { margin: 0; } +button, input { font: inherit; } + +.app-shell { + max-width: 1120px; + margin: 0 auto; + padding: 28px; +} + +.topbar { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-end; + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 6px; + color: #526070; + font-size: 14px; +} + +h1, h2 { margin: 0; } +h1 { font-size: 34px; } +h2 { + display: flex; + align-items: center; + gap: 10px; + font-size: 22px; + margin-bottom: 20px; +} + +.stepper { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.stepper button, +.actions button, +.checks button { + border: 1px solid #ccd4df; + border-radius: 8px; + background: #fff; + color: #172033; + min-height: 40px; + padding: 0 14px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; +} + +.stepper .active, +.primary { + background: #1f6feb !important; + border-color: #1f6feb !important; + color: #fff !important; +} + +.panel { + background: #fff; + border: 1px solid #d9e0ea; + border-radius: 8px; + padding: 24px; +} + +label { + display: grid; + gap: 8px; + margin-bottom: 16px; + color: #344054; +} + +input { + width: 100%; + min-height: 42px; + border: 1px solid #ccd4df; + border-radius: 8px; + padding: 0 12px; +} + +.checkbox { + grid-template-columns: 18px 1fr; + align-items: center; +} + +.checkbox input { min-height: auto; } + +.actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} + +.notice { + display: flex; + align-items: center; + gap: 8px; + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 14px; +} + +.success { background: #e8f7ee; color: #176c3a; } +.danger { background: #fdecec; color: #b42318; } + +.checks { + display: grid; + gap: 12px; +} + +.summary, +.check { + border-radius: 8px; + padding: 14px; + border: 1px solid #d9e0ea; +} + +.check { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.pass { border-color: #92d7aa; } +.warning { border-color: #f4c779; } +.fail { border-color: #ef9a9a; } + +.progress { + height: 10px; + background: #e8edf5; + border-radius: 999px; + overflow: hidden; +} + +.progress span { + display: block; + height: 100%; + background: #1f6feb; + transition: width 0.2s ease; +} + +.muted { color: #526070; } + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.stats div { + border: 1px solid #d9e0ea; + border-radius: 8px; + padding: 12px; +} + +.stats strong { + display: block; + font-size: 24px; +} + +.stats span { + color: #526070; + font-size: 13px; +} + +.logs { + height: 260px; + overflow: auto; + border: 1px solid #d9e0ea; + border-radius: 8px; + background: #0f172a; + padding: 14px; +} + +.logs p { + margin: 0 0 8px; + color: #dbeafe; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +.logs .error { color: #fecaca; } + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 760px) { + .app-shell { padding: 16px; } + .topbar { display: block; } + .stepper { grid-template-columns: 1fr; margin-top: 18px; } + .actions { flex-direction: column; } + .check { display: grid; } +} +``` + +- [ ] **Step 5: Build frontend** + +Run: + +```bash +cd web +npm install +npm run build +``` + +Expected: build succeeds and creates `src/resume_screening/web/static/index.html` from the project root. + +- [ ] **Step 6: Commit** + +```bash +git add web src/resume_screening/web/static +git commit -m "feat: add react web wizard" +``` + +## Task 8: Document Web Usage and Verify End to End + +**Files:** +- Modify: `README.md` +- Modify: `使用说明.md` + +- [ ] **Step 1: Add concise web usage docs** + +```markdown +## 网页版使用 + +双击项目目录里的 `启动简历筛选网页版.command`。 + +第一次启动时脚本会: + +1. 创建或复用 `.venv` +2. 安装当前工具 +3. 如果没有 `config.yaml`,从 `config.example.yaml` 复制一份 +4. 如果 React 页面还没有构建,自动运行 `npm install && npm run build` +5. 打开 `http://127.0.0.1:8765` + +页面分四步: + +1. 文件路径:确认简历文件夹、岗位说明书、招聘结果表 +2. 模型配置:填写 API 地址、API Key、模型名、超时时间 +3. 运行前预检:检查文件数量、岗位 sheet、结果表可写、模型配置 +4. 开始筛选:查看实时进度、日志和统计 + +如果模型超时或报错,工具会把对应简历写成 `待人工二筛`,并在日志里显示原因。 +``` + +- [ ] **Step 2: Run Python tests** + +Run: `pytest -v` + +Expected: PASS. + +- [ ] **Step 3: Run frontend build** + +Run: + +```bash +cd web +npm run build +``` + +Expected: PASS with Vite build output. + +- [ ] **Step 4: Smoke-test the web server** + +Run: + +```bash +.venv/bin/resume-screening web --config config.example.yaml --host 127.0.0.1 --port 8765 +``` + +Expected: server prints `启动本地网页:http://127.0.0.1:8765`. + +Open `http://127.0.0.1:8765` in the in-app browser. Confirm the page loads and the four wizard steps are visible. Stop the server after verification. + +- [ ] **Step 5: Commit** + +```bash +git add README.md 使用说明.md +git commit -m "docs: explain web interface usage" +``` + +## Task 9: Final Verification + +**Files:** +- No source files changed in this task. + +- [ ] **Step 1: Check worktree status** + +Run: `git status --short` + +Expected: no output. + +- [ ] **Step 2: Run full backend tests** + +Run: `pytest -v` + +Expected: PASS. + +- [ ] **Step 3: Run frontend build from a clean shell** + +Run: + +```bash +cd web +npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Verify command help** + +Run: `.venv/bin/resume-screening --help` + +Expected: output includes both `run` and `web`. + +- [ ] **Step 5: Verify local page responds** + +Run the server: + +```bash +.venv/bin/resume-screening web --config config.example.yaml --host 127.0.0.1 --port 8765 +``` + +Open `http://127.0.0.1:8765`. Expected: React wizard loads, `GET /api/config` succeeds, and the UI shows default local paths. Stop the server after verification. diff --git a/docs/superpowers/plans/2026-06-01-ios26-high-contrast-ui.md b/docs/superpowers/plans/2026-06-01-ios26-high-contrast-ui.md new file mode 100644 index 0000000..068f951 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-ios26-high-contrast-ui.md @@ -0,0 +1,114 @@ +# iOS 26 High Contrast UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refresh the React web interface with an iOS 26-inspired high-contrast workbench style without changing screening behavior. + +**Architecture:** Keep the current single React entry point and backend API contracts. Add semantic wrapper classes in `web/src/App.jsx`, replace the stylesheet in `web/src/styles.css`, then run the Vite build so the packaged local app uses the new static assets. + +**Tech Stack:** React 18, Vite, lucide-react, CSS. + +--- + +### Task 1: Add Visual Structure Classes + +**Files:** +- Modify: `web/src/App.jsx` + +- [ ] **Step 1: Add derived run status text** + +Inside `App`, after the `progress` memo, add: + +```jsx + const runStateLabel = { + queued: "排队中", + running: "筛选中", + stopping: "停止中", + completed: "已完成", + cancelled: "已取消", + failed: "失败" + }[run?.state] || "待开始"; +``` + +- [ ] **Step 2: Replace the top-level shell and stepper markup** + +Keep the existing event handlers and field bindings. Replace only wrapper class names and add status copy: + +```jsx +
+
+
+

本地运行

+

简历初筛工具

+

配置模型、检查文件、批量筛选并写入本地结果表。

+
+
+ 当前步骤 + {steps[step]} +
+
+``` + +- [ ] **Step 3: Move the stepper below the top bar** + +Render the existing `steps.map` inside: + +```jsx +