Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ uploads/images/*
test/*

.env
data/llm_config.json
data/llm_usage_log.json
.vscode/*
.idea/*
__pycache__/*
Expand Down
3 changes: 3 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -10,4 +12,5 @@
init_app_routes(app)

Base.metadata.create_all(bind=engine)
ensure_schema_updates()
create_admin()
236 changes: 236 additions & 0 deletions misc/ai_schedule_service.py
Original file line number Diff line number Diff line change
@@ -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,
}
59 changes: 59 additions & 0 deletions misc/llm_config_store.py
Original file line number Diff line number Diff line change
@@ -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")),
}
Loading