From 7c8732b7661f698c1c653e6dca9ca0962b081f7d Mon Sep 17 00:00:00 2001 From: ivrat24 <3240103100@zju.edu.cn> Date: Thu, 9 Jul 2026 19:22:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=20AI=20=E6=8E=92?= =?UTF-8?q?=E7=8F=AD=E6=9C=8D=E5=8A=A1=E3=80=81=E5=9C=BA=E5=9C=B0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E3=80=81=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B=E4=B8=8E?= =?UTF-8?q?=E5=91=A8=E6=AC=A1=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + main.py | 3 + misc/ai_schedule_service.py | 236 ++++++++ misc/llm_config_store.py | 59 ++ misc/llm_scheduler.py | 367 ++++++++++++ misc/llm_usage_store.py | 62 ++ misc/schedule_conflict_analyzer.py | 400 +++++++++++++ misc/schema_upgrade.py | 18 + misc/week_calendar.py | 47 ++ models/interview.py | 1 + models/venue.py | 19 + routes/interview.py | 914 +++++++++++++---------------- routes/recruit.py | 218 +++++++ 13 files changed, 1832 insertions(+), 514 deletions(-) create mode 100644 misc/ai_schedule_service.py create mode 100644 misc/llm_config_store.py create mode 100644 misc/llm_scheduler.py create mode 100644 misc/llm_usage_store.py create mode 100644 misc/schedule_conflict_analyzer.py create mode 100644 misc/schema_upgrade.py create mode 100644 misc/week_calendar.py create mode 100644 models/venue.py diff --git a/.gitignore b/.gitignore index 259cdda..bf26774 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,8 @@ uploads/images/* test/* .env +data/llm_config.json +data/llm_usage_log.json .vscode/* .idea/* __pycache__/* diff --git a/main.py b/main.py index f059810..3604446 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,9 @@ from fastapi import FastAPI from misc.model import create_admin +from misc.schema_upgrade import ensure_schema_updates from models import Base, engine +from models import venue # noqa: F401 — register InterviewVenue table from routes import init_app_routes @@ -10,4 +12,5 @@ init_app_routes(app) Base.metadata.create_all(bind=engine) +ensure_schema_updates() create_admin() \ No newline at end of file diff --git a/misc/ai_schedule_service.py b/misc/ai_schedule_service.py new file mode 100644 index 0000000..90c7e25 --- /dev/null +++ b/misc/ai_schedule_service.py @@ -0,0 +1,236 @@ +"""基于纳新问卷数据的 AI 面试排班服务。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + +from sqlalchemy.orm import Session + +from misc.llm_scheduler import generate_schedule_via_llm +from misc.llm_usage_store import append_usage_record +from misc.schedule_conflict_analyzer import ( + analyze_interview_format_feasibility, + build_unscheduled_details, + fill_unscheduled_candidates, +) +from models.recruit import Recruitment +from models.interview import Interview + + +def collect_pending_candidates(db: Session) -> List[Recruitment]: + all_candidates = db.query(Recruitment).filter( + Recruitment.interview_time_slots.isnot(None), + Recruitment.interview_time_slots != "", + Recruitment.interview_status.in_(["first_round", "second_round"]), + ).all() + + pending = [] + for candidate in all_candidates: + exists = db.query(Interview).filter( + Interview.uid == candidate.uid, + Interview.stage == candidate.interview_status, + ).first() + if not exists: + pending.append(candidate) + return pending + + +def execute_ai_schedule( + db: Session, + *, + base_date: str, + max_candidates_per_slot: int, + interview_format: str = "one_to_one", + selected_venues: Optional[List[str]] = None, + model_type: str, + api_base_url: str, + api_key: str, + model_name: str, + calculate_slot_date_fn: Callable[..., datetime], + persist_schedule_fn: Callable[..., List[Any]], + generate_csv_fn: Callable[..., str], +) -> Dict[str, Any]: + candidates = collect_pending_candidates(db) + + if not candidates: + return { + "success": False, + "message": "没有找到需要排班的纳新者(可能均已排班或未填写可面试时间)", + "total_candidates": 0, + "scheduled_candidates": 0, + "unscheduled_candidates": 0, + "schedule_details": [], + "venue_assignments": {}, + "created_count": 0, + "token_usage": None, + "format_recommendation": None, + "format_conflict": None, + "unscheduled_details": [], + "fallback_scheduled_count": 0, + } + + venue_count = len([v for v in (selected_venues or []) if v]) or 1 + format_analysis = analyze_interview_format_feasibility( + candidates, + interview_format=interview_format, + max_candidates_per_slot=max_candidates_per_slot, + venue_count=venue_count, + ) + if interview_format in ("one_to_one", "one_to_many") and format_analysis["suggest_many_to_many"]: + return { + "success": False, + "message": format_analysis["message"], + "total_candidates": len(candidates), + "scheduled_candidates": 0, + "unscheduled_candidates": len(candidates), + "schedule_details": [], + "venue_assignments": {}, + "created_count": 0, + "token_usage": None, + "format_recommendation": "many_to_many", + "format_conflict": format_analysis, + "unscheduled_details": build_unscheduled_details(candidates), + "fallback_scheduled_count": 0, + } + if interview_format in ("one_to_one", "one_to_many") and not format_analysis["feasible"]: + return { + "success": False, + "message": format_analysis["message"], + "total_candidates": len(candidates), + "scheduled_candidates": 0, + "unscheduled_candidates": len(candidates), + "schedule_details": [], + "venue_assignments": {}, + "created_count": 0, + "token_usage": None, + "format_recommendation": format_analysis.get("format_recommendation"), + "format_conflict": format_analysis, + "unscheduled_details": build_unscheduled_details(candidates), + "fallback_scheduled_count": 0, + } + + llm_result = generate_schedule_via_llm( + candidates=candidates, + base_date=base_date, + max_candidates_per_slot=max_candidates_per_slot, + model_type=model_type, + api_base_url=api_base_url, + api_key=api_key, + model_name=model_name, + calculate_slot_date_fn=calculate_slot_date_fn, + available_venues=selected_venues, + ) + + schedule_results = llm_result["schedule_results"] + token_usage = llm_result.get("token_usage") or {} + if token_usage: + append_usage_record( + model_name=llm_result.get("model_used") or model_name, + prompt_tokens=token_usage.get("prompt_tokens", 0), + completion_tokens=token_usage.get("completion_tokens", 0), + total_tokens=token_usage.get("total_tokens", 0), + candidate_count=len(candidates), + scheduled_count=len(schedule_results), + ) + + fallback_results, unscheduled_candidates = fill_unscheduled_candidates( + candidates, + schedule_results, + interview_format=interview_format, + max_candidates_per_slot=max_candidates_per_slot, + venue_count=venue_count, + venues=selected_venues or [], + base_date=base_date, + calculate_slot_date_fn=calculate_slot_date_fn, + ) + fallback_count = len(fallback_results) + if fallback_results: + schedule_results = schedule_results + fallback_results + + if not schedule_results: + failure_payload = { + "success": False, + "message": "大模型未生成有效排班方案,且系统自动补排也未能安排任何同学,请检查 API 配置或调整参数", + "total_candidates": len(candidates), + "scheduled_candidates": 0, + "unscheduled_candidates": len(unscheduled_candidates), + "schedule_details": [], + "venue_assignments": llm_result["venue_assignments"], + "llm_summary": llm_result.get("llm_summary"), + "model_used": llm_result.get("model_used"), + "created_count": 0, + "token_usage": token_usage, + "format_recommendation": None, + "format_conflict": None, + "unscheduled_details": build_unscheduled_details(unscheduled_candidates), + "fallback_scheduled_count": 0, + } + if interview_format in ("one_to_one", "one_to_many") and format_analysis["suggest_many_to_many"]: + failure_payload["format_recommendation"] = "many_to_many" + failure_payload["format_conflict"] = format_analysis + failure_payload["message"] = ( + f"{failure_payload['message']} {format_analysis['message']}" + ).strip() + return failure_payload + + created = persist_schedule_fn(db, schedule_results, base_date, interview_format) + db.commit() + + schedule_details = [] + for item in schedule_results: + interview_date = item["interview_date"] + schedule_details.append({ + "uid": item["uid"], + "name": item["name"], + "time_slot": item["time_slot"], + "display_slot": item["display_slot"], + "interview_date": interview_date.isoformat() if isinstance(interview_date, datetime) else interview_date, + "venue": item["venue"], + "interview_format": interview_format, + "is_pinned": False, + "position": f"{item['candidate_index']}/{item['total_in_slot']}", + "week": item["week"], + }) + schedule_details.sort( + key=lambda row: row["interview_date"] if isinstance(row["interview_date"], str) else str(row["interview_date"]) + ) + + unscheduled_count = len(unscheduled_candidates) + result_message = ( + f"AI 排班完成!方案 {len(schedule_results)} 人," + f"实际写入 {len(created)} 条,未排班 {unscheduled_count} 人" + ) + if fallback_count > 0: + result_message = ( + f"{result_message}(其中 {fallback_count} 人由系统自动补排)" + ) + format_recommendation = None + format_conflict = None + if ( + unscheduled_count > 0 + and interview_format in ("one_to_one", "one_to_many") + and format_analysis["suggest_many_to_many"] + ): + format_recommendation = "many_to_many" + format_conflict = format_analysis + result_message = f"{result_message}。{format_analysis['message']}" + + return { + "success": unscheduled_count == 0, + "message": result_message, + "total_candidates": len(candidates), + "scheduled_candidates": len(schedule_results), + "unscheduled_candidates": unscheduled_count, + "schedule_details": schedule_details, + "venue_assignments": llm_result["venue_assignments"], + "csv_file_path": generate_csv_fn(schedule_results, base_date, interview_format), + "llm_summary": llm_result.get("llm_summary"), + "model_used": llm_result.get("model_used"), + "created_count": len(created), + "token_usage": token_usage, + "format_recommendation": format_recommendation, + "format_conflict": format_conflict, + "unscheduled_details": build_unscheduled_details(unscheduled_candidates), + "fallback_scheduled_count": fallback_count, + } diff --git a/misc/llm_config_store.py b/misc/llm_config_store.py new file mode 100644 index 0000000..2db5439 --- /dev/null +++ b/misc/llm_config_store.py @@ -0,0 +1,59 @@ +"""管理员大模型配置持久化(不含敏感信息对外暴露完整 key)。""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Optional + +CONFIG_PATH = Path("data/llm_config.json") + + +def _ensure_dir() -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + + +def mask_api_key(api_key: str) -> str: + if not api_key: + return "" + if len(api_key) <= 8: + return "*" * len(api_key) + return f"{'*' * (len(api_key) - 4)}{api_key[-4:]}" + + +def load_llm_config() -> Dict[str, Any]: + if not CONFIG_PATH.exists(): + return {} + try: + return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def save_llm_config( + model_type: str, + api_base_url: str, + model_name: str, + api_key: Optional[str] = None, +) -> Dict[str, Any]: + _ensure_dir() + current = load_llm_config() + payload = { + "model_type": model_type, + "api_base_url": api_base_url, + "model_name": model_name, + "api_key": api_key if api_key else current.get("api_key", ""), + } + CONFIG_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return public_llm_config(payload) + + +def public_llm_config(raw: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + data = raw or load_llm_config() + return { + "model_type": data.get("model_type", "deepseek"), + "api_base_url": data.get("api_base_url", ""), + "model_name": data.get("model_name", ""), + "api_key_masked": mask_api_key(data.get("api_key", "")), + "has_api_key": bool(data.get("api_key")), + } diff --git a/misc/llm_scheduler.py b/misc/llm_scheduler.py new file mode 100644 index 0000000..4d40960 --- /dev/null +++ b/misc/llm_scheduler.py @@ -0,0 +1,367 @@ +"""大模型驱动的面试排班服务。""" + +from __future__ import annotations + +import json +import re +from collections import defaultdict +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +import requests + +from misc.week_calendar import build_display_slot + +MODEL_PRESETS: Dict[str, Dict[str, str]] = { + "openai": { + "label": "OpenAI", + "api_base_url": "https://api.openai.com/v1", + "default_model": "gpt-4o", + }, + "deepseek": { + "label": "DeepSeek", + "api_base_url": "https://api.deepseek.com/v1", + "default_model": "deepseek-chat", + }, + "qwen": { + "label": "通义千问", + "api_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "default_model": "qwen-plus", + }, + "moonshot": { + "label": "Moonshot (Kimi)", + "api_base_url": "https://api.moonshot.cn/v1", + "default_model": "moonshot-v1-8k", + }, + "zhipu": { + "label": "智谱 AI", + "api_base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_model": "glm-4-flash", + }, + "custom": { + "label": "自定义 (OpenAI 兼容)", + "api_base_url": "", + "default_model": "", + }, +} + + +def normalize_time_slot(slot: str) -> str: + """将 `周一_19` 转为 `周一 19:00-20:00`。""" + slot = (slot or "").strip() + if not slot: + return slot + if " " in slot: + return slot + if "_" in slot: + day, hour = slot.split("_", 1) + h = int(hour) + return f"{day} {h:02d}:00-{h + 1:02d}:00" + return slot + + +def format_time_slots(slots: List[str]) -> List[str]: + return [normalize_time_slot(s) for s in slots if s] + + +def extract_json_object(text: str) -> Dict[str, Any]: + text = (text or "").strip() + if not text: + raise ValueError("大模型返回内容为空") + + fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE) + if fenced: + text = fenced.group(1).strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError("无法从大模型响应中解析 JSON") + return json.loads(text[start : end + 1]) + + +def build_scheduling_prompt( + candidates: List[Dict[str, Any]], + base_date: str, + max_candidates_per_slot: int, + available_slots: List[str], + available_venues: Optional[List[str]] = None, +) -> str: + venues = [v for v in (available_venues or []) if v] or ["场地A"] + payload = { + "base_date": base_date, + "max_candidates_per_slot": max_candidates_per_slot, + "available_time_slots": available_slots, + "available_venues": venues, + "candidates": candidates, + "rules": [ + "每位面试者只能安排在一个时间段", + "必须优先安排在其 preferred_slots 中的时段", + "week=0 表示 base_date 起该星期的第一次可选时段,week=1 表示再往后一周", + "展示标签将按实际日历周自动显示为本周/下周/下下周", + "同一 time_slot + week 组合的人数不得超过 max_candidates_per_slot", + f"venue 必须从 available_venues 中选择: {', '.join(venues)}", + "尽量在不同场地之间均衡分配人数", + "无法安排的面试者放入 unscheduled_uids", + ], + "output_schema": { + "schedules": [ + { + "uid": "学号", + "time_slot": "周一 19:00-20:00", + "week": 0, + "venue": "场地A", + } + ], + "unscheduled_uids": ["学号"], + "summary": "排班说明", + }, + } + return ( + "你是高校社团面试排班助手。请根据候选人可参加面试时间段,生成公平、可执行的排班方案。\n" + "请只输出 JSON,不要输出 Markdown 说明。\n\n" + f"{json.dumps(payload, ensure_ascii=False, indent=2)}" + ) + + +def call_llm_chat_completion( + api_base_url: str, + api_key: str, + model_name: str, + prompt: str, + timeout: int = 180, +) -> Tuple[str, Dict[str, int]]: + base = api_base_url.rstrip("/") + if base.endswith("/chat/completions"): + url = base + else: + url = f"{base}/chat/completions" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + body = { + "model": model_name, + "messages": [ + { + "role": "system", + "content": "你是面试排班专家,擅长在满足候选人时间偏好的前提下做负载均衡排班。", + }, + {"role": "user", "content": prompt}, + ], + "temperature": 0.2, + } + + for use_json_mode in (True, False): + request_body = dict(body) + if use_json_mode: + request_body["response_format"] = {"type": "json_object"} + + try: + response = requests.post(url, headers=headers, json=request_body, timeout=timeout) + except requests.RequestException as exc: + raise ValueError(f"调用大模型 API 失败: {exc}") from exc + + if response.status_code >= 400: + if use_json_mode: + continue + detail = response.text[:500] + raise ValueError(f"大模型 API 返回错误 ({response.status_code}): {detail}") + + data = response.json() + usage_raw = data.get("usage") or {} + usage = { + "prompt_tokens": int(usage_raw.get("prompt_tokens") or 0), + "completion_tokens": int(usage_raw.get("completion_tokens") or 0), + "total_tokens": int(usage_raw.get("total_tokens") or 0), + } + try: + return data["choices"][0]["message"]["content"], usage + except (KeyError, IndexError, TypeError) as exc: + raise ValueError(f"大模型响应格式异常: {data}") from exc + + detail = response.text[:500] if "response" in locals() else "unknown error" + raise ValueError(f"大模型 API 返回错误: {detail}") + + +def validate_and_build_schedule_results( + llm_result: Dict[str, Any], + candidates_by_uid: Dict[str, Any], + base_date: str, + max_candidates_per_slot: int, + calculate_slot_date_fn, + allowed_venues: Optional[List[str]] = None, +) -> Tuple[List[Dict[str, Any]], List[Any], Dict[str, int], Dict[str, List[str]]]: + venues = [v for v in (allowed_venues or []) if v] or ["场地A"] + schedules = llm_result.get("schedules") or [] + unscheduled_uids = set(llm_result.get("unscheduled_uids") or []) + + schedule_results: List[Dict[str, Any]] = [] + slot_counts: Dict[str, int] = defaultdict(int) + slot_members: Dict[str, List[str]] = defaultdict(list) + assigned_uids = set() + + for item in schedules: + uid = str(item.get("uid", "")).strip() + if not uid or uid not in candidates_by_uid: + continue + + candidate = candidates_by_uid[uid] + time_slot = normalize_time_slot(str(item.get("time_slot", "")).strip()) + if not time_slot: + unscheduled_uids.add(uid) + continue + + try: + week = int(item.get("week", 0)) + except (TypeError, ValueError): + week = 0 + week = 0 if week < 0 else 1 if week > 0 else 0 + + preferences = format_time_slots( + json.loads(candidate.interview_time_slots) + if isinstance(candidate.interview_time_slots, str) + else (candidate.interview_time_slots or []) + ) + if time_slot not in preferences: + unscheduled_uids.add(uid) + continue + + slot_key = f"{time_slot}_week_{week}" + if slot_counts[slot_key] >= max_candidates_per_slot: + unscheduled_uids.add(uid) + continue + + if uid in assigned_uids: + continue + + slot_counts[slot_key] += 1 + assigned_uids.add(uid) + slot_members[slot_key].append(uid) + + interview_date = calculate_slot_date_fn(base_date, time_slot, week) + display_slot = build_display_slot(base_date, time_slot, interview_date) + venue = (item.get("venue") or venues[0]).strip() or venues[0] + if venue not in venues: + venue = venues[slot_counts[slot_key] % len(venues)] + + schedule_results.append( + { + "uid": uid, + "name": candidate.name, + "time_slot": slot_key, + "display_slot": display_slot, + "interview_date": interview_date, + "venue": venue, + "candidate_index": slot_counts[slot_key], + "total_in_slot": 0, + "week": week, + } + ) + + for result in schedule_results: + result["total_in_slot"] = slot_counts[result["time_slot"]] + + unscheduled_candidates = [ + candidates_by_uid[uid] + for uid in unscheduled_uids + if uid in candidates_by_uid and uid not in assigned_uids + ] + + venue_assignments: Dict[str, List[str]] = defaultdict(list) + for result in schedule_results: + venue_assignments[result["time_slot"]].append(result["venue"]) + venue_assignments = {key: sorted(set(values)) for key, values in venue_assignments.items()} + return schedule_results, unscheduled_candidates, dict(slot_counts), venue_assignments + + +def generate_schedule_via_llm( + candidates: List[Any], + base_date: str, + max_candidates_per_slot: int, + model_type: str, + api_base_url: str, + api_key: str, + model_name: str, + calculate_slot_date_fn, + available_slots: Optional[List[str]] = None, + available_venues: Optional[List[str]] = None, +) -> Dict[str, Any]: + datetime.strptime(base_date, "%Y-%m-%d") + + preset = MODEL_PRESETS.get(model_type, MODEL_PRESETS["custom"]) + resolved_base_url = (api_base_url or preset.get("api_base_url") or "").strip() + resolved_model = (model_name or preset.get("default_model") or "").strip() + + if not resolved_base_url: + raise ValueError("请填写 API Base URL") + if not resolved_model: + raise ValueError("请填写模型名称") + if not api_key: + raise ValueError("请填写 API Key") + + candidates_by_uid = {c.uid: c for c in candidates} + candidate_payload = [] + slot_set = set(available_slots or []) + + for candidate in candidates: + raw_slots = ( + json.loads(candidate.interview_time_slots) + if isinstance(candidate.interview_time_slots, str) + else (candidate.interview_time_slots or []) + ) + formatted_slots = format_time_slots(raw_slots) + slot_set.update(formatted_slots) + candidate_payload.append( + { + "uid": candidate.uid, + "name": candidate.name, + "major_name": candidate.major_name, + "grade": candidate.grade, + "interview_status": candidate.interview_status, + "preferred_slots": formatted_slots, + } + ) + + venues = [v for v in (available_venues or []) if v] + + prompt = build_scheduling_prompt( + candidates=candidate_payload, + base_date=base_date, + max_candidates_per_slot=max_candidates_per_slot, + available_slots=sorted(slot_set), + available_venues=venues, + ) + + llm_text, token_usage = call_llm_chat_completion( + api_base_url=resolved_base_url, + api_key=api_key, + model_name=resolved_model, + prompt=prompt, + ) + llm_result = extract_json_object(llm_text) + + schedule_results, unscheduled, time_slot_counts, venue_assignments = ( + validate_and_build_schedule_results( + llm_result=llm_result, + candidates_by_uid=candidates_by_uid, + base_date=base_date, + max_candidates_per_slot=max_candidates_per_slot, + calculate_slot_date_fn=calculate_slot_date_fn, + allowed_venues=venues, + ) + ) + + return { + "schedule_results": schedule_results, + "unscheduled": unscheduled, + "time_slot_counts": time_slot_counts, + "venue_assignments": venue_assignments, + "llm_summary": llm_result.get("summary", ""), + "model_used": resolved_model, + "token_usage": token_usage, + } diff --git a/misc/llm_usage_store.py b/misc/llm_usage_store.py new file mode 100644 index 0000000..98beda9 --- /dev/null +++ b/misc/llm_usage_store.py @@ -0,0 +1,62 @@ +"""AI 排班 Token 使用记录持久化。""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +USAGE_LOG_PATH = Path("data/llm_usage_log.json") +MAX_ENTRIES = 200 + + +def _ensure_dir() -> None: + USAGE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + + +def load_usage_log() -> List[Dict[str, Any]]: + if not USAGE_LOG_PATH.exists(): + return [] + try: + data = json.loads(USAGE_LOG_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + +def append_usage_record( + *, + model_name: str, + prompt_tokens: int, + completion_tokens: int, + total_tokens: int, + candidate_count: int = 0, + scheduled_count: int = 0, +) -> Dict[str, Any]: + _ensure_dir() + records = load_usage_log() + entry = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "model_name": model_name, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "candidate_count": candidate_count, + "scheduled_count": scheduled_count, + } + records.append(entry) + if len(records) > MAX_ENTRIES: + records = records[-MAX_ENTRIES:] + USAGE_LOG_PATH.write_text( + json.dumps(records, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return entry + + +def get_usage_history(limit: int = 50) -> List[Dict[str, Any]]: + records = load_usage_log() + if limit <= 0: + return records + return records[-limit:] diff --git a/misc/schedule_conflict_analyzer.py b/misc/schedule_conflict_analyzer.py new file mode 100644 index 0000000..0ff1622 --- /dev/null +++ b/misc/schedule_conflict_analyzer.py @@ -0,0 +1,400 @@ +"""面试形式可行性分析:检测一对一/一对多下是否存在不可避免的时间冲突。""" + +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from misc.llm_scheduler import format_time_slots +from misc.week_calendar import build_display_slot + + +SlotWeek = Tuple[str, int] + + +def slot_week_key(slot: str, week: int) -> str: + return f"{slot}_week_{week}" + + +def parse_slot_week_key(slot_key: str) -> Optional[SlotWeek]: + if "_week_" not in slot_key: + return None + slot, week_raw = slot_key.rsplit("_week_", 1) + try: + week = int(week_raw) + except ValueError: + return None + week = 0 if week < 0 else 1 if week > 0 else 0 + return slot, week + + +def get_effective_slot_capacity( + interview_format: str, + max_candidates_per_slot: int, + venue_count: int, +) -> int: + """单个 (时间段, 周次) 在指定面试形式下的最大可容纳人数。""" + venues = max(1, venue_count) + max_per = max(1, max_candidates_per_slot) + if interview_format == "one_to_one": + return venues + if interview_format == "one_to_many": + return max_per + if interview_format == "many_to_many": + return max_per * venues + return max_per + + +def _max_flow_assign_candidates( + candidate_options: Dict[str, List[SlotWeek]], + slot_capacities: Dict[SlotWeek, int], +) -> Dict[str, SlotWeek]: + """最大流求解:为候选人在剩余时段容量内分配具体时段。""" + if not candidate_options: + return {} + + active_capacities = {key: cap for key, cap in slot_capacities.items() if cap > 0} + if not active_capacities: + return {} + + slot_week_keys = sorted(active_capacities.keys()) + slot_index = {key: idx for idx, key in enumerate(slot_week_keys)} + uids = sorted(candidate_options.keys()) + + source = 0 + candidate_base = 1 + slot_base = candidate_base + len(uids) + sink = slot_base + len(slot_week_keys) + node_count = sink + 1 + + graph: List[List[List[int]]] = [[] for _ in range(node_count)] + + def add_edge(frm: int, to: int, cap: int) -> None: + graph[frm].append([to, len(graph[to]), cap]) + graph[to].append([frm, len(graph[frm]) - 1, 0]) + + for idx, uid in enumerate(uids): + candidate_node = candidate_base + idx + add_edge(source, candidate_node, 1) + seen_slots = set() + for slot_key in candidate_options[uid]: + if slot_key in seen_slots or slot_key not in slot_index: + continue + seen_slots.add(slot_key) + slot_node = slot_base + slot_index[slot_key] + add_edge(candidate_node, slot_node, 1) + + for slot_idx, slot_key in enumerate(slot_week_keys): + add_edge(slot_base + slot_idx, sink, active_capacities[slot_key]) + + assignments: Dict[str, SlotWeek] = {} + while True: + parent = [-1] * node_count + parent_edge: List[Optional[List[int]]] = [None] * node_count + parent[source] = source + queue = [source] + head = 0 + while head < len(queue) and parent[sink] == -1: + node = queue[head] + head += 1 + for edge in graph[node]: + to_node, _, capacity = edge + if parent[to_node] == -1 and capacity > 0: + parent[to_node] = node + parent_edge[to_node] = edge + queue.append(to_node) + + if parent[sink] == -1: + break + + matched_uid: Optional[str] = None + matched_slot: Optional[SlotWeek] = None + node = sink + while node != source: + prev = parent[node] + edge = parent_edge[node] + assert edge is not None + if candidate_base <= prev < slot_base and slot_base <= node < sink: + matched_uid = uids[prev - candidate_base] + matched_slot = slot_week_keys[node - slot_base] + edge[2] -= 1 + rev_idx = edge[1] + graph[node][rev_idx][2] += 1 + node = prev + + if matched_uid and matched_slot: + assignments[matched_uid] = matched_slot + + return assignments + + +def _max_flow_assignable_count( + candidate_options: Dict[str, List[SlotWeek]], + slot_capacity: int, +) -> int: + """用最大流计算在当前容量下最多能排多少人。""" + if not candidate_options: + return 0 + + slot_week_keys = sorted({key for opts in candidate_options.values() for key in opts}) + if not slot_week_keys: + return 0 + + slot_capacities = {key: slot_capacity for key in slot_week_keys} + return len(_max_flow_assign_candidates(candidate_options, slot_capacities)) + + +def _parse_candidate_preferred_slots(candidate: Any) -> List[str]: + raw = candidate.interview_time_slots + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, TypeError): + parsed = [] + else: + parsed = raw or [] + if not isinstance(parsed, list): + return [] + return format_time_slots([str(item) for item in parsed if item]) + + +def _build_candidate_slot_options(preferred_slots: Sequence[str]) -> List[SlotWeek]: + options: List[SlotWeek] = [] + for slot in preferred_slots: + options.append((slot, 0)) + options.append((slot, 1)) + return options + + +def _collect_conflict_slots( + candidate_options: Dict[str, List[SlotWeek]], + candidates_by_uid: Dict[str, Any], + slot_capacity: int, +) -> List[Dict[str, Any]]: + """找出需求明显超过单时段容量的热点冲突。""" + slot_demand: Dict[str, List[str]] = defaultdict(list) + for uid, options in candidate_options.items(): + seen_slots = set() + for slot, _week in options: + if slot in seen_slots: + continue + seen_slots.add(slot) + slot_demand[slot].append(uid) + + conflicts: List[Dict[str, Any]] = [] + for slot, uids in sorted(slot_demand.items(), key=lambda item: len(item[1]), reverse=True): + demand = len(uids) + if demand <= slot_capacity: + continue + names = [getattr(candidates_by_uid[uid], "name", uid) for uid in uids[:8]] + conflicts.append( + { + "time_slot": slot, + "demand": demand, + "capacity": slot_capacity, + "candidate_uids": uids[:20], + "candidate_names": names, + } + ) + return conflicts[:8] + + +def analyze_interview_format_feasibility( + candidates: Sequence[Any], + interview_format: str, + max_candidates_per_slot: int, + venue_count: int, +) -> Dict[str, Any]: + """分析当前面试形式能否为全部待排班同学生成可行方案。""" + if interview_format == "many_to_many": + return { + "feasible": True, + "assignable_count": len(candidates), + "total_candidates": len(candidates), + "suggest_many_to_many": False, + "many_to_many_feasible": True, + "conflict_slots": [], + "message": "", + "format_recommendation": None, + } + + candidates_by_uid = {c.uid: c for c in candidates} + candidate_options: Dict[str, List[SlotWeek]] = {} + for candidate in candidates: + preferred = _parse_candidate_preferred_slots(candidate) + if not preferred: + continue + candidate_options[candidate.uid] = _build_candidate_slot_options(preferred) + + total = len(candidates) + if total == 0: + return { + "feasible": True, + "assignable_count": 0, + "total_candidates": 0, + "suggest_many_to_many": False, + "many_to_many_feasible": True, + "conflict_slots": [], + "message": "", + "format_recommendation": None, + } + + slot_capacity = get_effective_slot_capacity( + interview_format, max_candidates_per_slot, venue_count + ) + assignable = _max_flow_assignable_count(candidate_options, slot_capacity) + feasible = assignable >= total + conflict_slots = _collect_conflict_slots( + candidate_options, candidates_by_uid, slot_capacity + ) + + many_capacity = get_effective_slot_capacity( + "many_to_many", max_candidates_per_slot, venue_count + ) + many_assignable = _max_flow_assignable_count(candidate_options, many_capacity) + many_to_many_feasible = many_assignable >= total + + format_label = { + "one_to_one": "一对一", + "one_to_many": "一对多", + }.get(interview_format, interview_format) + + suggest_many_to_many = (not feasible) and many_to_many_feasible + message = "" + format_recommendation: Optional[str] = None + + if suggest_many_to_many: + format_recommendation = "many_to_many" + sample = conflict_slots[0] if conflict_slots else None + if sample: + names = "、".join(sample["candidate_names"][:3]) + message = ( + f"当前选择「{format_label}」时,至少 {total - assignable} 人无法避免时间冲突" + f"(如 {sample['time_slot']} 有 {sample['demand']} 人可选," + f"但该形式每时段最多 {sample['capacity']} 人)。" + f"涉及同学包括 {names} 等。建议改用「多对多」面试形式。" + ) + else: + message = ( + f"当前选择「{format_label}」无法为全部 {total} 人安排面试," + f"但改用「多对多」后可排下 {many_assignable} 人。建议切换为多对多。" + ) + elif not feasible: + message = ( + f"当前参数下最多只能排 {assignable}/{total} 人。" + f"请增加勾选场地、提高每时段最大人数,或让同学补充更多可面试时间段。" + ) + + return { + "feasible": feasible, + "assignable_count": assignable, + "total_candidates": total, + "suggest_many_to_many": suggest_many_to_many, + "many_to_many_feasible": many_to_many_feasible, + "many_to_many_assignable_count": many_assignable, + "conflict_slots": conflict_slots, + "message": message, + "format_recommendation": format_recommendation, + "slot_capacity": slot_capacity, + "interview_format": interview_format, + } + + +def fill_unscheduled_candidates( + candidates: Sequence[Any], + schedule_results: List[Dict[str, Any]], + *, + interview_format: str, + max_candidates_per_slot: int, + venue_count: int, + venues: List[str], + base_date: str, + calculate_slot_date_fn, +) -> Tuple[List[Dict[str, Any]], List[Any]]: + """在大模型排班后,用确定性算法为剩余同学补排时段。""" + resolved_venues = [v for v in venues if v] or ["场地A"] + per_slot_capacity = get_effective_slot_capacity( + interview_format, max_candidates_per_slot, venue_count + ) + candidates_by_uid = {candidate.uid: candidate for candidate in candidates} + assigned_uids = {item["uid"] for item in schedule_results} + + slot_counts: Dict[str, int] = defaultdict(int) + for item in schedule_results: + slot_counts[item["time_slot"]] += 1 + + candidate_options: Dict[str, List[SlotWeek]] = {} + all_slot_week_keys: set[SlotWeek] = set() + for candidate in candidates: + if candidate.uid in assigned_uids: + continue + preferred = _parse_candidate_preferred_slots(candidate) + if not preferred: + continue + options = _build_candidate_slot_options(preferred) + candidate_options[candidate.uid] = options + all_slot_week_keys.update(options) + + if not candidate_options: + unscheduled = [c for c in candidates if c.uid not in assigned_uids] + return [], unscheduled + + slot_capacities: Dict[SlotWeek, int] = {} + for slot, week in all_slot_week_keys: + slot_key = slot_week_key(slot, week) + used = slot_counts.get(slot_key, 0) + slot_capacities[(slot, week)] = max(0, per_slot_capacity - used) + + assignments = _max_flow_assign_candidates(candidate_options, slot_capacities) + fallback_results: List[Dict[str, Any]] = [] + + for uid, (slot, week) in assignments.items(): + candidate = candidates_by_uid[uid] + slot_key = slot_week_key(slot, week) + slot_counts[slot_key] += 1 + interview_date = calculate_slot_date_fn(base_date, slot, week) + display_slot = build_display_slot(base_date, slot, interview_date) + venue = resolved_venues[(slot_counts[slot_key] - 1) % len(resolved_venues)] + fallback_results.append( + { + "uid": uid, + "name": candidate.name, + "time_slot": slot_key, + "display_slot": display_slot, + "interview_date": interview_date, + "venue": venue, + "candidate_index": slot_counts[slot_key], + "total_in_slot": 0, + "week": week, + "assigned_by": "fallback", + } + ) + + for result in fallback_results: + result["total_in_slot"] = slot_counts[result["time_slot"]] + + for result in schedule_results: + result["total_in_slot"] = slot_counts[result["time_slot"]] + + still_unassigned = [ + candidates_by_uid[uid] + for uid in candidate_options + if uid not in assignments + ] + no_preference = [ + candidate + for candidate in candidates + if candidate.uid not in assigned_uids and candidate.uid not in candidate_options + ] + return fallback_results, still_unassigned + no_preference + + +def build_unscheduled_details(unscheduled: Sequence[Any]) -> List[Dict[str, str]]: + return [ + { + "uid": candidate.uid, + "name": candidate.name, + } + for candidate in unscheduled + ] diff --git a/misc/schema_upgrade.py b/misc/schema_upgrade.py new file mode 100644 index 0000000..c32446a --- /dev/null +++ b/misc/schema_upgrade.py @@ -0,0 +1,18 @@ +"""启动时补齐已有 SQLite 表结构(create_all 不会 ALTER 已有表)。""" + +from sqlalchemy import inspect, text + +from models import engine + + +def ensure_schema_updates() -> None: + inspector = inspect(engine) + if "interview" in inspector.get_table_names(): + columns = {col["name"] for col in inspector.get_columns("interview")} + if "is_pinned" not in columns: + with engine.begin() as conn: + conn.execute( + text( + "ALTER TABLE interview ADD COLUMN is_pinned BOOLEAN NOT NULL DEFAULT 0" + ) + ) diff --git a/misc/week_calendar.py b/misc/week_calendar.py new file mode 100644 index 0000000..9c51821 --- /dev/null +++ b/misc/week_calendar.py @@ -0,0 +1,47 @@ +"""以周一为一周起点,计算相对基准日的「本周 / 下周 / 下下周」标签。""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Union + +DateLike = Union[date, datetime, str] + + +def to_date(value: DateLike) -> date: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return datetime.strptime(str(value)[:10], "%Y-%m-%d").date() + + +def week_start_monday(value: DateLike) -> date: + current = to_date(value) + return current - timedelta(days=current.weekday()) + + +def calendar_weeks_after(base: DateLike, target: DateLike) -> int: + base_start = week_start_monday(base) + target_start = week_start_monday(target) + return (target_start - base_start).days // 7 + + +def format_relative_week_label(base: DateLike, target: DateLike) -> str: + diff = calendar_weeks_after(base, target) + if diff <= 0: + return "本周" + if diff == 1: + return "下周" + if diff == 2: + return "下下周" + return f"{diff}周后" + + +def build_display_slot( + base: DateLike, + time_slot: str, + interview_date: DateLike, +) -> str: + label = format_relative_week_label(base, interview_date) + return f"{time_slot} ({label})" diff --git a/models/interview.py b/models/interview.py index 0ce9dbe..1da7617 100644 --- a/models/interview.py +++ b/models/interview.py @@ -43,6 +43,7 @@ class Interview(Base): notes = Column(Text) # 备注 status = Column(String(20), default='scheduled') # 排班状态: scheduled, completed, cancelled notification_sent = Column(Boolean, default=False) # 是否已发送通知 + is_pinned = Column(Boolean, default=False) # 手动固定排班,AI 不会覆盖 # 评分项目(1-10分) technical_skills = Column(Float) # 技术能力 diff --git a/models/venue.py b/models/venue.py new file mode 100644 index 0000000..d671121 --- /dev/null +++ b/models/venue.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String + +from . import Base + + +class InterviewVenue(Base): + __tablename__ = "interview_venue" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(String(255), default="") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" diff --git a/routes/interview.py b/routes/interview.py index a533896..c001834 100644 --- a/routes/interview.py +++ b/routes/interview.py @@ -15,10 +15,13 @@ from models import get_db from models.recruit import Recruitment from models.interview import Interview, InterviewTimeSlot +from models.venue import InterviewVenue from models.admin import Admin from models.user import User from misc.auth import get_current_admin from routes.admin import is_manager +from misc.llm_scheduler import generate_schedule_via_llm, MODEL_PRESETS +from misc.week_calendar import build_display_slot, calendar_weeks_after, format_relative_week_label, to_date router = APIRouter() @@ -31,6 +34,7 @@ def get_interview_format_label(format_type: str) -> str: } return format_labels.get(format_type, '一对一') + def parse_time_slots(time_slots_json: str) -> List[str]: try: if not time_slots_json or time_slots_json.strip() == "": @@ -98,330 +102,70 @@ def calculate_slot_date(base_date: str, time_slot: str, week_offset: int = 0) -> return datetime.strptime(base_date, "%Y-%m-%d") -def get_available_dates_for_slot(base_date: str, time_slot: str, num_weeks: int = 2) -> List[datetime]: - dates = [] - for week in range(num_weeks): - date = calculate_slot_date(base_date, time_slot, week) - dates.append(date) - return dates +def _persist_schedule_results( + db: Session, + schedule_results: List[Dict[str, Any]], + base_date: str, + interview_format: str = "one_to_one", +) -> List[Interview]: + created_schedules = [] + for schedule_info in schedule_results: + recruit = db.query(Recruitment).filter(Recruitment.uid == schedule_info['uid']).first() + current_stage = recruit.interview_status if recruit else "first_round" + + existing_schedule = db.query(Interview).filter( + Interview.uid == schedule_info['uid'], + Interview.stage == current_stage, + ).first() + if existing_schedule: + print(f"Skipping existing schedule: {schedule_info['uid']} ({current_stage})") + continue + + slot_parts = schedule_info['time_slot'].split('_week_') + if len(slot_parts) != 2: + print(f"Error: Invalid time_slot format: {schedule_info['time_slot']}") + continue + + original_slot = slot_parts[0] + week_num = int(slot_parts[1]) + slot_name_parts = original_slot.split(' ') + if len(slot_name_parts) != 2: + print(f"Error: Invalid original_slot format: {original_slot}") + continue + + day_name = slot_name_parts[0] + time_range = slot_name_parts[1] + time_parts = time_range.split('-') + if len(time_parts) != 2: + print(f"Error: Invalid time_range format: {time_range}") + continue + + start_time, end_time = time_parts + time_slot = get_or_create_time_slot(db, original_slot, day_name, start_time, end_time, week_num) + + new_schedule = Interview( + uid=schedule_info['uid'], + stage=current_stage, + interview_date=schedule_info['interview_date'], + interview_format=interview_format, + interview_duration=40, + location=schedule_info['venue'], + notes=f"大模型排班 - {schedule_info['time_slot']} - 第{schedule_info['candidate_index']}位", + status="scheduled", + notification_sent=False, + time_slot_id=time_slot.id, + is_pinned=False, + ) + db.add(new_schedule) + created_schedules.append(new_schedule) + time_slot.current_count += 1 -def auto_schedule_algorithm(candidates: List[Recruitment], base_date: str, max_per_slot: int = 8) -> Dict[str, Any]: - if not candidates: - return { - 'allocations': {}, - 'schedule_results': [], - 'venue_assignments': {}, - 'unscheduled': [], - 'time_slot_counts': {}, - 'time_slot_instances': {} - } - - try: - datetime.strptime(base_date, "%Y-%m-%d") - except ValueError: - raise ValueError(f"无效的基准日期格式: {base_date}") - - - time_slot_instances = {} - time_slot_counts = defaultdict(int) - - all_time_slots = set() - valid_candidates = [] - - for candidate in candidates: - time_slots = parse_time_slots(candidate.interview_time_slots) - if time_slots: - valid_candidates.append(candidate) - for slot in time_slots: - all_time_slots.add(slot) - - if not all_time_slots: - return { - 'allocations': {}, - 'schedule_results': [], - 'venue_assignments': {}, - 'unscheduled': valid_candidates, - 'time_slot_counts': {}, - 'time_slot_instances': {} - } - - for slot in all_time_slots: - slot_instances = [] - for week in range(2): # 本周和下周 - date = calculate_slot_date(base_date, slot, week) - slot_key = f"{slot}_week_{week}" - slot_instances.append({ - 'slot_key': slot_key, - 'original_slot': slot, - 'date': date, - 'week': week, - 'count': 0 - }) - time_slot_instances[slot] = slot_instances - - - allocations = defaultdict(list) - candidate_to_slot = {} - unscheduled = [] - - candidates_with_preferences = [] - for candidate in valid_candidates: - time_slots = parse_time_slots(candidate.interview_time_slots) - candidates_with_preferences.append({ - 'candidate': candidate, - 'preferences': time_slots, - 'preference_count': len(time_slots) - }) - - candidates_with_preferences.sort(key=lambda x: x['preference_count']) - - for candidate_info in candidates_with_preferences: - candidate = candidate_info['candidate'] - preferences = candidate_info['preferences'] - - sorted_preferences = sorted(preferences, key=lambda slot: - time_slot_instances[slot][0]['date'] if slot in time_slot_instances else datetime.max) - - - best_slot_instance = None - for slot in sorted_preferences: - if slot in time_slot_instances: - instance = time_slot_instances[slot][0] - if instance['count'] < 7: - best_slot_instance = instance - break - - instance = time_slot_instances[slot][1] - if instance['count'] < 7: - best_slot_instance = instance - break - - if best_slot_instance: - allocations[best_slot_instance['slot_key']].append(candidate) - best_slot_instance['count'] += 1 - time_slot_counts[best_slot_instance['slot_key']] = best_slot_instance['count'] - candidate_to_slot[candidate.uid] = best_slot_instance['slot_key'] - else: - unscheduled.append(candidate) - - - def rebalance_overloaded_slots(): - max_iterations = 200 - iteration = 0 - while True: - overloaded_slots = [] - for slot_key, candidates_list in allocations.items(): - if len(candidates_list) > 5: - overloaded_slots.append(slot_key) - - if not overloaded_slots: - break - - changes_made = False - for slot_key in overloaded_slots: - - if slot_key not in allocations or len(allocations[slot_key]) <= 5: - continue - - candidates_list = allocations[slot_key].copy() - target_count = 5 # 目标人数 - - for candidate in candidates_list: - if slot_key not in allocations or len(allocations[slot_key]) <= target_count: - break - - candidate_preferences = parse_time_slots(candidate.interview_time_slots) - alternative_slot = None - - for pref_slot in candidate_preferences: - if pref_slot in time_slot_instances: - for week in range(2): - instance = time_slot_instances[pref_slot][week] - if (instance['slot_key'] != slot_key and - instance['count'] < 5): - alternative_slot = instance - break - if alternative_slot: - break - - if alternative_slot: - allocations[slot_key].remove(candidate) - original_slot_name = slot_key.split('_week_')[0] - original_week = int(slot_key.split('_week_')[1]) - time_slot_instances[original_slot_name][original_week]['count'] -= 1 - time_slot_counts[slot_key] -= 1 - - allocations[alternative_slot['slot_key']].append(candidate) - alternative_slot['count'] += 1 - time_slot_counts[alternative_slot['slot_key']] = alternative_slot['count'] - candidate_to_slot[candidate.uid] = alternative_slot['slot_key'] - - changes_made = True - break - iteration += 1 - if not changes_made or iteration >= max_iterations - 1: - break - - rebalance_overloaded_slots() - - - def optimize_underloaded_slots(): - max_iterations = 200 - for iteration in range(max_iterations): - changes_made = False - - - underloaded_slots = [] - for slot_key, candidates_list in allocations.items(): - if len(candidates_list) < 4: - underloaded_slots.append(slot_key) - - - for slot_key in underloaded_slots: - if slot_key not in allocations or len(allocations[slot_key]) >= 4: - continue - - candidates_list = allocations[slot_key].copy() - - for candidate in candidates_list: - - if slot_key not in allocations or len(allocations[slot_key]) >= 4: - break - - candidate_preferences = parse_time_slots(candidate.interview_time_slots) - - - alternative_slots = [] - for pref_slot in candidate_preferences: - if pref_slot in time_slot_instances: - for week in range(2): - instance = time_slot_instances[pref_slot][week] - if (instance['slot_key'] != slot_key and - instance['count'] < 4): - alternative_slots.append(instance) - - if alternative_slots: - best_alternative = max(alternative_slots, key=lambda x: x['count']) - - allocations[slot_key].remove(candidate) - original_slot_name = slot_key.split('_week_')[0] - original_week = int(slot_key.split('_week_')[1]) - time_slot_instances[original_slot_name][original_week]['count'] -= 1 - time_slot_counts[slot_key] -= 1 - - allocations[best_alternative['slot_key']].append(candidate) - best_alternative['count'] += 1 - time_slot_counts[best_alternative['slot_key']] = best_alternative['count'] - candidate_to_slot[candidate.uid] = best_alternative['slot_key'] - - changes_made = True - - break - - if not changes_made: - break - - optimize_underloaded_slots() - - - def redistribute_underloaded_slots(): - max_iterations = 100 - for iteration in range(max_iterations): - changes_made = False - - - underloaded_slots = [] - for slot_key, candidates_list in allocations.items(): - if len(candidates_list) < 4: - underloaded_slots.append(slot_key) - - - for slot_key in underloaded_slots: - if slot_key not in allocations or len(allocations[slot_key]) >= 4: - continue - - candidates_list = allocations[slot_key].copy() - - for candidate in candidates_list: - - if slot_key not in allocations or len(allocations[slot_key]) >= 4: - break - - candidate_preferences = parse_time_slots(candidate.interview_time_slots) - - - alternative_slots = [] - for pref_slot in candidate_preferences: - if pref_slot in time_slot_instances: - for week in range(2): - instance = time_slot_instances[pref_slot][week] - if (instance['slot_key'] != slot_key and - instance['count'] <= 7 and instance['count'] >= 4): - alternative_slots.append(instance) - - if alternative_slots: - best_alternative = min(alternative_slots, key=lambda x: x['count']) - - allocations[slot_key].remove(candidate) - original_slot_name = slot_key.split('_week_')[0] - original_week = int(slot_key.split('_week_')[1]) - time_slot_instances[original_slot_name][original_week]['count'] -= 1 - time_slot_counts[slot_key] -= 1 - - allocations[best_alternative['slot_key']].append(candidate) - best_alternative['count'] += 1 - time_slot_counts[best_alternative['slot_key']] = best_alternative['count'] - candidate_to_slot[candidate.uid] = best_alternative['slot_key'] - - changes_made = True - break - - if not changes_made: - break - - redistribute_underloaded_slots() - - - schedule_results = [] - venue_assignments = {} - - for slot_key, candidates_list in allocations.items(): - - - parts = slot_key.split('_week_') - original_slot = parts[0] - week_num = int(parts[1]) - - interview_date = calculate_slot_date(base_date, original_slot, week_num) - - venue = "场地A" - venue_assignments[slot_key] = ["场地A"] - - for i, candidate in enumerate(candidates_list): - week_label = "本周" if week_num == 0 else "下周" - display_slot = f"{original_slot} ({week_label})" - - schedule_results.append({ - 'uid': candidate.uid, - 'name': candidate.name, - 'time_slot': slot_key, - 'display_slot': display_slot, - 'interview_date': interview_date, - 'venue': venue, - 'candidate_index': i + 1, - 'total_in_slot': len(candidates_list), - 'week': week_num - }) - - return { - 'allocations': allocations, - 'schedule_results': schedule_results, - 'venue_assignments': venue_assignments, - 'unscheduled': unscheduled, - 'time_slot_counts': dict(time_slot_counts), - 'time_slot_instances': time_slot_instances - } + if recruit: + recruit.interview_status = current_stage + recruit.interview_completed = False + + return created_schedules class InterviewSchedule(BaseModel): @@ -434,6 +178,7 @@ class InterviewSchedule(BaseModel): notes: Optional[str] = None status: str = Field("scheduled", pattern="^(scheduled|completed|cancelled)$") notification_sent: bool = False + is_pinned: bool = True class InterviewScheduleUpdate(BaseModel): @@ -444,6 +189,7 @@ class InterviewScheduleUpdate(BaseModel): notes: Optional[str] = None status: Optional[str] = Field(None, pattern="^(scheduled|completed|cancelled)$") notification_sent: Optional[bool] = None + is_pinned: Optional[bool] = None class InterviewScheduleResponse(BaseModel): @@ -462,9 +208,14 @@ class InterviewScheduleResponse(BaseModel): class AutoScheduleRequest(BaseModel): - base_date: str - max_candidates_per_slot: int = 8 # 每个时间段最多面试者数量 - max_venues_per_slot: int = 2 # 每个时间段最多场地数量 + base_date: str + max_candidates_per_slot: int = 8 + interview_format: str = Field("one_to_one", pattern="^(one_to_one|one_to_many|many_to_many)$") + selected_venues: List[str] = Field(default_factory=list) + model_type: str = Field(..., description="模型类型: openai/deepseek/qwen/moonshot/zhipu/custom") + api_base_url: str = Field(default="", description="OpenAI 兼容 API Base URL") + api_key: str = Field(..., description="API Key") + model_name: str = Field(default="", description="模型名称,留空则使用预设默认值") class AutoScheduleResponse(BaseModel): @@ -475,7 +226,70 @@ class AutoScheduleResponse(BaseModel): unscheduled_candidates: int schedule_details: List[Dict[str, Any]] venue_assignments: Dict[str, List[str]] - csv_file_path: Optional[str] = None + csv_file_path: Optional[str] = None + llm_summary: Optional[str] = None + model_used: Optional[str] = None + created_count: int = 0 + token_usage: Optional[Dict[str, int]] = None + format_recommendation: Optional[str] = None + format_conflict: Optional[Dict[str, Any]] = None + unscheduled_details: List[Dict[str, str]] = [] + fallback_scheduled_count: int = 0 + + +class BatchRevokeScheduleRequest(BaseModel): + schedule_ids: List[int] = Field(default_factory=list) + + +class VenueCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: str = "" + + +class VenueUpdateRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = None + is_active: Optional[bool] = None + + +def _decrement_time_slot_count(db: Session, time_slot_id: Optional[int]) -> None: + if not time_slot_id: + return + time_slot = db.query(InterviewTimeSlot).filter(InterviewTimeSlot.id == time_slot_id).first() + if time_slot: + time_slot.current_count = max(0, (time_slot.current_count or 0) - 1) + + +def _ensure_default_venues(db: Session) -> None: + rename_targets = ("紫金港A101", "紫金港 A101", "紫金港A 101") + for old_name in rename_targets: + venue = db.query(InterviewVenue).filter(InterviewVenue.name == old_name).first() + if venue: + venue.name = "紫金港 - 东A101" + venue.updated_at = datetime.utcnow() + updated_interviews = db.query(Interview).filter(Interview.location == old_name).all() + for interview in updated_interviews: + interview.location = "紫金港 - 东A101" + db.commit() + + if db.query(InterviewVenue).count() == 0: + db.add(InterviewVenue(name="紫金港 - 东A101", description="默认面试场地")) + db.commit() + + +@router.get("/llm-model-presets", tags=["interview"]) +def get_llm_model_presets(): + return { + "presets": [ + { + "id": key, + "label": value["label"], + "api_base_url": value["api_base_url"], + "default_model": value["default_model"], + } + for key, value in MODEL_PRESETS.items() + ] + } @router.post("/schedule", tags=["interview"]) @@ -516,155 +330,34 @@ def create_interview_schedule( def auto_schedule_interviews( request: AutoScheduleRequest, db: Session = Depends(get_db), - ): try: - - candidates = db.query(Recruitment).filter( - Recruitment.interview_time_slots.isnot(None), - Recruitment.interview_time_slots != "", - Recruitment.interview_status.in_(["first_round", "second_round"]) # 排班一面和二面的 - ).all() - - print(f"Found {len(candidates)} candidates available for scheduling") - - if not candidates: - return AutoScheduleResponse( - success=False, - message="没有找到可排班的面试者", - total_candidates=0, - scheduled_candidates=0, - unscheduled_candidates=0, - schedule_details=[], - venue_assignments={} - ) - - - algorithm_result = auto_schedule_algorithm( - candidates, - request.base_date, - request.max_candidates_per_slot + from misc.ai_schedule_service import execute_ai_schedule + + result = execute_ai_schedule( + db, + base_date=request.base_date, + max_candidates_per_slot=request.max_candidates_per_slot, + interview_format=request.interview_format, + selected_venues=request.selected_venues, + model_type=request.model_type, + api_base_url=request.api_base_url, + api_key=request.api_key, + model_name=request.model_name, + calculate_slot_date_fn=calculate_slot_date, + persist_schedule_fn=_persist_schedule_results, + generate_csv_fn=generate_schedule_csv, ) - print(algorithm_result['schedule_results']) - - print(f"Algorithm result: Successfully scheduled {len(algorithm_result['schedule_results'])} people, unscheduled {len(algorithm_result['unscheduled'])} people") - - - created_schedules = [] - for schedule_info in algorithm_result['schedule_results']: - existing_schedule = db.query(Interview).filter( - Interview.uid == schedule_info['uid'] - ).first() - - if existing_schedule: - print(f"Skipping existing schedule: {schedule_info['uid']}") - continue # 跳过已存在的排班 - - recruit = db.query(Recruitment).filter(Recruitment.uid == schedule_info['uid']).first() - current_stage = recruit.interview_status if recruit else "first_round" - - slot_parts = schedule_info['time_slot'].split('_week_') - - if len(slot_parts) != 2: - print(f"Error: Invalid time_slot format: {schedule_info['time_slot']}") - continue - - original_slot = slot_parts[0] - week_num = int(slot_parts[1]) - - slot_name_parts = original_slot.split(' ') - if len(slot_name_parts) != 2: - print(f"Error: Invalid original_slot format: {original_slot}") - continue - - day_name = slot_name_parts[0] # 如 "周一" - time_range = slot_name_parts[1] - - time_parts = time_range.split('-') - if len(time_parts) != 2: - print(f"Error: Invalid time_range format: {time_range}") - continue - - start_time, end_time = time_parts - - time_slot = get_or_create_time_slot(db, original_slot, day_name, start_time, end_time, week_num) - - new_schedule = Interview( - uid=schedule_info['uid'], - stage=current_stage, # 使用当前面试阶段 - interview_date=schedule_info['interview_date'], - interview_format="one_to_one", # 默认一对一面试 - interview_duration=40, - location=schedule_info['venue'], - notes=f"自动排班 - {schedule_info['time_slot']} - 第{schedule_info['candidate_index']}位", - status="scheduled", - notification_sent=False, - time_slot_id=time_slot.id - ) - - db.add(new_schedule) - created_schedules.append(new_schedule) - - time_slot.current_count += 1 - - recruit = db.query(Recruitment).filter(Recruitment.uid == schedule_info['uid']).first() - if recruit: - recruit.interview_status = current_stage - recruit.interview_completed = False # 设置为未完成状态 - - - db.commit() - print(f"Successfully created {len(created_schedules)} interview schedule records") - - - total_candidates = len(candidates) - scheduled_candidates = len(algorithm_result['schedule_results']) - unscheduled_candidates = len(algorithm_result['unscheduled']) - - - schedule_details = [] - for schedule_info in algorithm_result['schedule_results']: - schedule_details.append({ - 'uid': schedule_info['uid'], - 'name': schedule_info['name'], - 'time_slot': schedule_info['time_slot'], - 'display_slot': schedule_info['display_slot'], - 'interview_date': schedule_info['interview_date'].isoformat(), - 'venue': schedule_info['venue'], - 'position': f"{schedule_info['candidate_index']}/{schedule_info['total_in_slot']}", - 'week': schedule_info['week'] - }) - - - time_slot_stats = [] - for time_slot, count in algorithm_result['time_slot_counts'].items(): - venues = algorithm_result['venue_assignments'].get(time_slot, []) - time_slot_stats.append({ - 'time_slot': time_slot, - 'candidate_count': count, - 'venues': venues - }) - - - csv_file_path = generate_schedule_csv(algorithm_result['schedule_results'], request.base_date) - - return AutoScheduleResponse( - success=True, - message=f"自动排班完成!成功排班 {scheduled_candidates} 人,未排班 {unscheduled_candidates} 人", - total_candidates=total_candidates, - scheduled_candidates=scheduled_candidates, - unscheduled_candidates=unscheduled_candidates, - schedule_details=schedule_details, - venue_assignments=algorithm_result['venue_assignments'], - csv_file_path=csv_file_path - ) - + return AutoScheduleResponse(**result) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: db.rollback() print(f"Auto scheduling failed: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error occurred during auto scheduling: {e}" + detail=f"Error occurred during auto scheduling: {e}", ) @@ -780,6 +473,7 @@ def get_interview_schedules( "notes": schedule.notes, "status": schedule.status, "notification_sent": schedule.notification_sent, + "is_pinned": schedule.is_pinned, "created_at": schedule.created_at, "updated_at": schedule.updated_at }) @@ -840,6 +534,7 @@ def delete_interview_schedule( ) try: + _decrement_time_slot_count(db, schedule.time_slot_id) db.delete(schedule) db.commit() @@ -1079,54 +774,121 @@ def send_schedule_notification( detail=f"Error occurred when sending interview notification: {e}" ) -def generate_schedule_csv(schedule_results: List[Dict[str, Any]], base_date: str) -> str: - +def generate_schedule_csv( + schedule_results: List[Dict[str, Any]], + base_date: str, + interview_format: str = "one_to_one", +) -> str: + from openpyxl import Workbook + from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + from openpyxl.utils import get_column_letter + from openpyxl.worksheet.table import Table, TableStyleInfo + uploads_dir = "uploads" if not os.path.exists(uploads_dir): os.makedirs(uploads_dir) - + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"interview_schedule_{timestamp}.csv" + filename = f"interview_schedule_{timestamp}.xlsx" file_path = os.path.join(uploads_dir, filename) - - - with open(file_path, 'w', newline='', encoding='utf-8-sig') as csvfile: - fieldnames = [ - '序号', '学号', '姓名', '面试阶段', '面试日期', '面试时间', - '时间段', '场地', '面试官', '面试时长(分钟)', '备注' + + headers = [ + '序号', '学号', '姓名', '面试阶段', '面试日期', '面试时间', + '时间段', '场地', '面试形式', '面试时长(分钟)', '备注', + ] + + def _schedule_datetime(value): + if isinstance(value, datetime): + return value + try: + return datetime.fromisoformat(str(value).replace('Z', '+00:00')) + except (TypeError, ValueError): + return datetime.min + + sorted_results = sorted( + schedule_results, + key=lambda item: _schedule_datetime(item["interview_date"]), + ) + + wb = Workbook() + ws = wb.active + ws.title = "面试排班表" + ws.append(headers) + + header_fill = PatternFill("solid", fgColor="4472C4") + header_font = Font(bold=True, color="FFFFFF") + thin_border = Border( + left=Side(style="thin", color="BFBFBF"), + right=Side(style="thin", color="BFBFBF"), + top=Side(style="thin", color="BFBFBF"), + bottom=Side(style="thin", color="BFBFBF"), + ) + + for col_idx, title in enumerate(headers, start=1): + cell = ws.cell(row=1, column=col_idx, value=title) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = thin_border + + for i, schedule in enumerate(sorted_results, 1): + interview_date = schedule['interview_date'] + if isinstance(interview_date, datetime): + date_str = interview_date.strftime('%Y-%m-%d') + time_str = interview_date.strftime('%H:%M') + else: + try: + dt = datetime.fromisoformat(str(interview_date).replace('Z', '+00:00')) + date_str = dt.strftime('%Y-%m-%d') + time_str = dt.strftime('%H:%M') + except (TypeError, ValueError): + date_str = str(interview_date) + time_str = "" + + row_values = [ + i, + schedule['uid'], + schedule['name'], + '一面', + date_str, + time_str, + schedule['display_slot'], + schedule['venue'], + get_interview_format_label(interview_format), + 40, + f"第{schedule['candidate_index']}位,共{schedule['total_in_slot']}人", ] - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - - writer.writeheader() - - for i, schedule in enumerate(schedule_results, 1): - interview_date = schedule['interview_date'] - if isinstance(interview_date, datetime): - date_str = interview_date.strftime('%Y-%m-%d') - time_str = interview_date.strftime('%H:%M') - else: - try: - dt = datetime.fromisoformat(interview_date.replace('Z', '+00:00')) - date_str = dt.strftime('%Y-%m-%d') - time_str = dt.strftime('%H:%M') - except: - date_str = str(interview_date) - time_str = "" - - writer.writerow({ - '序号': i, - '学号': schedule['uid'], - '姓名': schedule['name'], - '面试阶段': '简历筛选', # 默认为简历筛选 - '面试日期': date_str, - '面试时间': time_str, - '时间段': schedule['display_slot'], - '场地': schedule['venue'], - '面试官': '待分配', - '面试时长(分钟)': 40, - '备注': f"第{schedule['candidate_index']}位,共{schedule['total_in_slot']}人" - }) - + ws.append(row_values) + row_idx = ws.max_row + for col_idx in range(1, len(headers) + 1): + cell = ws.cell(row=row_idx, column=col_idx) + cell.border = thin_border + cell.alignment = Alignment(vertical="center", wrap_text=True) + if col_idx in (1, 10): + cell.alignment = Alignment(horizontal="center", vertical="center") + + last_row = max(ws.max_row, 1) + last_col = get_column_letter(len(headers)) + table_ref = f"A1:{last_col}{last_row}" + table = Table(displayName="InterviewScheduleTable", ref=table_ref) + table.tableStyleInfo = TableStyleInfo( + name="TableStyleMedium2", + showFirstColumn=False, + showLastColumn=False, + showRowStripes=True, + showColumnStripes=False, + ) + ws.add_table(table) + ws.freeze_panes = "A2" + + column_widths = { + 'A': 8, 'B': 14, 'C': 12, 'D': 10, 'E': 14, 'F': 10, + 'G': 28, 'H': 14, 'I': 12, 'J': 14, 'K': 28, + } + for col, width in column_widths.items(): + ws.column_dimensions[col].width = width + + wb.save(file_path) return file_path @@ -1171,7 +933,7 @@ def get_interview_time_slots( target_date = base_date_obj + timedelta(days=days_to_add) date_str = target_date.strftime("%m/%d") day_name = weekdays[target_day] - week_label = '本周' if week == 0 else '下周' + week_label = format_relative_week_label(base_date_obj, target_date) for time_range in day_config['times']: slot_name = f"{day_name} {time_range}" @@ -1238,6 +1000,124 @@ def get_interview_time_slots( ) +@router.post("/batch-revoke-schedule", tags=["interview"]) +def batch_revoke_schedule( + request: BatchRevokeScheduleRequest, + db: Session = Depends(get_db), +): + if not request.schedule_ids: + raise HTTPException(status_code=400, detail="请选择要撤销的排班记录") + + try: + revoked = 0 + for schedule_id in request.schedule_ids: + schedule = db.query(Interview).filter(Interview.id == schedule_id).first() + if not schedule: + continue + _decrement_time_slot_count(db, schedule.time_slot_id) + db.delete(schedule) + revoked += 1 + db.commit() + return { + "message": f"已撤销 {revoked} 条排班记录", + "revoked_count": revoked, + } + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"批量撤销排班失败: {e}", + ) + + +@router.get("/venues", tags=["interview"]) +def list_interview_venues(db: Session = Depends(get_db)): + _ensure_default_venues(db) + venues = db.query(InterviewVenue).order_by(InterviewVenue.id.asc()).all() + return { + "venues": [ + { + "id": venue.id, + "name": venue.name, + "description": venue.description or "", + "is_active": venue.is_active, + "created_at": venue.created_at, + "updated_at": venue.updated_at, + } + for venue in venues + ] + } + + +@router.post("/venues", tags=["interview"]) +def create_interview_venue( + request: VenueCreateRequest, + db: Session = Depends(get_db), +): + name = request.name.strip() + if not name: + raise HTTPException(status_code=400, detail="场地名称不能为空") + exists = db.query(InterviewVenue).filter(InterviewVenue.name == name).first() + if exists: + raise HTTPException(status_code=400, detail="场地名称已存在") + + venue = InterviewVenue(name=name, description=request.description.strip()) + db.add(venue) + db.commit() + db.refresh(venue) + return {"message": "场地创建成功", "venue": venue} + + +@router.put("/venues/{venue_id}", tags=["interview"]) +def update_interview_venue( + venue_id: int, + request: VenueUpdateRequest, + db: Session = Depends(get_db), +): + venue = db.query(InterviewVenue).filter(InterviewVenue.id == venue_id).first() + if not venue: + raise HTTPException(status_code=404, detail="场地不存在") + + if request.name is not None: + name = request.name.strip() + if not name: + raise HTTPException(status_code=400, detail="场地名称不能为空") + duplicate = db.query(InterviewVenue).filter( + InterviewVenue.name == name, + InterviewVenue.id != venue_id, + ).first() + if duplicate: + raise HTTPException(status_code=400, detail="场地名称已存在") + venue.name = name + if request.description is not None: + venue.description = request.description.strip() + if request.is_active is not None: + venue.is_active = request.is_active + + venue.updated_at = datetime.utcnow() + db.commit() + db.refresh(venue) + return {"message": "场地更新成功", "venue": venue} + + +@router.delete("/venues/{venue_id}", tags=["interview"]) +def delete_interview_venue( + venue_id: int, + db: Session = Depends(get_db), +): + venue = db.query(InterviewVenue).filter(InterviewVenue.id == venue_id).first() + if not venue: + raise HTTPException(status_code=404, detail="场地不存在") + + in_use = db.query(Interview).filter(Interview.location == venue.name).first() + if in_use: + raise HTTPException(status_code=400, detail="该场地已有排班记录,无法删除,可改为停用") + + db.delete(venue) + db.commit() + return {"message": "场地删除成功"} + + @router.get("/download-schedule-csv/{filename}", tags=["interview"]) def download_schedule_csv( filename: str, @@ -1250,11 +1130,17 @@ def download_schedule_csv( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found" ) + + lower_name = filename.lower() + if lower_name.endswith(".xlsx"): + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + else: + media_type = "text/csv" return FileResponse( path=file_path, filename=filename, - media_type='text/csv', + media_type=media_type, headers={'Content-Disposition': f'attachment; filename="{filename}"'} ) @@ -1302,10 +1188,10 @@ def complete_interview( if matched_slot == base_time_slot: interview_date = interview.interview_date base_date = datetime.now().date() - days_diff = (interview_date.date() - base_date).days - interview_week = 1 if days_diff >= 7 else 0 + interview_week = calendar_weeks_after(base_date, interview_date.date()) + interview_week = 0 if interview_week < 0 else interview_week - print(f" Week check: days_diff={days_diff}, interview_week={interview_week}") + print(f" Week check: interview_week={interview_week}, target_week={request.week}") if interview_week == request.week: target_interviews.append(interview) diff --git a/routes/recruit.py b/routes/recruit.py index f4a2ddb..292c0fe 100644 --- a/routes/recruit.py +++ b/routes/recruit.py @@ -1181,3 +1181,221 @@ def export_recruits( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error occurred when exporting data: {str(e)}" ) + + +class RecruitAiScheduleRequest(BaseModel): + base_date: str + max_candidates_per_slot: int = 8 + interview_format: str = Field("one_to_one", pattern="^(one_to_one|one_to_many|many_to_many)$") + selected_venues: List[str] = Field(default_factory=list) + model_type: str + api_base_url: str = "" + api_key: str = "" + model_name: str = "" + save_config: bool = True + + +class RecruitLlmConfigRequest(BaseModel): + model_type: str + api_base_url: str + model_name: str + api_key: str = "" + + +@router.get("/llm-model-presets", tags=["recruit"]) +def get_recruit_llm_model_presets(): + from misc.llm_scheduler import MODEL_PRESETS + + return { + "presets": [ + { + "id": key, + "label": value["label"], + "api_base_url": value["api_base_url"], + "default_model": value["default_model"], + } + for key, value in MODEL_PRESETS.items() + ] + } + + +@router.get("/llm-config", tags=["recruit"]) +def get_recruit_llm_config( + _: bool = Depends(login_required_operator), +): + from misc.llm_config_store import public_llm_config + + return {"config": public_llm_config()} + + +@router.post("/llm-config", tags=["recruit"]) +def save_recruit_llm_config( + data: RecruitLlmConfigRequest, + _: bool = Depends(login_required_operator), +): + from misc.llm_config_store import public_llm_config, save_llm_config + + saved = save_llm_config( + model_type=data.model_type, + api_base_url=data.api_base_url, + model_name=data.model_name, + api_key=data.api_key or None, + ) + return {"message": "AI 配置已保存", "config": saved} + + +@router.get("/ai-schedule/token-usage", tags=["recruit"]) +def get_ai_schedule_token_usage( + limit: int = 30, + _: bool = Depends(login_required_operator), +): + from misc.llm_usage_store import get_usage_history + + records = get_usage_history(limit=limit) + return {"records": records} + + +@router.get("/schedule-board", tags=["recruit"]) +def get_schedule_board( + db: Session = Depends(get_db), + _: bool = Depends(login_required_operator), +): + from misc.ai_schedule_service import collect_pending_candidates + from routes.interview import get_interview_format_label + + pending = collect_pending_candidates(db) + pending_items = [ + { + "uid": c.uid, + "name": c.name, + "major_name": c.major_name, + "grade": c.grade, + "interview_status": c.interview_status, + } + for c in pending + ] + + scheduled_rows = ( + db.query(Interview, Recruitment) + .join(Recruitment, Recruitment.uid == Interview.uid) + .filter( + Interview.stage.in_(["first_round", "second_round"]), + Interview.status == "scheduled", + ) + .order_by(Interview.interview_date.asc()) + .all() + ) + + scheduled_items = [] + for schedule, recruit in scheduled_rows: + scheduled_items.append({ + "schedule_id": schedule.id, + "uid": schedule.uid, + "name": recruit.name, + "major_name": recruit.major_name, + "grade": recruit.grade, + "stage": schedule.stage, + "interview_date": schedule.interview_date, + "location": schedule.location, + "interview_format": schedule.interview_format, + "interview_format_label": get_interview_format_label(schedule.interview_format), + "is_pinned": schedule.is_pinned, + "notification_sent": schedule.notification_sent, + }) + + return { + "pending_count": len(pending_items), + "scheduled_count": len(scheduled_items), + "pending": pending_items, + "scheduled": scheduled_items, + } + + +@router.get("/ai-schedule/preview", tags=["recruit"]) +def preview_ai_schedule_candidates( + interview_format: str = "one_to_one", + max_candidates_per_slot: int = 8, + venue_count: int = 1, + db: Session = Depends(get_db), + _: bool = Depends(login_required_operator), +): + from misc.ai_schedule_service import collect_pending_candidates + from misc.schedule_conflict_analyzer import analyze_interview_format_feasibility + + if interview_format not in ("one_to_one", "one_to_many", "many_to_many"): + interview_format = "one_to_one" + + pending = collect_pending_candidates(db) + format_analysis = analyze_interview_format_feasibility( + pending, + interview_format=interview_format, + max_candidates_per_slot=max(1, max_candidates_per_slot), + venue_count=max(1, venue_count), + ) + return { + "pending_count": len(pending), + "candidates": [ + { + "uid": c.uid, + "name": c.name, + "major_name": c.major_name, + "grade": c.grade, + "interview_status": c.interview_status, + } + for c in pending + ], + "format_analysis": format_analysis, + } + + +@router.post("/ai-schedule", tags=["recruit"]) +def recruit_ai_schedule( + request: RecruitAiScheduleRequest, + db: Session = Depends(get_db), + _: bool = Depends(login_required_operator), +): + from misc.llm_config_store import load_llm_config, save_llm_config + from misc.ai_schedule_service import execute_ai_schedule + from routes.interview import ( + AutoScheduleResponse, + calculate_slot_date, + generate_schedule_csv, + _persist_schedule_results, + ) + + api_key = request.api_key.strip() + if not api_key: + api_key = load_llm_config().get("api_key", "") + if not api_key: + raise HTTPException(status_code=400, detail="请填写 API Key 或先在配置中保存") + + if request.save_config: + save_llm_config( + model_type=request.model_type, + api_base_url=request.api_base_url, + model_name=request.model_name, + api_key=api_key, + ) + + try: + result = execute_ai_schedule( + db, + base_date=request.base_date, + max_candidates_per_slot=request.max_candidates_per_slot, + interview_format=request.interview_format, + selected_venues=request.selected_venues, + model_type=request.model_type, + api_base_url=request.api_base_url, + api_key=api_key, + model_name=request.model_name, + calculate_slot_date_fn=calculate_slot_date, + persist_schedule_fn=_persist_schedule_results, + generate_csv_fn=generate_schedule_csv, + ) + return AutoScheduleResponse(**result) + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"AI 排班失败: {e}")