"""《申报策略建议书》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 _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 "—"))
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''
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 = _log()
path = write_html_page(f"report-{result['report_id']}.html", html_text)
if log:
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 = _log()
if log:
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)
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,
skipped: list[dict[str, Any]] | None = None,
) -> str:
"""批量匹配汇总页:各教师 Top 1 一览;被跳过(画像待补录)教师单列。"""
log = _log()
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'))} | 无可推荐赛道(画像待补全) |
")
for entry in skipped or []:
rows.append(
""
f"| {_esc(entry.get('teacher_name'))} | "
f"画像待补录,本期未匹配({_esc(entry.get('reason'))}) | "
"
"
)
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)
if log:
log.info("match_brief_written batch_no=%s path=%s stage=report", batch_no, path)
return path