"""教师画像 / 指南赛道 / 推荐报告 的维护与查询。""" from __future__ import annotations import json from typing import Any, Optional from db.connection import get_conn, init_db from service.match_service import _norm_teacher, _norm_track, _to_int from util.logging_config import get_skill_logger from util.timeutil import now_unix _TEACHER_TEXT_FIELDS = ("teacher_name", "department", "title", "research_tags", "recent_papers", "project_history") _TEACHER_INT_FIELDS = ("sci_q1_count", "core_paper_count") _TRACK_TEXT_FIELDS = ("agency", "program_name", "track_name", "track_tags", "priority_keywords", "min_title", "source_doc") _TRACK_INT_FIELDS = ("year", "min_sci_q1", "quota") _KNOWN_MIN_TITLES = ("讲师及以上", "副高及以上", "正高及以上") def _log(): try: return get_skill_logger() except RuntimeError: return None def _snake(name: str) -> str: return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_") def upsert_teacher(fields: dict[str, Any]) -> dict[str, Any]: """按姓名幂等写入教师画像;未提供字段不覆盖。""" teacher_name = str(fields.get("teacher_name") or "").strip() if not teacher_name: raise ValueError("教师姓名(teacher_name)不能为空") values: dict[str, Any] = {"teacher_name": teacher_name} for key in _TEACHER_TEXT_FIELDS: if key != "teacher_name" and fields.get(key) not in (None, ""): values[key] = str(fields[key]).strip() for key in _TEACHER_INT_FIELDS: if fields.get(key) not in (None, ""): number = _to_int(fields[key]) if number is None or number < 0: raise ValueError(f"字段 {key} 必须是非负整数:{fields[key]}") values[key] = number init_db() conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT id FROM teacher_profiles WHERE teacher_name = ?", (teacher_name,)) row = cur.fetchone() if row: teacher_id = int(row[0]) set_clause = ", ".join(f"{k} = ?" for k in values if k != "teacher_name") cur.execute( f"UPDATE teacher_profiles SET {set_clause}, updated_at = ? WHERE id = ?", [values[k] for k in values if k != "teacher_name"] + [now_unix(), teacher_id], ) action = "updated" else: columns = ", ".join(values.keys()) placeholders = ", ".join("?" for _ in values) cur.execute( f"INSERT INTO teacher_profiles ({columns}, created_at, updated_at) VALUES ({placeholders}, ?, ?)", list(values.values()) + [now_unix(), now_unix()], ) teacher_id = int(cur.lastrowid) action = "created" conn.commit() finally: conn.close() log = _log() if log: log.info("teacher_upserted teacher=%s action=%s stage=profile", teacher_name, action) return {"ok": True, "teacher_name": teacher_name, "id": teacher_id, "action": action} def upsert_track(fields: dict[str, Any]) -> dict[str, Any]: """按(年度+学部+项目类型+赛道名)幂等写入指南赛道。""" year = _to_int(fields.get("year")) track_name = str(fields.get("track_name") or "").strip() if year is None or year < 2020 or year > 2100: raise ValueError(f"指南年度无效:{fields.get('year')}") if not track_name: raise ValueError("赛道名称(track_name)不能为空") if fields.get("min_title") and str(fields["min_title"]).strip() not in _KNOWN_MIN_TITLES: raise ValueError(f"职称门槛只接受 {'、'.join(_KNOWN_MIN_TITLES)}:{fields['min_title']}") values: dict[str, Any] = {"year": year, "track_name": track_name} for key in _TRACK_TEXT_FIELDS: if key not in ("track_name",) and fields.get(key) not in (None, ""): values[key] = str(fields[key]).strip() for key in ("min_sci_q1", "quota"): if fields.get(key) not in (None, ""): number = _to_int(fields[key]) if number is None or number < 0: raise ValueError(f"字段 {key} 必须是非负整数:{fields[key]}") values[key] = number init_db() conn = get_conn() try: cur = conn.cursor() cur.execute( "SELECT id FROM guide_tracks WHERE year = ? AND COALESCE(agency,'') = ? AND COALESCE(program_name,'') = ? AND track_name = ?", (year, values.get("agency", ""), values.get("program_name", ""), track_name), ) row = cur.fetchone() if row: track_id = int(row[0]) set_clause = ", ".join(f"{k} = ?" for k in values if k != "track_name") cur.execute( f"UPDATE guide_tracks SET {set_clause}, updated_at = ? WHERE id = ?", [values[k] for k in values if k != "track_name"] + [now_unix(), track_id], ) action = "updated" else: columns = ", ".join(values.keys()) placeholders = ", ".join("?" for _ in values) cur.execute( f"INSERT INTO guide_tracks ({columns}, created_at, updated_at) VALUES ({placeholders}, ?, ?)", list(values.values()) + [now_unix(), now_unix()], ) track_id = int(cur.lastrowid) action = "created" conn.commit() finally: conn.close() log = _log() if log: log.info("track_upserted year=%s track=%s action=%s stage=track", year, track_name, action) return {"ok": True, "year": year, "track_name": track_name, "id": track_id, "action": action} def _import_generic(rows: Any, upsert, label: str) -> dict[str, Any]: if not isinstance(rows, list) or not rows: raise ValueError(f"导入内容应为非空 JSON 数组({label})") if len(rows) > 500: raise ValueError(f"单次导入条数过多({len(rows)}),上限 500 条") results = [] for idx, raw in enumerate(rows): if not isinstance(raw, dict): raise ValueError(f"第 {idx + 1} 条不是 JSON 对象") results.append(upsert(raw)) return { "ok": True, "count": len(results), "created": sum(1 for r in results if r["action"] == "created"), "updated": sum(1 for r in results if r["action"] == "updated"), "items": results, } def import_teachers(rows: list[dict[str, Any]]) -> dict[str, Any]: """批量导入教师画像。""" return _import_generic(rows, lambda row: upsert_teacher({_snake(k): v for k, v in _norm_teacher(row).items()}), "教师画像") def import_tracks(rows: list[dict[str, Any]]) -> dict[str, Any]: """批量导入指南赛道。""" return _import_generic(rows, lambda row: upsert_track({_snake(k): v for k, v in _norm_track(row).items()}), "指南赛道") def list_teachers(department: str = "") -> list[dict[str, Any]]: init_db() conn = get_conn() try: cur = conn.cursor() if department: cur.execute("SELECT * FROM teacher_profiles WHERE department LIKE ? ORDER BY id", (f"%{department}%",)) else: cur.execute("SELECT * FROM teacher_profiles ORDER BY id") columns = [d[0] for d in cur.description] rows = [dict(zip(columns, row)) for row in cur.fetchall()] finally: conn.close() return rows def list_tracks(year: Optional[int] = None) -> list[dict[str, Any]]: init_db() conn = get_conn() try: cur = conn.cursor() if year: cur.execute("SELECT * FROM guide_tracks WHERE year = ? ORDER BY id", (year,)) else: cur.execute("SELECT * FROM guide_tracks ORDER BY year DESC, id") columns = [d[0] for d in cur.description] rows = [dict(zip(columns, row)) for row in cur.fetchall()] finally: conn.close() return rows def list_reports(limit: int = 20, tier: str = "") -> list[dict[str, Any]]: init_db() limit = max(1, min(int(limit), 200)) conn = get_conn() try: cur = conn.cursor() if tier: cur.execute( "SELECT id, batch_no, season, teacher_name, department, eligible_count, top_json, report_file, created_at FROM match_reports WHERE top_json LIKE ? ORDER BY id DESC LIMIT ?", (f"%{tier}%", limit), ) else: cur.execute( "SELECT id, batch_no, season, teacher_name, department, eligible_count, top_json, report_file, created_at FROM match_reports ORDER BY id DESC LIMIT ?", (limit,), ) columns = [d[0] for d in cur.description] rows = [dict(zip(columns, row)) for row in cur.fetchall()] finally: conn.close() return rows def get_report(report_id: int) -> dict[str, Any]: init_db() conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT * FROM match_reports WHERE id = ?", (report_id,)) row = cur.fetchone() if not row: raise ValueError(f"推荐报告不存在:{report_id}") columns = [d[0] for d in cur.description] report = dict(zip(columns, row)) finally: conn.close() for key in ("top_json", "tiers_json", "excluded_json"): try: report[key] = json.loads(report.get(key) or "[]") except (TypeError, ValueError): report[key] = [] return report def stats() -> dict[str, Any]: init_db() conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM teacher_profiles") teacher_total = int(cur.fetchone()[0]) cur.execute("SELECT year, COUNT(*) FROM guide_tracks GROUP BY year ORDER BY year DESC") tracks_by_year = {str(r[0]): int(r[1]) for r in cur.fetchall()} cur.execute("SELECT COUNT(*) FROM match_reports") report_total = int(cur.fetchone()[0]) cur.execute( "SELECT teacher_name, season, top_json FROM match_reports ORDER BY id DESC LIMIT 50" ) strong = [] for teacher_name, season, top_json in cur.fetchall(): try: top = json.loads(top_json or "[]") except (TypeError, ValueError): top = [] if top and top[0].get("tier") == "强烈推荐": strong.append({"teacher_name": teacher_name, "season": season, "top_track": top[0]["track"], "match_pct": top[0]["match_pct"]}) strong = strong[:5] finally: conn.close() return { "ok": True, "teacher_total": teacher_total, "tracks_by_year": tracks_by_year, "report_total": report_total, "strong_top": strong, }