- 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 同步表述
211 lines
8.3 KiB
Python
211 lines
8.3 KiB
Python
"""《申报策略建议书》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 '<p class="muted">没有可推荐的赛道:请检查画像完整性或指南赛道库。</p>'
|
||
medals = ["🥇", "🥈", "🥉"]
|
||
cards = []
|
||
for idx, entry in enumerate(top3):
|
||
badge = _TIER_BADGE.get(str(entry.get("tier")), "tier-not")
|
||
reasons = "".join(f"<li>{_esc(r)}</li>" for r in entry.get("reasons") or [])
|
||
cards.append(
|
||
f'<div class="card-item">'
|
||
f'<div class="card-head"><span class="medal">{medals[idx] if idx < 3 else "·"}</span>'
|
||
f'<strong>{_esc(entry.get("track"))}</strong>'
|
||
f'<span class="tag {badge}">{_esc(entry.get("tier"))} · {_pct(entry.get("match_pct"))}</span></div>'
|
||
f'<ol class="reasons">{reasons}</ol>'
|
||
f"</div>"
|
||
)
|
||
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(
|
||
"<tr>"
|
||
f'<td class="nowrap"><span class="tag {badge}">{_esc(tier)}</span></td>'
|
||
f'<td>{_esc(entry.get("track"))}</td>'
|
||
f'<td class="nowrap">{_pct(entry.get("match_pct"))}</td>'
|
||
f'<td class="muted">{_esc("、".join(entry.get("matched_tags") or []))}</td>'
|
||
"</tr>"
|
||
)
|
||
if not rows:
|
||
return '<tr><td colspan="4" class="muted">无</td></tr>'
|
||
return "".join(rows)
|
||
|
||
|
||
def _excluded_table(excluded: list[dict[str, Any]]) -> str:
|
||
if not excluded:
|
||
return ""
|
||
rows = "".join(f"<tr><td>{_esc(e.get('track'))}</td><td class='muted'>{_esc(e.get('reason'))}</td></tr>" for e in excluded)
|
||
return f'<div class="section"><h2>被硬门槛剔除的赛道</h2><table><tr><th>赛道</th><th>原因</th></tr>{rows}</table></div>'
|
||
|
||
|
||
def build_match_html(result: dict[str, Any], batch_no: str = "") -> str:
|
||
body = f"""
|
||
<div class="verdict-line">
|
||
<span class="tag tier-info">{_esc(result.get("teacher_name"))}</span>
|
||
<span class="muted">{_esc(result.get("department") or "")} · 指南年度 {_esc(result.get("season"))} · 可匹配赛道 {_esc(result.get("eligible_count"))} 个 · 批次 {_esc(batch_no or result.get("batch_no"))}</span>
|
||
</div>
|
||
<div class="section"><h2>Top 3 最匹配赛道</h2>{_top_cards(result.get("top3") or [])}</div>
|
||
<div class="section"><h2>完整分层清单</h2>
|
||
<table><tr><th>分层</th><th>赛道</th><th>匹配度</th><th>命中标签</th></tr>{_tier_table(result.get("tiers") or {})}</table>
|
||
</div>
|
||
{_excluded_table(result.get("excluded") or [])}
|
||
<footer>推荐由标签匹配算法生成、仅供参考;申报决策请结合个人规划与学院统筹,以当年度正式指南为准。</footer>
|
||
"""
|
||
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(
|
||
"<tr>"
|
||
f"<td>{_esc(result.get('teacher_name'))}</td>"
|
||
f"<td class='muted'>{_esc(result.get('department'))}</td>"
|
||
f"<td><span class='tag {badge}'>{_esc(first.get('tier'))}</span></td>"
|
||
f"<td>{_esc(first.get('track'))}</td>"
|
||
f"<td class='nowrap'>{_pct(first.get('match_pct'))}</td>"
|
||
"</tr>"
|
||
)
|
||
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>')
|
||
|
||
body = f"""
|
||
<div class="verdict-line">
|
||
<span class="muted">指南年度 {_esc(season)} · 教师 {_esc(len(results))} 人 · 批次 {_esc(batch_no)}</span>
|
||
</div>
|
||
<div class="section"><h2>各教师 Top 1 赛道一览</h2>
|
||
<table><tr><th>教师</th><th>学院</th><th>分层</th><th>最匹配赛道</th><th>匹配度</th></tr>{''.join(rows)}</table>
|
||
</div>
|
||
<footer>汇总由批量匹配自动生成,仅用于科研动员参考;详细理由见各教师《申报策略建议书》。</footer>
|
||
"""
|
||
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
|