fix: 批量匹配跳过画像待补录教师 + render-html 按行重建 + 金标准 fixtures 业务化(P0+P1)

- run_match 新增 skip_incomplete:批量缺省跳过画像不完整教师并记录 skipped,指定单教师仍 PROFILE_INCOMPLETE;汇总页出"画像待补录"行
- render-html 补 --report-id 行上下文(inputMapping $row.$pk),CLI 容错解析回退最近 10 份
- report_service 日志改容错 _log(),修复未 setup 时单跑测试崩溃的顺序依赖
- fixtures 替换为教师画像/赛道/期望结论金标准样例,新增 sample_tracks.json
- 新增 4 个批量跳过用例(314 项,除 3 个已知 jsonschema 环境失败外全绿)
- tests/README 增补本机无共享 runtime 的测试方式;TUTORIAL/ACTIONS.md 同步表述
This commit is contained in:
chenfuqiang
2026-09-24 18:01:41 +08:00
parent 7c1f40b0e6
commit 4a8613b27e
15 changed files with 267 additions and 47 deletions

View File

@@ -247,8 +247,20 @@ def _write_report(result: dict[str, Any], batch_no: str) -> str:
return write_report_for_kernel(result=result, batch_no=batch_no)
def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]], season: str, *, batch_no: str = "") -> dict[str, Any]:
"""匹配内核:逐教师匹配 → 写库 → 生成建议书(多人时另出汇总页)。"""
def run_match(
teachers_raw: list[dict[str, Any]],
tracks: list[dict[str, Any]],
season: str,
*,
batch_no: str = "",
skip_incomplete: bool = True,
) -> dict[str, Any]:
"""匹配内核:逐教师匹配 → 写库 → 生成建议书(多人时另出汇总页)。
skip_incomplete=True(批量缺省):画像不完整(E1)的教师被跳过并记入
``skipped``,不中断批次;skip_incomplete=False(指定单教师):直接抛
ValueError,由 CLI 映射 PROFILE_INCOMPLETE。
"""
from service.report_service import build_batch_brief
log = _log()
@@ -263,11 +275,21 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]],
log.info("match_kernel_start batch_no=%s teachers=%s tracks=%s season=%s stage=kernel", batch_no, len(teachers_raw), len(tracks), season)
results = []
skipped: list[dict[str, str]] = []
conn = get_conn()
try:
cur = conn.cursor()
for teacher_raw in teachers_raw:
result = match_teacher(teacher_raw, tracks, season)
try:
result = match_teacher(teacher_raw, tracks, season)
except ValueError as exc:
if not skip_incomplete:
raise
raw_name = str((teacher_raw or {}).get("teacherName") or (teacher_raw or {}).get("teacher_name") or "").strip() or "未命名教师"
skipped.append({"teacher_name": raw_name, "reason": str(exc)})
if log:
log.warning("match_teacher_skipped teacher=%s reason=%s stage=kernel", raw_name, exc)
continue
cur.execute(
"""
INSERT INTO match_reports
@@ -296,7 +318,7 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]],
finally:
conn.close()
brief_file = build_batch_brief(batch_no=batch_no, results=results, season=season)
brief_file = build_batch_brief(batch_no=batch_no, results=results, season=season, skipped=skipped)
tier_counts: dict[str, int] = {}
for result in results:
for tier, entries in result["tiers"].items():
@@ -307,13 +329,19 @@ def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]],
"batch_no": batch_no,
"season": season,
"count": len(results),
"skipped": skipped,
"tier_counts": tier_counts,
"brief_file": brief_file,
"disclaimer": "推荐仅供参考,申报决策请结合个人规划与学院统筹。",
"reports": results,
}
if log:
log.info("match_kernel_done batch_no=%s count=%s stage=kernel", batch_no, len(results))
log.info(
"match_kernel_done batch_no=%s count=%s skipped=%s stage=kernel",
batch_no,
len(results),
len(skipped),
)
return payload

View File

@@ -18,6 +18,14 @@ _TEMPLATE_REL = "assets/html/match-report.html"
_TIER_BADGE = {"强烈推荐": "tier-strong", "可尝试": "tier-try", "不建议": "tier-not"}
def _log():
"""容错取 logger:未 setup_skill_logging(如单测直调 service)时返回 None。"""
try:
return get_skill_logger()
except RuntimeError:
return None
def _esc(value: Any) -> str:
return html_escape(str(value if value is not None else "—"))
@@ -106,9 +114,10 @@ def build_match_html(result: dict[str, Any], batch_no: str = "") -> str:
def write_report_for_kernel(*, result: dict[str, Any], batch_no: str) -> str:
html_text = build_match_html(result, batch_no)
log = get_skill_logger()
log = _log()
path = write_html_page(f"report-{result['report_id']}.html", html_text)
log.info("match_report_written report_id=%s path=%s stage=report", result["report_id"], path)
if log:
log.info("match_report_written report_id=%s path=%s stage=report", result["report_id"], path)
return path
@@ -116,8 +125,9 @@ def build_report_for_report_id(report_id: int) -> str:
"""按报告 id 从库中取数并重写 HTML(重建入口用)。"""
from db.connection import get_conn
log = get_skill_logger()
log.info("report_render_start report_id=%s stage=render", report_id)
log = _log()
if log:
log.info("report_render_start report_id=%s stage=render", report_id)
conn = get_conn()
try:
cur = conn.cursor()
@@ -139,13 +149,20 @@ def build_report_for_report_id(report_id: int) -> str:
result["top3"] = result.get("top") or []
html_text = build_match_html(result)
path = write_html_page(f"report-{report_id}.html", html_text)
log.info("report_render_done report_id=%s path=%s stage=render", report_id, path)
if log:
log.info("report_render_done report_id=%s path=%s stage=render", report_id, path)
return path
def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: str) -> str:
"""批量匹配汇总页:各教师 Top 1 一览。"""
log = get_skill_logger()
def build_batch_brief(
*,
batch_no: str,
results: list[dict[str, Any]],
season: str,
skipped: list[dict[str, Any]] | None = None,
) -> str:
"""批量匹配汇总页:各教师 Top 1 一览;被跳过(画像待补录)教师单列。"""
log = _log()
rows = []
for result in results:
top3 = result.get("top3") or []
@@ -163,6 +180,13 @@ def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: s
)
else:
rows.append(f"<tr><td>{_esc(result.get('teacher_name'))}</td><td colspan='4' class='muted'>无可推荐赛道(画像待补全)</td></tr>")
for entry in skipped or []:
rows.append(
"<tr>"
f"<td>{_esc(entry.get('teacher_name'))}</td>"
f"<td colspan='4' class='muted'>画像待补录,本期未匹配({_esc(entry.get('reason'))})</td>"
"</tr>"
)
if not rows:
rows.append('<tr><td colspan="5" class="muted">本期无匹配结果。</td></tr>')
@@ -181,5 +205,6 @@ def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: s
{"TITLE": "申报赛道匹配汇总", "GENERATED_AT": str(season), "BODY": body},
)
path = write_html_page(f"brief-{batch_no}.html", page)
log.info("match_brief_written batch_no=%s path=%s stage=report", batch_no, path)
if log:
log.info("match_brief_written batch_no=%s path=%s stage=report", batch_no, path)
return path

View File

@@ -485,7 +485,9 @@ def cmd_match(*, teacher: str = "", year: Optional[int] = None) -> int:
return 1
season = str(year or tracks[0].get("year") or "")
result = run_match(teachers, tracks, season)
# 指定教师 → 严格模式,画像不完整直接 PROFILE_INCOMPLETE;
# 缺省全量 → 跳过画像待补录教师并记录,不中断批次(E1)。
result = run_match(teachers, tracks, season, skip_incomplete=not teacher)
payload = {k: v for k, v in result.items() if k != "reports"}
payload["reports_brief"] = [
{
@@ -499,9 +501,14 @@ def cmd_match(*, teacher: str = "", year: Optional[int] = None) -> int:
for r in result["reports"]
]
_print_json(payload)
skipped_count = len(result.get("skipped") or [])
message = f"匹配完成 {result['count']} 名教师,建议书已生成"
if skipped_count:
message += f";{skipped_count} 名教师画像待补录已跳过"
log.info("match_skipped count=%s stage=match", skipped_count)
finish(
status="success",
message=f"匹配完成 {result['count']} 名教师,建议书已生成",
message=message,
skill=SKILL_SLUG,
stage="match",
batch_no=result["batch_no"],
@@ -568,7 +575,7 @@ def cmd_match_batch_input() -> int:
return 1
season = str(tracks[0].get("year") or "")
result = run_match(teachers, tracks, season)
result = run_match(teachers, tracks, season, skip_incomplete=True)
for index, report in enumerate(result["reports"], start=1):
top = (report.get("top3") or [{}])[0]
emit(
@@ -577,16 +584,27 @@ def cmd_match_batch_input() -> int:
skill=SKILL_SLUG,
stage="batch",
)
for entry in result.get("skipped") or []:
emit(
f"{entry.get('teacher_name')}:画像待补录,本期已跳过({entry.get('reason')})",
type="warn",
skill=SKILL_SLUG,
stage="batch",
)
_print_json(
{
"ok": True,
"batch_no": result["batch_no"],
"count": result["count"],
"skipped": result.get("skipped") or [],
"tier_counts": result["tier_counts"],
"brief_file": result["brief_file"],
}
)
finish(status="success", message=f"批量匹配完成 {result['count']} 名教师", skill=SKILL_SLUG, stage="batch", batch_no=result["batch_no"])
message = f"批量匹配完成 {result['count']} 名教师"
if result.get("skipped"):
message += f",{len(result['skipped'])} 名画像待补录已跳过"
finish(status="success", message=message, skill=SKILL_SLUG, stage="batch", batch_no=result["batch_no"])
return 0
except JobStopped:
raise