Files
match-guide-track-default/scripts/service/profile_service.py
chenfuqiang 7c1f40b0e6
All checks were successful
技能自动化发布 / release (push) Successful in 6s
feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
2026-09-13 15:50:33 +08:00

273 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""教师画像 / 指南赛道 / 推荐报告 的维护与查询。"""
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,
}