feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s

This commit is contained in:
chenfuqiang
2026-09-13 15:50:33 +08:00
commit 7c1f40b0e6
205 changed files with 29720 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
"""教师画像 / 指南赛道 / 推荐报告 的维护与查询。"""
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,
}