feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s

This commit is contained in:
chenfuqiang
2026-09-13 15:50:33 +08:00
commit 7c1f40b0e6
205 changed files with 29720 additions and 0 deletions

View File

@@ -0,0 +1,185 @@
"""《申报策略建议书》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