From 3933817d870676b25cc3bf220475eac382b1b747 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 20:51:45 +0800 Subject: [PATCH 01/59] feat: add CLI config foundation --- config.example.yaml | 26 +++++++++++ pyproject.toml | 29 ++++++++++++ src/resume_screening/__init__.py | 1 + src/resume_screening/config.py | 57 +++++++++++++++++++++++ tests/test_config.py | 79 ++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 config.example.yaml create mode 100644 pyproject.toml create mode 100644 src/resume_screening/__init__.py create mode 100644 src/resume_screening/config.py create mode 100644 tests/test_config.py diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..a5d49bf --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,26 @@ +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: false + +screening: + score_pass: 8 + score_excellent: 9 + categories: + recommend: "推荐初试" + consider: "可考虑" + manual: "待人工二筛" + reject: "未通过" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dee847b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "resume-screening" +version = "0.1.0" +description = "Resume screening CLI for 小A招聘表" +requires-python = ">=3.11" +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", +] + +[project.scripts] +resume-screening = "resume_screening.cli:app" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/src/resume_screening/__init__.py b/src/resume_screening/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/src/resume_screening/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/resume_screening/config.py b/src/resume_screening/config.py new file mode 100644 index 0000000..a09e408 --- /dev/null +++ b/src/resume_screening/config.py @@ -0,0 +1,57 @@ +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field, ValidationError, model_validator + + +class CategoryConfig(BaseModel): + recommend: str = "推荐初试" + consider: str = "可考虑" + manual: str = "待人工二筛" + reject: str = "未通过" + + +class ScreeningConfig(BaseModel): + score_pass: float = 8 + score_excellent: float = 9 + categories: CategoryConfig = Field(default_factory=CategoryConfig) + + +class ModelConfig(BaseModel): + provider: Literal["openai-compatible"] = "openai-compatible" + base_url: str = "" + api_key: str = "" + model: str = "" + timeout_seconds: int = 60 + temperature: float = 0.1 + allow_without_model: bool = False + + @model_validator(mode="after") + def require_model_config_unless_manual_mode(self) -> "ModelConfig": + if self.allow_without_model: + return self + missing = [name for name in ("base_url", "api_key", "model") if not getattr(self, name)] + if missing: + joined = ", ".join(missing) + raise ValueError(f"model config missing required values: {joined}") + return self + + +class AppConfig(BaseModel): + resume_dir: Path + job_book: Path + result_book: Path + default_source_channel: str = "" + default_interviewer: str = "" + index_path: Path = Path("data/processed_index.json") + ocr_command: str = "tesseract" + model: ModelConfig + screening: ScreeningConfig = Field(default_factory=ScreeningConfig) + + +def load_config(path: Path) -> AppConfig: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValidationError.from_exception_data("AppConfig", []) + return AppConfig.model_validate(raw) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..038c8e3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from resume_screening.config import AppConfig, load_config + + +def test_load_config_reads_paths_and_model_settings(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "resume_dir": "/Users/mac/Downloads", + "job_book": "/Users/mac/Downloads/小A自动化岗位说明书.xlsx", + "result_book": "/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx", + "default_source_channel": "", + "default_interviewer": "", + "model": { + "provider": "openai-compatible", + "base_url": "https://api.example.com/v1", + "api_key": "test-key", + "model": "screening-model", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": False, + }, + "screening": { + "score_pass": 8, + "score_excellent": 9, + "categories": { + "recommend": "推荐初试", + "consider": "可考虑", + "manual": "待人工二筛", + "reject": "未通过", + }, + }, + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + loaded = load_config(config_path) + + assert loaded.resume_dir == Path("/Users/mac/Downloads") + assert loaded.model.base_url == "https://api.example.com/v1" + assert loaded.screening.categories.manual == "待人工二筛" + + +def test_config_rejects_missing_model_when_manual_mode_disabled() -> None: + with pytest.raises(ValidationError): + AppConfig.model_validate( + { + "resume_dir": "/Users/mac/Downloads", + "job_book": "/Users/mac/Downloads/小A自动化岗位说明书.xlsx", + "result_book": "/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx", + "model": { + "provider": "openai-compatible", + "base_url": "", + "api_key": "", + "model": "", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": False, + }, + "screening": { + "score_pass": 8, + "score_excellent": 9, + "categories": { + "recommend": "推荐初试", + "consider": "可考虑", + "manual": "待人工二筛", + "reject": "未通过", + }, + }, + } + ) From 4fff247d20ecd94ef5766319686b6fb3197b07e1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 20:59:27 +0800 Subject: [PATCH 02/59] fix: harden config foundation --- config.example.yaml | 2 +- src/resume_screening/cli.py | 4 ++++ src/resume_screening/config.py | 4 ++-- tests/test_config.py | 20 ++++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/resume_screening/cli.py diff --git a/config.example.yaml b/config.example.yaml index a5d49bf..850c6b7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,7 +14,7 @@ model: model: "" timeout_seconds: 60 temperature: 0.1 - allow_without_model: false + allow_without_model: true screening: score_pass: 8 diff --git a/src/resume_screening/cli.py b/src/resume_screening/cli.py new file mode 100644 index 0000000..23d4619 --- /dev/null +++ b/src/resume_screening/cli.py @@ -0,0 +1,4 @@ +import typer + + +app = typer.Typer() diff --git a/src/resume_screening/config.py b/src/resume_screening/config.py index a09e408..17970e2 100644 --- a/src/resume_screening/config.py +++ b/src/resume_screening/config.py @@ -2,7 +2,7 @@ from typing import Literal import yaml -from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic import BaseModel, Field, model_validator class CategoryConfig(BaseModel): @@ -53,5 +53,5 @@ class AppConfig(BaseModel): def load_config(path: Path) -> AppConfig: raw = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(raw, dict): - raise ValidationError.from_exception_data("AppConfig", []) + raise ValueError("config file must contain a YAML mapping") return AppConfig.model_validate(raw) diff --git a/tests/test_config.py b/tests/test_config.py index 038c8e3..29128fb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -77,3 +77,23 @@ def test_config_rejects_missing_model_when_manual_mode_disabled() -> None: }, } ) + + +def test_cli_app_imports_cleanly() -> None: + from resume_screening.cli import app + + assert app is not None + + +def test_example_config_loads() -> None: + loaded = load_config(Path("config.example.yaml")) + + assert loaded.model.allow_without_model is True + + +def test_load_config_rejects_non_mapping_yaml(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + config_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8") + + with pytest.raises(ValueError, match="config file must contain a YAML mapping"): + load_config(config_path) From a58c0614857d2a48804df9bbee6e0886e048faf0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 21:04:48 +0800 Subject: [PATCH 03/59] feat: parse resume filenames --- src/resume_screening/filename_parser.py | 43 +++++++++++ src/resume_screening/models.py | 96 +++++++++++++++++++++++++ tests/test_filename_parser.py | 34 +++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/resume_screening/filename_parser.py create mode 100644 src/resume_screening/models.py create mode 100644 tests/test_filename_parser.py diff --git a/src/resume_screening/filename_parser.py b/src/resume_screening/filename_parser.py new file mode 100644 index 0000000..7b9dad5 --- /dev/null +++ b/src/resume_screening/filename_parser.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from resume_screening.models import ParsedFilename + + +STANDARD_PATTERN = re.compile( + r"^【(?P[^_】]+)_(?P[^】]+)】(?P\S+)\s+(?P.+?)$" +) + + +def parse_resume_filename(path: Path) -> ParsedFilename: + stem = path.stem.strip() + match = STANDARD_PATTERN.match(stem) + if not match: + guessed_name = re.split(r"[-_\s]+", stem, maxsplit=1)[0].strip() + return ParsedFilename( + path=path, + is_standard=False, + candidate_name=guessed_name, + missing_fields=["文件名不规范", "岗位名称", "期望工作地", "岗位标注薪资", "工龄"], + ) + + location_salary = match.group("location_salary").strip() + parts = location_salary.split() + expected_location = parts[0].strip() if parts else "" + salary_range = " ".join(parts[1:]).strip() if len(parts) > 1 else "" + missing_fields: list[str] = [] + if not salary_range: + missing_fields.append("岗位标注薪资") + + return ParsedFilename( + path=path, + is_standard=True, + job_name=match.group("job").strip(), + expected_location=expected_location, + salary_range=salary_range, + candidate_name=match.group("name").strip(), + work_experience=match.group("experience").strip(), + missing_fields=missing_fields, + ) diff --git a/src/resume_screening/models.py b/src/resume_screening/models.py new file mode 100644 index 0000000..5a4d388 --- /dev/null +++ b/src/resume_screening/models.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +CATEGORY_RECOMMEND = "推荐初试" +CATEGORY_CONSIDER = "可考虑" +CATEGORY_MANUAL = "待人工二筛" +CATEGORY_REJECT = "未通过" + + +@dataclass(frozen=True) +class ParsedFilename: + path: Path + is_standard: bool + job_name: str = "" + expected_location: str = "" + salary_range: str = "" + candidate_name: str = "" + work_experience: str = "" + missing_fields: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class JobRequirement: + sheet_name: str + fields: dict[str, str] + raw_text: str + is_complete: bool + missing_fields: list[str] + + +@dataclass(frozen=True) +class ExtractionResult: + text: str + method: str + errors: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class DuplicateMatch: + is_duplicate: bool + matched_filename: str = "" + matched_row_id: int | None = None + similarity: float = 0.0 + + +@dataclass(frozen=True) +class ScreeningScores: + ability: float + ego: float + desire: float + learning_ability: float + job_fit: float + experience_fit: float + skill_fit: float + stability_risk: float + + +@dataclass(frozen=True) +class ScreeningResult: + category: str + overall_score: float + summary: str + screening_reason: str + missing_information: list[str] + reject_reason: str + scores: ScreeningScores + + +@dataclass +class PipelineStats: + processed: int = 0 + recommend: int = 0 + consider: int = 0 + manual: int = 0 + reject: int = 0 + duplicate: int = 0 + model_failures: int = 0 + extraction_failures: int = 0 + non_standard_names: int = 0 + + def count_category(self, category: str) -> None: + if category == CATEGORY_RECOMMEND: + self.recommend += 1 + elif category == CATEGORY_CONSIDER: + self.consider += 1 + elif category == CATEGORY_MANUAL: + self.manual += 1 + elif category == CATEGORY_REJECT: + self.reject += 1 + + +JsonDict = dict[str, Any] diff --git a/tests/test_filename_parser.py b/tests/test_filename_parser.py new file mode 100644 index 0000000..a311ed3 --- /dev/null +++ b/tests/test_filename_parser.py @@ -0,0 +1,34 @@ +from pathlib import Path + +from resume_screening.filename_parser import parse_resume_filename + + +def test_parse_standard_resume_filename() -> None: + parsed = parse_resume_filename(Path("【财务总监_北京 18-28K】郭燕婷 10年以上.pdf")) + + assert parsed.is_standard is True + assert parsed.job_name == "财务总监" + assert parsed.expected_location == "北京" + assert parsed.salary_range == "18-28K" + assert parsed.candidate_name == "郭燕婷" + assert parsed.work_experience == "10年以上" + assert parsed.missing_fields == [] + + +def test_parse_non_standard_filename_marks_missing_fields() -> None: + parsed = parse_resume_filename(Path("郭燕婷-简历.pdf")) + + assert parsed.is_standard is False + assert parsed.candidate_name == "郭燕婷" + assert "文件名不规范" in parsed.missing_fields + assert "岗位名称" in parsed.missing_fields + + +def test_parse_standard_filename_without_salary_marks_salary_missing() -> None: + parsed = parse_resume_filename(Path("【算法工程师_北京】张三 3年.pdf")) + + assert parsed.is_standard is True + assert parsed.job_name == "算法工程师" + assert parsed.expected_location == "北京" + assert parsed.salary_range == "" + assert "岗位标注薪资" in parsed.missing_fields From 45104480034c6da0bb5778ec41f7b5cc2e7dd128 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 21:11:51 +0800 Subject: [PATCH 04/59] feat: read job requirements workbook --- src/resume_screening/job_requirements.py | 69 ++++++++++++++++++++++++ tests/test_job_requirements.py | 47 ++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/resume_screening/job_requirements.py create mode 100644 tests/test_job_requirements.py diff --git a/src/resume_screening/job_requirements.py b/src/resume_screening/job_requirements.py new file mode 100644 index 0000000..e8bde12 --- /dev/null +++ b/src/resume_screening/job_requirements.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import load_workbook + +from resume_screening.models import JobRequirement + + +PLACEHOLDER_MARKERS = ("XXXX", "具体描述", "无则填", "年 月") +REQUIRED_FIELDS = ("岗位名称", "学历", "工作经验", "具体描述", "1. 核心职责") + + +def _clean(value: object) -> str: + return str(value).strip() if value is not None else "" + + +def _is_placeholder(value: str) -> bool: + return not value or any(marker in value for marker in PLACEHOLDER_MARKERS) + + +def _extract_sheet_fields(rows: list[list[str]]) -> dict[str, str]: + fields: dict[str, str] = {} + for row in rows: + for index in range(0, len(row), 2): + key = row[index].strip() if index < len(row) else "" + value = row[index + 1].strip() if index + 1 < len(row) else "" + if key and value: + fields[key] = value + return fields + + +def _raw_text(rows: list[list[str]]) -> str: + lines = [] + for row in rows: + text = " ".join(cell for cell in row if cell) + if text: + lines.append(text) + return "\n".join(lines) + + +def _missing_required(fields: dict[str, str]) -> list[str]: + missing = [] + for field_name in REQUIRED_FIELDS: + value = fields.get(field_name, "") + if _is_placeholder(value): + missing.append(field_name) + return missing + + +def load_job_requirements(path: Path) -> dict[str, JobRequirement]: + workbook = load_workbook(path, data_only=True) + jobs: dict[str, JobRequirement] = {} + for sheet_name in workbook.sheetnames: + if sheet_name == "模板": + continue + sheet = workbook[sheet_name] + rows = [[_clean(cell) for cell in row] for row in sheet.iter_rows(values_only=True)] + fields = _extract_sheet_fields(rows) + missing = _missing_required(fields) + raw_text = _raw_text(rows) + jobs[sheet_name] = JobRequirement( + sheet_name=sheet_name, + fields=fields, + raw_text=raw_text, + is_complete=len(missing) == 0, + missing_fields=missing, + ) + return jobs diff --git a/tests/test_job_requirements.py b/tests/test_job_requirements.py new file mode 100644 index 0000000..e3f48cd --- /dev/null +++ b/tests/test_job_requirements.py @@ -0,0 +1,47 @@ +from pathlib import Path + +from openpyxl import Workbook + +from resume_screening.job_requirements import load_job_requirements + + +def make_job_book(path: Path) -> None: + workbook = Workbook() + template = workbook.active + template.title = "模板" + template["A1"] = "岗位说明书" + product = workbook.create_sheet("产品总监") + product.append(["岗位说明书"]) + product.append([None, None, None, None, None, None, None, "HR(03)20260001"]) + product.append(["一、基本信息"]) + product.append(["岗位名称", "技术产品总监", "所属部门", "研发部", "直接上级", "郑廉", "岗位等级", None]) + product.append(["岗位编制", "1/1", "岗位性质", "全职", None, None, "职级", "总监"]) + product.append(["工作地点", "北京", "工作方式", "半线上", None, None, "工作经验", "8年以上"]) + product.append(["学历", "统招本科及以上", "专业", "计算机、信息工程", "证书持有", "", "技能/素质", "AI产品、Agent"]) + product.append(["语言要求", "普通话", None, None, "晋升路径", "", None, None]) + product.append(["二、岗位目的"]) + product.append(["具体描述", "负责AI产品规划和跨团队落地。"]) + product.append(["三、主要工作职责及核心考核指标"]) + product.append(["1. 核心职责", "负责产品路线图和核心指标。"]) + product.append(["2. 核心职责", "推动研发、算法、运营协同。"]) + product.append(["3. 核心职责", "建立产品数据反馈机制。"]) + product.append(["4. 补充职责", "有Agent经验优先。"]) + product.append(["四、其他说明"]) + product.append(["补充说明", "无"]) + empty = workbook.create_sheet("算法") + empty.append(["岗位说明书"]) + empty.append(["一、基本信息"]) + workbook.save(path) + + +def test_load_job_requirements_ignores_template_and_detects_completeness(tmp_path: Path) -> None: + path = tmp_path / "jobs.xlsx" + make_job_book(path) + + jobs = load_job_requirements(path) + + assert "模板" not in jobs + assert jobs["产品总监"].is_complete is True + assert "负责AI产品规划" in jobs["产品总监"].raw_text + assert jobs["算法"].is_complete is False + assert "岗位名称" in jobs["算法"].missing_fields From 4c027a8518166acaf9117d5926093377e7f45c06 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 18 May 2026 21:20:54 +0800 Subject: [PATCH 05/59] feat: extract resume text --- src/resume_screening/extractors.py | 83 ++++++++++++++++++++++++++++++ tests/test_extractors.py | 30 +++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/resume_screening/extractors.py create mode 100644 tests/test_extractors.py diff --git a/src/resume_screening/extractors.py b/src/resume_screening/extractors.py new file mode 100644 index 0000000..7161b95 --- /dev/null +++ b/src/resume_screening/extractors.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from docx import Document +from pptx import Presentation +from pypdf import PdfReader + +from resume_screening.models import ExtractionResult + + +SUPPORTED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".doc", ".docx", ".pptx"} + + +def extract_text(path: Path, ocr_command: str) -> ExtractionResult: + suffix = path.suffix.lower() + if suffix not in SUPPORTED_EXTENSIONS: + return ExtractionResult(text="", method="unsupported", errors=["不支持的文件格式"]) + try: + if suffix == ".pdf": + return _extract_pdf(path) + if suffix in {".jpg", ".jpeg", ".png"}: + return _extract_image_ocr(path, ocr_command) + if suffix == ".docx": + return _extract_docx(path) + if suffix == ".doc": + return _extract_doc(path) + if suffix == ".pptx": + return _extract_pptx(path) + except Exception as exc: + return ExtractionResult(text="", method=suffix.lstrip("."), errors=[f"正文提取失败:{exc}"]) + return ExtractionResult(text="", method="unsupported", errors=["不支持的文件格式"]) + + +def _extract_pdf(path: Path) -> ExtractionResult: + reader = PdfReader(str(path)) + text = "\n".join(page.extract_text() or "" for page in reader.pages).strip() + if len(text) < 80: + return ExtractionResult(text=text, method="pdf", errors=["PDF文本过短,可能需要OCR人工复核"]) + return ExtractionResult(text=text, method="pdf") + + +def _extract_image_ocr(path: Path, ocr_command: str) -> ExtractionResult: + completed = subprocess.run( + [ocr_command, str(path), "stdout", "-l", "chi_sim+eng"], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + if completed.returncode != 0: + return ExtractionResult(text="", method="ocr", errors=[f"OCR失败:{completed.stderr.strip()}"]) + return ExtractionResult(text=completed.stdout.strip(), method="ocr") + + +def _extract_docx(path: Path) -> ExtractionResult: + document = Document(str(path)) + text = "\n".join(paragraph.text for paragraph in document.paragraphs if paragraph.text.strip()) + return ExtractionResult(text=text.strip(), method="docx") + + +def _extract_doc(path: Path) -> ExtractionResult: + completed = subprocess.run( + ["textutil", "-convert", "txt", "-stdout", str(path)], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + if completed.returncode != 0: + return ExtractionResult(text="", method="doc", errors=[f"DOC转换失败:{completed.stderr.strip()}"]) + return ExtractionResult(text=completed.stdout.strip(), method="doc") + + +def _extract_pptx(path: Path) -> ExtractionResult: + presentation = Presentation(str(path)) + parts: list[str] = [] + for slide in presentation.slides: + for shape in slide.shapes: + if hasattr(shape, "text") and shape.text.strip(): + parts.append(shape.text.strip()) + return ExtractionResult(text="\n".join(parts), method="pptx") diff --git a/tests/test_extractors.py b/tests/test_extractors.py new file mode 100644 index 0000000..7336527 --- /dev/null +++ b/tests/test_extractors.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from resume_screening.extractors import extract_text + + +def test_extract_unsupported_file_returns_error(tmp_path: Path) -> None: + path = tmp_path / "resume.txt" + path.write_text("hello", encoding="utf-8") + + result = extract_text(path, ocr_command="tesseract") + + assert result.text == "" + assert result.method == "unsupported" + assert "不支持的文件格式" in result.errors + + +def test_extract_docx_reads_paragraphs(tmp_path: Path) -> None: + from docx import Document + + path = tmp_path / "resume.docx" + document = Document() + document.add_paragraph("郭燕婷") + document.add_paragraph("财务总监候选人,10年以上经验") + document.save(path) + + result = extract_text(path, ocr_command="tesseract") + + assert "郭燕婷" in result.text + assert "10年以上经验" in result.text + assert result.method == "docx" From b0bf7001d1e239601c0ab0a79f04e31e8a1de3e1 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 10:40:51 +0800 Subject: [PATCH 06/59] fix: harden document extraction --- src/resume_screening/extractors.py | 28 +++++--- tests/test_extractors.py | 102 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/resume_screening/extractors.py b/src/resume_screening/extractors.py index 7161b95..d6776f3 100644 --- a/src/resume_screening/extractors.py +++ b/src/resume_screening/extractors.py @@ -37,18 +37,23 @@ def _extract_pdf(path: Path) -> ExtractionResult: reader = PdfReader(str(path)) text = "\n".join(page.extract_text() or "" for page in reader.pages).strip() if len(text) < 80: - return ExtractionResult(text=text, method="pdf", errors=["PDF文本过短,可能需要OCR人工复核"]) + return ExtractionResult(text="", method="pdf", errors=["PDF文本过短,可能需要OCR人工复核"]) return ExtractionResult(text=text, method="pdf") def _extract_image_ocr(path: Path, ocr_command: str) -> ExtractionResult: - completed = subprocess.run( - [ocr_command, str(path), "stdout", "-l", "chi_sim+eng"], - capture_output=True, - text=True, - check=False, - timeout=120, - ) + try: + completed = subprocess.run( + [ocr_command, str(path), "stdout", "-l", "chi_sim+eng"], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + except FileNotFoundError: + return ExtractionResult(text="", method="ocr", errors=[f"OCR命令不存在:{ocr_command}"]) + except subprocess.TimeoutExpired: + return ExtractionResult(text="", method="ocr", errors=["OCR超时"]) if completed.returncode != 0: return ExtractionResult(text="", method="ocr", errors=[f"OCR失败:{completed.stderr.strip()}"]) return ExtractionResult(text=completed.stdout.strip(), method="ocr") @@ -56,7 +61,12 @@ def _extract_image_ocr(path: Path, ocr_command: str) -> ExtractionResult: def _extract_docx(path: Path) -> ExtractionResult: document = Document(str(path)) - text = "\n".join(paragraph.text for paragraph in document.paragraphs if paragraph.text.strip()) + parts = [paragraph.text for paragraph in document.paragraphs if paragraph.text.strip()] + for table in document.tables: + for row in table.rows: + for cell in row.cells: + parts.extend(paragraph.text for paragraph in cell.paragraphs if paragraph.text.strip()) + text = "\n".join(parts) return ExtractionResult(text=text.strip(), method="docx") diff --git a/tests/test_extractors.py b/tests/test_extractors.py index 7336527..cd562e3 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -1,4 +1,6 @@ from pathlib import Path +import subprocess +from types import SimpleNamespace from resume_screening.extractors import extract_text @@ -28,3 +30,103 @@ def test_extract_docx_reads_paragraphs(tmp_path: Path) -> None: assert "郭燕婷" in result.text assert "10年以上经验" in result.text assert result.method == "docx" + + +def test_short_pdf_text_is_not_treated_as_usable(monkeypatch, tmp_path: Path) -> None: + class FakePage: + def extract_text(self) -> str: + return "1" + + class FakePdfReader: + def __init__(self, path: str) -> None: + self.pages = [FakePage()] + + monkeypatch.setattr("resume_screening.extractors.PdfReader", FakePdfReader) + path = tmp_path / "resume.pdf" + path.write_bytes(b"%PDF-1.4") + + result = extract_text(path, ocr_command="tesseract") + + assert result.text == "" + assert result.method == "pdf" + assert result.errors == ["PDF文本过短,可能需要OCR人工复核"] + + +def test_ocr_missing_command_returns_actionable_ocr_error(monkeypatch, tmp_path: Path) -> None: + def raise_missing(*args, **kwargs): + raise FileNotFoundError + + monkeypatch.setattr("resume_screening.extractors.subprocess.run", raise_missing) + path = tmp_path / "resume.png" + path.write_bytes(b"image") + + result = extract_text(path, ocr_command="missing-tesseract") + + assert result.text == "" + assert result.method == "ocr" + assert result.errors == ["OCR命令不存在:missing-tesseract"] + + +def test_ocr_timeout_returns_actionable_ocr_error(monkeypatch, tmp_path: Path) -> None: + def raise_timeout(*args, **kwargs): + raise subprocess.TimeoutExpired(cmd="tesseract", timeout=120) + + monkeypatch.setattr("resume_screening.extractors.subprocess.run", raise_timeout) + path = tmp_path / "resume.jpg" + path.write_bytes(b"image") + + result = extract_text(path, ocr_command="tesseract") + + assert result.text == "" + assert result.method == "ocr" + assert result.errors == ["OCR超时"] + + +def test_ocr_nonzero_exit_returns_ocr_failure(monkeypatch, tmp_path: Path) -> None: + def fake_run(*args, **kwargs): + return SimpleNamespace(returncode=1, stdout="", stderr="bad image") + + monkeypatch.setattr("resume_screening.extractors.subprocess.run", fake_run) + path = tmp_path / "resume.jpeg" + path.write_bytes(b"image") + + result = extract_text(path, ocr_command="tesseract") + + assert result.text == "" + assert result.method == "ocr" + assert result.errors == ["OCR失败:bad image"] + + +def test_extract_docx_reads_table_cells(tmp_path: Path) -> None: + from docx import Document + + path = tmp_path / "table_resume.docx" + document = Document() + table = document.add_table(rows=1, cols=2) + table.cell(0, 0).text = "候选人" + table.cell(0, 1).text = "郭燕婷" + document.save(path) + + result = extract_text(path, ocr_command="tesseract") + + assert "候选人" in result.text + assert "郭燕婷" in result.text + assert result.method == "docx" + + +def test_extract_pptx_reads_slide_text(tmp_path: Path) -> None: + from pptx import Presentation + + path = tmp_path / "resume.pptx" + presentation = Presentation() + slide = presentation.slides.add_slide(presentation.slide_layouts[5]) + slide.shapes.title.text = "郭燕婷" + textbox = slide.shapes.add_textbox(0, 0, 3000000, 1000000) + textbox.text = "财务总监候选人" + presentation.save(path) + + result = extract_text(path, ocr_command="tesseract") + + assert "郭燕婷" in result.text + assert "财务总监候选人" in result.text + assert result.method == "pptx" From 8f1f6cba54b77949fea30f283a62c3711979ab01 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 10:41:49 +0800 Subject: [PATCH 07/59] feat: detect duplicate resume content --- src/resume_screening/duplicates.py | 81 ++++++++++++++++++++++++++++++ tests/test_duplicates.py | 21 ++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/resume_screening/duplicates.py create mode 100644 tests/test_duplicates.py diff --git a/src/resume_screening/duplicates.py b/src/resume_screening/duplicates.py new file mode 100644 index 0000000..4e24c3e --- /dev/null +++ b/src/resume_screening/duplicates.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from difflib import SequenceMatcher +from pathlib import Path + +from resume_screening.models import DuplicateMatch + + +def normalize_text(text: str) -> str: + return re.sub(r"\s+", "", text).strip() + + +def fingerprint(text: str) -> str: + normalized = normalize_text(text) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class IndexEntry: + fingerprint: str + normalized_text: str + filename: str + row_id: int + candidate_name: str + job_name: str + processed_at: str + + +class DuplicateIndex: + def __init__(self, path: Path, entries: list[IndexEntry]) -> None: + self.path = path + self.entries = entries + + @classmethod + def load(cls, path: Path) -> "DuplicateIndex": + if not path.exists(): + return cls(path=path, entries=[]) + raw = json.loads(path.read_text(encoding="utf-8")) + entries = [IndexEntry(**item) for item in raw.get("entries", [])] + return cls(path=path, entries=entries) + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = {"entries": [asdict(entry) for entry in self.entries]} + self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def find(self, text: str, threshold: float = 0.92) -> DuplicateMatch: + normalized = normalize_text(text) + current_hash = fingerprint(normalized) + for entry in self.entries: + if entry.fingerprint == current_hash: + return DuplicateMatch(True, entry.filename, entry.row_id, 1.0) + best_entry: IndexEntry | None = None + best_ratio = 0.0 + for entry in self.entries: + ratio = SequenceMatcher(None, normalized, entry.normalized_text).ratio() + if ratio > best_ratio: + best_ratio = ratio + best_entry = entry + if best_entry and best_ratio >= threshold: + return DuplicateMatch(True, best_entry.filename, best_entry.row_id, best_ratio) + return DuplicateMatch(False) + + def add(self, text: str, filename: str, row_id: int, candidate_name: str, job_name: str) -> None: + normalized = normalize_text(text) + self.entries.append( + IndexEntry( + fingerprint=fingerprint(normalized), + normalized_text=normalized, + filename=filename, + row_id=row_id, + candidate_name=candidate_name, + job_name=job_name, + processed_at=datetime.now(timezone.utc).isoformat(), + ) + ) diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py new file mode 100644 index 0000000..1fdcb3d --- /dev/null +++ b/tests/test_duplicates.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from resume_screening.duplicates import DuplicateIndex, normalize_text + + +def test_normalize_text_removes_spacing_noise() -> None: + assert normalize_text("郭 燕婷\n\n10年以上经验") == "郭燕婷10年以上经验" + + +def test_duplicate_index_detects_exact_content(tmp_path: Path) -> None: + path = tmp_path / "processed_index.json" + index = DuplicateIndex.load(path) + index.add(text="郭燕婷10年以上财务经验", filename="a.pdf", row_id=7, candidate_name="郭燕婷", job_name="财务总监") + index.save() + + reloaded = DuplicateIndex.load(path) + match = reloaded.find("郭燕婷 10年以上 财务经验") + + assert match.is_duplicate is True + assert match.matched_filename == "a.pdf" + assert match.matched_row_id == 7 From 86dca2d53136132199d0585e8991ecb16ca23cd8 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 10:42:56 +0800 Subject: [PATCH 08/59] feat: validate model screening responses --- src/resume_screening/model_client.py | 110 +++++++++++++++++++++++++++ tests/test_model_client.py | 53 +++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/resume_screening/model_client.py create mode 100644 tests/test_model_client.py diff --git a/src/resume_screening/model_client.py b/src/resume_screening/model_client.py new file mode 100644 index 0000000..6cf6d7a --- /dev/null +++ b/src/resume_screening/model_client.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json + +import httpx +from pydantic import BaseModel, Field, ValidationError, field_validator + +from resume_screening.config import ModelConfig +from resume_screening.models import ( + CATEGORY_CONSIDER, + CATEGORY_MANUAL, + CATEGORY_RECOMMEND, + CATEGORY_REJECT, + JobRequirement, + ScreeningResult, + ScreeningScores, +) + + +VALID_CATEGORIES = {CATEGORY_RECOMMEND, CATEGORY_CONSIDER, CATEGORY_MANUAL, CATEGORY_REJECT} + + +class ModelScoresPayload(BaseModel): + ability: float = Field(ge=0, le=10) + ego: float = Field(ge=0, le=10) + desire: float = Field(ge=0, le=10) + learning_ability: float = Field(ge=0, le=10) + job_fit: float = Field(ge=0, le=10) + experience_fit: float = Field(ge=0, le=10) + skill_fit: float = Field(ge=0, le=10) + stability_risk: float = Field(ge=0, le=10) + + +class ModelPayload(BaseModel): + category: str + overall_score: float = Field(ge=0, le=10) + summary: str + screening_reason: str + missing_information: list[str] + reject_reason: str = "" + scores: ModelScoresPayload + + @field_validator("category") + @classmethod + def validate_category(cls, value: str) -> str: + if value not in VALID_CATEGORIES: + raise ValueError("category must be one of 推荐初试, 可考虑, 待人工二筛, 未通过") + return value + + +def parse_model_response(payload: dict) -> ScreeningResult: + try: + parsed = ModelPayload.model_validate(payload) + except ValidationError as exc: + raise ValueError(f"模型返回格式不符合JSON结构:{exc}") from exc + return ScreeningResult( + category=parsed.category, + overall_score=parsed.overall_score, + summary=parsed.summary, + screening_reason=parsed.screening_reason, + missing_information=parsed.missing_information, + reject_reason=parsed.reject_reason, + scores=ScreeningScores(**parsed.scores.model_dump()), + ) + + +class ModelClient: + def __init__(self, config: ModelConfig) -> None: + self.config = config + + def evaluate(self, resume_text: str, job: JobRequirement, filename_metadata: dict[str, str]) -> ScreeningResult: + prompt = self._build_prompt(resume_text, job, filename_metadata) + response = httpx.post( + f"{self.config.base_url.rstrip('/')}/chat/completions", + headers={"Authorization": f"Bearer {self.config.api_key}", "Content-Type": "application/json"}, + json={ + "model": self.config.model, + "temperature": self.config.temperature, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": "你是招聘初筛助手,只返回严格JSON。"}, + {"role": "user", "content": prompt}, + ], + }, + timeout=self.config.timeout_seconds, + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + return parse_model_response(json.loads(content)) + + def _build_prompt(self, resume_text: str, job: JobRequirement, filename_metadata: dict[str, str]) -> str: + return json.dumps( + { + "instruction": "根据岗位要求和简历内容进行初筛。评分均为0到10分,8分通过,9分优秀。稳定性/风险高分表示低风险。只返回指定JSON字段。", + "required_json_fields": [ + "category", + "overall_score", + "summary", + "screening_reason", + "missing_information", + "reject_reason", + "scores", + ], + "allowed_categories": sorted(VALID_CATEGORIES), + "filename_metadata": filename_metadata, + "job_requirement": {"sheet_name": job.sheet_name, "fields": job.fields, "raw_text": job.raw_text}, + "resume_text": resume_text[:12000], + }, + ensure_ascii=False, + ) diff --git a/tests/test_model_client.py b/tests/test_model_client.py new file mode 100644 index 0000000..12f4490 --- /dev/null +++ b/tests/test_model_client.py @@ -0,0 +1,53 @@ +import pytest + +from resume_screening.model_client import parse_model_response + + +def test_parse_model_response_accepts_valid_json() -> None: + result = parse_model_response( + { + "category": "推荐初试", + "overall_score": 8.5, + "summary": "候选人具备财务管理经验。", + "screening_reason": "经验和岗位要求匹配。", + "missing_information": [], + "reject_reason": "", + "scores": { + "ability": 8, + "ego": 6, + "desire": 8, + "learning_ability": 8, + "job_fit": 9, + "experience_fit": 9, + "skill_fit": 8, + "stability_risk": 7, + }, + } + ) + + assert result.category == "推荐初试" + assert result.scores.job_fit == 9 + + +def test_parse_model_response_rejects_unknown_category() -> None: + with pytest.raises(ValueError, match="category"): + parse_model_response( + { + "category": "强烈推荐", + "overall_score": 8.5, + "summary": "摘要", + "screening_reason": "理由", + "missing_information": [], + "reject_reason": "", + "scores": { + "ability": 8, + "ego": 6, + "desire": 8, + "learning_ability": 8, + "job_fit": 9, + "experience_fit": 9, + "skill_fit": 8, + "stability_risk": 7, + }, + } + ) From a7f0aa62f315cc651fc2e324320c018da2c48afe Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 10:44:08 +0800 Subject: [PATCH 09/59] feat: append screening results to workbook --- src/resume_screening/excel_writer.py | 100 +++++++++++++++++++++++++++ tests/test_excel_writer.py | 63 +++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/resume_screening/excel_writer.py create mode 100644 tests/test_excel_writer.py diff --git a/src/resume_screening/excel_writer.py b/src/resume_screening/excel_writer.py new file mode 100644 index 0000000..bbdfd22 --- /dev/null +++ b/src/resume_screening/excel_writer.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.styles import PatternFill + +from resume_screening.models import CATEGORY_REJECT, DuplicateMatch, ParsedFilename, ScreeningResult + + +BASE_HEADERS = ["ID", "当前阶段", "候选人", "附件", "岗位方向", "来源渠道", "候选人摘要(妙记)", "量化打分", "可投入周期", "面试负责人", "下次动作日", "不推进原因", "推荐/触达人"] +EXTRA_HEADERS = ["初筛分类", "初筛理由", "缺失信息"] +DUPLICATE_FILL = PatternFill(fill_type="solid", fgColor="FFFFC7CE") + + +class ResultWorkbookWriter: + def __init__(self, path: Path, sheet_name: str = "多维表格") -> None: + self.path = path + self.workbook = load_workbook(path) + self.sheet = self.workbook[sheet_name] + self._ensure_headers() + + def _ensure_headers(self) -> None: + headers = [cell.value for cell in self.sheet[1]] + for header in EXTRA_HEADERS: + if header not in headers: + self.sheet.cell(row=1, column=len(headers) + 1).value = header + headers.append(header) + + def next_id(self) -> int: + ids: list[int] = [] + for row in self.sheet.iter_rows(min_row=2, min_col=1, max_col=1, values_only=True): + value = row[0] + if isinstance(value, int): + ids.append(value) + return (max(ids) if ids else 0) + 1 + + def append_result( + self, + parsed: ParsedFilename, + result: ScreeningResult, + duplicate: DuplicateMatch, + extraction_errors: list[str], + default_source_channel: str, + default_interviewer: str, + ) -> int: + row_id = self.next_id() + missing = list(result.missing_information) + list(parsed.missing_fields) + list(extraction_errors) + if duplicate.is_duplicate: + missing.append(f"疑似重复简历:与历史附件 {duplicate.matched_filename} 内容一致") + row = [ + row_id, + result.category, + parsed.candidate_name, + parsed.path.name, + parsed.job_name, + default_source_channel, + result.summary, + format_score_block(result), + "", + default_interviewer, + "", + result.reject_reason if result.category == CATEGORY_REJECT else "", + "", + result.category, + result.screening_reason, + ";".join(item for item in missing if item), + ] + self.sheet.append(row) + row_number = self.sheet.max_row + if duplicate.is_duplicate: + for cell in self.sheet[row_number]: + cell.fill = DUPLICATE_FILL + return row_id + + def save(self) -> None: + self.workbook.save(self.path) + + +def format_score_block(result: ScreeningResult) -> str: + conclusion = "优秀" if result.overall_score >= 9 else "通过" if result.overall_score >= 8 else "未通过" + scores = result.scores + return "\n".join( + [ + f"综合评分:{result.overall_score:g}/10", + f"结论:{conclusion}", + "", + f"能力:{scores.ability:g}", + f"ego大小:{scores.ego:g}", + f"野心desire:{scores.desire:g}", + f"学习能力与聪明程度:{scores.learning_ability:g}", + "", + f"岗位匹配度:{scores.job_fit:g}", + f"经验匹配:{scores.experience_fit:g}", + f"技能匹配:{scores.skill_fit:g}", + f"稳定性/风险:{scores.stability_risk:g}", + "", + f"评价:{result.screening_reason}", + ] + ) diff --git a/tests/test_excel_writer.py b/tests/test_excel_writer.py new file mode 100644 index 0000000..47b0401 --- /dev/null +++ b/tests/test_excel_writer.py @@ -0,0 +1,63 @@ +from pathlib import Path + +from openpyxl import Workbook, load_workbook + +from resume_screening.excel_writer import ResultWorkbookWriter +from resume_screening.models import DuplicateMatch, ParsedFilename, ScreeningResult, ScreeningScores + + +def create_result_book(path: Path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.title = "多维表格" + sheet.append(["ID", "当前阶段", "候选人", "附件", "岗位方向", "来源渠道", "候选人摘要(妙记)", "量化打分", "可投入周期", "面试负责人", "下次动作日", "不推进原因", "推荐/触达人"]) + sheet.append([1, "未通过", "历史候选人", "old.pdf", "财务", "BOSS", "", "", "", "", "", "不匹配", ""]) + workbook.save(path) + + +def make_result() -> ScreeningResult: + return ScreeningResult( + category="推荐初试", + overall_score=8.5, + summary="候选人具备财务管理经验。", + screening_reason="经验匹配。", + missing_information=[], + reject_reason="", + scores=ScreeningScores(8, 6, 8, 8, 9, 9, 8, 7), + ) + + +def test_writer_appends_columns_and_duplicate_red_fill(tmp_path: Path) -> None: + path = tmp_path / "result.xlsx" + create_result_book(path) + writer = ResultWorkbookWriter(path) + parsed = ParsedFilename( + path=Path("【财务总监_北京 18-28K】郭燕婷 10年以上.pdf"), + is_standard=True, + job_name="财务总监", + expected_location="北京", + salary_range="18-28K", + candidate_name="郭燕婷", + work_experience="10年以上", + ) + + row_id = writer.append_result( + parsed=parsed, + result=make_result(), + duplicate=DuplicateMatch(True, "old.pdf", 1, 1.0), + extraction_errors=[], + default_source_channel="", + default_interviewer="", + ) + writer.save() + + workbook = load_workbook(path) + sheet = workbook["多维表格"] + headers = [cell.value for cell in sheet[1]] + assert headers[-3:] == ["初筛分类", "初筛理由", "缺失信息"] + assert row_id == 2 + assert sheet["A3"].value == 2 + assert sheet["C3"].value == "郭燕婷" + assert sheet["N3"].value == "推荐初试" + assert "疑似重复简历" in sheet["P3"].value + assert sheet["A3"].fill.fgColor.rgb == "FFFFC7CE" From 7e679327a3dd638b622ac4ea35d4c997b866e1d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 10:46:00 +0800 Subject: [PATCH 10/59] feat: orchestrate resume screening pipeline --- src/resume_screening/cli.py | 25 +++++- src/resume_screening/pipeline.py | 140 +++++++++++++++++++++++++++++++ tests/test_cli.py | 11 +++ tests/test_pipeline.py | 75 +++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/resume_screening/pipeline.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_pipeline.py diff --git a/src/resume_screening/cli.py b/src/resume_screening/cli.py index 23d4619..6227f79 100644 --- a/src/resume_screening/cli.py +++ b/src/resume_screening/cli.py @@ -1,4 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + import typer +from resume_screening.config import load_config +from resume_screening.pipeline import ScreeningPipeline + + +app = typer.Typer(no_args_is_help=True) + -app = typer.Typer() +@app.command() +def run(config: Path = typer.Option(..., "--config", "-c", exists=True, readable=True)) -> None: + app_config = load_config(config) + stats = ScreeningPipeline(app_config).run() + typer.echo("处理完成") + typer.echo(f"处理数量:{stats.processed}") + typer.echo(f"推荐初试:{stats.recommend}") + typer.echo(f"可考虑:{stats.consider}") + typer.echo(f"待人工二筛:{stats.manual}") + typer.echo(f"未通过:{stats.reject}") + typer.echo(f"疑似重复:{stats.duplicate}") + typer.echo(f"模型失败:{stats.model_failures}") + typer.echo(f"提取失败/警告:{stats.extraction_failures}") + typer.echo(f"文件名不规范:{stats.non_standard_names}") diff --git a/src/resume_screening/pipeline.py b/src/resume_screening/pipeline.py new file mode 100644 index 0000000..d24025e --- /dev/null +++ b/src/resume_screening/pipeline.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx + +from resume_screening.config import AppConfig +from resume_screening.duplicates import DuplicateIndex +from resume_screening.excel_writer import ResultWorkbookWriter +from resume_screening.extractors import SUPPORTED_EXTENSIONS, extract_text +from resume_screening.filename_parser import parse_resume_filename +from resume_screening.job_requirements import load_job_requirements +from resume_screening.model_client import ModelClient +from resume_screening.models import ( + CATEGORY_MANUAL, + DuplicateMatch, + ExtractionResult, + JobRequirement, + ParsedFilename, + PipelineStats, + ScreeningResult, + ScreeningScores, +) + + +class ScreeningPipeline: + def __init__(self, config: AppConfig) -> None: + self.config = config + + 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() + + for path in self._resume_files(): + 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 + + writer.save() + index.save() + return stats + + def _resume_files(self) -> list[Path]: + skip = {self.config.job_book.resolve(), self.config.result_book.resolve()} + files: list[Path] = [] + for path in self.config.resume_dir.iterdir(): + if not path.is_file(): + continue + if path.resolve() in skip: + continue + if path.suffix.lower() in SUPPORTED_EXTENSIONS: + files.append(path) + return sorted(files) + + def _screen( + self, + parsed: ParsedFilename, + extraction: ExtractionResult, + jobs: dict[str, JobRequirement], + client: ModelClient | None, + ) -> ScreeningResult: + missing = list(parsed.missing_fields) + if not parsed.is_standard: + missing.append("文件名不规范/字段无法从文件名确认") + if not extraction.text: + missing.extend(extraction.errors or ["无法提取简历正文"]) + return manual_result("文件名不规范,需人工确认", missing) + if not extraction.text: + missing.extend(extraction.errors or ["无法提取简历正文"]) + return manual_result("无法提取简历正文,需人工确认", missing) + + job = jobs.get(parsed.job_name) + if not job: + missing.append("岗位名称未匹配到岗位说明书sheet") + return manual_result("岗位名称未匹配到岗位说明书sheet,需人工确认", missing) + if not job.is_complete: + missing.append("岗位要求不完整,需人工确认") + missing.extend(job.missing_fields) + return manual_result("岗位要求不完整,需人工确认", missing) + if client is None: + missing.append("模型配置缺失,已按人工二筛处理") + return manual_result("模型配置缺失,需人工确认", missing) + + try: + return client.evaluate( + resume_text=extraction.text, + job=job, + filename_metadata={ + "job_name": parsed.job_name, + "expected_location": parsed.expected_location, + "salary_range": parsed.salary_range, + "candidate_name": parsed.candidate_name, + "work_experience": parsed.work_experience, + }, + ) + except httpx.TimeoutException: + print(f"模型评估失败:请求超时 - {parsed.path.name}") + return manual_result("模型评估失败,需人工确认", missing + ["模型评估失败:请求超时"]) + except httpx.HTTPStatusError as exc: + print(f"模型评估失败:HTTP {exc.response.status_code} - {parsed.path.name}") + return manual_result("模型评估失败,需人工确认", missing + [f"模型评估失败:HTTP {exc.response.status_code}"]) + except Exception as exc: + print(f"模型评估失败:{exc} - {parsed.path.name}") + return manual_result("模型评估失败,需人工确认", missing + [f"模型评估失败:{exc}"]) + + +def manual_result(reason: str, missing: list[str]) -> ScreeningResult: + return ScreeningResult( + category=CATEGORY_MANUAL, + overall_score=0, + summary=reason, + screening_reason=reason, + missing_information=missing, + reject_reason="", + scores=ScreeningScores(0, 0, 0, 0, 0, 0, 0, 0), + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2bb89eb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,11 @@ +from typer.testing import CliRunner + +from resume_screening.cli import app + + +def test_cli_requires_config_argument() -> None: + runner = CliRunner() + result = runner.invoke(app, ["run"]) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "Error" in result.output diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..79faa81 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,75 @@ +from pathlib import Path + +from openpyxl import Workbook, load_workbook + +from resume_screening.config import AppConfig +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 = "模板" + template.append(["岗位说明书"]) + job = workbook.create_sheet("财务总监") + job.append(["岗位说明书"]) + job.append(["一、基本信息"]) + workbook.save(path) + + +def test_pipeline_writes_manual_review_for_incomplete_job(tmp_path: Path) -> None: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + resume = resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.docx" + from docx import Document + 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) + 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": "", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": True, + }, + "screening": { + "score_pass": 8, + "score_excellent": 9, + "categories": { + "recommend": "推荐初试", + "consider": "可考虑", + "manual": "待人工二筛", + "reject": "未通过", + }, + }, + } + ) + + stats = ScreeningPipeline(config).run() + + assert stats.processed == 1 + assert stats.manual == 1 + workbook = load_workbook(result_book) + sheet = workbook["多维表格"] + assert sheet["B2"].value == "待人工二筛" + assert "岗位要求不完整" in sheet["P2"].value From 904f961683f57d3a7690d3f00aabaa2b287facbf Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 10:47:37 +0800 Subject: [PATCH 11/59] docs: add resume screening usage --- README.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 3 +++ src/resume_screening/cli.py | 9 +++++++++ tests/test_cli.py | 15 +++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef480bc --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Resume Screening CLI + +## Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install -e ".[test]" +cp config.example.yaml config.yaml +``` + +Edit `config.yaml` and set `model.base_url`, `model.api_key`, and `model.model`. + +## 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. + +## Manual-Review Mode + +Set `model.allow_without_model: true` to process files without model calls. +Rows that require model judgment are classified as `待人工二筛`. diff --git a/pyproject.toml b/pyproject.toml index dee847b..599fabc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ "httpx>=0.27", ] +[project.optional-dependencies] +test = ["pytest>=8.0"] + [project.scripts] resume-screening = "resume_screening.cli:app" diff --git a/src/resume_screening/cli.py b/src/resume_screening/cli.py index 6227f79..45d250c 100644 --- a/src/resume_screening/cli.py +++ b/src/resume_screening/cli.py @@ -11,6 +11,11 @@ app = typer.Typer(no_args_is_help=True) +@app.callback() +def main() -> None: + """Resume screening automation.""" + + @app.command() def run(config: Path = typer.Option(..., "--config", "-c", exists=True, readable=True)) -> None: app_config = load_config(config) @@ -25,3 +30,7 @@ def run(config: Path = typer.Option(..., "--config", "-c", exists=True, readable typer.echo(f"模型失败:{stats.model_failures}") typer.echo(f"提取失败/警告:{stats.extraction_failures}") typer.echo(f"文件名不规范:{stats.non_standard_names}") + + +if __name__ == "__main__": + app() diff --git a/tests/test_cli.py b/tests/test_cli.py index 2bb89eb..5723ed5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,6 @@ +import subprocess +import sys + from typer.testing import CliRunner from resume_screening.cli import app @@ -9,3 +12,15 @@ def test_cli_requires_config_argument() -> None: assert result.exit_code != 0 assert "Missing option" in result.output or "Error" in result.output + + +def test_cli_module_help_renders() -> None: + result = subprocess.run( + [sys.executable, "-m", "resume_screening.cli", "--help"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "run" in result.stdout From a0164e570e43e869320c94991de1d47d92cfec07 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 11:10:50 +0800 Subject: [PATCH 12/59] docs: add Chinese usage guide --- ...77\347\224\250\350\257\264\346\230\216.md" | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 "\344\275\277\347\224\250\350\257\264\346\230\216.md" diff --git "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" new file mode 100644 index 0000000..d21f2e5 --- /dev/null +++ "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -0,0 +1,239 @@ +# 简历初筛工具使用说明 + +这份文档给日常使用看,不讲代码,只讲怎么配置、怎么运行、结果怎么看。 + +## 这个工具做什么 + +这个工具会自动做这几件事: + +1. 扫描 `/Users/mac/Downloads` 里面的简历文件。 +2. 每次运行都重新读取岗位要求表: + `/Users/mac/Downloads/小A自动化岗位说明书.xlsx` +3. 识别简历内容,并根据岗位要求调用模型做初步筛选。 +4. 把结果追加写入招聘表: + `/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx` +5. 不覆盖历史记录,只往下新增。 +6. 如果发现简历内容重复,会照常新增一行,但整行标红。 +7. 如果模型超时、API 报错、文件名不规范、岗位要求不完整,会标记为 `待人工二筛`。 + +## 简历文件名格式 + +推荐把简历文件名改成这个格式: + +```text +【岗位名称_期望工作地 薪资】候选人姓名 工龄.pdf +``` + +例子: + +```text +【财务总监_北京 18-28K】郭燕婷 10年以上.pdf +``` + +工具会从文件名里识别: + +- 岗位名称 +- 期望工作地 +- 薪资 +- 候选人姓名 +- 工龄 + +如果文件名不符合格式,工具仍然会尝试读取简历正文,但结果会默认进入 `待人工二筛`。 + +## 第一次使用 + +打开终端,执行: + +```bash +cd /Users/mac/Desktop/codex/.worktrees/resume-screening-cli +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install -e ".[test]" +``` + +如果已经执行过这些命令,以后不用重复执行。 + +## 配置 API + +配置文件是: + +```text +/Users/mac/Desktop/codex/.worktrees/resume-screening-cli/config.yaml +``` + +如果没有这个文件,先复制一份: + +```bash +cp config.example.yaml config.yaml +``` + +打开 `config.yaml`,找到 `model` 这一段: + +```yaml +model: + provider: "openai-compatible" + base_url: "" + api_key: "" + model: "" + timeout_seconds: 60 + temperature: 0.1 + allow_without_model: true +``` + +改成你的 API 信息: + +```yaml +model: + provider: "openai-compatible" + base_url: "https://你的API地址/v1" + api_key: "你的API_KEY" + model: "你的模型名" + timeout_seconds: 60 + temperature: 0.1 + allow_without_model: false +``` + +说明: + +- `base_url`:API 地址,通常以 `/v1` 结尾。 +- `api_key`:你的 API Key。 +- `model`:模型名称。 +- `allow_without_model: false`:表示必须调用模型进行筛选。 + +如果暂时没有 API,也可以保留: + +```yaml +allow_without_model: true +``` + +这样工具会运行,但需要模型判断的简历会进入 `待人工二筛`。 + +## 运行工具 + +每次运行前,先进入目录并激活环境: + +```bash +cd /Users/mac/Desktop/codex/.worktrees/resume-screening-cli +source .venv/bin/activate +``` + +然后运行: + +```bash +resume-screening run --config config.yaml +``` + +运行结束后,终端会显示统计结果,例如: + +```text +处理完成 +处理数量:10 +推荐初试:3 +可考虑:2 +待人工二筛:4 +未通过:1 +疑似重复:1 +模型失败:0 +提取失败/警告:1 +文件名不规范:2 +``` + +## 到哪里看结果 + +结果会写入这个表: + +```text +/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx +``` + +写入位置: + +```text +多维表格 +``` + +工具会在原表后面新增三列: + +- `初筛分类` +- `初筛理由` +- `缺失信息` + +不会删除或覆盖旧数据。 + +## 初筛分类是什么意思 + +工具会给每份简历分成四类: + +- `推荐初试`:比较符合岗位要求,建议进入初试。 +- `可考虑`:有一定匹配度,但没有特别确定。 +- `待人工二筛`:信息缺失、岗位要求不完整、文件名不规范、模型失败等,需要人工看一眼。 +- `未通过`:明显不符合岗位要求。 + +评分是 10 分制: + +- 8 分及以上:通过 +- 9 分及以上:优秀 + +## 常见问题 + +### 1. 提示模型超时 + +Excel 的 `缺失信息` 里会写: + +```text +模型评估失败:请求超时 +``` + +可以把 `config.yaml` 里的超时时间调大: + +```yaml +timeout_seconds: 120 +``` + +### 2. 提示 API 认证错误 + +检查: + +- `api_key` 是否填错 +- `base_url` 是否正确 +- `model` 名称是否正确 + +### 3. 很多简历进入待人工二筛 + +常见原因: + +- 文件名不符合格式 +- 岗位说明书对应 sheet 内容不完整 +- 简历是扫描 PDF,文本无法直接提取 +- 没有配置模型 API + +### 4. 重复简历为什么还会写入 + +这是故意设计的。 + +因为同一份简历可能改名后投递不同岗位,工具不会跳过它,而是新增一行并标红,方便你人工判断。 + +### 5. 会不会覆盖原来的招聘表 + +不会。 + +工具只会向下新增,不会覆盖历史行。 + +## 日常使用步骤 + +最简单的流程是: + +1. 把简历放到 `/Users/mac/Downloads`。 +2. 确认岗位说明书已更新: + `/Users/mac/Downloads/小A自动化岗位说明书.xlsx` +3. 确认 `config.yaml` 里的 API 已配置。 +4. 运行: + +```bash +cd /Users/mac/Desktop/codex/.worktrees/resume-screening-cli +source .venv/bin/activate +resume-screening run --config config.yaml +``` + +5. 打开结果表查看: + `/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx` From 22090072b0072f57c30e9ceaa906da19e43a9cef Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 11:22:25 +0800 Subject: [PATCH 13/59] fix: make package install reliable --- README.md | 2 +- pyproject.toml | 3 +++ "\344\275\277\347\224\250\350\257\264\346\230\216.md" | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef480bc..2096181 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ```bash python3 -m venv .venv source .venv/bin/activate -python3 -m pip install -e ".[test]" +python3 -m pip install ".[test]" cp config.example.yaml config.yaml ``` diff --git a/pyproject.toml b/pyproject.toml index 599fabc..6959329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ test = ["pytest>=8.0"] [project.scripts] resume-screening = "resume_screening.cli:app" +[tool.setuptools] +package-dir = {"" = "src"} + [tool.setuptools.packages.find] where = ["src"] diff --git "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" index d21f2e5..d7aea09 100644 --- "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -48,7 +48,7 @@ cd /Users/mac/Desktop/codex/.worktrees/resume-screening-cli python3 -m venv .venv source .venv/bin/activate -python3 -m pip install -e ".[test]" +python3 -m pip install ".[test]" ``` 如果已经执行过这些命令,以后不用重复执行。 From a989a64f07a69c2a92d35a89274039c798914b7d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 15:05:45 +0800 Subject: [PATCH 14/59] docs: add web interface design --- .gitignore | 1 + .../2026-05-19-resume-screening-web-design.md | 207 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-resume-screening-web-design.md diff --git a/.gitignore b/.gitignore index 6007d99..f23747c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .scratch/ .worktrees/ +.superpowers/ node_modules data/ logs/ diff --git a/docs/superpowers/specs/2026-05-19-resume-screening-web-design.md b/docs/superpowers/specs/2026-05-19-resume-screening-web-design.md new file mode 100644 index 0000000..8bb5614 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-resume-screening-web-design.md @@ -0,0 +1,207 @@ +# Resume Screening Web Design + +## Overview + +Build a local web interface for the existing resume screening automation. + +The web version should make the current CLI easier to use without changing the core screening behavior. It runs entirely on the user's Mac: a local Python backend reads and writes local files, while a React frontend provides a step-by-step browser interface. + +The first web version prioritizes reliable local operation, clear progress visibility, and safe Excel writeback. It is not a cloud product and does not introduce user accounts, remote storage, or online file upload. + +## Confirmed Decisions + +- UI style: step-by-step wizard. +- Frontend/backend approach: React frontend plus Python backend. +- Startup method: double-click a local `.command` file as the primary user entry. +- Configuration storage: save editable settings to local `config.yaml`. +- Default paths: prefill the current known local paths, and allow editing in the page. +- Precheck depth: simple precheck only. +- Run feedback: show real-time progress and logs. +- File writeback: continue writing to the original recruiting workbook and append rows without overwriting history. + +## Local Architecture + +The system has three local pieces: + +1. A double-click startup script. +2. A FastAPI backend service. +3. A React frontend served locally. + +The startup script launches the backend and opens the browser to the local web page. The React page calls local API endpoints exposed by the backend. The browser never reads or writes arbitrary local files directly; local file access is handled by the Python process. + +The backend reuses the existing screening modules for: + +- Configuration loading. +- Resume directory scanning. +- Filename parsing. +- Document text extraction. +- Job requirements workbook reading. +- Model evaluation. +- Duplicate content detection. +- Excel row append and duplicate row red marking. + +This keeps CLI and web behavior aligned and avoids creating a second screening implementation. + +## User Flow + +The first web version has four wizard steps. + +### Step 1: File Paths + +Show editable path fields for: + +- Resume directory, defaulting to `/Users/mac/Downloads`. +- Job requirements workbook, defaulting to `/Users/mac/Downloads/小A自动化岗位说明书.xlsx`. +- Recruiting result workbook, defaulting to `/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx`. + +The page should make it clear when a path is missing or unreadable, but it should not scan every file in detail on this step. + +### Step 2: Model Configuration + +Show editable fields for: + +- API base URL. +- API key. +- Model name. +- Timeout seconds. +- Allow-without-model fallback option if it already exists in the CLI configuration. + +Saving this step writes to local `config.yaml`. The API key remains local and is not written to the recruiting Excel workbook. + +### Step 3: Precheck + +Run a lightweight precheck and show: + +- Number of supported resume files found. +- Whether the job requirements workbook exists and can be read. +- Whether usable job sheets are present. +- Whether the recruiting workbook exists and appears writable. +- Whether required model settings are present, unless offline/manual fallback is enabled. + +The precheck does not show the full resume file list and does not perform full model evaluation. + +### Step 4: Run Screening + +Show a start button, progress indicator, current file name, running logs, and final statistics. + +Final statistics include: + +- Processed file count. +- Appended row count. +- Duplicate-content row count. +- Category counts for `推荐初试`, `可考虑`, `待人工二筛`, and `未通过`. +- Model timeout or model failure count. + +The page may show the result workbook path after completion, but it should not automatically open Excel while the backend is writing the workbook. + +## Backend API + +The backend exposes local JSON endpoints for the React frontend. + +### `GET /api/config` + +Return the current effective configuration, including defaults from the project when `config.yaml` is incomplete. + +Sensitive values such as API keys may be returned masked unless the implementation needs plain values for editing. The first version can return the raw local value because the page is local-only, but the design should keep masking as an easy later improvement. + +### `POST /api/config` + +Validate and save editable path and model settings to `config.yaml`. + +Validation should catch obvious malformed values, such as empty required paths or non-numeric timeout values. It should not require every external file to exist before saving, because users may edit paths before creating files. + +### `GET /api/precheck` + +Run the lightweight precheck using the current saved configuration. + +The endpoint returns a structured result with: + +- Overall status: pass, warning, or fail. +- Individual check items. +- Human-readable messages for the page. + +Warnings should allow the user to continue when the existing CLI would still be able to route affected resumes to `待人工二筛`. + +### `POST /api/runs` + +Start one screening run. + +Only one run may execute at a time. If a run is already active, return the existing run status or a clear conflict response so the page can show that another task is running. + +### `GET /api/runs/{run_id}` + +Return the latest run status: + +- Run state: queued, running, completed, failed, or cancelled if cancellation is later added. +- Current file index and total file count. +- Current file name. +- Recent logs. +- Accumulated warnings and errors. +- Current statistics. + +The first implementation can use polling from the React page. Server-sent events or WebSockets can be added later if needed, but they are not required for the first version. + +## Run State and Concurrency + +The backend keeps an in-memory run registry for the current process. This is enough for the first local-only version. + +The run worker executes in the background so the API remains responsive while screening is in progress. A process-level lock prevents two runs from writing to the same workbook at the same time. + +If the backend process is closed during a run, the run is considered interrupted. The existing Excel append behavior and local processed index should remain the source of truth for what was already written. + +## Error Handling + +The web version follows the CLI's conservative screening behavior. + +- If a resume has a non-standard filename, still extract text and route to `待人工二筛`. +- If the matching job sheet is missing or incomplete, route affected resumes to `待人工二筛`. +- If document text extraction fails, write a manual-review result when possible and log the extraction issue. +- If the model API times out or returns invalid output, do not stop the batch. Mark that resume as `待人工二筛` and log the model problem. +- If the recruiting workbook is locked by Excel or not writable, stop before modifying it and show a clear error. +- If a duplicate content fingerprint is detected, write the row and apply the existing red row marking behavior. + +The page should distinguish blocking errors from warnings. Blocking errors stop the run before unsafe writeback; warnings are visible but allow the run to continue. + +## Files and Build Shape + +The implementation should keep the web layer separate from the screening core. + +Expected project additions: + +- A web backend module under `src/resume_screening/web/`. +- React frontend source under a dedicated web directory, such as `web/`. +- A command entry for launching the local web server. +- A double-click `.command` startup file for everyday use. +- Tests for configuration endpoints, precheck behavior, and run-state locking. + +The backend should import the existing core modules rather than shelling out to the CLI whenever practical. If a small orchestration adapter is needed, it should sit between the web worker and the existing pipeline. + +## Testing and Verification + +Automated tests should cover: + +- Loading current configuration through the API. +- Saving edited configuration to a temporary config file. +- Precheck results for valid and invalid test paths. +- Rejecting or reusing a run request while another run is active. +- Model failure fallback remaining visible in run logs and statistics. + +Manual verification should cover: + +- Double-click startup opens the local page. +- The React wizard can save paths and API settings. +- Precheck displays simple status without listing every resume. +- A test run shows progress and logs. +- The result workbook append behavior remains consistent with the CLI. + +The implementation is complete only after the local web page can be opened in a browser and the primary wizard flow has been exercised. + +## Out of Scope for First Web Version + +- Cloud deployment. +- Multi-user accounts or authentication. +- Uploading resumes through a remote webpage. +- Editing the recruiting workbook directly in the web UI. +- A historical run database. +- Parallel screening runs. +- Complex desktop app packaging. From c8805cb478d5a42c5b870e1d9e9277df310955b1 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 15:14:21 +0800 Subject: [PATCH 15/59] docs: add web implementation plan --- .../plans/2026-05-19-resume-screening-web.md | 1786 +++++++++++++++++ 1 file changed, 1786 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-resume-screening-web.md 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. From 8eb9ec091d029b6d8a4ab7e55727399738eab8a7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 15:18:55 +0800 Subject: [PATCH 16/59] feat: add web config store --- pyproject.toml | 5 ++ src/resume_screening/web/__init__.py | 1 + src/resume_screening/web/config_store.py | 80 ++++++++++++++++++++++++ tests/test_web_config_store.py | 66 +++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 src/resume_screening/web/__init__.py create mode 100644 src/resume_screening/web/config_store.py create mode 100644 tests/test_web_config_store.py diff --git a/pyproject.toml b/pyproject.toml index 6959329..cf3ee10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ dependencies = [ "python-docx>=1.1", "python-pptx>=0.6", "httpx>=0.27", + "fastapi>=0.111", + "uvicorn>=0.30", ] [project.optional-dependencies] @@ -30,6 +32,9 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +"resume_screening.web" = ["static/**/*"] + [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] diff --git a/src/resume_screening/web/__init__.py b/src/resume_screening/web/__init__.py new file mode 100644 index 0000000..798e647 --- /dev/null +++ b/src/resume_screening/web/__init__.py @@ -0,0 +1 @@ +"""Local web interface for resume screening.""" diff --git a/src/resume_screening/web/config_store.py b/src/resume_screening/web/config_store.py new file mode 100644 index 0000000..965db7b --- /dev/null +++ b/src/resume_screening/web/config_store.py @@ -0,0 +1,80 @@ +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 diff --git a/tests/test_web_config_store.py b/tests/test_web_config_store.py new file mode 100644 index 0000000..a35b4d4 --- /dev/null +++ b/tests/test_web_config_store.py @@ -0,0 +1,66 @@ +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) From df4da681276fc576f940722a89654453bacdf406 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 15:21:18 +0800 Subject: [PATCH 17/59] test: cover web config path serialization --- tests/test_web_config_store.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_web_config_store.py b/tests/test_web_config_store.py index a35b4d4..efe345d 100644 --- a/tests/test_web_config_store.py +++ b/tests/test_web_config_store.py @@ -58,9 +58,18 @@ def test_save_config_for_web_round_trips_paths_and_model(tmp_path: Path) -> None def test_config_to_public_dict_serializes_paths() -> None: - data = load_config_for_web(Path("config.example.yaml")) + data = { + "resume_dir": Path("/tmp/resumes"), + "job_book": Path("/tmp/jobs.xlsx"), + "result_book": Path("/tmp/result.xlsx"), + "index_path": Path("data/processed_index.json"), + "model": {"timeout_seconds": 60}, + } public = config_to_public_dict(data) assert isinstance(public["resume_dir"], str) + assert isinstance(public["job_book"], str) + assert isinstance(public["result_book"], str) + assert isinstance(public["index_path"], str) assert isinstance(public["model"]["timeout_seconds"], int) From cbd788df595da55f996407c756d363a771c9160c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:13:35 +0800 Subject: [PATCH 18/59] feat: add web precheck service --- src/resume_screening/web/precheck.py | 120 +++++++++++++++++++++++++++ tests/test_web_precheck.py | 62 ++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/resume_screening/web/precheck.py create mode 100644 tests/test_web_precheck.py diff --git a/src/resume_screening/web/precheck.py b/src/resume_screening/web/precheck.py new file mode 100644 index 0000000..0162f3a --- /dev/null +++ b/src/resume_screening/web/precheck.py @@ -0,0 +1,120 @@ +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: + if missing_model: + items.append(PrecheckItem("模型配置", "pass", "当前允许无模型运行,简历会进入待人工二筛")) + else: + items.append(PrecheckItem("模型配置", "pass", f"模型已配置:{model.model}")) + 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, + ) diff --git a/tests/test_web_precheck.py b/tests/test_web_precheck.py new file mode 100644 index 0000000..a338042 --- /dev/null +++ b/tests/test_web_precheck.py @@ -0,0 +1,62 @@ +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) From 211b2e47fa1dfaa91e6eef2ff7a958d6614244a0 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:14:43 +0800 Subject: [PATCH 19/59] feat: emit pipeline progress events --- src/resume_screening/models.py | 13 +++++++- src/resume_screening/pipeline.py | 26 +++++++++++++-- tests/test_pipeline_events.py | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/test_pipeline_events.py diff --git a/src/resume_screening/models.py b/src/resume_screening/models.py index 5a4d388..9d0885a 100644 --- a/src/resume_screening/models.py +++ b/src/resume_screening/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, Literal CATEGORY_RECOMMEND = "推荐初试" @@ -93,4 +93,15 @@ def count_category(self, category: str) -> None: self.reject += 1 +@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 + + JsonDict = dict[str, Any] diff --git a/src/resume_screening/pipeline.py b/src/resume_screening/pipeline.py index d24025e..39d8cf1 100644 --- a/src/resume_screening/pipeline.py +++ b/src/resume_screening/pipeline.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from pathlib import Path import httpx @@ -17,6 +18,7 @@ ExtractionResult, JobRequirement, ParsedFilename, + PipelineEvent, PipelineStats, ScreeningResult, ScreeningScores, @@ -24,8 +26,13 @@ class ScreeningPipeline: - def __init__(self, config: AppConfig) -> None: + 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) @@ -33,8 +40,11 @@ def run(self) -> PipelineStats: 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 path in self._resume_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) @@ -59,9 +69,21 @@ def run(self) -> PipelineStats: 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 def _resume_files(self) -> list[Path]: diff --git a/tests/test_pipeline_events.py b/tests/test_pipeline_events.py new file mode 100644 index 0000000..0535093 --- /dev/null +++ b/tests/test_pipeline_events.py @@ -0,0 +1,56 @@ +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 From cf3ab2f1eade1edcff4cf3c5638236217748807f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:16:04 +0800 Subject: [PATCH 20/59] feat: add web run manager --- src/resume_screening/web/runs.py | 140 +++++++++++++++++++++++++++++++ tests/test_web_runs.py | 77 +++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/resume_screening/web/runs.py create mode 100644 tests/test_web_runs.py diff --git a/src/resume_screening/web/runs.py b/src/resume_screening/web/runs.py new file mode 100644 index 0000000..f7a257c --- /dev/null +++ b/src/resume_screening/web/runs.py @@ -0,0 +1,140 @@ +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, Protocol +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 RunnablePipeline(Protocol): + def run(self) -> PipelineStats: + pass + + +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]], RunnablePipeline] | 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 diff --git a/tests/test_web_runs.py b/tests/test_web_runs.py new file mode 100644 index 0000000..7e57f29 --- /dev/null +++ b/tests/test_web_runs.py @@ -0,0 +1,77 @@ +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) From 94febf6fc402542ce27852e942ad3b77685fb628 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:17:27 +0800 Subject: [PATCH 21/59] feat: add local web api --- src/resume_screening/web/app.py | 82 +++++++++++++++++++++++++++++++++ tests/test_web_api.py | 69 +++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/resume_screening/web/app.py create mode 100644 tests/test_web_api.py diff --git a/src/resume_screening/web/app.py b/src/resume_screening/web/app.py new file mode 100644 index 0000000..a14e34c --- /dev/null +++ b/src/resume_screening/web/app.py @@ -0,0 +1,82 @@ +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 diff --git a/tests/test_web_api.py b/tests/test_web_api.py new file mode 100644 index 0000000..9c0281f --- /dev/null +++ b/tests/test_web_api.py @@ -0,0 +1,69 @@ +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"] From d962a0f915b99dcaba9adc4bb7d8467b1d1b1c18 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:19:27 +0800 Subject: [PATCH 22/59] feat: add web launcher command --- src/resume_screening/cli.py | 15 ++++++++++ tests/test_cli.py | 12 ++++++++ ...7\275\221\351\241\265\347\211\210.command" | 28 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100755 "\345\220\257\345\212\250\347\256\200\345\216\206\347\255\233\351\200\211\347\275\221\351\241\265\347\211\210.command" diff --git a/src/resume_screening/cli.py b/src/resume_screening/cli.py index 45d250c..d5aca9a 100644 --- a/src/resume_screening/cli.py +++ b/src/resume_screening/cli.py @@ -32,5 +32,20 @@ def run(config: Path = typer.Option(..., "--config", "-c", exists=True, readable typer.echo(f"文件名不规范:{stats.non_standard_names}") +@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) + + if __name__ == "__main__": app() diff --git a/tests/test_cli.py b/tests/test_cli.py index 5723ed5..2f66030 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,3 +24,15 @@ def test_cli_module_help_renders() -> None: assert result.returncode == 0 assert "run" in result.stdout + + +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 diff --git "a/\345\220\257\345\212\250\347\256\200\345\216\206\347\255\233\351\200\211\347\275\221\351\241\265\347\211\210.command" "b/\345\220\257\345\212\250\347\256\200\345\216\206\347\255\233\351\200\211\347\275\221\351\241\265\347\211\210.command" new file mode 100755 index 0000000..5bffc07 --- /dev/null +++ "b/\345\220\257\345\212\250\347\256\200\345\216\206\347\255\233\351\200\211\347\275\221\351\241\265\347\211\210.command" @@ -0,0 +1,28 @@ +#!/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 . + +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 From ad712b87e58c532b62bfa2ac1640d1abfbc111bb Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:22:16 +0800 Subject: [PATCH 23/59] feat: add react web wizard --- src/resume_screening/web/static/assets/app.js | 95 + .../web/static/assets/index.css | 1 + src/resume_screening/web/static/index.html | 13 + web/index.html | 12 + web/package-lock.json | 1582 +++++++++++++++++ web/package.json | 19 + web/src/App.jsx | 196 ++ web/src/main.jsx | 10 + web/src/styles.css | 211 +++ web/vite.config.js | 17 + 10 files changed, 2156 insertions(+) create mode 100644 src/resume_screening/web/static/assets/app.js create mode 100644 src/resume_screening/web/static/assets/index.css create mode 100644 src/resume_screening/web/static/index.html create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.jsx create mode 100644 web/src/main.jsx create mode 100644 web/src/styles.css create mode 100644 web/vite.config.js diff --git a/src/resume_screening/web/static/assets/app.js b/src/resume_screening/web/static/assets/app.js new file mode 100644 index 0000000..fb8a2d4 --- /dev/null +++ b/src/resume_screening/web/static/assets/app.js @@ -0,0 +1,95 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const o of u.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=n(l);fetch(l.href,u)}})();function ac(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Xi={exports:{}},nl={},Gi={exports:{}},j={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Zn=Symbol.for("react.element"),cc=Symbol.for("react.portal"),fc=Symbol.for("react.fragment"),dc=Symbol.for("react.strict_mode"),pc=Symbol.for("react.profiler"),mc=Symbol.for("react.provider"),hc=Symbol.for("react.context"),vc=Symbol.for("react.forward_ref"),yc=Symbol.for("react.suspense"),gc=Symbol.for("react.memo"),wc=Symbol.for("react.lazy"),Do=Symbol.iterator;function kc(e){return e===null||typeof e!="object"?null:(e=Do&&e[Do]||e["@@iterator"],typeof e=="function"?e:null)}var Zi={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ji=Object.assign,qi={};function un(e,t,n){this.props=e,this.context=t,this.refs=qi,this.updater=n||Zi}un.prototype.isReactComponent={};un.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};un.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function bi(){}bi.prototype=un.prototype;function Au(e,t,n){this.props=e,this.context=t,this.refs=qi,this.updater=n||Zi}var Vu=Au.prototype=new bi;Vu.constructor=Au;Ji(Vu,un.prototype);Vu.isPureReactComponent=!0;var Fo=Array.isArray,es=Object.prototype.hasOwnProperty,Bu={current:null},ts={key:!0,ref:!0,__self:!0,__source:!0};function ns(e,t,n){var r,l={},u=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(u=""+t.key),t)es.call(t,r)&&!ts.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,G=C[W];if(0>>1;Wl(kl,L))ytl(nr,kl)?(C[W]=nr,C[yt]=L,W=yt):(C[W]=kl,C[vt]=L,W=vt);else if(ytl(nr,L))C[W]=nr,C[yt]=L,W=yt;else break e}}return z}function l(C,z){var L=C.sortIndex-z.sortIndex;return L!==0?L:C.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var o=Date,i=o.now();e.unstable_now=function(){return o.now()-i}}var s=[],c=[],m=1,v=null,h=3,w=!1,k=!1,S=!1,M=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(C){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=C)r(c),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(c)}}function p(C){if(S=!1,d(C),!k)if(n(s)!==null)k=!0,gl(g);else{var z=n(c);z!==null&&wl(p,z.startTime-C)}}function g(C,z){k=!1,S&&(S=!1,f(P),P=-1),w=!0;var L=h;try{for(d(z),v=n(s);v!==null&&(!(v.expirationTime>z)||C&&!Pe());){var W=v.callback;if(typeof W=="function"){v.callback=null,h=v.priorityLevel;var G=W(v.expirationTime<=z);z=e.unstable_now(),typeof G=="function"?v.callback=G:v===n(s)&&r(s),d(z)}else r(s);v=n(s)}if(v!==null)var tr=!0;else{var vt=n(c);vt!==null&&wl(p,vt.startTime-z),tr=!1}return tr}finally{v=null,h=L,w=!1}}var _=!1,N=null,P=-1,H=5,T=-1;function Pe(){return!(e.unstable_now()-TC||125W?(C.sortIndex=L,t(c,C),n(s)===null&&C===n(c)&&(S?(f(P),P=-1):S=!0,wl(p,L-W))):(C.sortIndex=G,t(s,C),k||w||(k=!0,gl(g))),C},e.unstable_shouldYield=Pe,e.unstable_wrapCallback=function(C){var z=h;return function(){var L=h;h=z;try{return C.apply(this,arguments)}finally{h=L}}}})(is);os.exports=is;var Rc=os.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Mc=Z,ge=Rc;function y(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Yl=Object.prototype.hasOwnProperty,Oc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,$o={},Ao={};function Ic(e){return Yl.call(Ao,e)?!0:Yl.call($o,e)?!1:Oc.test(e)?Ao[e]=!0:($o[e]=!0,!1)}function Dc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Fc(e,t,n,r){if(t===null||typeof t>"u"||Dc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ae(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ae(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ae(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ae(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ae(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new ae(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ae(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ae(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ae(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ae(e,5,!1,e.toLowerCase(),null,!1,!1)});var Wu=/[\-:]([a-z])/g;function Qu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ae("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!0,!0)});function Ku(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2i||l[o]!==u[i]){var s=` +`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{El=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?kn(e):""}function Uc(e){switch(e.tag){case 5:return kn(e.type);case 16:return kn("Lazy");case 13:return kn("Suspense");case 19:return kn("SuspenseList");case 0:case 2:case 15:return e=Cl(e.type,!1),e;case 11:return e=Cl(e.type.render,!1),e;case 1:return e=Cl(e.type,!0),e;default:return""}}function Jl(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case It:return"Fragment";case Ot:return"Portal";case Xl:return"Profiler";case Yu:return"StrictMode";case Gl:return"Suspense";case Zl:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case cs:return(e.displayName||"Context")+".Consumer";case as:return(e._context.displayName||"Context")+".Provider";case Xu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Gu:return t=e.displayName||null,t!==null?t:Jl(e.type)||"Memo";case Je:t=e._payload,e=e._init;try{return Jl(e(t))}catch{}}return null}function $c(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Jl(t);case 8:return t===Yu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ds(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ac(e){var t=ds(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=Ac(e))}function ps(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ds(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Rr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ql(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Bo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ms(e,t){t=t.checked,t!=null&&Ku(e,"checked",t,!1)}function bl(e,t){ms(e,t);var n=ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?eu(e,t.type,n):t.hasOwnProperty("defaultValue")&&eu(e,t.type,ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ho(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function eu(e,t,n){(t!=="number"||Rr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Sn=Array.isArray;function Kt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=or.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function On(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Cn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Vc=["Webkit","ms","Moz","O"];Object.keys(Cn).forEach(function(e){Vc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Cn[t]=Cn[e]})});function gs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Cn.hasOwnProperty(e)&&Cn[e]?(""+t).trim():t+"px"}function ws(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=gs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Bc=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ru(e,t){if(t){if(Bc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(y(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(y(61))}if(t.style!=null&&typeof t.style!="object")throw Error(y(62))}}function lu(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var uu=null;function Zu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ou=null,Yt=null,Xt=null;function Ko(e){if(e=bn(e)){if(typeof ou!="function")throw Error(y(280));var t=e.stateNode;t&&(t=il(t),ou(e.stateNode,e.type,t))}}function ks(e){Yt?Xt?Xt.push(e):Xt=[e]:Yt=e}function Ss(){if(Yt){var e=Yt,t=Xt;if(Xt=Yt=null,Ko(e),t)for(e=0;e>>=0,e===0?32:31-(bc(e)/ef|0)|0}var ir=64,sr=4194304;function xn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=xn(i):(u&=o,u!==0&&(r=xn(u)))}else o=n&~l,o!==0?r=xn(o):u!==0&&(r=xn(u));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Jn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Re(t),e[t]=n}function lf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Nn),ti=" ",ni=!1;function Vs(e,t){switch(e){case"keyup":return Mf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Bs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Dt=!1;function If(e,t){switch(e){case"compositionend":return Bs(t);case"keypress":return t.which!==32?null:(ni=!0,ti);case"textInput":return e=t.data,e===ti&&ni?null:e;default:return null}}function Df(e,t){if(Dt)return e==="compositionend"||!lo&&Vs(e,t)?(e=$s(),Er=to=tt=null,Dt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=oi(n)}}function Ks(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ks(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ys(){for(var e=window,t=Rr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Rr(e.document)}return t}function uo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Qf(e){var t=Ys(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ks(n.ownerDocument.documentElement,n)){if(r!==null&&uo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=ii(n,u);var o=ii(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Ft=null,du=null,zn=null,pu=!1;function si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;pu||Ft==null||Ft!==Rr(r)||(r=Ft,"selectionStart"in r&&uo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),zn&&An(zn,r)||(zn=r,r=$r(du,"onSelect"),0At||(e.current=wu[At],wu[At]=null,At--)}function I(e,t){At++,wu[At]=e.current,e.current=t}var dt={},ue=mt(dt),de=mt(!1),_t=dt;function bt(e,t){var n=e.type.contextTypes;if(!n)return dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function pe(e){return e=e.childContextTypes,e!=null}function Vr(){F(de),F(ue)}function hi(e,t,n){if(ue.current!==dt)throw Error(y(168));I(ue,t),I(de,n)}function na(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(y(108,$c(e)||"Unknown",l));return V({},n,r)}function Br(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||dt,_t=ue.current,I(ue,e),I(de,de.current),!0}function vi(e,t,n){var r=e.stateNode;if(!r)throw Error(y(169));n?(e=na(e,t,_t),r.__reactInternalMemoizedMergedChildContext=e,F(de),F(ue),I(ue,e)):F(de),I(de,n)}var Ve=null,sl=!1,Ul=!1;function ra(e){Ve===null?Ve=[e]:Ve.push(e)}function rd(e){sl=!0,ra(e)}function ht(){if(!Ul&&Ve!==null){Ul=!0;var e=0,t=O;try{var n=Ve;for(O=1;e>=o,l-=o,Be=1<<32-Re(t)+l|n<P?(H=N,N=null):H=N.sibling;var T=h(f,N,d[P],p);if(T===null){N===null&&(N=H);break}e&&N&&T.alternate===null&&t(f,N),a=u(T,a,P),_===null?g=T:_.sibling=T,_=T,N=H}if(P===d.length)return n(f,N),U&>(f,P),g;if(N===null){for(;PP?(H=N,N=null):H=N.sibling;var Pe=h(f,N,T.value,p);if(Pe===null){N===null&&(N=H);break}e&&N&&Pe.alternate===null&&t(f,N),a=u(Pe,a,P),_===null?g=Pe:_.sibling=Pe,_=Pe,N=H}if(T.done)return n(f,N),U&>(f,P),g;if(N===null){for(;!T.done;P++,T=d.next())T=v(f,T.value,p),T!==null&&(a=u(T,a,P),_===null?g=T:_.sibling=T,_=T);return U&>(f,P),g}for(N=r(f,N);!T.done;P++,T=d.next())T=w(N,f,P,T.value,p),T!==null&&(e&&T.alternate!==null&&N.delete(T.key===null?P:T.key),a=u(T,a,P),_===null?g=T:_.sibling=T,_=T);return e&&N.forEach(function(an){return t(f,an)}),U&>(f,P),g}function M(f,a,d,p){if(typeof d=="object"&&d!==null&&d.type===It&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case lr:e:{for(var g=d.key,_=a;_!==null;){if(_.key===g){if(g=d.type,g===It){if(_.tag===7){n(f,_.sibling),a=l(_,d.props.children),a.return=f,f=a;break e}}else if(_.elementType===g||typeof g=="object"&&g!==null&&g.$$typeof===Je&&wi(g)===_.type){n(f,_.sibling),a=l(_,d.props),a.ref=vn(f,_,d),a.return=f,f=a;break e}n(f,_);break}else t(f,_);_=_.sibling}d.type===It?(a=Ct(d.props.children,f.mode,p,d.key),a.return=f,f=a):(p=Tr(d.type,d.key,d.props,null,f.mode,p),p.ref=vn(f,a,d),p.return=f,f=p)}return o(f);case Ot:e:{for(_=d.key;a!==null;){if(a.key===_)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Kl(d,f.mode,p),a.return=f,f=a}return o(f);case Je:return _=d._init,M(f,a,_(d._payload),p)}if(Sn(d))return k(f,a,d,p);if(fn(d))return S(f,a,d,p);hr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=Ql(d,f.mode,p),a.return=f,f=a),o(f)):n(f,a)}return M}var tn=ia(!0),sa=ia(!1),Qr=mt(null),Kr=null,Ht=null,ao=null;function co(){ao=Ht=Kr=null}function fo(e){var t=Qr.current;F(Qr),e._currentValue=t}function xu(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Zt(e,t){Kr=e,ao=Ht=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(fe=!0),e.firstContext=null)}function _e(e){var t=e._currentValue;if(ao!==e)if(e={context:e,memoizedValue:t,next:null},Ht===null){if(Kr===null)throw Error(y(308));Ht=e,Kr.dependencies={lanes:0,firstContext:e}}else Ht=Ht.next=e;return t}var St=null;function po(e){St===null?St=[e]:St.push(e)}function aa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,po(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ye(e,r)}function Ye(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var qe=!1;function mo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ca(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function We(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function it(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,R&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ye(e,n)}return l=r.interleaved,l===null?(t.next=t,po(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ye(e,n)}function _r(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}function ki(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Yr(e,t,n,r){var l=e.updateQueue;qe=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,c=s.next;s.next=null,o===null?u=c:o.next=c,o=s;var m=e.alternate;m!==null&&(m=m.updateQueue,i=m.lastBaseUpdate,i!==o&&(i===null?m.firstBaseUpdate=c:i.next=c,m.lastBaseUpdate=s))}if(u!==null){var v=l.baseState;o=0,m=c=s=null,i=u;do{var h=i.lane,w=i.eventTime;if((r&h)===h){m!==null&&(m=m.next={eventTime:w,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var k=e,S=i;switch(h=t,w=n,S.tag){case 1:if(k=S.payload,typeof k=="function"){v=k.call(w,v,h);break e}v=k;break e;case 3:k.flags=k.flags&-65537|128;case 0:if(k=S.payload,h=typeof k=="function"?k.call(w,v,h):k,h==null)break e;v=V({},v,h);break e;case 2:qe=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[i]:h.push(i))}else w={eventTime:w,lane:h,tag:i.tag,payload:i.payload,callback:i.callback,next:null},m===null?(c=m=w,s=v):m=m.next=w,o|=h;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;h=i,i=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(m===null&&(s=v),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=m,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);zt|=o,e.lanes=o,e.memoizedState=v}}function Si(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Al.transition;Al.transition={};try{e(!1),t()}finally{O=n,Al.transition=r}}function Pa(){return Ne().memoizedState}function id(e,t,n){var r=at(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},za(e))La(t,n);else if(n=aa(e,t,n,r),n!==null){var l=ie();Me(n,e,r,l),ja(n,t,r)}}function sd(e,t,n){var r=at(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(za(e))La(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,Oe(i,o)){var s=t.interleaved;s===null?(l.next=l,po(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=aa(e,t,l,r),n!==null&&(l=ie(),Me(n,e,r,l),ja(n,t,r))}}function za(e){var t=e.alternate;return e===A||t!==null&&t===A}function La(e,t){Ln=Gr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ja(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}var Zr={readContext:_e,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},ad={readContext:_e,useCallback:function(e,t){return De().memoizedState=[e,t===void 0?null:t],e},useContext:_e,useEffect:Ei,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Pr(4194308,4,xa.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Pr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Pr(4,2,e,t)},useMemo:function(e,t){var n=De();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=De();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=id.bind(null,A,e),[r.memoizedState,e]},useRef:function(e){var t=De();return e={current:e},t.memoizedState=e},useState:xi,useDebugValue:xo,useDeferredValue:function(e){return De().memoizedState=e},useTransition:function(){var e=xi(!1),t=e[0];return e=od.bind(null,e[1]),De().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=A,l=De();if(U){if(n===void 0)throw Error(y(407));n=n()}else{if(n=t(),q===null)throw Error(y(349));Pt&30||ma(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,Ei(va.bind(null,r,u,e),[e]),r.flags|=2048,Xn(9,ha.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=De(),t=q.identifierPrefix;if(U){var n=He,r=Be;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Kn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Fe]=t,e[Hn]=r,Aa(e,t,!1,!1),t.stateNode=e;e:{switch(o=lu(n,r),n){case"dialog":D("cancel",e),D("close",e),l=r;break;case"iframe":case"object":case"embed":D("load",e),l=r;break;case"video":case"audio":for(l=0;lln&&(t.flags|=128,r=!0,yn(u,!1),t.lanes=4194304)}else{if(!r)if(e=Xr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),yn(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!U)return re(t),null}else 2*Q()-u.renderingStartTime>ln&&n!==1073741824&&(t.flags|=128,r=!0,yn(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Q(),t.sibling=null,n=$.current,I($,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return zo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?he&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(y(156,t.tag))}function yd(e,t){switch(io(t),t.tag){case 1:return pe(t.type)&&Vr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return nn(),F(de),F(ue),yo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return vo(t),null;case 13:if(F($),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(y(340));en()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F($),null;case 4:return nn(),null;case 10:return fo(t.type._context),null;case 22:case 23:return zo(),null;case 24:return null;default:return null}}var yr=!1,le=!1,gd=typeof WeakSet=="function"?WeakSet:Set,E=null;function Wt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){B(e,t,r)}else n.current=null}function Tu(e,t,n){try{n()}catch(r){B(e,t,r)}}var Oi=!1;function wd(e,t){if(mu=Fr,e=Ys(),uo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,c=0,m=0,v=e,h=null;t:for(;;){for(var w;v!==n||l!==0&&v.nodeType!==3||(i=o+l),v!==u||r!==0&&v.nodeType!==3||(s=o+r),v.nodeType===3&&(o+=v.nodeValue.length),(w=v.firstChild)!==null;)h=v,v=w;for(;;){if(v===e)break t;if(h===n&&++c===l&&(i=o),h===u&&++m===r&&(s=o),(w=v.nextSibling)!==null)break;v=h,h=v.parentNode}v=w}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(hu={focusedElem:e,selectionRange:n},Fr=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var k=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(k!==null){var S=k.memoizedProps,M=k.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?S:Le(t.type,S),M);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(p){B(t,t.return,p)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return k=Oi,Oi=!1,k}function jn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&Tu(t,n,u)}l=l.next}while(l!==r)}}function fl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Ru(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ha(e){var t=e.alternate;t!==null&&(e.alternate=null,Ha(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Fe],delete t[Hn],delete t[gu],delete t[td],delete t[nd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Wa(e){return e.tag===5||e.tag===3||e.tag===4}function Ii(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Wa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Mu(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Ar));else if(r!==4&&(e=e.child,e!==null))for(Mu(e,t,n),e=e.sibling;e!==null;)Mu(e,t,n),e=e.sibling}function Ou(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ou(e,t,n),e=e.sibling;e!==null;)Ou(e,t,n),e=e.sibling}var b=null,je=!1;function Ze(e,t,n){for(n=n.child;n!==null;)Qa(e,t,n),n=n.sibling}function Qa(e,t,n){if(Ue&&typeof Ue.onCommitFiberUnmount=="function")try{Ue.onCommitFiberUnmount(rl,n)}catch{}switch(n.tag){case 5:le||Wt(n,t);case 6:var r=b,l=je;b=null,Ze(e,t,n),b=r,je=l,b!==null&&(je?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(je?(e=b,n=n.stateNode,e.nodeType===8?Fl(e.parentNode,n):e.nodeType===1&&Fl(e,n),Un(e)):Fl(b,n.stateNode));break;case 4:r=b,l=je,b=n.stateNode.containerInfo,je=!0,Ze(e,t,n),b=r,je=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&(u&2||u&4)&&Tu(n,t,o),l=l.next}while(l!==r)}Ze(e,t,n);break;case 1:if(!le&&(Wt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){B(n,t,i)}Ze(e,t,n);break;case 21:Ze(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,Ze(e,t,n),le=r):Ze(e,t,n);break;default:Ze(e,t,n)}}function Di(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new gd),t.forEach(function(r){var l=zd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=Q()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Sd(r/1960))-r,10e?16:e,nt===null)var r=!1;else{if(e=nt,nt=null,br=0,R&6)throw Error(y(331));var l=R;for(R|=4,E=e.current;E!==null;){var u=E,o=u.child;if(E.flags&16){var i=u.deletions;if(i!==null){for(var s=0;sQ()-No?Et(e,0):_o|=n),me(e,t)}function ba(e,t){t===0&&(e.mode&1?(t=sr,sr<<=1,!(sr&130023424)&&(sr=4194304)):t=1);var n=ie();e=Ye(e,t),e!==null&&(Jn(e,t,n),me(e,n))}function Pd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ba(e,n)}function zd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(t),ba(e,n)}var ec;ec=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||de.current)fe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return fe=!1,hd(e,t,n);fe=!!(e.flags&131072)}else fe=!1,U&&t.flags&1048576&&la(t,Wr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;zr(e,t),e=t.pendingProps;var l=bt(t,ue.current);Zt(t,n),l=wo(null,t,r,e,l,n);var u=ko();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pe(r)?(u=!0,Br(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,mo(t),l.updater=cl,t.stateNode=l,l._reactInternals=t,Cu(t,r,e,n),t=Pu(null,t,r,!0,u,n)):(t.tag=0,U&&u&&oo(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(zr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=jd(r),e=Le(r,e),l){case 0:t=Nu(null,t,r,e,n);break e;case 1:t=Ti(null,t,r,e,n);break e;case 11:t=Li(null,t,r,e,n);break e;case 14:t=ji(null,t,r,Le(r.type,e),n);break e}throw Error(y(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Nu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Ti(e,t,r,l,n);case 3:e:{if(Fa(t),e===null)throw Error(y(387));r=t.pendingProps,u=t.memoizedState,l=u.element,ca(e,t),Yr(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=rn(Error(y(423)),t),t=Ri(e,t,r,n,l);break e}else if(r!==l){l=rn(Error(y(424)),t),t=Ri(e,t,r,n,l);break e}else for(ve=ot(t.stateNode.containerInfo.firstChild),ye=t,U=!0,Te=null,n=sa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(en(),r===l){t=Xe(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return fa(t),e===null&&Su(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,vu(r,l)?o=null:u!==null&&vu(r,u)&&(t.flags|=32),Da(e,t),oe(e,t,o,n),t.child;case 6:return e===null&&Su(t),null;case 13:return Ua(e,t,n);case 4:return ho(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=tn(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Li(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,I(Qr,r._currentValue),r._currentValue=o,u!==null)if(Oe(u.value,o)){if(u.children===l.children&&!de.current){t=Xe(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=We(-1,n&-n),s.tag=2;var c=u.updateQueue;if(c!==null){c=c.shared;var m=c.pending;m===null?s.next=s:(s.next=m.next,m.next=s),c.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),xu(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(y(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),xu(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Zt(t,n),l=_e(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=Le(r,t.pendingProps),l=Le(r.type,l),ji(e,t,r,l,n);case 15:return Oa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),zr(e,t),t.tag=1,pe(r)?(e=!0,Br(t)):e=!1,Zt(t,n),Ta(t,r,l),Cu(t,r,l,n),Pu(null,t,r,!0,e,n);case 19:return $a(e,t,n);case 22:return Ia(e,t,n)}throw Error(y(156,t.tag))};function tc(e,t){return zs(e,t)}function Ld(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new Ld(e,t,n,r)}function jo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function jd(e){if(typeof e=="function")return jo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Xu)return 11;if(e===Gu)return 14}return 2}function ct(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Tr(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")jo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case It:return Ct(n.children,l,u,t);case Yu:o=8,l|=8;break;case Xl:return e=Ee(12,n,t,l|2),e.elementType=Xl,e.lanes=u,e;case Gl:return e=Ee(13,n,t,l),e.elementType=Gl,e.lanes=u,e;case Zl:return e=Ee(19,n,t,l),e.elementType=Zl,e.lanes=u,e;case fs:return pl(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case as:o=10;break e;case cs:o=9;break e;case Xu:o=11;break e;case Gu:o=14;break e;case Je:o=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function Ct(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function pl(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=fs,e.lanes=n,e.stateNode={isHidden:!1},e}function Ql(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Kl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Td(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Nl(0),this.expirationTimes=Nl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Nl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function To(e,t,n,r,l,u,o,i,s){return e=new Td(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Ee(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},mo(u),e}function Rd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(uc)}catch(e){console.error(e)}}uc(),us.exports=we;var Fd=us.exports,oc,Wi=Fd;oc=Wi.createRoot,Wi.hydrateRoot;/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ud=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),ic=(...e)=>e.filter((t,n,r)=>!!t&&t.trim()!==""&&r.indexOf(t)===n).join(" ").trim();/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var $d={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ad=Z.forwardRef(({color:e="currentColor",size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:l="",children:u,iconNode:o,...i},s)=>Z.createElement("svg",{ref:s,...$d,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:ic("lucide",l),...i},[...o.map(([c,m])=>Z.createElement(c,m)),...Array.isArray(u)?u:[u]]));/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Rt=(e,t)=>{const n=Z.forwardRef(({className:r,...l},u)=>Z.createElement(Ad,{ref:u,iconNode:t,className:ic(`lucide-${Ud(e)}`,r),...l}));return n.displayName=`${e}`,n};/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Vd=Rt("CircleAlert",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Bd=Rt("CircleCheck",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Hd=Rt("FileSpreadsheet",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M8 13h2",key:"yr2amv"}],["path",{d:"M14 13h2",key:"un5t4a"}],["path",{d:"M8 17h2",key:"2yhykz"}],["path",{d:"M14 17h2",key:"10kma7"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Wd=Rt("KeyRound",[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qd=Rt("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qi=Rt("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);/** + * @license lucide-react v0.468.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Kd=["文件路径","模型配置","运行前预检","开始筛选"],$u={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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Yd(){const[e,t]=Z.useState(0),[n,r]=Z.useState($u),[l,u]=Z.useState(""),[o,i]=Z.useState(""),[s,c]=Z.useState(null),[m,v]=Z.useState(null),[h,w]=Z.useState("");Z.useEffect(()=>{wn("/api/config").then(p=>r(Yi(p.config))).catch(p=>i(p.message))},[]),Z.useEffect(()=>{if(!h||(m==null?void 0:m.state)==="completed"||(m==null?void 0:m.state)==="failed")return;const p=window.setInterval(()=>{wn(`/api/runs/${h}`).then(g=>v(g.run)).catch(g=>i(g.message))},1200);return()=>window.clearInterval(p)},[h,m==null?void 0:m.state]);const k=Z.useMemo(()=>!m||!m.total?0:Math.round(m.current/m.total*100),[m]);function S(p,g){r(_=>({..._,[p]:g}))}function M(p,g){r(_=>({..._,model:{..._.model,[p]:g}}))}async function f(){i(""),u("");try{const p=await wn("/api/config",{method:"POST",body:JSON.stringify(n)});r(Yi(p.config)),u("配置已保存到本地 config.yaml")}catch(p){i(p.message)}}async function a(){i("");try{const p=await wn("/api/precheck");c(p),t(2)}catch(p){i(p.message)}}async function d(){i("");try{const p=await wn("/api/runs",{method:"POST",body:"{}"});v(p.run),w(p.run.run_id),t(3)}catch(p){i(p.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Kd.map((p,g)=>x.jsxs("button",{className:g===e?"active":"",onClick:()=>t(g),children:[g+1,". ",p]},p))})]}),l&&x.jsxs("div",{className:"notice success",children:[x.jsx(Bd,{size:18}),l]}),o&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Vd,{size:18}),o]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Hd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:p=>S("resume_dir",p.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:p=>S("job_book",p.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:p=>S("result_book",p.target.value)})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:p=>M("base_url",p.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:p=>M("api_key",p.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:p=>M("model",p.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:p=>M("timeout_seconds",Number(p.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:p=>M("allow_without_model",p.target.checked)}),"允许无模型运行并进入待人工二筛"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:a,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!s&&x.jsx("button",{className:"primary",onClick:a,children:"开始预检"}),s&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${s.status}`,children:["支持格式简历:",s.resume_file_count," 个"]}),s.items.map(p=>x.jsxs("div",{className:`check ${p.status}`,children:[x.jsx("strong",{children:p.name}),x.jsx("span",{children:p.message})]},p.name)),x.jsxs("button",{className:"primary",onClick:d,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(m==null?void 0:m.state)==="running"?x.jsx(Qd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!m&&x.jsx("button",{className:"primary",onClick:d,children:"开始筛选"}),m&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${k}%`}})}),x.jsxs("p",{className:"muted",children:[m.current_file||"等待任务更新"," ",m.total?`${m.current}/${m.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(m.stats||{}).map(([p,g])=>x.jsxs("div",{children:[x.jsx("strong",{children:g}),x.jsx("span",{children:p})]},p))}),x.jsx("div",{className:"logs",children:(m.logs||[]).map((p,g)=>x.jsx("p",{className:p.level,children:p.message},`${p.timestamp}-${g}`))}),m.error&&x.jsx("div",{className:"notice danger",children:m.error})]})]})]})}oc(document.getElementById("root")).render(x.jsx(_c.StrictMode,{children:x.jsx(Yd,{})})); diff --git a/src/resume_screening/web/static/assets/index.css b/src/resume_screening/web/static/assets/index.css new file mode 100644 index 0000000..60f4193 --- /dev/null +++ b/src/resume_screening/web/static/assets/index.css @@ -0,0 +1 @@ +: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 .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}} diff --git a/src/resume_screening/web/static/index.html b/src/resume_screening/web/static/index.html new file mode 100644 index 0000000..081d56a --- /dev/null +++ b/src/resume_screening/web/static/index.html @@ -0,0 +1,13 @@ + + + + + + 简历初筛工具 + + + + +
+ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b7a7fd5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 简历初筛工具 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..aefe5a9 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1582 @@ +{ + "name": "resume-screening-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "resume-screening-web", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "^4.2.1", + "lucide-react": "^0.468.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "vite": "^5.4.0" + }, + "devDependencies": {} + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.359", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.359.tgz", + "integrity": "sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a39cc5d --- /dev/null +++ b/web/package.json @@ -0,0 +1,19 @@ +{ + "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": {} +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..356e160 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,196 @@ +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; +} + +function mergeConfig(config) { + return { ...emptyConfig, ...config, model: { ...emptyConfig.model, ...config.model } }; +} + +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(mergeConfig(body.config))) + .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(""); + try { + const body = await requestJson("/api/config", { method: "POST", body: JSON.stringify(config) }); + setConfig(mergeConfig(body.config)); + setNotice("配置已保存到本地 config.yaml"); + } catch (exc) { + setError(exc.message); + } + } + + async function runPrecheck() { + setError(""); + try { + const body = await requestJson("/api/precheck"); + setPrecheck(body); + setStep(2); + } catch (exc) { + setError(exc.message); + } + } + + async function startRun() { + setError(""); + try { + const body = await requestJson("/api/runs", { method: "POST", body: "{}" }); + setRun(body.run); + setRunId(body.run.run_id); + setStep(3); + } catch (exc) { + setError(exc.message); + } + } + + 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}
} + + )} +
+ )} +
+ ); +} diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..2bedb2e --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.jsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")).render( + + + +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..d7813a1 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,211 @@ +: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; } +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..3bb2429 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,17 @@ +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]" + } + } + } +}); From 781d57f73b7ce538c2573b999d835a5f8be9676d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 16:24:06 +0800 Subject: [PATCH 24/59] docs: explain web interface usage --- README.md | 13 +++++++++ ...77\347\224\250\350\257\264\346\230\216.md" | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index 2096181..bae0967 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,19 @@ It rereads `/Users/mac/Downloads/小A自动化岗位说明书.xlsx` on every run Historical rows are preserved. Duplicate resume content is still appended and marked red. +## 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. Confirm resume folder, job requirements workbook, and result workbook paths. +2. Save model API settings. +3. Run a lightweight precheck. +4. Start screening and watch live progress, logs, and final statistics. + ## Manual-Review Mode Set `model.allow_without_model: true` to process files without model calls. diff --git "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" index d7aea09..8c388ad 100644 --- "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -110,6 +110,33 @@ allow_without_model: true ## 运行工具 +### 方法一:网页版运行 + +双击项目目录里的: + +```text +启动简历筛选网页版.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. 开始筛选:查看实时进度、日志和统计 + +如果模型超时或报错,工具会把对应简历写成 `待人工二筛`,并在日志里显示原因。 + +### 方法二:命令行运行 + 每次运行前,先进入目录并激活环境: ```bash From d193b20dde2e1d982bbc15e7c8cb916832c25404 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 18:03:06 +0800 Subject: [PATCH 25/59] feat: scan nested files and unsupported formats --- README.md | 1 + src/resume_screening/file_scanner.py | 28 +++++++++++++ src/resume_screening/pipeline.py | 14 ++----- src/resume_screening/web/precheck.py | 23 ++--------- src/resume_screening/web/static/assets/app.js | 2 +- tests/test_file_scanner.py | 22 +++++++++++ tests/test_pipeline.py | 39 +++++++++++++++++++ tests/test_web_api.py | 6 ++- tests/test_web_precheck.py | 6 ++- web/src/App.jsx | 2 +- ...77\347\224\250\350\257\264\346\230\216.md" | 5 ++- 11 files changed, 110 insertions(+), 38 deletions(-) create mode 100644 src/resume_screening/file_scanner.py create mode 100644 tests/test_file_scanner.py diff --git a/README.md b/README.md index bae0967..f51e5fe 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The tool appends rows to `/Users/mac/Downloads/小A科技(北京)组织招 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. ## Web Interface diff --git a/src/resume_screening/file_scanner.py b/src/resume_screening/file_scanner.py new file mode 100644 index 0000000..16524ba --- /dev/null +++ b/src/resume_screening/file_scanner.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path + + +def iter_candidate_files(resume_dir: Path, job_book: Path, result_book: Path) -> list[Path]: + if not resume_dir.exists() or not resume_dir.is_dir(): + return [] + + skip = {job_book.resolve(), result_book.resolve()} + files: list[Path] = [] + for path in resume_dir.rglob("*"): + if not path.is_file(): + continue + if _has_hidden_part(path, resume_dir): + continue + if path.resolve() in skip: + continue + files.append(path) + return sorted(files) + + +def _has_hidden_part(path: Path, root: Path) -> bool: + try: + relative = path.relative_to(root) + except ValueError: + relative = path + return any(part.startswith(".") for part in relative.parts) diff --git a/src/resume_screening/pipeline.py b/src/resume_screening/pipeline.py index 39d8cf1..ce83405 100644 --- a/src/resume_screening/pipeline.py +++ b/src/resume_screening/pipeline.py @@ -8,7 +8,8 @@ from resume_screening.config import AppConfig from resume_screening.duplicates import DuplicateIndex from resume_screening.excel_writer import ResultWorkbookWriter -from resume_screening.extractors import SUPPORTED_EXTENSIONS, extract_text +from resume_screening.extractors import extract_text +from resume_screening.file_scanner import iter_candidate_files from resume_screening.filename_parser import parse_resume_filename from resume_screening.job_requirements import load_job_requirements from resume_screening.model_client import ModelClient @@ -87,16 +88,7 @@ def run(self) -> PipelineStats: return stats def _resume_files(self) -> list[Path]: - skip = {self.config.job_book.resolve(), self.config.result_book.resolve()} - files: list[Path] = [] - for path in self.config.resume_dir.iterdir(): - if not path.is_file(): - continue - if path.resolve() in skip: - continue - if path.suffix.lower() in SUPPORTED_EXTENSIONS: - files.append(path) - return sorted(files) + return iter_candidate_files(self.config.resume_dir, self.config.job_book, self.config.result_book) def _screen( self, diff --git a/src/resume_screening/web/precheck.py b/src/resume_screening/web/precheck.py index 0162f3a..d38f9ff 100644 --- a/src/resume_screening/web/precheck.py +++ b/src/resume_screening/web/precheck.py @@ -7,7 +7,7 @@ from openpyxl import load_workbook from resume_screening.config import AppConfig -from resume_screening.extractors import SUPPORTED_EXTENSIONS +from resume_screening.file_scanner import iter_candidate_files from resume_screening.job_requirements import load_job_requirements CheckStatus = Literal["pass", "warning", "fail"] @@ -28,21 +28,6 @@ class PrecheckResult: 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 @@ -65,13 +50,13 @@ def _overall_status(items: list[PrecheckItem]) -> CheckStatus: def run_precheck(config: AppConfig) -> PrecheckResult: items: list[PrecheckItem] = [] - resume_count = _supported_resume_count(config.resume_dir, config.job_book, config.result_book) + resume_count = len(iter_candidate_files(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", "没有找到支持格式的简历文件")) + items.append(PrecheckItem("简历文件夹", "warning", "没有找到待处理文件")) else: - items.append(PrecheckItem("简历文件夹", "pass", f"找到 {resume_count} 个支持格式文件")) + items.append(PrecheckItem("简历文件夹", "pass", f"找到 {resume_count} 个待处理文件")) usable_jobs = 0 if not config.job_book.exists(): diff --git a/src/resume_screening/web/static/assets/app.js b/src/resume_screening/web/static/assets/app.js index fb8a2d4..e2181e7 100644 --- a/src/resume_screening/web/static/assets/app.js +++ b/src/resume_screening/web/static/assets/app.js @@ -92,4 +92,4 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Kd=["文件路径","模型配置","运行前预检","开始筛选"],$u={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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Yd(){const[e,t]=Z.useState(0),[n,r]=Z.useState($u),[l,u]=Z.useState(""),[o,i]=Z.useState(""),[s,c]=Z.useState(null),[m,v]=Z.useState(null),[h,w]=Z.useState("");Z.useEffect(()=>{wn("/api/config").then(p=>r(Yi(p.config))).catch(p=>i(p.message))},[]),Z.useEffect(()=>{if(!h||(m==null?void 0:m.state)==="completed"||(m==null?void 0:m.state)==="failed")return;const p=window.setInterval(()=>{wn(`/api/runs/${h}`).then(g=>v(g.run)).catch(g=>i(g.message))},1200);return()=>window.clearInterval(p)},[h,m==null?void 0:m.state]);const k=Z.useMemo(()=>!m||!m.total?0:Math.round(m.current/m.total*100),[m]);function S(p,g){r(_=>({..._,[p]:g}))}function M(p,g){r(_=>({..._,model:{..._.model,[p]:g}}))}async function f(){i(""),u("");try{const p=await wn("/api/config",{method:"POST",body:JSON.stringify(n)});r(Yi(p.config)),u("配置已保存到本地 config.yaml")}catch(p){i(p.message)}}async function a(){i("");try{const p=await wn("/api/precheck");c(p),t(2)}catch(p){i(p.message)}}async function d(){i("");try{const p=await wn("/api/runs",{method:"POST",body:"{}"});v(p.run),w(p.run.run_id),t(3)}catch(p){i(p.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Kd.map((p,g)=>x.jsxs("button",{className:g===e?"active":"",onClick:()=>t(g),children:[g+1,". ",p]},p))})]}),l&&x.jsxs("div",{className:"notice success",children:[x.jsx(Bd,{size:18}),l]}),o&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Vd,{size:18}),o]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Hd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:p=>S("resume_dir",p.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:p=>S("job_book",p.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:p=>S("result_book",p.target.value)})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:p=>M("base_url",p.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:p=>M("api_key",p.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:p=>M("model",p.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:p=>M("timeout_seconds",Number(p.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:p=>M("allow_without_model",p.target.checked)}),"允许无模型运行并进入待人工二筛"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:a,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!s&&x.jsx("button",{className:"primary",onClick:a,children:"开始预检"}),s&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${s.status}`,children:["支持格式简历:",s.resume_file_count," 个"]}),s.items.map(p=>x.jsxs("div",{className:`check ${p.status}`,children:[x.jsx("strong",{children:p.name}),x.jsx("span",{children:p.message})]},p.name)),x.jsxs("button",{className:"primary",onClick:d,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(m==null?void 0:m.state)==="running"?x.jsx(Qd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!m&&x.jsx("button",{className:"primary",onClick:d,children:"开始筛选"}),m&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${k}%`}})}),x.jsxs("p",{className:"muted",children:[m.current_file||"等待任务更新"," ",m.total?`${m.current}/${m.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(m.stats||{}).map(([p,g])=>x.jsxs("div",{children:[x.jsx("strong",{children:g}),x.jsx("span",{children:p})]},p))}),x.jsx("div",{className:"logs",children:(m.logs||[]).map((p,g)=>x.jsx("p",{className:p.level,children:p.message},`${p.timestamp}-${g}`))}),m.error&&x.jsx("div",{className:"notice danger",children:m.error})]})]})]})}oc(document.getElementById("root")).render(x.jsx(_c.StrictMode,{children:x.jsx(Yd,{})})); + */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Kd=["文件路径","模型配置","运行前预检","开始筛选"],$u={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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Yd(){const[e,t]=Z.useState(0),[n,r]=Z.useState($u),[l,u]=Z.useState(""),[o,i]=Z.useState(""),[s,c]=Z.useState(null),[m,v]=Z.useState(null),[h,w]=Z.useState("");Z.useEffect(()=>{wn("/api/config").then(p=>r(Yi(p.config))).catch(p=>i(p.message))},[]),Z.useEffect(()=>{if(!h||(m==null?void 0:m.state)==="completed"||(m==null?void 0:m.state)==="failed")return;const p=window.setInterval(()=>{wn(`/api/runs/${h}`).then(g=>v(g.run)).catch(g=>i(g.message))},1200);return()=>window.clearInterval(p)},[h,m==null?void 0:m.state]);const k=Z.useMemo(()=>!m||!m.total?0:Math.round(m.current/m.total*100),[m]);function S(p,g){r(_=>({..._,[p]:g}))}function M(p,g){r(_=>({..._,model:{..._.model,[p]:g}}))}async function f(){i(""),u("");try{const p=await wn("/api/config",{method:"POST",body:JSON.stringify(n)});r(Yi(p.config)),u("配置已保存到本地 config.yaml")}catch(p){i(p.message)}}async function a(){i("");try{const p=await wn("/api/precheck");c(p),t(2)}catch(p){i(p.message)}}async function d(){i("");try{const p=await wn("/api/runs",{method:"POST",body:"{}"});v(p.run),w(p.run.run_id),t(3)}catch(p){i(p.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Kd.map((p,g)=>x.jsxs("button",{className:g===e?"active":"",onClick:()=>t(g),children:[g+1,". ",p]},p))})]}),l&&x.jsxs("div",{className:"notice success",children:[x.jsx(Bd,{size:18}),l]}),o&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Vd,{size:18}),o]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Hd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:p=>S("resume_dir",p.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:p=>S("job_book",p.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:p=>S("result_book",p.target.value)})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:p=>M("base_url",p.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:p=>M("api_key",p.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:p=>M("model",p.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:p=>M("timeout_seconds",Number(p.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:p=>M("allow_without_model",p.target.checked)}),"允许无模型运行并进入待人工二筛"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:a,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!s&&x.jsx("button",{className:"primary",onClick:a,children:"开始预检"}),s&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${s.status}`,children:["待处理文件:",s.resume_file_count," 个"]}),s.items.map(p=>x.jsxs("div",{className:`check ${p.status}`,children:[x.jsx("strong",{children:p.name}),x.jsx("span",{children:p.message})]},p.name)),x.jsxs("button",{className:"primary",onClick:d,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(m==null?void 0:m.state)==="running"?x.jsx(Qd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!m&&x.jsx("button",{className:"primary",onClick:d,children:"开始筛选"}),m&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${k}%`}})}),x.jsxs("p",{className:"muted",children:[m.current_file||"等待任务更新"," ",m.total?`${m.current}/${m.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(m.stats||{}).map(([p,g])=>x.jsxs("div",{children:[x.jsx("strong",{children:g}),x.jsx("span",{children:p})]},p))}),x.jsx("div",{className:"logs",children:(m.logs||[]).map((p,g)=>x.jsx("p",{className:p.level,children:p.message},`${p.timestamp}-${g}`))}),m.error&&x.jsx("div",{className:"notice danger",children:m.error})]})]})]})}oc(document.getElementById("root")).render(x.jsx(_c.StrictMode,{children:x.jsx(Yd,{})})); diff --git a/tests/test_file_scanner.py b/tests/test_file_scanner.py new file mode 100644 index 0000000..dc4f37e --- /dev/null +++ b/tests/test_file_scanner.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from resume_screening.file_scanner import iter_candidate_files + + +def test_iter_candidate_files_recurses_and_includes_all_visible_file_types(tmp_path: Path) -> None: + resume_dir = tmp_path / "resumes" + nested = resume_dir / "nested" + hidden = resume_dir / ".hidden" + nested.mkdir(parents=True) + hidden.mkdir() + pdf = resume_dir / "a.pdf" + txt = nested / "b.txt" + hidden_file = hidden / "c.pdf" + job_book = resume_dir / "jobs.xlsx" + result_book = nested / "result.xlsx" + for path in (pdf, txt, hidden_file, job_book, result_book): + path.write_text("x", encoding="utf-8") + + files = iter_candidate_files(resume_dir, job_book, result_book) + + assert files == [pdf, txt] diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 79faa81..8fe5234 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -73,3 +73,42 @@ def test_pipeline_writes_manual_review_for_incomplete_job(tmp_path: Path) -> Non sheet = workbook["多维表格"] assert sheet["B2"].value == "待人工二筛" assert "岗位要求不完整" in sheet["P2"].value + + +def test_pipeline_processes_nested_unsupported_file_as_manual_review(tmp_path: Path) -> None: + resume_dir = tmp_path / "resumes" + nested = resume_dir / "subfolder" + nested.mkdir(parents=True) + resume = nested / "【财务总监_北京 18-28K】郭燕婷 10年以上.txt" + resume.write_text("郭燕婷,10年以上财务经验。", encoding="utf-8") + job_book = tmp_path / "jobs.xlsx" + result_book = tmp_path / "result.xlsx" + create_incomplete_job_book(job_book) + create_result_book(result_book) + 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": "", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": True, + }, + } + ) + + stats = ScreeningPipeline(config).run() + + assert stats.processed == 1 + assert stats.manual == 1 + workbook = load_workbook(result_book) + sheet = workbook["多维表格"] + assert sheet["D2"].value == resume.name + assert sheet["B2"].value == "待人工二筛" + assert "不支持的文件格式" in sheet["P2"].value diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 9c0281f..3b564f2 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -26,8 +26,10 @@ def create_job_book(path: Path) -> None: def valid_payload(tmp_path: Path) -> dict: resume_dir = tmp_path / "resumes" - resume_dir.mkdir() + nested = resume_dir / "nested" + nested.mkdir(parents=True) (resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.pdf").write_text("fake", encoding="utf-8") + (nested / "【财务总监_上海 20-30K】李四 8年.txt").write_text("fake", encoding="utf-8") job_book = tmp_path / "jobs.xlsx" result_book = tmp_path / "result.xlsx" create_job_book(job_book) @@ -65,5 +67,5 @@ def test_precheck_endpoint_returns_items(tmp_path: Path) -> None: assert response.status_code == 200 body = response.json() assert body["status"] in {"pass", "warning"} - assert body["resume_file_count"] == 1 + assert body["resume_file_count"] == 2 assert body["items"] diff --git a/tests/test_web_precheck.py b/tests/test_web_precheck.py index a338042..e2f54d3 100644 --- a/tests/test_web_precheck.py +++ b/tests/test_web_precheck.py @@ -27,8 +27,10 @@ def create_job_book(path: Path) -> None: def make_config(tmp_path: Path) -> AppConfig: resume_dir = tmp_path / "resumes" - resume_dir.mkdir() + nested = resume_dir / "nested" + nested.mkdir(parents=True) (resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.pdf").write_text("fake", encoding="utf-8") + (nested / "【财务总监_上海 20-30K】李四 8年.txt").write_text("fake", encoding="utf-8") job_book = tmp_path / "jobs.xlsx" result_book = tmp_path / "result.xlsx" create_job_book(job_book) @@ -48,7 +50,7 @@ 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 result.resume_file_count == 2 assert any(item.name == "岗位说明书" and item.status == "pass" for item in result.items) diff --git a/web/src/App.jsx b/web/src/App.jsx index 356e160..8143607 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -164,7 +164,7 @@ export function App() { {!precheck && } {precheck && (
-
支持格式简历:{precheck.resume_file_count} 个
+
待处理文件:{precheck.resume_file_count} 个
{precheck.items.map((item) =>
{item.name}{item.message}
)}
diff --git "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 8c388ad..2d72058 100644 --- "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -6,7 +6,7 @@ 这个工具会自动做这几件事: -1. 扫描 `/Users/mac/Downloads` 里面的简历文件。 +1. 递归扫描 `/Users/mac/Downloads` 里面的文件,包括子文件夹。 2. 每次运行都重新读取岗位要求表: `/Users/mac/Downloads/小A自动化岗位说明书.xlsx` 3. 识别简历内容,并根据岗位要求调用模型做初步筛选。 @@ -15,6 +15,7 @@ 5. 不覆盖历史记录,只往下新增。 6. 如果发现简历内容重复,会照常新增一行,但整行标红。 7. 如果模型超时、API 报错、文件名不规范、岗位要求不完整,会标记为 `待人工二筛`。 +8. 所有可见普通文件都会进入处理;暂不支持正文提取的格式也会写入表格,并标记为 `待人工二筛`。 ## 简历文件名格式 @@ -250,7 +251,7 @@ timeout_seconds: 120 最简单的流程是: -1. 把简历放到 `/Users/mac/Downloads`。 +1. 把简历放到 `/Users/mac/Downloads`,可以直接放在子文件夹里。 2. 确认岗位说明书已更新: `/Users/mac/Downloads/小A自动化岗位说明书.xlsx` 3. 确认 `config.yaml` 里的 API 已配置。 From 1b0082bd40e3436bfc9cb2e70ec91f4dee01393c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 18:34:40 +0800 Subject: [PATCH 26/59] fix: use configured model and sanitize extracted text --- README.md | 1 + src/resume_screening/config.py | 3 + src/resume_screening/duplicates.py | 3 +- src/resume_screening/model_client.py | 36 +++++----- src/resume_screening/pipeline.py | 8 ++- src/resume_screening/text_utils.py | 19 +++++ src/resume_screening/web/static/assets/app.js | 2 +- tests/test_duplicates.py | 7 ++ tests/test_model_client.py | 21 ++++++ tests/test_pipeline.py | 71 +++++++++++++++++++ web/src/App.jsx | 2 +- ...77\347\224\250\350\257\264\346\230\216.md" | 2 + 12 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 src/resume_screening/text_utils.py diff --git a/README.md b/README.md index f51e5fe..0344550 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,4 @@ The web page has four steps: 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/src/resume_screening/config.py b/src/resume_screening/config.py index 17970e2..78c1fa9 100644 --- a/src/resume_screening/config.py +++ b/src/resume_screening/config.py @@ -37,6 +37,9 @@ def require_model_config_unless_manual_mode(self) -> "ModelConfig": raise ValueError(f"model config missing required values: {joined}") return self + def is_complete(self) -> bool: + return all(getattr(self, name) for name in ("base_url", "api_key", "model")) + class AppConfig(BaseModel): resume_dir: Path diff --git a/src/resume_screening/duplicates.py b/src/resume_screening/duplicates.py index 4e24c3e..94c181e 100644 --- a/src/resume_screening/duplicates.py +++ b/src/resume_screening/duplicates.py @@ -9,10 +9,11 @@ from pathlib import Path from resume_screening.models import DuplicateMatch +from resume_screening.text_utils import sanitize_text def normalize_text(text: str) -> str: - return re.sub(r"\s+", "", text).strip() + return re.sub(r"\s+", "", sanitize_text(text)).strip() def fingerprint(text: str) -> str: diff --git a/src/resume_screening/model_client.py b/src/resume_screening/model_client.py index 6cf6d7a..b09e745 100644 --- a/src/resume_screening/model_client.py +++ b/src/resume_screening/model_client.py @@ -15,6 +15,7 @@ ScreeningResult, ScreeningScores, ) +from resume_screening.text_utils import sanitize_jsonable, sanitize_text VALID_CATEGORIES = {CATEGORY_RECOMMEND, CATEGORY_CONSIDER, CATEGORY_MANUAL, CATEGORY_REJECT} @@ -50,7 +51,7 @@ def validate_category(cls, value: str) -> str: def parse_model_response(payload: dict) -> ScreeningResult: try: - parsed = ModelPayload.model_validate(payload) + parsed = ModelPayload.model_validate(sanitize_jsonable(payload)) except ValidationError as exc: raise ValueError(f"模型返回格式不符合JSON结构:{exc}") from exc return ScreeningResult( @@ -89,22 +90,23 @@ def evaluate(self, resume_text: str, job: JobRequirement, filename_metadata: dic return parse_model_response(json.loads(content)) def _build_prompt(self, resume_text: str, job: JobRequirement, filename_metadata: dict[str, str]) -> str: + payload = { + "instruction": "根据岗位要求和简历内容进行初筛。评分均为0到10分,8分通过,9分优秀。稳定性/风险高分表示低风险。只返回指定JSON字段。", + "required_json_fields": [ + "category", + "overall_score", + "summary", + "screening_reason", + "missing_information", + "reject_reason", + "scores", + ], + "allowed_categories": sorted(VALID_CATEGORIES), + "filename_metadata": filename_metadata, + "job_requirement": {"sheet_name": job.sheet_name, "fields": job.fields, "raw_text": job.raw_text}, + "resume_text": sanitize_text(resume_text)[:12000], + } return json.dumps( - { - "instruction": "根据岗位要求和简历内容进行初筛。评分均为0到10分,8分通过,9分优秀。稳定性/风险高分表示低风险。只返回指定JSON字段。", - "required_json_fields": [ - "category", - "overall_score", - "summary", - "screening_reason", - "missing_information", - "reject_reason", - "scores", - ], - "allowed_categories": sorted(VALID_CATEGORIES), - "filename_metadata": filename_metadata, - "job_requirement": {"sheet_name": job.sheet_name, "fields": job.fields, "raw_text": job.raw_text}, - "resume_text": resume_text[:12000], - }, + sanitize_jsonable(payload), ensure_ascii=False, ) diff --git a/src/resume_screening/pipeline.py b/src/resume_screening/pipeline.py index ce83405..52ac926 100644 --- a/src/resume_screening/pipeline.py +++ b/src/resume_screening/pipeline.py @@ -24,6 +24,7 @@ ScreeningResult, ScreeningScores, ) +from resume_screening.text_utils import sanitize_text class ScreeningPipeline: @@ -39,7 +40,7 @@ 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) + client = ModelClient(self.config.model) if self.config.model.is_complete() else None stats = PipelineStats() files = self._resume_files() self._emit(PipelineEvent("run_started", f"开始处理 {len(files)} 个文件", total=len(files))) @@ -48,6 +49,11 @@ def run(self) -> PipelineStats: 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) + extraction = ExtractionResult( + text=sanitize_text(extraction.text), + method=extraction.method, + errors=[sanitize_text(error) for error in extraction.errors], + ) duplicate = index.find(extraction.text) if extraction.text else DuplicateMatch(False) result = self._screen(parsed, extraction, jobs, client) row_id = writer.append_result( diff --git a/src/resume_screening/text_utils.py b/src/resume_screening/text_utils.py new file mode 100644 index 0000000..9502780 --- /dev/null +++ b/src/resume_screening/text_utils.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + + +def sanitize_text(value: str) -> str: + return value.encode("utf-8", errors="replace").decode("utf-8") + + +def sanitize_jsonable(value: Any) -> Any: + if isinstance(value, str): + return sanitize_text(value) + if isinstance(value, list): + return [sanitize_jsonable(item) for item in value] + if isinstance(value, tuple): + return tuple(sanitize_jsonable(item) for item in value) + if isinstance(value, dict): + return {sanitize_jsonable(key): sanitize_jsonable(item) for key, item in value.items()} + return value diff --git a/src/resume_screening/web/static/assets/app.js b/src/resume_screening/web/static/assets/app.js index e2181e7..cc75761 100644 --- a/src/resume_screening/web/static/assets/app.js +++ b/src/resume_screening/web/static/assets/app.js @@ -92,4 +92,4 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Kd=["文件路径","模型配置","运行前预检","开始筛选"],$u={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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Yd(){const[e,t]=Z.useState(0),[n,r]=Z.useState($u),[l,u]=Z.useState(""),[o,i]=Z.useState(""),[s,c]=Z.useState(null),[m,v]=Z.useState(null),[h,w]=Z.useState("");Z.useEffect(()=>{wn("/api/config").then(p=>r(Yi(p.config))).catch(p=>i(p.message))},[]),Z.useEffect(()=>{if(!h||(m==null?void 0:m.state)==="completed"||(m==null?void 0:m.state)==="failed")return;const p=window.setInterval(()=>{wn(`/api/runs/${h}`).then(g=>v(g.run)).catch(g=>i(g.message))},1200);return()=>window.clearInterval(p)},[h,m==null?void 0:m.state]);const k=Z.useMemo(()=>!m||!m.total?0:Math.round(m.current/m.total*100),[m]);function S(p,g){r(_=>({..._,[p]:g}))}function M(p,g){r(_=>({..._,model:{..._.model,[p]:g}}))}async function f(){i(""),u("");try{const p=await wn("/api/config",{method:"POST",body:JSON.stringify(n)});r(Yi(p.config)),u("配置已保存到本地 config.yaml")}catch(p){i(p.message)}}async function a(){i("");try{const p=await wn("/api/precheck");c(p),t(2)}catch(p){i(p.message)}}async function d(){i("");try{const p=await wn("/api/runs",{method:"POST",body:"{}"});v(p.run),w(p.run.run_id),t(3)}catch(p){i(p.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Kd.map((p,g)=>x.jsxs("button",{className:g===e?"active":"",onClick:()=>t(g),children:[g+1,". ",p]},p))})]}),l&&x.jsxs("div",{className:"notice success",children:[x.jsx(Bd,{size:18}),l]}),o&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Vd,{size:18}),o]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Hd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:p=>S("resume_dir",p.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:p=>S("job_book",p.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:p=>S("result_book",p.target.value)})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:p=>M("base_url",p.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:p=>M("api_key",p.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:p=>M("model",p.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:p=>M("timeout_seconds",Number(p.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:p=>M("allow_without_model",p.target.checked)}),"允许无模型运行并进入待人工二筛"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:a,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!s&&x.jsx("button",{className:"primary",onClick:a,children:"开始预检"}),s&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${s.status}`,children:["待处理文件:",s.resume_file_count," 个"]}),s.items.map(p=>x.jsxs("div",{className:`check ${p.status}`,children:[x.jsx("strong",{children:p.name}),x.jsx("span",{children:p.message})]},p.name)),x.jsxs("button",{className:"primary",onClick:d,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(m==null?void 0:m.state)==="running"?x.jsx(Qd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!m&&x.jsx("button",{className:"primary",onClick:d,children:"开始筛选"}),m&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${k}%`}})}),x.jsxs("p",{className:"muted",children:[m.current_file||"等待任务更新"," ",m.total?`${m.current}/${m.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(m.stats||{}).map(([p,g])=>x.jsxs("div",{children:[x.jsx("strong",{children:g}),x.jsx("span",{children:p})]},p))}),x.jsx("div",{className:"logs",children:(m.logs||[]).map((p,g)=>x.jsx("p",{className:p.level,children:p.message},`${p.timestamp}-${g}`))}),m.error&&x.jsx("div",{className:"notice danger",children:m.error})]})]})]})}oc(document.getElementById("root")).render(x.jsx(_c.StrictMode,{children:x.jsx(Yd,{})})); + */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Kd=["文件路径","模型配置","运行前预检","开始筛选"],$u={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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Yd(){const[e,t]=Z.useState(0),[n,r]=Z.useState($u),[l,u]=Z.useState(""),[o,i]=Z.useState(""),[s,c]=Z.useState(null),[m,v]=Z.useState(null),[h,w]=Z.useState("");Z.useEffect(()=>{wn("/api/config").then(p=>r(Yi(p.config))).catch(p=>i(p.message))},[]),Z.useEffect(()=>{if(!h||(m==null?void 0:m.state)==="completed"||(m==null?void 0:m.state)==="failed")return;const p=window.setInterval(()=>{wn(`/api/runs/${h}`).then(g=>v(g.run)).catch(g=>i(g.message))},1200);return()=>window.clearInterval(p)},[h,m==null?void 0:m.state]);const k=Z.useMemo(()=>!m||!m.total?0:Math.round(m.current/m.total*100),[m]);function S(p,g){r(_=>({..._,[p]:g}))}function M(p,g){r(_=>({..._,model:{..._.model,[p]:g}}))}async function f(){i(""),u("");try{const p=await wn("/api/config",{method:"POST",body:JSON.stringify(n)});r(Yi(p.config)),u("配置已保存到本地 config.yaml")}catch(p){i(p.message)}}async function a(){i("");try{const p=await wn("/api/precheck");c(p),t(2)}catch(p){i(p.message)}}async function d(){i("");try{const p=await wn("/api/runs",{method:"POST",body:"{}"});v(p.run),w(p.run.run_id),t(3)}catch(p){i(p.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Kd.map((p,g)=>x.jsxs("button",{className:g===e?"active":"",onClick:()=>t(g),children:[g+1,". ",p]},p))})]}),l&&x.jsxs("div",{className:"notice success",children:[x.jsx(Bd,{size:18}),l]}),o&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Vd,{size:18}),o]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Hd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:p=>S("resume_dir",p.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:p=>S("job_book",p.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:p=>S("result_book",p.target.value)})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:p=>M("base_url",p.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:p=>M("api_key",p.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:p=>M("model",p.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:p=>M("timeout_seconds",Number(p.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:p=>M("allow_without_model",p.target.checked)}),"模型不可用时允许进入待人工二筛兜底"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:a,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!s&&x.jsx("button",{className:"primary",onClick:a,children:"开始预检"}),s&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${s.status}`,children:["待处理文件:",s.resume_file_count," 个"]}),s.items.map(p=>x.jsxs("div",{className:`check ${p.status}`,children:[x.jsx("strong",{children:p.name}),x.jsx("span",{children:p.message})]},p.name)),x.jsxs("button",{className:"primary",onClick:d,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(m==null?void 0:m.state)==="running"?x.jsx(Qd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!m&&x.jsx("button",{className:"primary",onClick:d,children:"开始筛选"}),m&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${k}%`}})}),x.jsxs("p",{className:"muted",children:[m.current_file||"等待任务更新"," ",m.total?`${m.current}/${m.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(m.stats||{}).map(([p,g])=>x.jsxs("div",{children:[x.jsx("strong",{children:g}),x.jsx("span",{children:p})]},p))}),x.jsx("div",{className:"logs",children:(m.logs||[]).map((p,g)=>x.jsx("p",{className:p.level,children:p.message},`${p.timestamp}-${g}`))}),m.error&&x.jsx("div",{className:"notice danger",children:m.error})]})]})]})}oc(document.getElementById("root")).render(x.jsx(_c.StrictMode,{children:x.jsx(Yd,{})})); diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py index 1fdcb3d..bfdc1a9 100644 --- a/tests/test_duplicates.py +++ b/tests/test_duplicates.py @@ -7,6 +7,13 @@ def test_normalize_text_removes_spacing_noise() -> None: assert normalize_text("郭 燕婷\n\n10年以上经验") == "郭燕婷10年以上经验" +def test_normalize_text_replaces_invalid_unicode_surrogates() -> None: + normalized = normalize_text("郭\ud835燕婷") + + normalized.encode("utf-8") + assert "\ud835" not in normalized + + def test_duplicate_index_detects_exact_content(tmp_path: Path) -> None: path = tmp_path / "processed_index.json" index = DuplicateIndex.load(path) diff --git a/tests/test_model_client.py b/tests/test_model_client.py index 12f4490..47ac3f4 100644 --- a/tests/test_model_client.py +++ b/tests/test_model_client.py @@ -1,5 +1,8 @@ import pytest +from resume_screening.config import ModelConfig +from resume_screening.model_client import ModelClient +from resume_screening.models import JobRequirement from resume_screening.model_client import parse_model_response @@ -51,3 +54,21 @@ def test_parse_model_response_rejects_unknown_category() -> None: }, } ) + + +def test_build_prompt_replaces_invalid_unicode_surrogates() -> None: + client = ModelClient( + ModelConfig( + provider="openai-compatible", + base_url="https://api.example.com/v1", + api_key="test", + model="screening-model", + allow_without_model=False, + ) + ) + job = JobRequirement("财务总监", {}, "岗位要求", True, []) + + prompt = client._build_prompt("候选人\ud835简历", job, {"candidate_name": "郭燕婷"}) + + prompt.encode("utf-8") + assert "\ud835" not in prompt diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 8fe5234..c0420a5 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -3,6 +3,7 @@ from openpyxl import Workbook, load_workbook from resume_screening.config import AppConfig +from resume_screening.models import ScreeningResult, ScreeningScores from resume_screening.pipeline import ScreeningPipeline @@ -25,6 +26,17 @@ def create_incomplete_job_book(path: Path) -> None: workbook.save(path) +def create_complete_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 test_pipeline_writes_manual_review_for_incomplete_job(tmp_path: Path) -> None: resume_dir = tmp_path / "resumes" resume_dir.mkdir() @@ -112,3 +124,62 @@ def test_pipeline_processes_nested_unsupported_file_as_manual_review(tmp_path: P assert sheet["D2"].value == resume.name assert sheet["B2"].value == "待人工二筛" assert "不支持的文件格式" in sheet["P2"].value + + +def test_pipeline_uses_model_when_config_is_complete_even_if_manual_fallback_allowed(tmp_path: Path, monkeypatch) -> None: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + resume = resume_dir / "【财务总监_北京 18-28K】郭燕婷 10年以上.docx" + from docx import Document + + document = Document() + document.add_paragraph("郭燕婷,10年以上财务经验。") + document.save(resume) + job_book = tmp_path / "jobs.xlsx" + result_book = tmp_path / "result.xlsx" + create_complete_job_book(job_book) + create_result_book(result_book) + + class FakeModelClient: + def __init__(self, config) -> None: + self.config = config + + def evaluate(self, resume_text, job, filename_metadata) -> ScreeningResult: + return ScreeningResult( + category="推荐初试", + overall_score=8.5, + summary="模型已评估", + screening_reason="岗位匹配", + missing_information=[], + reject_reason="", + scores=ScreeningScores(8, 6, 8, 8, 9, 9, 8, 7), + ) + + monkeypatch.setattr("resume_screening.pipeline.ModelClient", FakeModelClient) + 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": "https://api.example.com/v1", + "api_key": "test-key", + "model": "screening-model", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": True, + }, + } + ) + + stats = ScreeningPipeline(config).run() + + assert stats.processed == 1 + assert stats.recommend == 1 + assert stats.manual == 0 + workbook = load_workbook(result_book) + sheet = workbook["多维表格"] + assert sheet["B2"].value == "推荐初试" + assert sheet["G2"].value == "模型已评估" diff --git a/web/src/App.jsx b/web/src/App.jsx index 8143607..8334a7e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -150,7 +150,7 @@ export function App() { - +
diff --git "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 2d72058..cc3f642 100644 --- "a/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -101,6 +101,8 @@ model: - `model`:模型名称。 - `allow_without_model: false`:表示必须调用模型进行筛选。 +如果 API 地址、API Key、模型名都配置完整,工具会优先调用模型进行筛选。即使保留 `allow_without_model: true`,它也只是在模型配置缺失时作为兜底。 + 如果暂时没有 API,也可以保留: ```yaml From fc19370b5e2748ce396474214464635f56b877e8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 10:34:48 +0800 Subject: [PATCH 27/59] fix: route more resumes through model screening --- README.md | 11 +- config.example.yaml | 6 + src/resume_screening/config.py | 1 + src/resume_screening/filename_parser.py | 65 ++++++++- src/resume_screening/job_matcher.py | 45 ++++++ src/resume_screening/model_client.py | 129 ++++++++++++++++- src/resume_screening/pipeline.py | 24 +++- src/resume_screening/web/config_store.py | 1 + src/resume_screening/web/precheck.py | 36 ++++- src/resume_screening/web/static/assets/app.js | 40 +++--- .../web/static/assets/index.css | 2 +- tests/test_filename_parser.py | 24 ++++ tests/test_model_client.py | 62 +++++++++ tests/test_pipeline.py | 131 ++++++++++++++++++ tests/test_web_precheck.py | 16 +++ web/src/App.jsx | 34 ++++- web/src/styles.css | 11 +- ...77\347\224\250\350\257\264\346\230\216.md" | 52 ++++++- 18 files changed, 645 insertions(+), 45 deletions(-) create mode 100644 src/resume_screening/job_matcher.py diff --git a/README.md b/README.md index 0344550..7afa9e8 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ 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 @@ -22,6 +30,7 @@ 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`. ## Web Interface @@ -31,7 +40,7 @@ On first launch, the script creates or reuses `.venv`, installs the tool, create The web page has four steps: -1. Confirm resume folder, job requirements workbook, and result workbook paths. +1. Confirm resume folder, job requirements workbook, result workbook paths, and optional job aliases. 2. Save model API settings. 3. Run a lightweight precheck. 4. Start screening and watch live progress, logs, and final statistics. diff --git a/config.example.yaml b/config.example.yaml index 850c6b7..68f7fdf 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,6 +2,12 @@ resume_dir: "/Users/mac/Downloads" job_book: "/Users/mac/Downloads/小A自动化岗位说明书.xlsx" result_book: "/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx" +job_aliases: {} +# 示例: +# job_aliases: +# 后端开发工程师: "全栈" +# 后端开发实习岗: "全栈" + default_source_channel: "" default_interviewer: "" index_path: "data/processed_index.json" diff --git a/src/resume_screening/config.py b/src/resume_screening/config.py index 78c1fa9..4185398 100644 --- a/src/resume_screening/config.py +++ b/src/resume_screening/config.py @@ -45,6 +45,7 @@ class AppConfig(BaseModel): resume_dir: Path job_book: Path result_book: Path + job_aliases: dict[str, str] = Field(default_factory=dict) default_source_channel: str = "" default_interviewer: str = "" index_path: Path = Path("data/processed_index.json") diff --git a/src/resume_screening/filename_parser.py b/src/resume_screening/filename_parser.py index 7b9dad5..9910924 100644 --- a/src/resume_screening/filename_parser.py +++ b/src/resume_screening/filename_parser.py @@ -15,13 +15,10 @@ def parse_resume_filename(path: Path) -> ParsedFilename: stem = path.stem.strip() match = STANDARD_PATTERN.match(stem) if not match: - guessed_name = re.split(r"[-_\s]+", stem, maxsplit=1)[0].strip() - return ParsedFilename( - path=path, - is_standard=False, - candidate_name=guessed_name, - missing_fields=["文件名不规范", "岗位名称", "期望工作地", "岗位标注薪资", "工龄"], - ) + underscore_parsed = _parse_underscore_filename(path, stem) + if underscore_parsed is not None: + return underscore_parsed + return _non_standard_filename(path, stem) location_salary = match.group("location_salary").strip() parts = location_salary.split() @@ -41,3 +38,57 @@ def parse_resume_filename(path: Path) -> ParsedFilename: work_experience=match.group("experience").strip(), missing_fields=missing_fields, ) + + +def _parse_underscore_filename(path: Path, stem: str) -> ParsedFilename | None: + parts = [part.strip() for part in stem.split("_") if part.strip()] + if len(parts) < 5: + return None + + job_name = parts[0] + expected_location = parts[1] + salary_parts = parts[2:-2] + candidate_name = parts[-2] + work_experience = parts[-1] + salary_range = _join_salary_parts(salary_parts) + + missing_fields = [] + if not job_name: + missing_fields.append("岗位名称") + if not expected_location: + missing_fields.append("期望工作地") + if not salary_range: + missing_fields.append("岗位标注薪资") + if not candidate_name: + missing_fields.append("候选人姓名") + if not work_experience: + missing_fields.append("工龄") + + return ParsedFilename( + path=path, + is_standard=True, + job_name=job_name, + expected_location=expected_location, + salary_range=salary_range, + candidate_name=candidate_name, + work_experience=work_experience, + missing_fields=missing_fields, + ) + + +def _join_salary_parts(parts: list[str]) -> str: + if not parts: + return "" + if len(parts) == 2 and parts[1] in {"天", "日", "月", "年"}: + return f"{parts[0]}/{parts[1]}" + return "_".join(parts) + + +def _non_standard_filename(path: Path, stem: str) -> ParsedFilename: + guessed_name = re.split(r"[-_\s]+", stem, maxsplit=1)[0].strip() + return ParsedFilename( + path=path, + is_standard=False, + candidate_name=guessed_name, + missing_fields=["文件名不规范", "岗位名称", "期望工作地", "岗位标注薪资", "工龄"], + ) diff --git a/src/resume_screening/job_matcher.py b/src/resume_screening/job_matcher.py new file mode 100644 index 0000000..3b67c32 --- /dev/null +++ b/src/resume_screening/job_matcher.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from resume_screening.models import JobRequirement, ParsedFilename + + +@dataclass(frozen=True) +class JobMatch: + job: JobRequirement | None + note: str = "" + + +def resolve_job( + parsed: ParsedFilename, + jobs: dict[str, JobRequirement], + aliases: dict[str, str] | None = None, +) -> JobMatch: + if not parsed.job_name: + return JobMatch(None) + + if parsed.job_name in jobs: + return JobMatch(jobs[parsed.job_name]) + + alias_target = (aliases or {}).get(parsed.job_name, "") + if alias_target: + job = jobs.get(alias_target) + if job is not None: + return JobMatch(job, f"岗位别名映射:{parsed.job_name} -> {alias_target}") + return JobMatch(None, f"岗位别名无效:{parsed.job_name} -> {alias_target}") + + normalized_name = _normalize_job_name(parsed.job_name) + for job in jobs.values(): + field_name = job.fields.get("岗位名称", "") + if field_name and _normalize_job_name(field_name) == normalized_name: + note = "" + if job.sheet_name != parsed.job_name: + note = f"岗位名称匹配:{parsed.job_name} -> {job.sheet_name}" + return JobMatch(job, note) + + return JobMatch(None) + + +def _normalize_job_name(value: str) -> str: + return "".join(value.split()) diff --git a/src/resume_screening/model_client.py b/src/resume_screening/model_client.py index b09e745..d6d7d4d 100644 --- a/src/resume_screening/model_client.py +++ b/src/resume_screening/model_client.py @@ -19,6 +19,26 @@ VALID_CATEGORIES = {CATEGORY_RECOMMEND, CATEGORY_CONSIDER, CATEGORY_MANUAL, CATEGORY_REJECT} +SCORE_ALIASES = { + "ability": ("能力", "能力评分", "综合能力", "四项人格/潜力-能力", "project_depth"), + "ego": ("自我认知", "自驱/自我认知", "自我", "ego"), + "desire": ("意愿", "欲望", "求职意愿", "动机", "desire"), + "learning_ability": ("学习能力", "学习潜力", "学习力", "project_depth"), + "job_fit": ("岗位匹配", "岗位匹配度", "岗位匹配评分", "匹配度"), + "experience_fit": ("经验匹配", "经验匹配度", "经历匹配", "工作经验匹配", "experience_match"), + "skill_fit": ("技能匹配", "技能匹配度", "技术匹配", "能力技能匹配", "skill_match"), + "stability_risk": ("稳定性/风险", "稳定性风险", "稳定性", "风险稳定性", "稳定风险"), +} +SCORE_LABELS = { + "ability": "能力", + "ego": "自我认知", + "desire": "意愿", + "learning_ability": "学习能力", + "job_fit": "岗位匹配度", + "experience_fit": "经验匹配度", + "skill_fit": "技能匹配度", + "stability_risk": "稳定性/风险", +} class ModelScoresPayload(BaseModel): @@ -51,7 +71,7 @@ def validate_category(cls, value: str) -> str: def parse_model_response(payload: dict) -> ScreeningResult: try: - parsed = ModelPayload.model_validate(sanitize_jsonable(payload)) + parsed = ModelPayload.model_validate(_normalize_model_payload(sanitize_jsonable(payload))) except ValidationError as exc: raise ValueError(f"模型返回格式不符合JSON结构:{exc}") from exc return ScreeningResult( @@ -65,6 +85,95 @@ def parse_model_response(payload: dict) -> ScreeningResult: ) +def _normalize_model_payload(payload: object) -> object: + if not isinstance(payload, dict): + return payload + normalized = dict(payload) + for key in ("summary", "screening_reason", "reject_reason"): + if key in normalized: + normalized[key] = _stringify_model_value(normalized[key]) + if "missing_information" in normalized: + normalized["missing_information"] = _listify_model_value(normalized["missing_information"]) + else: + normalized["missing_information"] = [] + if isinstance(normalized.get("scores"), dict): + scores, notes = _normalize_scores(normalized["scores"], normalized.get("overall_score", 0)) + normalized["scores"] = scores + normalized["missing_information"].extend(notes) + return normalized + + +def _stringify_model_value(value: object) -> str: + if value is None: + return "" + if isinstance(value, list): + return ";".join(_stringify_model_value(item) for item in value if _stringify_model_value(item)) + if isinstance(value, dict): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def _listify_model_value(value: object) -> list[str]: + if value is None or value == "": + return [] + if isinstance(value, list): + return [_stringify_model_value(item) for item in value if _stringify_model_value(item)] + return [_stringify_model_value(value)] + + +def _normalize_scores(scores: dict, overall_score: object) -> tuple[dict, list[str]]: + normalized = dict(scores) + for target, aliases in SCORE_ALIASES.items(): + if target in normalized: + continue + for alias in aliases: + if alias in scores: + normalized[target] = scores[alias] + break + notes: list[str] = [] + for target in SCORE_LABELS: + if target in normalized: + continue + normalized[target] = _fallback_score(target, normalized, overall_score) + notes.append(f"模型未返回{SCORE_LABELS[target]}评分,已用综合/相关评分暂填") + if "job_fit" not in scores and "岗位匹配" not in scores and "岗位匹配度" not in scores: + job_fit = _average_scores(normalized, ("experience_fit", "skill_fit", "location_match")) + if job_fit is not None: + normalized["job_fit"] = job_fit + return normalized, notes + + +def _fallback_score(target: str, scores: dict, overall_score: object) -> float: + if target in {"ego", "desire"}: + return _coerce_score(overall_score) or 0.0 + if target == "job_fit": + averaged = _average_scores(scores, ("experience_fit", "skill_fit", "location_match")) + if averaged is not None: + return averaged + for key in ("ability", "learning_ability", "experience_fit", "skill_fit", "job_fit"): + value = _coerce_score(scores.get(key)) + if value is not None: + return value + return _coerce_score(overall_score) or 0.0 + + +def _average_scores(scores: dict, keys: tuple[str, ...]) -> float | None: + values = [_coerce_score(scores.get(key)) for key in keys] + present = [value for value in values if value is not None] + if not present: + return None + return round(sum(present) / len(present), 2) + + +def _coerce_score(value: object) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + class ModelClient: def __init__(self, config: ModelConfig) -> None: self.config = config @@ -91,7 +200,23 @@ def evaluate(self, resume_text: str, job: JobRequirement, filename_metadata: dic def _build_prompt(self, resume_text: str, job: JobRequirement, filename_metadata: dict[str, str]) -> str: payload = { - "instruction": "根据岗位要求和简历内容进行初筛。评分均为0到10分,8分通过,9分优秀。稳定性/风险高分表示低风险。只返回指定JSON字段。", + "instruction": "根据岗位要求和简历内容进行初筛。优先给出推荐初试、可考虑或未通过;只有正文严重不足、候选人关键信息缺失到无法判断、或证据明显矛盾时才使用待人工二筛。岗位要求不完整时,基于现有岗位信息尽力评估,并把缺失项写入missing_information。评分均为0到10分,8分通过,9分优秀。稳定性/风险高分表示低风险。只返回指定JSON字段。", + "classification_rules": { + "推荐初试": "overall_score >= 8,核心经验、能力或技能与岗位要求明显匹配。", + "可考虑": "overall_score 在 6 到 8 之间,存在匹配点但仍需面试确认。", + "未通过": "overall_score < 6,核心经验、技能或条件明显不匹配。", + "待人工二筛": "仅用于信息不足或模型无法可靠判断,不作为默认分类。", + }, + "scores_schema": { + "ability": "能力评分,0到10", + "ego": "自我认知/ego评分,0到10", + "desire": "意愿/desire评分,0到10", + "learning_ability": "学习能力与聪明程度评分,0到10", + "job_fit": "岗位匹配度评分,0到10", + "experience_fit": "经验匹配评分,0到10", + "skill_fit": "技能匹配评分,0到10", + "stability_risk": "稳定性/风险评分,0到10,高分表示低风险", + }, "required_json_fields": [ "category", "overall_score", diff --git a/src/resume_screening/pipeline.py b/src/resume_screening/pipeline.py index 52ac926..61b061f 100644 --- a/src/resume_screening/pipeline.py +++ b/src/resume_screening/pipeline.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import replace from pathlib import Path import httpx @@ -11,6 +12,7 @@ from resume_screening.extractors import extract_text from resume_screening.file_scanner import iter_candidate_files from resume_screening.filename_parser import parse_resume_filename +from resume_screening.job_matcher import resolve_job from resume_screening.job_requirements import load_job_requirements from resume_screening.model_client import ModelClient from resume_screening.models import ( @@ -113,20 +115,24 @@ def _screen( missing.extend(extraction.errors or ["无法提取简历正文"]) return manual_result("无法提取简历正文,需人工确认", missing) - job = jobs.get(parsed.job_name) - if not job: + job_match = resolve_job(parsed, jobs, self.config.job_aliases) + if job_match.note: + missing.append(job_match.note) + if not job_match.job: missing.append("岗位名称未匹配到岗位说明书sheet") + if jobs: + missing.append(f"可用岗位sheet:{'、'.join(jobs)}") return manual_result("岗位名称未匹配到岗位说明书sheet,需人工确认", missing) + job = job_match.job if not job.is_complete: - missing.append("岗位要求不完整,需人工确认") + missing.append("岗位要求不完整") missing.extend(job.missing_fields) - return manual_result("岗位要求不完整,需人工确认", missing) if client is None: missing.append("模型配置缺失,已按人工二筛处理") return manual_result("模型配置缺失,需人工确认", missing) try: - return client.evaluate( + result = client.evaluate( resume_text=extraction.text, job=job, filename_metadata={ @@ -135,8 +141,10 @@ def _screen( "salary_range": parsed.salary_range, "candidate_name": parsed.candidate_name, "work_experience": parsed.work_experience, + "matched_job_sheet": job.sheet_name, }, ) + return _append_missing_information(result, missing) except httpx.TimeoutException: print(f"模型评估失败:请求超时 - {parsed.path.name}") return manual_result("模型评估失败,需人工确认", missing + ["模型评估失败:请求超时"]) @@ -158,3 +166,9 @@ def manual_result(reason: str, missing: list[str]) -> ScreeningResult: reject_reason="", scores=ScreeningScores(0, 0, 0, 0, 0, 0, 0, 0), ) + + +def _append_missing_information(result: ScreeningResult, missing: list[str]) -> ScreeningResult: + merged = [item for item in [*missing, *result.missing_information] if item] + deduped = list(dict.fromkeys(merged)) + return replace(result, missing_information=deduped) diff --git a/src/resume_screening/web/config_store.py b/src/resume_screening/web/config_store.py index 965db7b..179ccbd 100644 --- a/src/resume_screening/web/config_store.py +++ b/src/resume_screening/web/config_store.py @@ -13,6 +13,7 @@ "resume_dir": "/Users/mac/Downloads", "job_book": "/Users/mac/Downloads/小A自动化岗位说明书.xlsx", "result_book": "/Users/mac/Downloads/小A科技(北京)组织招聘.xlsx", + "job_aliases": {}, "default_source_channel": "", "default_interviewer": "", "index_path": "data/processed_index.json", diff --git a/src/resume_screening/web/precheck.py b/src/resume_screening/web/precheck.py index d38f9ff..6a20834 100644 --- a/src/resume_screening/web/precheck.py +++ b/src/resume_screening/web/precheck.py @@ -8,7 +8,10 @@ from resume_screening.config import AppConfig from resume_screening.file_scanner import iter_candidate_files +from resume_screening.filename_parser import parse_resume_filename +from resume_screening.job_matcher import resolve_job from resume_screening.job_requirements import load_job_requirements +from resume_screening.models import JobRequirement CheckStatus = Literal["pass", "warning", "fail"] @@ -50,7 +53,8 @@ def _overall_status(items: list[PrecheckItem]) -> CheckStatus: def run_precheck(config: AppConfig) -> PrecheckResult: items: list[PrecheckItem] = [] - resume_count = len(iter_candidate_files(config.resume_dir, config.job_book, config.result_book)) + resume_files = iter_candidate_files(config.resume_dir, config.job_book, config.result_book) + resume_count = len(resume_files) 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: @@ -58,6 +62,7 @@ def run_precheck(config: AppConfig) -> PrecheckResult: else: items.append(PrecheckItem("简历文件夹", "pass", f"找到 {resume_count} 个待处理文件")) + jobs = {} usable_jobs = 0 if not config.job_book.exists(): items.append(PrecheckItem("岗位说明书", "fail", f"文件不存在:{config.job_book}")) @@ -72,6 +77,20 @@ def run_precheck(config: AppConfig) -> PrecheckResult: except Exception as exc: items.append(PrecheckItem("岗位说明书", "fail", f"读取失败:{exc}")) + if jobs and resume_files: + unmatched_jobs = _unmatched_resume_jobs(resume_files, jobs, config.job_aliases) + if unmatched_jobs: + preview = "、".join(f"{name}({count})" for name, count in unmatched_jobs[:6]) + items.append( + PrecheckItem( + "岗位匹配", + "warning", + f"{sum(count for _, count in unmatched_jobs)} 个文件岗位未匹配岗位说明书:{preview}。可补充对应sheet,或在 config.yaml 的 job_aliases 中配置映射。", + ) + ) + else: + items.append(PrecheckItem("岗位匹配", "pass", "文件名岗位均已匹配到岗位说明书")) + if not config.result_book.exists(): items.append(PrecheckItem("招聘结果表", "fail", f"文件不存在:{config.result_book}")) else: @@ -103,3 +122,18 @@ def run_precheck(config: AppConfig) -> PrecheckResult: usable_job_sheet_count=usable_jobs, items=items, ) + + +def _unmatched_resume_jobs( + resume_files: list[Path], + jobs: dict[str, JobRequirement], + aliases: dict[str, str], +) -> list[tuple[str, int]]: + counts: dict[str, int] = {} + for path in resume_files: + parsed = parse_resume_filename(path) + if not parsed.job_name: + continue + if resolve_job(parsed, jobs, aliases).job is None: + counts[parsed.job_name] = counts.get(parsed.job_name, 0) + 1 + return sorted(counts.items(), key=lambda item: (-item[1], item[0])) diff --git a/src/resume_screening/web/static/assets/app.js b/src/resume_screening/web/static/assets/app.js index cc75761..a8c88ce 100644 --- a/src/resume_screening/web/static/assets/app.js +++ b/src/resume_screening/web/static/assets/app.js @@ -1,4 +1,4 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const o of u.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=n(l);fetch(l.href,u)}})();function ac(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Xi={exports:{}},nl={},Gi={exports:{}},j={};/** +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const o of u.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=n(l);fetch(l.href,u)}})();function cc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Gi={exports:{}},nl={},Zi={exports:{}},L={};/** * @license React * react.production.min.js * @@ -6,7 +6,7 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Zn=Symbol.for("react.element"),cc=Symbol.for("react.portal"),fc=Symbol.for("react.fragment"),dc=Symbol.for("react.strict_mode"),pc=Symbol.for("react.profiler"),mc=Symbol.for("react.provider"),hc=Symbol.for("react.context"),vc=Symbol.for("react.forward_ref"),yc=Symbol.for("react.suspense"),gc=Symbol.for("react.memo"),wc=Symbol.for("react.lazy"),Do=Symbol.iterator;function kc(e){return e===null||typeof e!="object"?null:(e=Do&&e[Do]||e["@@iterator"],typeof e=="function"?e:null)}var Zi={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Ji=Object.assign,qi={};function un(e,t,n){this.props=e,this.context=t,this.refs=qi,this.updater=n||Zi}un.prototype.isReactComponent={};un.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};un.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function bi(){}bi.prototype=un.prototype;function Au(e,t,n){this.props=e,this.context=t,this.refs=qi,this.updater=n||Zi}var Vu=Au.prototype=new bi;Vu.constructor=Au;Ji(Vu,un.prototype);Vu.isPureReactComponent=!0;var Fo=Array.isArray,es=Object.prototype.hasOwnProperty,Bu={current:null},ts={key:!0,ref:!0,__self:!0,__source:!0};function ns(e,t,n){var r,l={},u=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(u=""+t.key),t)es.call(t,r)&&!ts.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,G=C[W];if(0>>1;Wl(kl,L))ytl(nr,kl)?(C[W]=nr,C[yt]=L,W=yt):(C[W]=kl,C[vt]=L,W=vt);else if(ytl(nr,L))C[W]=nr,C[yt]=L,W=yt;else break e}}return z}function l(C,z){var L=C.sortIndex-z.sortIndex;return L!==0?L:C.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var o=Date,i=o.now();e.unstable_now=function(){return o.now()-i}}var s=[],c=[],m=1,v=null,h=3,w=!1,k=!1,S=!1,M=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(C){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=C)r(c),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(c)}}function p(C){if(S=!1,d(C),!k)if(n(s)!==null)k=!0,gl(g);else{var z=n(c);z!==null&&wl(p,z.startTime-C)}}function g(C,z){k=!1,S&&(S=!1,f(P),P=-1),w=!0;var L=h;try{for(d(z),v=n(s);v!==null&&(!(v.expirationTime>z)||C&&!Pe());){var W=v.callback;if(typeof W=="function"){v.callback=null,h=v.priorityLevel;var G=W(v.expirationTime<=z);z=e.unstable_now(),typeof G=="function"?v.callback=G:v===n(s)&&r(s),d(z)}else r(s);v=n(s)}if(v!==null)var tr=!0;else{var vt=n(c);vt!==null&&wl(p,vt.startTime-z),tr=!1}return tr}finally{v=null,h=L,w=!1}}var _=!1,N=null,P=-1,H=5,T=-1;function Pe(){return!(e.unstable_now()-TC||125W?(C.sortIndex=L,t(c,C),n(s)===null&&C===n(c)&&(S?(f(P),P=-1):S=!0,wl(p,L-W))):(C.sortIndex=G,t(s,C),k||w||(k=!0,gl(g))),C},e.unstable_shouldYield=Pe,e.unstable_wrapCallback=function(C){var z=h;return function(){var L=h;h=z;try{return C.apply(this,arguments)}finally{h=L}}}})(is);os.exports=is;var Rc=os.exports;/** + */(function(e){function t(N,z){var j=N.length;N.push(z);e:for(;0>>1,Z=N[W];if(0>>1;Wl(kl,j))ytl(nr,kl)?(N[W]=nr,N[yt]=j,W=yt):(N[W]=kl,N[vt]=j,W=vt);else if(ytl(nr,j))N[W]=nr,N[yt]=j,W=yt;else break e}}return z}function l(N,z){var j=N.sortIndex-z.sortIndex;return j!==0?j:N.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var o=Date,i=o.now();e.unstable_now=function(){return o.now()-i}}var s=[],c=[],h=1,v=null,p=3,k=!1,S=!1,E=!1,M=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(N){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=N)r(c),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(c)}}function y(N){if(E=!1,d(N),!S)if(n(s)!==null)S=!0,gl(_);else{var z=n(c);z!==null&&wl(y,z.startTime-N)}}function _(N,z){S=!1,E&&(E=!1,f(P),P=-1),k=!0;var j=p;try{for(d(z),v=n(s);v!==null&&(!(v.expirationTime>z)||N&&!Pe());){var W=v.callback;if(typeof W=="function"){v.callback=null,p=v.priorityLevel;var Z=W(v.expirationTime<=z);z=e.unstable_now(),typeof Z=="function"?v.callback=Z:v===n(s)&&r(s),d(z)}else r(s);v=n(s)}if(v!==null)var tr=!0;else{var vt=n(c);vt!==null&&wl(y,vt.startTime-z),tr=!1}return tr}finally{v=null,p=j,k=!1}}var m=!1,w=null,P=-1,H=5,T=-1;function Pe(){return!(e.unstable_now()-TN||125W?(N.sortIndex=j,t(c,N),n(s)===null&&N===n(c)&&(E?(f(P),P=-1):E=!0,wl(y,j-W))):(N.sortIndex=Z,t(s,N),S||k||(S=!0,gl(_))),N},e.unstable_shouldYield=Pe,e.unstable_wrapCallback=function(N){var z=p;return function(){var j=p;p=z;try{return N.apply(this,arguments)}finally{p=j}}}})(ss);is.exports=ss;var Oc=is.exports;/** * @license React * react-dom.production.min.js * @@ -30,59 +30,59 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var Mc=Z,ge=Rc;function y(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Yl=Object.prototype.hasOwnProperty,Oc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,$o={},Ao={};function Ic(e){return Yl.call(Ao,e)?!0:Yl.call($o,e)?!1:Oc.test(e)?Ao[e]=!0:($o[e]=!0,!1)}function Dc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Fc(e,t,n,r){if(t===null||typeof t>"u"||Dc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ae(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ae(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ae(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ae(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ae(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new ae(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ae(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ae(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ae(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ae(e,5,!1,e.toLowerCase(),null,!1,!1)});var Wu=/[\-:]([a-z])/g;function Qu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ae("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!0,!0)});function Ku(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Yl=Object.prototype.hasOwnProperty,Ic=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,$o={},Ao={};function Dc(e){return Yl.call(Ao,e)?!0:Yl.call($o,e)?!1:Ic.test(e)?Ao[e]=!0:($o[e]=!0,!1)}function Fc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Uc(e,t,n,r){if(t===null||typeof t>"u"||Fc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ae(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var te={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){te[e]=new ae(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];te[t]=new ae(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){te[e]=new ae(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){te[e]=new ae(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){te[e]=new ae(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){te[e]=new ae(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){te[e]=new ae(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){te[e]=new ae(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){te[e]=new ae(e,5,!1,e.toLowerCase(),null,!1,!1)});var Wu=/[\-:]([a-z])/g;function Qu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Wu,Qu);te[t]=new ae(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!1,!1)});te.xlinkHref=new ae("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){te[e]=new ae(e,1,!1,e.toLowerCase(),null,!0,!0)});function Ku(e,t,n,r){var l=te.hasOwnProperty(t)?te[t]:null;(l!==null?l.type!==0:r||!(2i||l[o]!==u[i]){var s=` -`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{El=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?kn(e):""}function Uc(e){switch(e.tag){case 5:return kn(e.type);case 16:return kn("Lazy");case 13:return kn("Suspense");case 19:return kn("SuspenseList");case 0:case 2:case 15:return e=Cl(e.type,!1),e;case 11:return e=Cl(e.type.render,!1),e;case 1:return e=Cl(e.type,!0),e;default:return""}}function Jl(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case It:return"Fragment";case Ot:return"Portal";case Xl:return"Profiler";case Yu:return"StrictMode";case Gl:return"Suspense";case Zl:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case cs:return(e.displayName||"Context")+".Consumer";case as:return(e._context.displayName||"Context")+".Provider";case Xu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Gu:return t=e.displayName||null,t!==null?t:Jl(e.type)||"Memo";case Je:t=e._payload,e=e._init;try{return Jl(e(t))}catch{}}return null}function $c(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Jl(t);case 8:return t===Yu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ds(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ac(e){var t=ds(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=Ac(e))}function ps(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ds(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Rr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ql(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Bo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ms(e,t){t=t.checked,t!=null&&Ku(e,"checked",t,!1)}function bl(e,t){ms(e,t);var n=ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?eu(e,t.type,n):t.hasOwnProperty("defaultValue")&&eu(e,t.type,ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ho(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function eu(e,t,n){(t!=="number"||Rr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Sn=Array.isArray;function Kt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=or.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function On(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Cn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Vc=["Webkit","ms","Moz","O"];Object.keys(Cn).forEach(function(e){Vc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Cn[t]=Cn[e]})});function gs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Cn.hasOwnProperty(e)&&Cn[e]?(""+t).trim():t+"px"}function ws(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=gs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Bc=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ru(e,t){if(t){if(Bc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(y(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(y(61))}if(t.style!=null&&typeof t.style!="object")throw Error(y(62))}}function lu(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var uu=null;function Zu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ou=null,Yt=null,Xt=null;function Ko(e){if(e=bn(e)){if(typeof ou!="function")throw Error(y(280));var t=e.stateNode;t&&(t=il(t),ou(e.stateNode,e.type,t))}}function ks(e){Yt?Xt?Xt.push(e):Xt=[e]:Yt=e}function Ss(){if(Yt){var e=Yt,t=Xt;if(Xt=Yt=null,Ko(e),t)for(e=0;e>>=0,e===0?32:31-(bc(e)/ef|0)|0}var ir=64,sr=4194304;function xn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=xn(i):(u&=o,u!==0&&(r=xn(u)))}else o=n&~l,o!==0?r=xn(o):u!==0&&(r=xn(u));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Jn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Re(t),e[t]=n}function lf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Nn),ti=" ",ni=!1;function Vs(e,t){switch(e){case"keyup":return Mf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Bs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Dt=!1;function If(e,t){switch(e){case"compositionend":return Bs(t);case"keypress":return t.which!==32?null:(ni=!0,ti);case"textInput":return e=t.data,e===ti&&ni?null:e;default:return null}}function Df(e,t){if(Dt)return e==="compositionend"||!lo&&Vs(e,t)?(e=$s(),Er=to=tt=null,Dt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=oi(n)}}function Ks(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ks(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Ys(){for(var e=window,t=Rr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Rr(e.document)}return t}function uo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Qf(e){var t=Ys(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ks(n.ownerDocument.documentElement,n)){if(r!==null&&uo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=ii(n,u);var o=ii(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Ft=null,du=null,zn=null,pu=!1;function si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;pu||Ft==null||Ft!==Rr(r)||(r=Ft,"selectionStart"in r&&uo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),zn&&An(zn,r)||(zn=r,r=$r(du,"onSelect"),0At||(e.current=wu[At],wu[At]=null,At--)}function I(e,t){At++,wu[At]=e.current,e.current=t}var dt={},ue=mt(dt),de=mt(!1),_t=dt;function bt(e,t){var n=e.type.contextTypes;if(!n)return dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function pe(e){return e=e.childContextTypes,e!=null}function Vr(){F(de),F(ue)}function hi(e,t,n){if(ue.current!==dt)throw Error(y(168));I(ue,t),I(de,n)}function na(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(y(108,$c(e)||"Unknown",l));return V({},n,r)}function Br(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||dt,_t=ue.current,I(ue,e),I(de,de.current),!0}function vi(e,t,n){var r=e.stateNode;if(!r)throw Error(y(169));n?(e=na(e,t,_t),r.__reactInternalMemoizedMergedChildContext=e,F(de),F(ue),I(ue,e)):F(de),I(de,n)}var Ve=null,sl=!1,Ul=!1;function ra(e){Ve===null?Ve=[e]:Ve.push(e)}function rd(e){sl=!0,ra(e)}function ht(){if(!Ul&&Ve!==null){Ul=!0;var e=0,t=O;try{var n=Ve;for(O=1;e>=o,l-=o,Be=1<<32-Re(t)+l|n<P?(H=N,N=null):H=N.sibling;var T=h(f,N,d[P],p);if(T===null){N===null&&(N=H);break}e&&N&&T.alternate===null&&t(f,N),a=u(T,a,P),_===null?g=T:_.sibling=T,_=T,N=H}if(P===d.length)return n(f,N),U&>(f,P),g;if(N===null){for(;PP?(H=N,N=null):H=N.sibling;var Pe=h(f,N,T.value,p);if(Pe===null){N===null&&(N=H);break}e&&N&&Pe.alternate===null&&t(f,N),a=u(Pe,a,P),_===null?g=Pe:_.sibling=Pe,_=Pe,N=H}if(T.done)return n(f,N),U&>(f,P),g;if(N===null){for(;!T.done;P++,T=d.next())T=v(f,T.value,p),T!==null&&(a=u(T,a,P),_===null?g=T:_.sibling=T,_=T);return U&>(f,P),g}for(N=r(f,N);!T.done;P++,T=d.next())T=w(N,f,P,T.value,p),T!==null&&(e&&T.alternate!==null&&N.delete(T.key===null?P:T.key),a=u(T,a,P),_===null?g=T:_.sibling=T,_=T);return e&&N.forEach(function(an){return t(f,an)}),U&>(f,P),g}function M(f,a,d,p){if(typeof d=="object"&&d!==null&&d.type===It&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case lr:e:{for(var g=d.key,_=a;_!==null;){if(_.key===g){if(g=d.type,g===It){if(_.tag===7){n(f,_.sibling),a=l(_,d.props.children),a.return=f,f=a;break e}}else if(_.elementType===g||typeof g=="object"&&g!==null&&g.$$typeof===Je&&wi(g)===_.type){n(f,_.sibling),a=l(_,d.props),a.ref=vn(f,_,d),a.return=f,f=a;break e}n(f,_);break}else t(f,_);_=_.sibling}d.type===It?(a=Ct(d.props.children,f.mode,p,d.key),a.return=f,f=a):(p=Tr(d.type,d.key,d.props,null,f.mode,p),p.ref=vn(f,a,d),p.return=f,f=p)}return o(f);case Ot:e:{for(_=d.key;a!==null;){if(a.key===_)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Kl(d,f.mode,p),a.return=f,f=a}return o(f);case Je:return _=d._init,M(f,a,_(d._payload),p)}if(Sn(d))return k(f,a,d,p);if(fn(d))return S(f,a,d,p);hr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=Ql(d,f.mode,p),a.return=f,f=a),o(f)):n(f,a)}return M}var tn=ia(!0),sa=ia(!1),Qr=mt(null),Kr=null,Ht=null,ao=null;function co(){ao=Ht=Kr=null}function fo(e){var t=Qr.current;F(Qr),e._currentValue=t}function xu(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Zt(e,t){Kr=e,ao=Ht=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(fe=!0),e.firstContext=null)}function _e(e){var t=e._currentValue;if(ao!==e)if(e={context:e,memoizedValue:t,next:null},Ht===null){if(Kr===null)throw Error(y(308));Ht=e,Kr.dependencies={lanes:0,firstContext:e}}else Ht=Ht.next=e;return t}var St=null;function po(e){St===null?St=[e]:St.push(e)}function aa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,po(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ye(e,r)}function Ye(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var qe=!1;function mo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ca(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function We(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function it(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,R&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ye(e,n)}return l=r.interleaved,l===null?(t.next=t,po(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ye(e,n)}function _r(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}function ki(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Yr(e,t,n,r){var l=e.updateQueue;qe=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,c=s.next;s.next=null,o===null?u=c:o.next=c,o=s;var m=e.alternate;m!==null&&(m=m.updateQueue,i=m.lastBaseUpdate,i!==o&&(i===null?m.firstBaseUpdate=c:i.next=c,m.lastBaseUpdate=s))}if(u!==null){var v=l.baseState;o=0,m=c=s=null,i=u;do{var h=i.lane,w=i.eventTime;if((r&h)===h){m!==null&&(m=m.next={eventTime:w,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var k=e,S=i;switch(h=t,w=n,S.tag){case 1:if(k=S.payload,typeof k=="function"){v=k.call(w,v,h);break e}v=k;break e;case 3:k.flags=k.flags&-65537|128;case 0:if(k=S.payload,h=typeof k=="function"?k.call(w,v,h):k,h==null)break e;v=V({},v,h);break e;case 2:qe=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[i]:h.push(i))}else w={eventTime:w,lane:h,tag:i.tag,payload:i.payload,callback:i.callback,next:null},m===null?(c=m=w,s=v):m=m.next=w,o|=h;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;h=i,i=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(m===null&&(s=v),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=m,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);zt|=o,e.lanes=o,e.memoizedState=v}}function Si(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Al.transition;Al.transition={};try{e(!1),t()}finally{O=n,Al.transition=r}}function Pa(){return Ne().memoizedState}function id(e,t,n){var r=at(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},za(e))La(t,n);else if(n=aa(e,t,n,r),n!==null){var l=ie();Me(n,e,r,l),ja(n,t,r)}}function sd(e,t,n){var r=at(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(za(e))La(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,Oe(i,o)){var s=t.interleaved;s===null?(l.next=l,po(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=aa(e,t,l,r),n!==null&&(l=ie(),Me(n,e,r,l),ja(n,t,r))}}function za(e){var t=e.alternate;return e===A||t!==null&&t===A}function La(e,t){Ln=Gr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ja(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}var Zr={readContext:_e,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},ad={readContext:_e,useCallback:function(e,t){return De().memoizedState=[e,t===void 0?null:t],e},useContext:_e,useEffect:Ei,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Pr(4194308,4,xa.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Pr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Pr(4,2,e,t)},useMemo:function(e,t){var n=De();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=De();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=id.bind(null,A,e),[r.memoizedState,e]},useRef:function(e){var t=De();return e={current:e},t.memoizedState=e},useState:xi,useDebugValue:xo,useDeferredValue:function(e){return De().memoizedState=e},useTransition:function(){var e=xi(!1),t=e[0];return e=od.bind(null,e[1]),De().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=A,l=De();if(U){if(n===void 0)throw Error(y(407));n=n()}else{if(n=t(),q===null)throw Error(y(349));Pt&30||ma(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,Ei(va.bind(null,r,u,e),[e]),r.flags|=2048,Xn(9,ha.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=De(),t=q.identifierPrefix;if(U){var n=He,r=Be;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Kn++,0")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{El=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?kn(e):""}function $c(e){switch(e.tag){case 5:return kn(e.type);case 16:return kn("Lazy");case 13:return kn("Suspense");case 19:return kn("SuspenseList");case 0:case 2:case 15:return e=_l(e.type,!1),e;case 11:return e=_l(e.type.render,!1),e;case 1:return e=_l(e.type,!0),e;default:return""}}function Jl(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case It:return"Fragment";case Mt:return"Portal";case Xl:return"Profiler";case Yu:return"StrictMode";case Gl:return"Suspense";case Zl:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case fs:return(e.displayName||"Context")+".Consumer";case cs:return(e._context.displayName||"Context")+".Provider";case Xu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Gu:return t=e.displayName||null,t!==null?t:Jl(e.type)||"Memo";case Je:t=e._payload,e=e._init;try{return Jl(e(t))}catch{}}return null}function Ac(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Jl(t);case 8:return t===Yu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ft(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function ps(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Vc(e){var t=ps(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=Vc(e))}function ms(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=ps(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Rr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ql(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Bo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ft(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function hs(e,t){t=t.checked,t!=null&&Ku(e,"checked",t,!1)}function bl(e,t){hs(e,t);var n=ft(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?eu(e,t.type,n):t.hasOwnProperty("defaultValue")&&eu(e,t.type,ft(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Ho(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function eu(e,t,n){(t!=="number"||Rr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Sn=Array.isArray;function Kt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=or.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var _n={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Bc=["Webkit","ms","Moz","O"];Object.keys(_n).forEach(function(e){Bc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),_n[t]=_n[e]})});function ws(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||_n.hasOwnProperty(e)&&_n[e]?(""+t).trim():t+"px"}function ks(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=ws(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Hc=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ru(e,t){if(t){if(Hc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(g(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(g(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(g(61))}if(t.style!=null&&typeof t.style!="object")throw Error(g(62))}}function lu(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var uu=null;function Zu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ou=null,Yt=null,Xt=null;function Ko(e){if(e=bn(e)){if(typeof ou!="function")throw Error(g(280));var t=e.stateNode;t&&(t=il(t),ou(e.stateNode,e.type,t))}}function Ss(e){Yt?Xt?Xt.push(e):Xt=[e]:Yt=e}function xs(){if(Yt){var e=Yt,t=Xt;if(Xt=Yt=null,Ko(e),t)for(e=0;e>>=0,e===0?32:31-(ef(e)/tf|0)|0}var ir=64,sr=4194304;function xn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=xn(i):(u&=o,u!==0&&(r=xn(u)))}else o=n&~l,o!==0?r=xn(o):u!==0&&(r=xn(u));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Jn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Re(t),e[t]=n}function uf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Nn),ti=" ",ni=!1;function Bs(e,t){switch(e){case"keyup":return Mf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Hs(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Dt=!1;function Df(e,t){switch(e){case"compositionend":return Hs(t);case"keypress":return t.which!==32?null:(ni=!0,ti);case"textInput":return e=t.data,e===ti&&ni?null:e;default:return null}}function Ff(e,t){if(Dt)return e==="compositionend"||!lo&&Bs(e,t)?(e=As(),Er=to=tt=null,Dt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=oi(n)}}function Ys(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ys(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Xs(){for(var e=window,t=Rr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Rr(e.document)}return t}function uo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Kf(e){var t=Xs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ys(n.ownerDocument.documentElement,n)){if(r!==null&&uo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=ii(n,u);var o=ii(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Ft=null,du=null,zn=null,pu=!1;function si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;pu||Ft==null||Ft!==Rr(r)||(r=Ft,"selectionStart"in r&&uo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),zn&&An(zn,r)||(zn=r,r=$r(du,"onSelect"),0At||(e.current=wu[At],wu[At]=null,At--)}function I(e,t){At++,wu[At]=e.current,e.current=t}var dt={},ue=mt(dt),de=mt(!1),Ct=dt;function bt(e,t){var n=e.type.contextTypes;if(!n)return dt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function pe(e){return e=e.childContextTypes,e!=null}function Vr(){F(de),F(ue)}function hi(e,t,n){if(ue.current!==dt)throw Error(g(168));I(ue,t),I(de,n)}function ra(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(g(108,Ac(e)||"Unknown",l));return V({},n,r)}function Br(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||dt,Ct=ue.current,I(ue,e),I(de,de.current),!0}function vi(e,t,n){var r=e.stateNode;if(!r)throw Error(g(169));n?(e=ra(e,t,Ct),r.__reactInternalMemoizedMergedChildContext=e,F(de),F(ue),I(ue,e)):F(de),I(de,n)}var Ve=null,sl=!1,Ul=!1;function la(e){Ve===null?Ve=[e]:Ve.push(e)}function ld(e){sl=!0,la(e)}function ht(){if(!Ul&&Ve!==null){Ul=!0;var e=0,t=O;try{var n=Ve;for(O=1;e>=o,l-=o,Be=1<<32-Re(t)+l|n<P?(H=w,w=null):H=w.sibling;var T=p(f,w,d[P],y);if(T===null){w===null&&(w=H);break}e&&w&&T.alternate===null&&t(f,w),a=u(T,a,P),m===null?_=T:m.sibling=T,m=T,w=H}if(P===d.length)return n(f,w),U&>(f,P),_;if(w===null){for(;PP?(H=w,w=null):H=w.sibling;var Pe=p(f,w,T.value,y);if(Pe===null){w===null&&(w=H);break}e&&w&&Pe.alternate===null&&t(f,w),a=u(Pe,a,P),m===null?_=Pe:m.sibling=Pe,m=Pe,w=H}if(T.done)return n(f,w),U&>(f,P),_;if(w===null){for(;!T.done;P++,T=d.next())T=v(f,T.value,y),T!==null&&(a=u(T,a,P),m===null?_=T:m.sibling=T,m=T);return U&>(f,P),_}for(w=r(f,w);!T.done;P++,T=d.next())T=k(w,f,P,T.value,y),T!==null&&(e&&T.alternate!==null&&w.delete(T.key===null?P:T.key),a=u(T,a,P),m===null?_=T:m.sibling=T,m=T);return e&&w.forEach(function(an){return t(f,an)}),U&>(f,P),_}function M(f,a,d,y){if(typeof d=="object"&&d!==null&&d.type===It&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case lr:e:{for(var _=d.key,m=a;m!==null;){if(m.key===_){if(_=d.type,_===It){if(m.tag===7){n(f,m.sibling),a=l(m,d.props.children),a.return=f,f=a;break e}}else if(m.elementType===_||typeof _=="object"&&_!==null&&_.$$typeof===Je&&wi(_)===m.type){n(f,m.sibling),a=l(m,d.props),a.ref=vn(f,m,d),a.return=f,f=a;break e}n(f,m);break}else t(f,m);m=m.sibling}d.type===It?(a=_t(d.props.children,f.mode,y,d.key),a.return=f,f=a):(y=Tr(d.type,d.key,d.props,null,f.mode,y),y.ref=vn(f,a,d),y.return=f,f=y)}return o(f);case Mt:e:{for(m=d.key;a!==null;){if(a.key===m)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Kl(d,f.mode,y),a.return=f,f=a}return o(f);case Je:return m=d._init,M(f,a,m(d._payload),y)}if(Sn(d))return S(f,a,d,y);if(fn(d))return E(f,a,d,y);hr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=Ql(d,f.mode,y),a.return=f,f=a),o(f)):n(f,a)}return M}var tn=sa(!0),aa=sa(!1),Qr=mt(null),Kr=null,Ht=null,ao=null;function co(){ao=Ht=Kr=null}function fo(e){var t=Qr.current;F(Qr),e._currentValue=t}function xu(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Zt(e,t){Kr=e,ao=Ht=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(fe=!0),e.firstContext=null)}function Ce(e){var t=e._currentValue;if(ao!==e)if(e={context:e,memoizedValue:t,next:null},Ht===null){if(Kr===null)throw Error(g(308));Ht=e,Kr.dependencies={lanes:0,firstContext:e}}else Ht=Ht.next=e;return t}var St=null;function po(e){St===null?St=[e]:St.push(e)}function ca(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,po(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ye(e,r)}function Ye(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var qe=!1;function mo(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function fa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function We(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function it(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,R&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ye(e,n)}return l=r.interleaved,l===null?(t.next=t,po(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ye(e,n)}function Cr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}function ki(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Yr(e,t,n,r){var l=e.updateQueue;qe=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,c=s.next;s.next=null,o===null?u=c:o.next=c,o=s;var h=e.alternate;h!==null&&(h=h.updateQueue,i=h.lastBaseUpdate,i!==o&&(i===null?h.firstBaseUpdate=c:i.next=c,h.lastBaseUpdate=s))}if(u!==null){var v=l.baseState;o=0,h=c=s=null,i=u;do{var p=i.lane,k=i.eventTime;if((r&p)===p){h!==null&&(h=h.next={eventTime:k,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var S=e,E=i;switch(p=t,k=n,E.tag){case 1:if(S=E.payload,typeof S=="function"){v=S.call(k,v,p);break e}v=S;break e;case 3:S.flags=S.flags&-65537|128;case 0:if(S=E.payload,p=typeof S=="function"?S.call(k,v,p):S,p==null)break e;v=V({},v,p);break e;case 2:qe=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,p=l.effects,p===null?l.effects=[i]:p.push(i))}else k={eventTime:k,lane:p,tag:i.tag,payload:i.payload,callback:i.callback,next:null},h===null?(c=h=k,s=v):h=h.next=k,o|=p;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;p=i,i=p.next,p.next=null,l.lastBaseUpdate=p,l.shared.pending=null}}while(!0);if(h===null&&(s=v),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);zt|=o,e.lanes=o,e.memoizedState=v}}function Si(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Al.transition;Al.transition={};try{e(!1),t()}finally{O=n,Al.transition=r}}function za(){return Ne().memoizedState}function sd(e,t,n){var r=at(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},ja(e))La(t,n);else if(n=ca(e,t,n,r),n!==null){var l=ie();Oe(n,e,r,l),Ta(n,t,r)}}function ad(e,t,n){var r=at(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(ja(e))La(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,Me(i,o)){var s=t.interleaved;s===null?(l.next=l,po(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=ca(e,t,l,r),n!==null&&(l=ie(),Oe(n,e,r,l),Ta(n,t,r))}}function ja(e){var t=e.alternate;return e===A||t!==null&&t===A}function La(e,t){jn=Gr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ta(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,qu(e,n)}}var Zr={readContext:Ce,useCallback:ne,useContext:ne,useEffect:ne,useImperativeHandle:ne,useInsertionEffect:ne,useLayoutEffect:ne,useMemo:ne,useReducer:ne,useRef:ne,useState:ne,useDebugValue:ne,useDeferredValue:ne,useTransition:ne,useMutableSource:ne,useSyncExternalStore:ne,useId:ne,unstable_isNewReconciler:!1},cd={readContext:Ce,useCallback:function(e,t){return De().memoizedState=[e,t===void 0?null:t],e},useContext:Ce,useEffect:Ei,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Pr(4194308,4,Ea.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Pr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Pr(4,2,e,t)},useMemo:function(e,t){var n=De();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=De();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sd.bind(null,A,e),[r.memoizedState,e]},useRef:function(e){var t=De();return e={current:e},t.memoizedState=e},useState:xi,useDebugValue:xo,useDeferredValue:function(e){return De().memoizedState=e},useTransition:function(){var e=xi(!1),t=e[0];return e=id.bind(null,e[1]),De().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=A,l=De();if(U){if(n===void 0)throw Error(g(407));n=n()}else{if(n=t(),q===null)throw Error(g(349));Pt&30||ha(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,Ei(ya.bind(null,r,u,e),[e]),r.flags|=2048,Xn(9,va.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=De(),t=q.identifierPrefix;if(U){var n=He,r=Be;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Kn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Fe]=t,e[Hn]=r,Aa(e,t,!1,!1),t.stateNode=e;e:{switch(o=lu(n,r),n){case"dialog":D("cancel",e),D("close",e),l=r;break;case"iframe":case"object":case"embed":D("load",e),l=r;break;case"video":case"audio":for(l=0;lln&&(t.flags|=128,r=!0,yn(u,!1),t.lanes=4194304)}else{if(!r)if(e=Xr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),yn(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!U)return re(t),null}else 2*Q()-u.renderingStartTime>ln&&n!==1073741824&&(t.flags|=128,r=!0,yn(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Q(),t.sibling=null,n=$.current,I($,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return zo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?he&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(y(156,t.tag))}function yd(e,t){switch(io(t),t.tag){case 1:return pe(t.type)&&Vr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return nn(),F(de),F(ue),yo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return vo(t),null;case 13:if(F($),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(y(340));en()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F($),null;case 4:return nn(),null;case 10:return fo(t.type._context),null;case 22:case 23:return zo(),null;case 24:return null;default:return null}}var yr=!1,le=!1,gd=typeof WeakSet=="function"?WeakSet:Set,E=null;function Wt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){B(e,t,r)}else n.current=null}function Tu(e,t,n){try{n()}catch(r){B(e,t,r)}}var Oi=!1;function wd(e,t){if(mu=Fr,e=Ys(),uo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,c=0,m=0,v=e,h=null;t:for(;;){for(var w;v!==n||l!==0&&v.nodeType!==3||(i=o+l),v!==u||r!==0&&v.nodeType!==3||(s=o+r),v.nodeType===3&&(o+=v.nodeValue.length),(w=v.firstChild)!==null;)h=v,v=w;for(;;){if(v===e)break t;if(h===n&&++c===l&&(i=o),h===u&&++m===r&&(s=o),(w=v.nextSibling)!==null)break;v=h,h=v.parentNode}v=w}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(hu={focusedElem:e,selectionRange:n},Fr=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var k=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(k!==null){var S=k.memoizedProps,M=k.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?S:Le(t.type,S),M);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(p){B(t,t.return,p)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return k=Oi,Oi=!1,k}function jn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&Tu(t,n,u)}l=l.next}while(l!==r)}}function fl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Ru(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ha(e){var t=e.alternate;t!==null&&(e.alternate=null,Ha(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Fe],delete t[Hn],delete t[gu],delete t[td],delete t[nd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Wa(e){return e.tag===5||e.tag===3||e.tag===4}function Ii(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Wa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Mu(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Ar));else if(r!==4&&(e=e.child,e!==null))for(Mu(e,t,n),e=e.sibling;e!==null;)Mu(e,t,n),e=e.sibling}function Ou(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ou(e,t,n),e=e.sibling;e!==null;)Ou(e,t,n),e=e.sibling}var b=null,je=!1;function Ze(e,t,n){for(n=n.child;n!==null;)Qa(e,t,n),n=n.sibling}function Qa(e,t,n){if(Ue&&typeof Ue.onCommitFiberUnmount=="function")try{Ue.onCommitFiberUnmount(rl,n)}catch{}switch(n.tag){case 5:le||Wt(n,t);case 6:var r=b,l=je;b=null,Ze(e,t,n),b=r,je=l,b!==null&&(je?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(je?(e=b,n=n.stateNode,e.nodeType===8?Fl(e.parentNode,n):e.nodeType===1&&Fl(e,n),Un(e)):Fl(b,n.stateNode));break;case 4:r=b,l=je,b=n.stateNode.containerInfo,je=!0,Ze(e,t,n),b=r,je=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&(u&2||u&4)&&Tu(n,t,o),l=l.next}while(l!==r)}Ze(e,t,n);break;case 1:if(!le&&(Wt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){B(n,t,i)}Ze(e,t,n);break;case 21:Ze(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,Ze(e,t,n),le=r):Ze(e,t,n);break;default:Ze(e,t,n)}}function Di(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new gd),t.forEach(function(r){var l=zd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=Q()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Sd(r/1960))-r,10e?16:e,nt===null)var r=!1;else{if(e=nt,nt=null,br=0,R&6)throw Error(y(331));var l=R;for(R|=4,E=e.current;E!==null;){var u=E,o=u.child;if(E.flags&16){var i=u.deletions;if(i!==null){for(var s=0;sQ()-No?Et(e,0):_o|=n),me(e,t)}function ba(e,t){t===0&&(e.mode&1?(t=sr,sr<<=1,!(sr&130023424)&&(sr=4194304)):t=1);var n=ie();e=Ye(e,t),e!==null&&(Jn(e,t,n),me(e,n))}function Pd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ba(e,n)}function zd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(t),ba(e,n)}var ec;ec=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||de.current)fe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return fe=!1,hd(e,t,n);fe=!!(e.flags&131072)}else fe=!1,U&&t.flags&1048576&&la(t,Wr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;zr(e,t),e=t.pendingProps;var l=bt(t,ue.current);Zt(t,n),l=wo(null,t,r,e,l,n);var u=ko();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pe(r)?(u=!0,Br(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,mo(t),l.updater=cl,t.stateNode=l,l._reactInternals=t,Cu(t,r,e,n),t=Pu(null,t,r,!0,u,n)):(t.tag=0,U&&u&&oo(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(zr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=jd(r),e=Le(r,e),l){case 0:t=Nu(null,t,r,e,n);break e;case 1:t=Ti(null,t,r,e,n);break e;case 11:t=Li(null,t,r,e,n);break e;case 14:t=ji(null,t,r,Le(r.type,e),n);break e}throw Error(y(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Nu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Ti(e,t,r,l,n);case 3:e:{if(Fa(t),e===null)throw Error(y(387));r=t.pendingProps,u=t.memoizedState,l=u.element,ca(e,t),Yr(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=rn(Error(y(423)),t),t=Ri(e,t,r,n,l);break e}else if(r!==l){l=rn(Error(y(424)),t),t=Ri(e,t,r,n,l);break e}else for(ve=ot(t.stateNode.containerInfo.firstChild),ye=t,U=!0,Te=null,n=sa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(en(),r===l){t=Xe(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return fa(t),e===null&&Su(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,vu(r,l)?o=null:u!==null&&vu(r,u)&&(t.flags|=32),Da(e,t),oe(e,t,o,n),t.child;case 6:return e===null&&Su(t),null;case 13:return Ua(e,t,n);case 4:return ho(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=tn(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Li(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,I(Qr,r._currentValue),r._currentValue=o,u!==null)if(Oe(u.value,o)){if(u.children===l.children&&!de.current){t=Xe(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=We(-1,n&-n),s.tag=2;var c=u.updateQueue;if(c!==null){c=c.shared;var m=c.pending;m===null?s.next=s:(s.next=m.next,m.next=s),c.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),xu(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(y(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),xu(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Zt(t,n),l=_e(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=Le(r,t.pendingProps),l=Le(r.type,l),ji(e,t,r,l,n);case 15:return Oa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),zr(e,t),t.tag=1,pe(r)?(e=!0,Br(t)):e=!1,Zt(t,n),Ta(t,r,l),Cu(t,r,l,n),Pu(null,t,r,!0,e,n);case 19:return $a(e,t,n);case 22:return Ia(e,t,n)}throw Error(y(156,t.tag))};function tc(e,t){return zs(e,t)}function Ld(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new Ld(e,t,n,r)}function jo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function jd(e){if(typeof e=="function")return jo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Xu)return 11;if(e===Gu)return 14}return 2}function ct(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Tr(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")jo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case It:return Ct(n.children,l,u,t);case Yu:o=8,l|=8;break;case Xl:return e=Ee(12,n,t,l|2),e.elementType=Xl,e.lanes=u,e;case Gl:return e=Ee(13,n,t,l),e.elementType=Gl,e.lanes=u,e;case Zl:return e=Ee(19,n,t,l),e.elementType=Zl,e.lanes=u,e;case fs:return pl(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case as:o=10;break e;case cs:o=9;break e;case Xu:o=11;break e;case Gu:o=14;break e;case Je:o=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function Ct(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function pl(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=fs,e.lanes=n,e.stateNode={isHidden:!1},e}function Ql(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Kl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Td(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Nl(0),this.expirationTimes=Nl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Nl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function To(e,t,n,r,l,u,o,i,s){return e=new Td(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Ee(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},mo(u),e}function Rd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(uc)}catch(e){console.error(e)}}uc(),us.exports=we;var Fd=us.exports,oc,Wi=Fd;oc=Wi.createRoot,Wi.hydrateRoot;/** +`+u.stack}return{value:e,source:t,stack:l,digest:null}}function Hl(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Cu(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var pd=typeof WeakMap=="function"?WeakMap:Map;function Oa(e,t,n){n=We(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){qr||(qr=!0,Iu=r),Cu(e,t)},n}function Ma(e,t,n){n=We(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Cu(e,t)}}var u=e.stateNode;return u!==null&&typeof u.componentDidCatch=="function"&&(n.callback=function(){Cu(e,t),typeof r!="function"&&(st===null?st=new Set([this]):st.add(this));var o=t.stack;this.componentDidCatch(t.value,{componentStack:o!==null?o:""})}),n}function Ni(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new pd;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=Pd.bind(null,e,t,n),t.then(e,e))}function Pi(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function zi(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=We(-1,1),t.tag=2,it(n,t,1))),n.lanes|=1),e)}var md=Ge.ReactCurrentOwner,fe=!1;function oe(e,t,n,r){t.child=e===null?aa(t,null,n,r):tn(t,e.child,n,r)}function ji(e,t,n,r,l){n=n.render;var u=t.ref;return Zt(t,l),r=wo(e,t,n,r,u,l),n=ko(),e!==null&&!fe?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Xe(e,t,l)):(U&&n&&oo(t),t.flags|=1,oe(e,t,r,l),t.child)}function Li(e,t,n,r,l){if(e===null){var u=n.type;return typeof u=="function"&&!Lo(u)&&u.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=u,Ia(e,t,u,r,l)):(e=Tr(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(u=e.child,!(e.lanes&l)){var o=u.memoizedProps;if(n=n.compare,n=n!==null?n:An,n(o,r)&&e.ref===t.ref)return Xe(e,t,l)}return t.flags|=1,e=ct(u,r),e.ref=t.ref,e.return=t,t.child=e}function Ia(e,t,n,r,l){if(e!==null){var u=e.memoizedProps;if(An(u,r)&&e.ref===t.ref)if(fe=!1,t.pendingProps=r=u,(e.lanes&l)!==0)e.flags&131072&&(fe=!0);else return t.lanes=e.lanes,Xe(e,t,l)}return Nu(e,t,n,r,l)}function Da(e,t,n){var r=t.pendingProps,l=r.children,u=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},I(Qt,he),he|=n;else{if(!(n&1073741824))return e=u!==null?u.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,I(Qt,he),he|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=u!==null?u.baseLanes:n,I(Qt,he),he|=r}else u!==null?(r=u.baseLanes|n,t.memoizedState=null):r=n,I(Qt,he),he|=r;return oe(e,t,l,n),t.child}function Fa(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Nu(e,t,n,r,l){var u=pe(n)?Ct:ue.current;return u=bt(t,u),Zt(t,l),n=wo(e,t,n,r,u,l),r=ko(),e!==null&&!fe?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Xe(e,t,l)):(U&&r&&oo(t),t.flags|=1,oe(e,t,n,l),t.child)}function Ti(e,t,n,r,l){if(pe(n)){var u=!0;Br(t)}else u=!1;if(Zt(t,l),t.stateNode===null)zr(e,t),Ra(t,n,r),_u(t,n,r,l),r=!0;else if(e===null){var o=t.stateNode,i=t.memoizedProps;o.props=i;var s=o.context,c=n.contextType;typeof c=="object"&&c!==null?c=Ce(c):(c=pe(n)?Ct:ue.current,c=bt(t,c));var h=n.getDerivedStateFromProps,v=typeof h=="function"||typeof o.getSnapshotBeforeUpdate=="function";v||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(i!==r||s!==c)&&Ci(t,o,r,c),qe=!1;var p=t.memoizedState;o.state=p,Yr(t,r,o,l),s=t.memoizedState,i!==r||p!==s||de.current||qe?(typeof h=="function"&&(Eu(t,n,h,r),s=t.memoizedState),(i=qe||_i(t,n,i,r,p,s,c))?(v||typeof o.UNSAFE_componentWillMount!="function"&&typeof o.componentWillMount!="function"||(typeof o.componentWillMount=="function"&&o.componentWillMount(),typeof o.UNSAFE_componentWillMount=="function"&&o.UNSAFE_componentWillMount()),typeof o.componentDidMount=="function"&&(t.flags|=4194308)):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=s),o.props=r,o.state=s,o.context=c,r=i):(typeof o.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{o=t.stateNode,fa(e,t),i=t.memoizedProps,c=t.type===t.elementType?i:je(t.type,i),o.props=c,v=t.pendingProps,p=o.context,s=n.contextType,typeof s=="object"&&s!==null?s=Ce(s):(s=pe(n)?Ct:ue.current,s=bt(t,s));var k=n.getDerivedStateFromProps;(h=typeof k=="function"||typeof o.getSnapshotBeforeUpdate=="function")||typeof o.UNSAFE_componentWillReceiveProps!="function"&&typeof o.componentWillReceiveProps!="function"||(i!==v||p!==s)&&Ci(t,o,r,s),qe=!1,p=t.memoizedState,o.state=p,Yr(t,r,o,l);var S=t.memoizedState;i!==v||p!==S||de.current||qe?(typeof k=="function"&&(Eu(t,n,k,r),S=t.memoizedState),(c=qe||_i(t,n,c,r,p,S,s)||!1)?(h||typeof o.UNSAFE_componentWillUpdate!="function"&&typeof o.componentWillUpdate!="function"||(typeof o.componentWillUpdate=="function"&&o.componentWillUpdate(r,S,s),typeof o.UNSAFE_componentWillUpdate=="function"&&o.UNSAFE_componentWillUpdate(r,S,s)),typeof o.componentDidUpdate=="function"&&(t.flags|=4),typeof o.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof o.componentDidUpdate!="function"||i===e.memoizedProps&&p===e.memoizedState||(t.flags|=4),typeof o.getSnapshotBeforeUpdate!="function"||i===e.memoizedProps&&p===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=S),o.props=r,o.state=S,o.context=s,r=c):(typeof o.componentDidUpdate!="function"||i===e.memoizedProps&&p===e.memoizedState||(t.flags|=4),typeof o.getSnapshotBeforeUpdate!="function"||i===e.memoizedProps&&p===e.memoizedState||(t.flags|=1024),r=!1)}return Pu(e,t,n,r,u,l)}function Pu(e,t,n,r,l,u){Fa(e,t);var o=(t.flags&128)!==0;if(!r&&!o)return l&&vi(t,n,!1),Xe(e,t,u);r=t.stateNode,md.current=t;var i=o&&typeof n.getDerivedStateFromError!="function"?null:r.render();return t.flags|=1,e!==null&&o?(t.child=tn(t,e.child,null,u),t.child=tn(t,null,i,u)):oe(e,t,i,u),t.memoizedState=r.state,l&&vi(t,n,!0),t.child}function Ua(e){var t=e.stateNode;t.pendingContext?hi(e,t.pendingContext,t.pendingContext!==t.context):t.context&&hi(e,t.context,!1),ho(e,t.containerInfo)}function Ri(e,t,n,r,l){return en(),so(l),t.flags|=256,oe(e,t,n,r),t.child}var zu={dehydrated:null,treeContext:null,retryLane:0};function ju(e){return{baseLanes:e,cachePool:null,transitions:null}}function $a(e,t,n){var r=t.pendingProps,l=$.current,u=!1,o=(t.flags&128)!==0,i;if((i=o)||(i=e!==null&&e.memoizedState===null?!1:(l&2)!==0),i?(u=!0,t.flags&=-129):(e===null||e.memoizedState!==null)&&(l|=1),I($,l&1),e===null)return Su(t),e=t.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?(t.mode&1?e.data==="$!"?t.lanes=8:t.lanes=1073741824:t.lanes=1,null):(o=r.children,e=r.fallback,u?(r=t.mode,u=t.child,o={mode:"hidden",children:o},!(r&1)&&u!==null?(u.childLanes=0,u.pendingProps=o):u=pl(o,r,0,null),e=_t(e,r,n,null),u.return=t,e.return=t,u.sibling=e,t.child=u,t.child.memoizedState=ju(n),t.memoizedState=zu,e):Eo(t,o));if(l=e.memoizedState,l!==null&&(i=l.dehydrated,i!==null))return hd(e,t,o,r,i,l,n);if(u){u=r.fallback,o=t.mode,l=e.child,i=l.sibling;var s={mode:"hidden",children:r.children};return!(o&1)&&t.child!==l?(r=t.child,r.childLanes=0,r.pendingProps=s,t.deletions=null):(r=ct(l,s),r.subtreeFlags=l.subtreeFlags&14680064),i!==null?u=ct(i,u):(u=_t(u,o,n,null),u.flags|=2),u.return=t,r.return=t,r.sibling=u,t.child=r,r=u,u=t.child,o=e.child.memoizedState,o=o===null?ju(n):{baseLanes:o.baseLanes|n,cachePool:null,transitions:o.transitions},u.memoizedState=o,u.childLanes=e.childLanes&~n,t.memoizedState=zu,r}return u=e.child,e=u.sibling,r=ct(u,{mode:"visible",children:r.children}),!(t.mode&1)&&(r.lanes=n),r.return=t,r.sibling=null,e!==null&&(n=t.deletions,n===null?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r}function Eo(e,t){return t=pl({mode:"visible",children:t},e.mode,0,null),t.return=e,e.child=t}function vr(e,t,n,r){return r!==null&&so(r),tn(t,e.child,null,n),e=Eo(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function hd(e,t,n,r,l,u,o){if(n)return t.flags&256?(t.flags&=-257,r=Hl(Error(g(422))),vr(e,t,o,r)):t.memoizedState!==null?(t.child=e.child,t.flags|=128,null):(u=r.fallback,l=t.mode,r=pl({mode:"visible",children:r.children},l,0,null),u=_t(u,l,o,null),u.flags|=2,r.return=t,u.return=t,r.sibling=u,t.child=r,t.mode&1&&tn(t,e.child,null,o),t.child.memoizedState=ju(o),t.memoizedState=zu,u);if(!(t.mode&1))return vr(e,t,o,null);if(l.data==="$!"){if(r=l.nextSibling&&l.nextSibling.dataset,r)var i=r.dgst;return r=i,u=Error(g(419)),r=Hl(u,r,void 0),vr(e,t,o,r)}if(i=(o&e.childLanes)!==0,fe||i){if(r=q,r!==null){switch(o&-o){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=l&(r.suspendedLanes|o)?0:l,l!==0&&l!==u.retryLane&&(u.retryLane=l,Ye(e,l),Oe(r,e,l,-1))}return jo(),r=Hl(Error(g(421))),vr(e,t,o,r)}return l.data==="$?"?(t.flags|=128,t.child=e.child,t=zd.bind(null,e),l._reactRetry=t,null):(e=u.treeContext,ve=ot(l.nextSibling),ye=t,U=!0,Te=null,e!==null&&(Se[xe++]=Be,Se[xe++]=He,Se[xe++]=Nt,Be=e.id,He=e.overflow,Nt=t),t=Eo(t,r.children),t.flags|=4096,t)}function Oi(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),xu(e.return,t,n)}function Wl(e,t,n,r,l){var u=e.memoizedState;u===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:l}:(u.isBackwards=t,u.rendering=null,u.renderingStartTime=0,u.last=r,u.tail=n,u.tailMode=l)}function Aa(e,t,n){var r=t.pendingProps,l=r.revealOrder,u=r.tail;if(oe(e,t,r.children,n),r=$.current,r&2)r=r&1|2,t.flags|=128;else{if(e!==null&&e.flags&128)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Oi(e,n,t);else if(e.tag===19)Oi(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(I($,r),!(t.mode&1))t.memoizedState=null;else switch(l){case"forwards":for(n=t.child,l=null;n!==null;)e=n.alternate,e!==null&&Xr(e)===null&&(l=n),n=n.sibling;n=l,n===null?(l=t.child,t.child=null):(l=n.sibling,n.sibling=null),Wl(t,!1,l,n,u);break;case"backwards":for(n=null,l=t.child,t.child=null;l!==null;){if(e=l.alternate,e!==null&&Xr(e)===null){t.child=l;break}e=l.sibling,l.sibling=n,n=l,l=e}Wl(t,!0,n,null,u);break;case"together":Wl(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function zr(e,t){!(t.mode&1)&&e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2)}function Xe(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),zt|=t.lanes,!(n&t.childLanes))return null;if(e!==null&&t.child!==e.child)throw Error(g(153));if(t.child!==null){for(e=t.child,n=ct(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=ct(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function vd(e,t,n){switch(t.tag){case 3:Ua(t),en();break;case 5:da(t);break;case 1:pe(t.type)&&Br(t);break;case 4:ho(t,t.stateNode.containerInfo);break;case 10:var r=t.type._context,l=t.memoizedProps.value;I(Qr,r._currentValue),r._currentValue=l;break;case 13:if(r=t.memoizedState,r!==null)return r.dehydrated!==null?(I($,$.current&1),t.flags|=128,null):n&t.child.childLanes?$a(e,t,n):(I($,$.current&1),e=Xe(e,t,n),e!==null?e.sibling:null);I($,$.current&1);break;case 19:if(r=(n&t.childLanes)!==0,e.flags&128){if(r)return Aa(e,t,n);t.flags|=128}if(l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),I($,$.current),r)break;return null;case 22:case 23:return t.lanes=0,Da(e,t,n)}return Xe(e,t,n)}var Va,Lu,Ba,Ha;Va=function(e,t){for(var n=t.child;n!==null;){if(n.tag===5||n.tag===6)e.appendChild(n.stateNode);else if(n.tag!==4&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}};Lu=function(){};Ba=function(e,t,n,r){var l=e.memoizedProps;if(l!==r){e=t.stateNode,xt($e.current);var u=null;switch(n){case"input":l=ql(e,l),r=ql(e,r),u=[];break;case"select":l=V({},l,{value:void 0}),r=V({},r,{value:void 0}),u=[];break;case"textarea":l=tu(e,l),r=tu(e,r),u=[];break;default:typeof l.onClick!="function"&&typeof r.onClick=="function"&&(e.onclick=Ar)}ru(n,r);var o;n=null;for(c in l)if(!r.hasOwnProperty(c)&&l.hasOwnProperty(c)&&l[c]!=null)if(c==="style"){var i=l[c];for(o in i)i.hasOwnProperty(o)&&(n||(n={}),n[o]="")}else c!=="dangerouslySetInnerHTML"&&c!=="children"&&c!=="suppressContentEditableWarning"&&c!=="suppressHydrationWarning"&&c!=="autoFocus"&&(On.hasOwnProperty(c)?u||(u=[]):(u=u||[]).push(c,null));for(c in r){var s=r[c];if(i=l!=null?l[c]:void 0,r.hasOwnProperty(c)&&s!==i&&(s!=null||i!=null))if(c==="style")if(i){for(o in i)!i.hasOwnProperty(o)||s&&s.hasOwnProperty(o)||(n||(n={}),n[o]="");for(o in s)s.hasOwnProperty(o)&&i[o]!==s[o]&&(n||(n={}),n[o]=s[o])}else n||(u||(u=[]),u.push(c,n)),n=s;else c==="dangerouslySetInnerHTML"?(s=s?s.__html:void 0,i=i?i.__html:void 0,s!=null&&i!==s&&(u=u||[]).push(c,s)):c==="children"?typeof s!="string"&&typeof s!="number"||(u=u||[]).push(c,""+s):c!=="suppressContentEditableWarning"&&c!=="suppressHydrationWarning"&&(On.hasOwnProperty(c)?(s!=null&&c==="onScroll"&&D("scroll",e),u||i===s||(u=[])):(u=u||[]).push(c,s))}n&&(u=u||[]).push("style",n);var c=u;(t.updateQueue=c)&&(t.flags|=4)}};Ha=function(e,t,n,r){n!==r&&(t.flags|=4)};function yn(e,t){if(!U)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function re(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags&14680064,r|=l.flags&14680064,l.return=e,l=l.sibling;else for(l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function yd(e,t,n){var r=t.pendingProps;switch(io(t),t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return re(t),null;case 1:return pe(t.type)&&Vr(),re(t),null;case 3:return r=t.stateNode,nn(),F(de),F(ue),yo(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(e===null||e.child===null)&&(mr(t)?t.flags|=4:e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,Te!==null&&(Uu(Te),Te=null))),Lu(e,t),re(t),null;case 5:vo(t);var l=xt(Qn.current);if(n=t.type,e!==null&&t.stateNode!=null)Ba(e,t,n,r,l),e.ref!==t.ref&&(t.flags|=512,t.flags|=2097152);else{if(!r){if(t.stateNode===null)throw Error(g(166));return re(t),null}if(e=xt($e.current),mr(t)){r=t.stateNode,n=t.type;var u=t.memoizedProps;switch(r[Fe]=t,r[Hn]=u,e=(t.mode&1)!==0,n){case"dialog":D("cancel",r),D("close",r);break;case"iframe":case"object":case"embed":D("load",r);break;case"video":case"audio":for(l=0;l<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Fe]=t,e[Hn]=r,Va(e,t,!1,!1),t.stateNode=e;e:{switch(o=lu(n,r),n){case"dialog":D("cancel",e),D("close",e),l=r;break;case"iframe":case"object":case"embed":D("load",e),l=r;break;case"video":case"audio":for(l=0;lln&&(t.flags|=128,r=!0,yn(u,!1),t.lanes=4194304)}else{if(!r)if(e=Xr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),yn(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!U)return re(t),null}else 2*Q()-u.renderingStartTime>ln&&n!==1073741824&&(t.flags|=128,r=!0,yn(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Q(),t.sibling=null,n=$.current,I($,r?n&1|2:n&1),t):(re(t),null);case 22:case 23:return zo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?he&1073741824&&(re(t),t.subtreeFlags&6&&(t.flags|=8192)):re(t),null;case 24:return null;case 25:return null}throw Error(g(156,t.tag))}function gd(e,t){switch(io(t),t.tag){case 1:return pe(t.type)&&Vr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return nn(),F(de),F(ue),yo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return vo(t),null;case 13:if(F($),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(g(340));en()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F($),null;case 4:return nn(),null;case 10:return fo(t.type._context),null;case 22:case 23:return zo(),null;case 24:return null;default:return null}}var yr=!1,le=!1,wd=typeof WeakSet=="function"?WeakSet:Set,C=null;function Wt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){B(e,t,r)}else n.current=null}function Tu(e,t,n){try{n()}catch(r){B(e,t,r)}}var Mi=!1;function kd(e,t){if(mu=Fr,e=Xs(),uo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,c=0,h=0,v=e,p=null;t:for(;;){for(var k;v!==n||l!==0&&v.nodeType!==3||(i=o+l),v!==u||r!==0&&v.nodeType!==3||(s=o+r),v.nodeType===3&&(o+=v.nodeValue.length),(k=v.firstChild)!==null;)p=v,v=k;for(;;){if(v===e)break t;if(p===n&&++c===l&&(i=o),p===u&&++h===r&&(s=o),(k=v.nextSibling)!==null)break;v=p,p=v.parentNode}v=k}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(hu={focusedElem:e,selectionRange:n},Fr=!1,C=t;C!==null;)if(t=C,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,C=e;else for(;C!==null;){t=C;try{var S=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(S!==null){var E=S.memoizedProps,M=S.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?E:je(t.type,E),M);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(g(163))}}catch(y){B(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,C=e;break}C=t.return}return S=Mi,Mi=!1,S}function Ln(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&Tu(t,n,u)}l=l.next}while(l!==r)}}function fl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Ru(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Wa(e){var t=e.alternate;t!==null&&(e.alternate=null,Wa(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Fe],delete t[Hn],delete t[gu],delete t[nd],delete t[rd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Qa(e){return e.tag===5||e.tag===3||e.tag===4}function Ii(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Qa(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ou(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Ar));else if(r!==4&&(e=e.child,e!==null))for(Ou(e,t,n),e=e.sibling;e!==null;)Ou(e,t,n),e=e.sibling}function Mu(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Mu(e,t,n),e=e.sibling;e!==null;)Mu(e,t,n),e=e.sibling}var b=null,Le=!1;function Ze(e,t,n){for(n=n.child;n!==null;)Ka(e,t,n),n=n.sibling}function Ka(e,t,n){if(Ue&&typeof Ue.onCommitFiberUnmount=="function")try{Ue.onCommitFiberUnmount(rl,n)}catch{}switch(n.tag){case 5:le||Wt(n,t);case 6:var r=b,l=Le;b=null,Ze(e,t,n),b=r,Le=l,b!==null&&(Le?(e=b,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):b.removeChild(n.stateNode));break;case 18:b!==null&&(Le?(e=b,n=n.stateNode,e.nodeType===8?Fl(e.parentNode,n):e.nodeType===1&&Fl(e,n),Un(e)):Fl(b,n.stateNode));break;case 4:r=b,l=Le,b=n.stateNode.containerInfo,Le=!0,Ze(e,t,n),b=r,Le=l;break;case 0:case 11:case 14:case 15:if(!le&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&(u&2||u&4)&&Tu(n,t,o),l=l.next}while(l!==r)}Ze(e,t,n);break;case 1:if(!le&&(Wt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){B(n,t,i)}Ze(e,t,n);break;case 21:Ze(e,t,n);break;case 22:n.mode&1?(le=(r=le)||n.memoizedState!==null,Ze(e,t,n),le=r):Ze(e,t,n);break;default:Ze(e,t,n)}}function Di(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new wd),t.forEach(function(r){var l=jd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=Q()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*xd(r/1960))-r,10e?16:e,nt===null)var r=!1;else{if(e=nt,nt=null,br=0,R&6)throw Error(g(331));var l=R;for(R|=4,C=e.current;C!==null;){var u=C,o=u.child;if(C.flags&16){var i=u.deletions;if(i!==null){for(var s=0;sQ()-No?Et(e,0):Co|=n),me(e,t)}function ec(e,t){t===0&&(e.mode&1?(t=sr,sr<<=1,!(sr&130023424)&&(sr=4194304)):t=1);var n=ie();e=Ye(e,t),e!==null&&(Jn(e,t,n),me(e,n))}function zd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),ec(e,n)}function jd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(g(314))}r!==null&&r.delete(t),ec(e,n)}var tc;tc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||de.current)fe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return fe=!1,vd(e,t,n);fe=!!(e.flags&131072)}else fe=!1,U&&t.flags&1048576&&ua(t,Wr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;zr(e,t),e=t.pendingProps;var l=bt(t,ue.current);Zt(t,n),l=wo(null,t,r,e,l,n);var u=ko();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,pe(r)?(u=!0,Br(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,mo(t),l.updater=cl,t.stateNode=l,l._reactInternals=t,_u(t,r,e,n),t=Pu(null,t,r,!0,u,n)):(t.tag=0,U&&u&&oo(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(zr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Td(r),e=je(r,e),l){case 0:t=Nu(null,t,r,e,n);break e;case 1:t=Ti(null,t,r,e,n);break e;case 11:t=ji(null,t,r,e,n);break e;case 14:t=Li(null,t,r,je(r.type,e),n);break e}throw Error(g(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),Nu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),Ti(e,t,r,l,n);case 3:e:{if(Ua(t),e===null)throw Error(g(387));r=t.pendingProps,u=t.memoizedState,l=u.element,fa(e,t),Yr(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=rn(Error(g(423)),t),t=Ri(e,t,r,n,l);break e}else if(r!==l){l=rn(Error(g(424)),t),t=Ri(e,t,r,n,l);break e}else for(ve=ot(t.stateNode.containerInfo.firstChild),ye=t,U=!0,Te=null,n=aa(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(en(),r===l){t=Xe(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return da(t),e===null&&Su(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,vu(r,l)?o=null:u!==null&&vu(r,u)&&(t.flags|=32),Fa(e,t),oe(e,t,o,n),t.child;case 6:return e===null&&Su(t),null;case 13:return $a(e,t,n);case 4:return ho(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=tn(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),ji(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,I(Qr,r._currentValue),r._currentValue=o,u!==null)if(Me(u.value,o)){if(u.children===l.children&&!de.current){t=Xe(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=We(-1,n&-n),s.tag=2;var c=u.updateQueue;if(c!==null){c=c.shared;var h=c.pending;h===null?s.next=s:(s.next=h.next,h.next=s),c.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),xu(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(g(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),xu(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Zt(t,n),l=Ce(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=je(r,t.pendingProps),l=je(r.type,l),Li(e,t,r,l,n);case 15:return Ia(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:je(r,l),zr(e,t),t.tag=1,pe(r)?(e=!0,Br(t)):e=!1,Zt(t,n),Ra(t,r,l),_u(t,r,l,n),Pu(null,t,r,!0,e,n);case 19:return Aa(e,t,n);case 22:return Da(e,t,n)}throw Error(g(156,t.tag))};function nc(e,t){return js(e,t)}function Ld(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new Ld(e,t,n,r)}function Lo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Td(e){if(typeof e=="function")return Lo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Xu)return 11;if(e===Gu)return 14}return 2}function ct(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Tr(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")Lo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case It:return _t(n.children,l,u,t);case Yu:o=8,l|=8;break;case Xl:return e=Ee(12,n,t,l|2),e.elementType=Xl,e.lanes=u,e;case Gl:return e=Ee(13,n,t,l),e.elementType=Gl,e.lanes=u,e;case Zl:return e=Ee(19,n,t,l),e.elementType=Zl,e.lanes=u,e;case ds:return pl(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case cs:o=10;break e;case fs:o=9;break e;case Xu:o=11;break e;case Gu:o=14;break e;case Je:o=16,r=null;break e}throw Error(g(130,e==null?e:typeof e,""))}return t=Ee(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function _t(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function pl(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=ds,e.lanes=n,e.stateNode={isHidden:!1},e}function Ql(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Kl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Rd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Nl(0),this.expirationTimes=Nl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Nl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function To(e,t,n,r,l,u,o,i,s){return e=new Rd(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Ee(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},mo(u),e}function Od(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(oc)}catch(e){console.error(e)}}oc(),os.exports=we;var Ud=os.exports,ic,Wi=Ud;ic=Wi.createRoot,Wi.hydrateRoot;/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Ud=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),ic=(...e)=>e.filter((t,n,r)=>!!t&&t.trim()!==""&&r.indexOf(t)===n).join(" ").trim();/** + */const $d=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),sc=(...e)=>e.filter((t,n,r)=>!!t&&t.trim()!==""&&r.indexOf(t)===n).join(" ").trim();/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */var $d={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + */var Ad={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Ad=Z.forwardRef(({color:e="currentColor",size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:l="",children:u,iconNode:o,...i},s)=>Z.createElement("svg",{ref:s,...$d,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:ic("lucide",l),...i},[...o.map(([c,m])=>Z.createElement(c,m)),...Array.isArray(u)?u:[u]]));/** + */const Vd=Y.forwardRef(({color:e="currentColor",size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:l="",children:u,iconNode:o,...i},s)=>Y.createElement("svg",{ref:s,...Ad,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:sc("lucide",l),...i},[...o.map(([c,h])=>Y.createElement(c,h)),...Array.isArray(u)?u:[u]]));/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Rt=(e,t)=>{const n=Z.forwardRef(({className:r,...l},u)=>Z.createElement(Ad,{ref:u,iconNode:t,className:ic(`lucide-${Ud(e)}`,r),...l}));return n.displayName=`${e}`,n};/** + */const Rt=(e,t)=>{const n=Y.forwardRef(({className:r,...l},u)=>Y.createElement(Vd,{ref:u,iconNode:t,className:sc(`lucide-${$d(e)}`,r),...l}));return n.displayName=`${e}`,n};/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Vd=Rt("CircleAlert",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]]);/** + */const Bd=Rt("CircleAlert",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Bd=Rt("CircleCheck",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** + */const Hd=Rt("CircleCheck",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Hd=Rt("FileSpreadsheet",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M8 13h2",key:"yr2amv"}],["path",{d:"M14 13h2",key:"un5t4a"}],["path",{d:"M8 17h2",key:"2yhykz"}],["path",{d:"M14 17h2",key:"10kma7"}]]);/** + */const Wd=Rt("FileSpreadsheet",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M8 13h2",key:"yr2amv"}],["path",{d:"M14 13h2",key:"un5t4a"}],["path",{d:"M8 17h2",key:"2yhykz"}],["path",{d:"M14 17h2",key:"10kma7"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Wd=Rt("KeyRound",[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]]);/** + */const Qd=Rt("KeyRound",[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Qd=Rt("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);/** + */const Kd=Rt("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. @@ -92,4 +92,6 @@ Error generating stack: `+u.message+` * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. - */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Kd=["文件路径","模型配置","运行前预检","开始筛选"],$u={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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Yd(){const[e,t]=Z.useState(0),[n,r]=Z.useState($u),[l,u]=Z.useState(""),[o,i]=Z.useState(""),[s,c]=Z.useState(null),[m,v]=Z.useState(null),[h,w]=Z.useState("");Z.useEffect(()=>{wn("/api/config").then(p=>r(Yi(p.config))).catch(p=>i(p.message))},[]),Z.useEffect(()=>{if(!h||(m==null?void 0:m.state)==="completed"||(m==null?void 0:m.state)==="failed")return;const p=window.setInterval(()=>{wn(`/api/runs/${h}`).then(g=>v(g.run)).catch(g=>i(g.message))},1200);return()=>window.clearInterval(p)},[h,m==null?void 0:m.state]);const k=Z.useMemo(()=>!m||!m.total?0:Math.round(m.current/m.total*100),[m]);function S(p,g){r(_=>({..._,[p]:g}))}function M(p,g){r(_=>({..._,model:{..._.model,[p]:g}}))}async function f(){i(""),u("");try{const p=await wn("/api/config",{method:"POST",body:JSON.stringify(n)});r(Yi(p.config)),u("配置已保存到本地 config.yaml")}catch(p){i(p.message)}}async function a(){i("");try{const p=await wn("/api/precheck");c(p),t(2)}catch(p){i(p.message)}}async function d(){i("");try{const p=await wn("/api/runs",{method:"POST",body:"{}"});v(p.run),w(p.run.run_id),t(3)}catch(p){i(p.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Kd.map((p,g)=>x.jsxs("button",{className:g===e?"active":"",onClick:()=>t(g),children:[g+1,". ",p]},p))})]}),l&&x.jsxs("div",{className:"notice success",children:[x.jsx(Bd,{size:18}),l]}),o&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Vd,{size:18}),o]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Hd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:p=>S("resume_dir",p.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:p=>S("job_book",p.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:p=>S("result_book",p.target.value)})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:p=>M("base_url",p.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:p=>M("api_key",p.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:p=>M("model",p.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:p=>M("timeout_seconds",Number(p.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:p=>M("allow_without_model",p.target.checked)}),"模型不可用时允许进入待人工二筛兜底"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:f,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:a,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!s&&x.jsx("button",{className:"primary",onClick:a,children:"开始预检"}),s&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${s.status}`,children:["待处理文件:",s.resume_file_count," 个"]}),s.items.map(p=>x.jsxs("div",{className:`check ${p.status}`,children:[x.jsx("strong",{children:p.name}),x.jsx("span",{children:p.message})]},p.name)),x.jsxs("button",{className:"primary",onClick:d,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(m==null?void 0:m.state)==="running"?x.jsx(Qd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!m&&x.jsx("button",{className:"primary",onClick:d,children:"开始筛选"}),m&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${k}%`}})}),x.jsxs("p",{className:"muted",children:[m.current_file||"等待任务更新"," ",m.total?`${m.current}/${m.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(m.stats||{}).map(([p,g])=>x.jsxs("div",{children:[x.jsx("strong",{children:g}),x.jsx("span",{children:p})]},p))}),x.jsx("div",{className:"logs",children:(m.logs||[]).map((p,g)=>x.jsx("p",{className:p.level,children:p.message},`${p.timestamp}-${g}`))}),m.error&&x.jsx("div",{className:"notice danger",children:m.error})]})]})]})}oc(document.getElementById("root")).render(x.jsx(_c.StrictMode,{children:x.jsx(Yd,{})})); + */const Ki=Rt("Save",[["path",{d:"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z",key:"1c8476"}],["path",{d:"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7",key:"1ydtos"}],["path",{d:"M7 3v4a1 1 0 0 0 1 1h7",key:"t51u73"}]]),Yd=["文件路径","模型配置","运行前预检","开始筛选"],$u={resume_dir:"",job_book:"",result_book:"",job_aliases:{},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:.1,allow_without_model:!0}};async function wn(e,t={}){var l;const n=await fetch(e,{headers:{"Content-Type":"application/json"},...t}),r=await n.json();if(!n.ok)throw new Error(typeof r.detail=="string"?r.detail:((l=r.detail)==null?void 0:l.message)||"请求失败");return r}function Yi(e){return{...$u,...e,model:{...$u.model,...e.model}}}function Xi(e={}){return Object.entries(e).map(([t,n])=>`${t}=${n}`).join(` +`)}function Xd(e){const t={};return e.split(/\r?\n/).forEach(n=>{const r=n.trim();if(!r||r.startsWith("#"))return;const l=r.includes("=")?"=":":",u=r.indexOf(l);if(u<=0)return;const o=r.slice(0,u).trim(),i=r.slice(u+1).trim();o&&i&&(t[o]=i)}),t}function Gd(){const[e,t]=Y.useState(0),[n,r]=Y.useState($u),[l,u]=Y.useState(""),[o,i]=Y.useState(""),[s,c]=Y.useState(""),[h,v]=Y.useState(null),[p,k]=Y.useState(null),[S,E]=Y.useState("");Y.useEffect(()=>{wn("/api/config").then(m=>{const w=Yi(m.config);r(w),u(Xi(w.job_aliases))}).catch(m=>c(m.message))},[]),Y.useEffect(()=>{if(!S||(p==null?void 0:p.state)==="completed"||(p==null?void 0:p.state)==="failed")return;const m=window.setInterval(()=>{wn(`/api/runs/${S}`).then(w=>k(w.run)).catch(w=>c(w.message))},1200);return()=>window.clearInterval(m)},[S,p==null?void 0:p.state]);const M=Y.useMemo(()=>!p||!p.total?0:Math.round(p.current/p.total*100),[p]);function f(m,w){r(P=>({...P,[m]:w}))}function a(m,w){r(P=>({...P,model:{...P.model,[m]:w}}))}async function d(){c(""),i("");try{const m=await wn("/api/config",{method:"POST",body:JSON.stringify({...n,job_aliases:Xd(l)})}),w=Yi(m.config);r(w),u(Xi(w.job_aliases)),i("配置已保存到本地 config.yaml")}catch(m){c(m.message)}}async function y(){c("");try{const m=await wn("/api/precheck");v(m),t(2)}catch(m){c(m.message)}}async function _(){c("");try{const m=await wn("/api/runs",{method:"POST",body:"{}"});k(m.run),E(m.run.run_id),t(3)}catch(m){c(m.message)}}return x.jsxs("main",{className:"app-shell",children:[x.jsxs("section",{className:"topbar",children:[x.jsxs("div",{children:[x.jsx("p",{className:"eyebrow",children:"本地运行"}),x.jsx("h1",{children:"简历初筛工具"})]}),x.jsx("div",{className:"stepper",children:Yd.map((m,w)=>x.jsxs("button",{className:w===e?"active":"",onClick:()=>t(w),children:[w+1,". ",m]},m))})]}),o&&x.jsxs("div",{className:"notice success",children:[x.jsx(Hd,{size:18}),o]}),s&&x.jsxs("div",{className:"notice danger",children:[x.jsx(Bd,{size:18}),s]}),e===0&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Wd,{size:22}),"文件路径"]}),x.jsxs("label",{children:["简历文件夹",x.jsx("input",{value:n.resume_dir,onChange:m=>f("resume_dir",m.target.value)})]}),x.jsxs("label",{children:["岗位说明书",x.jsx("input",{value:n.job_book,onChange:m=>f("job_book",m.target.value)})]}),x.jsxs("label",{children:["招聘结果表",x.jsx("input",{value:n.result_book,onChange:m=>f("result_book",m.target.value)})]}),x.jsxs("label",{children:["岗位别名映射",x.jsx("textarea",{value:l,onChange:m=>u(m.target.value),placeholder:`后端开发工程师=全栈 +后端开发实习岗=全栈`})]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:d,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:()=>t(1),children:"下一步"})]})]}),e===1&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[x.jsx(Qd,{size:22}),"模型配置"]}),x.jsxs("label",{children:["API 地址",x.jsx("input",{value:n.model.base_url,onChange:m=>a("base_url",m.target.value)})]}),x.jsxs("label",{children:["API Key",x.jsx("input",{type:"password",value:n.model.api_key,onChange:m=>a("api_key",m.target.value)})]}),x.jsxs("label",{children:["模型名",x.jsx("input",{value:n.model.model,onChange:m=>a("model",m.target.value)})]}),x.jsxs("label",{children:["超时时间(秒)",x.jsx("input",{type:"number",value:n.model.timeout_seconds,onChange:m=>a("timeout_seconds",Number(m.target.value))})]}),x.jsxs("label",{className:"checkbox",children:[x.jsx("input",{type:"checkbox",checked:n.model.allow_without_model,onChange:m=>a("allow_without_model",m.target.checked)}),"模型不可用时允许进入待人工二筛兜底"]}),x.jsxs("div",{className:"actions",children:[x.jsxs("button",{onClick:d,children:[x.jsx(Ki,{size:18}),"保存配置"]}),x.jsx("button",{className:"primary",onClick:y,children:"运行预检"})]})]}),e===2&&x.jsxs("section",{className:"panel",children:[x.jsx("h2",{children:"运行前预检"}),!h&&x.jsx("button",{className:"primary",onClick:y,children:"开始预检"}),h&&x.jsxs("div",{className:"checks",children:[x.jsxs("div",{className:`summary ${h.status}`,children:["待处理文件:",h.resume_file_count," 个"]}),h.items.map(m=>x.jsxs("div",{className:`check ${m.status}`,children:[x.jsx("strong",{children:m.name}),x.jsx("span",{children:m.message})]},m.name)),x.jsxs("button",{className:"primary",onClick:_,children:[x.jsx(Qi,{size:18}),"开始筛选"]})]})]}),e===3&&x.jsxs("section",{className:"panel",children:[x.jsxs("h2",{children:[(p==null?void 0:p.state)==="running"?x.jsx(Kd,{className:"spin",size:22}):x.jsx(Qi,{size:22}),"开始筛选"]}),!p&&x.jsx("button",{className:"primary",onClick:_,children:"开始筛选"}),p&&x.jsxs(x.Fragment,{children:[x.jsx("div",{className:"progress",children:x.jsx("span",{style:{width:`${M}%`}})}),x.jsxs("p",{className:"muted",children:[p.current_file||"等待任务更新"," ",p.total?`${p.current}/${p.total}`:""]}),x.jsx("div",{className:"stats",children:Object.entries(p.stats||{}).map(([m,w])=>x.jsxs("div",{children:[x.jsx("strong",{children:w}),x.jsx("span",{children:m})]},m))}),x.jsx("div",{className:"logs",children:(p.logs||[]).map((m,w)=>x.jsx("p",{className:m.level,children:m.message},`${m.timestamp}-${w}`))}),p.error&&x.jsx("div",{className:"notice danger",children:p.error})]})]})]})}ic(document.getElementById("root")).render(x.jsx(Nc.StrictMode,{children:x.jsx(Gd,{})})); diff --git a/src/resume_screening/web/static/assets/index.css b/src/resume_screening/web/static/assets/index.css index 60f4193..00f8add 100644 --- a/src/resume_screening/web/static/assets/index.css +++ b/src/resume_screening/web/static/assets/index.css @@ -1 +1 @@ -: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 .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}} +:root{color:#172033;background:#f5f7fb;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}body{margin:0}button,input,textarea{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}textarea{width:100%;min-height:92px;border:1px solid #ccd4df;border-radius:8px;padding:10px 12px;resize:vertical}.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 .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}} diff --git a/tests/test_filename_parser.py b/tests/test_filename_parser.py index a311ed3..7875f8f 100644 --- a/tests/test_filename_parser.py +++ b/tests/test_filename_parser.py @@ -32,3 +32,27 @@ def test_parse_standard_filename_without_salary_marks_salary_missing() -> None: assert parsed.expected_location == "北京" assert parsed.salary_range == "" assert "岗位标注薪资" in parsed.missing_fields + + +def test_parse_underscore_filename_with_monthly_salary() -> None: + parsed = parse_resume_filename(Path("后端开发工程师_北京_12-18K_张松_3年.pdf")) + + assert parsed.is_standard is True + assert parsed.job_name == "后端开发工程师" + assert parsed.expected_location == "北京" + assert parsed.salary_range == "12-18K" + assert parsed.candidate_name == "张松" + assert parsed.work_experience == "3年" + assert parsed.missing_fields == [] + + +def test_parse_underscore_filename_with_daily_salary() -> None: + parsed = parse_resume_filename(Path("后端开发实习岗_北京_500-1000元_天_kanosa_一年以内.pdf")) + + assert parsed.is_standard is True + assert parsed.job_name == "后端开发实习岗" + assert parsed.expected_location == "北京" + assert parsed.salary_range == "500-1000元/天" + assert parsed.candidate_name == "kanosa" + assert parsed.work_experience == "一年以内" + assert parsed.missing_fields == [] diff --git a/tests/test_model_client.py b/tests/test_model_client.py index 47ac3f4..4aa8c9f 100644 --- a/tests/test_model_client.py +++ b/tests/test_model_client.py @@ -56,6 +56,68 @@ def test_parse_model_response_rejects_unknown_category() -> None: ) +def test_parse_model_response_accepts_common_chinese_score_keys_and_reason_list() -> None: + result = parse_model_response( + { + "category": "推荐初试", + "overall_score": 8.1, + "summary": "候选人具备后端开发经验。", + "screening_reason": ["硬性条件匹配", "项目经验相关"], + "missing_information": "", + "reject_reason": "", + "scores": { + "能力": 8, + "自我认知": 6, + "意愿": 7, + "学习能力": 8, + "岗位匹配度": 8.5, + "经验匹配": 8.5, + "技能匹配": 8, + "稳定性/风险": 7.8, + }, + } + ) + + assert result.screening_reason == "硬性条件匹配;项目经验相关" + assert result.missing_information == [] + assert result.scores.job_fit == 8.5 + assert result.scores.stability_risk == 7.8 + + +def test_parse_model_response_accepts_observed_alternate_score_keys() -> None: + result = parse_model_response( + { + "category": "推荐初试", + "overall_score": 8.5, + "summary": "候选人3年Java后端经验。", + "screening_reason": ["工作年限匹配", "后端核心技术匹配"], + "missing_information": ["薪资期望需确认"], + "reject_reason": "", + "scores": { + "experience_match": 8.5, + "skill_match": 9.0, + "project_depth": 8.8, + "education_match": 8.0, + "communication_language": 7.5, + "stability_risk": 8.2, + "location_match": 8.5, + "job_info_completeness": 5.5, + }, + } + ) + + assert result.category == "推荐初试" + assert result.scores.ability == 8.8 + assert result.scores.learning_ability == 8.8 + assert result.scores.experience_fit == 8.5 + assert result.scores.skill_fit == 9.0 + assert result.scores.stability_risk == 8.2 + assert result.scores.ego == 8.5 + assert result.scores.desire == 8.5 + assert result.scores.job_fit == 8.67 + assert any("自我认知" in item for item in result.missing_information) + + def test_build_prompt_replaces_invalid_unicode_surrogates() -> None: client = ModelClient( ModelConfig( diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index c0420a5..1422973 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -183,3 +183,134 @@ def evaluate(self, resume_text, job, filename_metadata) -> ScreeningResult: sheet = workbook["多维表格"] assert sheet["B2"].value == "推荐初试" assert sheet["G2"].value == "模型已评估" + + +def test_pipeline_uses_model_for_incomplete_job_when_model_is_configured(tmp_path: Path, monkeypatch) -> None: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + resume = resume_dir / "财务总监_北京_18-28K_郭燕婷_10年以上.docx" + from docx import Document + + 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) + + class FakeModelClient: + def __init__(self, config) -> None: + self.config = config + + def evaluate(self, resume_text, job, filename_metadata) -> ScreeningResult: + assert job.sheet_name == "财务总监" + assert filename_metadata["job_name"] == "财务总监" + return ScreeningResult( + category="可考虑", + overall_score=7.5, + summary="模型已按现有岗位信息评估", + screening_reason="岗位信息不完整,但候选人经验有一定匹配度。", + missing_information=["模型认为仍需补充管理半径"], + reject_reason="", + scores=ScreeningScores(8, 6, 7, 7, 7.5, 8, 7, 6), + ) + + monkeypatch.setattr("resume_screening.pipeline.ModelClient", FakeModelClient) + 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": "https://api.example.com/v1", + "api_key": "test-key", + "model": "screening-model", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": False, + }, + } + ) + + stats = ScreeningPipeline(config).run() + + assert stats.processed == 1 + assert stats.consider == 1 + assert stats.manual == 0 + workbook = load_workbook(result_book) + sheet = workbook["多维表格"] + assert sheet["B2"].value == "可考虑" + assert sheet["G2"].value == "模型已按现有岗位信息评估" + assert "岗位要求不完整" in sheet["P2"].value + assert "模型认为仍需补充管理半径" in sheet["P2"].value + + +def test_pipeline_uses_configured_job_alias_for_model_evaluation(tmp_path: Path, monkeypatch) -> None: + resume_dir = tmp_path / "resumes" + resume_dir.mkdir() + resume = resume_dir / "后端开发工程师_北京_12-18K_张松_3年.docx" + from docx import Document + + document = Document() + document.add_paragraph("张松,3年后端开发经验。") + document.save(resume) + job_book = tmp_path / "jobs.xlsx" + result_book = tmp_path / "result.xlsx" + workbook = Workbook() + template = workbook.active + template.title = "模板" + job = workbook.create_sheet("全栈") + job.append(["岗位名称", "全栈工程师", "学历", "本科"]) + job.append(["工作经验", "3年以上", "具体描述", "负责后端和前端开发"]) + job.append(["1. 核心职责", "服务端开发、接口设计、系统稳定性"]) + workbook.save(job_book) + create_result_book(result_book) + + class FakeModelClient: + def __init__(self, config) -> None: + self.config = config + + def evaluate(self, resume_text, job, filename_metadata) -> ScreeningResult: + assert job.sheet_name == "全栈" + assert filename_metadata["job_name"] == "后端开发工程师" + assert filename_metadata["matched_job_sheet"] == "全栈" + return ScreeningResult( + category="推荐初试", + overall_score=8.2, + summary="模型已按别名岗位评估", + screening_reason="后端经验与全栈岗位服务端要求匹配。", + missing_information=[], + reject_reason="", + scores=ScreeningScores(8, 6, 8, 8, 8, 8, 8, 7), + ) + + monkeypatch.setattr("resume_screening.pipeline.ModelClient", FakeModelClient) + config = AppConfig.model_validate( + { + "resume_dir": resume_dir, + "job_book": job_book, + "result_book": result_book, + "index_path": tmp_path / "processed_index.json", + "job_aliases": {"后端开发工程师": "全栈"}, + "model": { + "provider": "openai-compatible", + "base_url": "https://api.example.com/v1", + "api_key": "test-key", + "model": "screening-model", + "timeout_seconds": 60, + "temperature": 0.1, + "allow_without_model": False, + }, + } + ) + + stats = ScreeningPipeline(config).run() + + assert stats.recommend == 1 + workbook = load_workbook(result_book) + sheet = workbook["多维表格"] + assert sheet["E2"].value == "后端开发工程师" + assert "岗位别名映射:后端开发工程师 -> 全栈" in sheet["P2"].value diff --git a/tests/test_web_precheck.py b/tests/test_web_precheck.py index e2f54d3..65f6d83 100644 --- a/tests/test_web_precheck.py +++ b/tests/test_web_precheck.py @@ -62,3 +62,19 @@ def test_precheck_fails_when_result_book_is_missing(tmp_path: Path) -> None: assert result.status == "fail" assert any(item.name == "招聘结果表" and item.status == "fail" for item in result.items) + + +def test_precheck_warns_when_resume_jobs_do_not_match_job_sheets(tmp_path: Path) -> None: + config = make_config(tmp_path) + for file_path in config.resume_dir.rglob("*"): + if file_path.is_file(): + file_path.unlink() + (config.resume_dir / "后端开发工程师_北京_12-18K_张松_3年.pdf").write_text("fake", encoding="utf-8") + + result = run_precheck(config) + + assert result.status == "warning" + assert any( + item.name == "岗位匹配" and item.status == "warning" and "后端开发工程师" in item.message + for item in result.items + ) diff --git a/web/src/App.jsx b/web/src/App.jsx index 8334a7e..ba9d320 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -7,6 +7,7 @@ const emptyConfig = { resume_dir: "", job_book: "", result_book: "", + job_aliases: {}, default_source_channel: "", default_interviewer: "", index_path: "data/processed_index.json", @@ -38,9 +39,29 @@ function mergeConfig(config) { return { ...emptyConfig, ...config, model: { ...emptyConfig.model, ...config.model } }; } +function formatJobAliases(aliases = {}) { + return Object.entries(aliases).map(([source, target]) => `${source}=${target}`).join("\n"); +} + +function parseJobAliases(text) { + const aliases = {}; + text.split(/\r?\n/).forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return; + const separator = trimmed.includes("=") ? "=" : ":"; + const index = trimmed.indexOf(separator); + if (index <= 0) return; + const source = trimmed.slice(0, index).trim(); + const target = trimmed.slice(index + 1).trim(); + if (source && target) aliases[source] = target; + }); + return aliases; +} + export function App() { const [step, setStep] = useState(0); const [config, setConfig] = useState(emptyConfig); + const [jobAliasText, setJobAliasText] = useState(""); const [notice, setNotice] = useState(""); const [error, setError] = useState(""); const [precheck, setPrecheck] = useState(null); @@ -49,7 +70,11 @@ export function App() { useEffect(() => { requestJson("/api/config") - .then((body) => setConfig(mergeConfig(body.config))) + .then((body) => { + const merged = mergeConfig(body.config); + setConfig(merged); + setJobAliasText(formatJobAliases(merged.job_aliases)); + }) .catch((exc) => setError(exc.message)); }, []); @@ -80,8 +105,10 @@ export function App() { setError(""); setNotice(""); try { - const body = await requestJson("/api/config", { method: "POST", body: JSON.stringify(config) }); - setConfig(mergeConfig(body.config)); + const body = await requestJson("/api/config", { method: "POST", body: JSON.stringify({ ...config, job_aliases: parseJobAliases(jobAliasText) }) }); + const merged = mergeConfig(body.config); + setConfig(merged); + setJobAliasText(formatJobAliases(merged.job_aliases)); setNotice("配置已保存到本地 config.yaml"); } catch (exc) { setError(exc.message); @@ -136,6 +163,7 @@ export function App() { +