Files
match-guide-track-default/scripts/service/report_service.py
chenfuqiang 7c1f40b0e6
All checks were successful
技能自动化发布 / release (push) Successful in 6s
feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
2026-09-13 15:50:33 +08:00

186 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""《申报策略建议书》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 '<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 = 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(
"<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>")
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)
log.info("match_brief_written batch_no=%s path=%s stage=report", batch_no, path)
return path