"""《申报策略建议书》HTML 组装。 - 每位教师一份 `report-{report_id}.html`(Top 3 卡片 + 完整分层清单 + 剔除原因) - 批量匹配另生成汇总页 `brief-{batch_no}.html`(各教师 Top 1 一览) 写入 `{skill_data}/html/`,宿主数据管理侧栏可预览;重建走固定 Action id `render-html`。 """ from __future__ import annotations import json from typing import Any from service.html_page_service import load_html_template, render_html_placeholders, write_html_page from util.logging_config import get_skill_logger _TEMPLATE_REL = "assets/html/match-report.html" _TIER_BADGE = {"强烈推荐": "tier-strong", "可尝试": "tier-try", "不建议": "tier-not"} def _esc(value: Any) -> str: return html_escape(str(value if value is not None else "—")) def html_escape(text: str) -> str: import html return html.escape(text, quote=True) def _pct(value: Any) -> str: try: return f"{int(value)}%" except (TypeError, ValueError): return "—" def _top_cards(top3: list[dict[str, Any]]) -> str: if not top3: return '

没有可推荐的赛道:请检查画像完整性或指南赛道库。

' medals = ["🥇", "🥈", "🥉"] cards = [] for idx, entry in enumerate(top3): badge = _TIER_BADGE.get(str(entry.get("tier")), "tier-not") reasons = "".join(f"
  • {_esc(r)}
  • " for r in entry.get("reasons") or []) cards.append( f'
    ' f'
    {medals[idx] if idx < 3 else "·"}' f'{_esc(entry.get("track"))}' f'{_esc(entry.get("tier"))} · {_pct(entry.get("match_pct"))}
    ' f'
      {reasons}
    ' f"
    " ) return "".join(cards) def _tier_table(tiers: dict[str, list[dict[str, Any]]]) -> str: rows = [] order = ["强烈推荐", "可尝试", "不建议"] for tier in order: for entry in tiers.get(tier) or []: badge = _TIER_BADGE.get(tier, "tier-not") rows.append( "" f'{_esc(tier)}' f'{_esc(entry.get("track"))}' f'{_pct(entry.get("match_pct"))}' f'{_esc("、".join(entry.get("matched_tags") or []))}' "" ) if not rows: return '无' return "".join(rows) def _excluded_table(excluded: list[dict[str, Any]]) -> str: if not excluded: return "" rows = "".join(f"{_esc(e.get('track'))}{_esc(e.get('reason'))}" for e in excluded) return f'

    被硬门槛剔除的赛道

    {rows}
    赛道原因
    ' def build_match_html(result: dict[str, Any], batch_no: str = "") -> str: body = f"""
    {_esc(result.get("teacher_name"))} {_esc(result.get("department") or "")} · 指南年度 {_esc(result.get("season"))} · 可匹配赛道 {_esc(result.get("eligible_count"))} 个 · 批次 {_esc(batch_no or result.get("batch_no"))}

    Top 3 最匹配赛道

    {_top_cards(result.get("top3") or [])}

    完整分层清单

    {_tier_table(result.get("tiers") or {})}
    分层赛道匹配度命中标签
    {_excluded_table(result.get("excluded") or [])} """ template = load_html_template(_TEMPLATE_REL) return render_html_placeholders( template, { "TITLE": f"申报策略建议书 · {result.get('teacher_name') or ''}", "GENERATED_AT": str(result.get("season") or ""), "BODY": body, }, ) 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() 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) return path 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) 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] record = dict(zip(columns, row)) finally: conn.close() result = dict(record) for key in ("top_json", "tiers_json", "excluded_json"): target = key[:-5] if key.endswith("_json") else key try: result[target] = json.loads(record.get(key) or "[]") except (TypeError, ValueError): result[target] = [] 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) return path def build_batch_brief(*, batch_no: str, results: list[dict[str, Any]], season: str) -> str: """批量匹配汇总页:各教师 Top 1 一览。""" log = get_skill_logger() rows = [] for result in results: top3 = result.get("top3") or [] if top3: first = top3[0] badge = _TIER_BADGE.get(str(first.get("tier")), "tier-not") rows.append( "" f"{_esc(result.get('teacher_name'))}" f"{_esc(result.get('department'))}" f"{_esc(first.get('tier'))}" f"{_esc(first.get('track'))}" f"{_pct(first.get('match_pct'))}" "" ) else: rows.append(f"{_esc(result.get('teacher_name'))}无可推荐赛道(画像待补全)") if not rows: rows.append('本期无匹配结果。') body = f"""
    指南年度 {_esc(season)} · 教师 {_esc(len(results))} 人 · 批次 {_esc(batch_no)}

    各教师 Top 1 赛道一览

    {''.join(rows)}
    教师学院分层最匹配赛道匹配度
    """ template = load_html_template(_TEMPLATE_REL) page = render_html_placeholders( template, {"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) return path