273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""教师画像 / 指南赛道 / 推荐报告 的维护与查询。"""
|
||
|
||
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,
|
||
}
|