Files
gaokao-volunteer-system/scripts/gaokao-visual-report-v2.py
Hermes Agent 08148fe282
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat: add consent gating and dev verify
2026-06-14 13:02:03 +08:00

750 lines
29 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.
"""
高考志愿填报可视化报告生成器 V2.0
支持:雷达图、热力图、对比表、风险检测
新增HTML导出、PDF导出、精美样式
"""
from datetime import datetime
# 尝试导入可选依赖
try:
from jinja2 import Template
HAS_JINJA = True
except ImportError:
HAS_JINJA = False
print("警告: jinja2 未安装HTML模板功能将受限")
try:
from weasyprint import HTML # type: ignore[import-untyped]
HAS_WEASYPRINT = True
except ImportError:
HAS_WEASYPRINT = False
print("警告: weasyprint 未安装PDF导出功能不可用")
def generate_student_radar(student_profile):
"""
生成考生画像雷达图数据
"""
scores = {
"兴趣匹配度": student_profile.get("interest_match", 0),
"能力匹配度": student_profile.get("ability_match", 0),
"就业匹配度": student_profile.get("employment_match", 0),
"家庭适配度": student_profile.get("family_match", 0),
}
# 综合得分计算
weighted_score = (
scores["兴趣匹配度"] * 0.3
+ scores["能力匹配度"] * 0.3
+ scores["就业匹配度"] * 0.25
+ scores["家庭适配度"] * 0.15
)
# 生成ASCII雷达图
radar_chart = f"""
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 {student_profile.get("name", "考生")} 画像雷达图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 兴趣匹配度 │
{scores["兴趣匹配度"]:>3}/10 │
│ │ │
{min(scores["能力匹配度"], 10):>2}/10 ─────────┼───────── {min(scores["就业匹配度"], 10):>2}/10 │
│ 能力匹配度 │ 就业匹配度 │
│ │ │
│ 【{weighted_score:.1f}/10】 │
│ 综合得分 │
│ │ │
{scores["家庭适配度"]:>3}/10 │
│ 家庭适配度 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 评分说明: │
│ • 兴趣匹配:基于霍兰德职业兴趣测试结果 │
│ • 能力匹配:学科成绩与专业要求的匹配度 │
│ • 就业匹配:专业就业前景评估 │
│ • 家庭适配:经济条件、地域要求的符合度 │
└─────────────────────────────────────────────────────────────────┘
"""
return radar_chart, weighted_score, scores
def generate_school_comparison(volunteer_list):
"""
生成院校对比决策表
"""
table = """
┌──────────────────────────────────────────────────────────────────────┐
│ 🎯 志愿方案可视化对比 │
├────────────┬────────────┬──────────┬─────────┬─────────┬───────────┤
│ 志愿类型 │ 院校 │ 专业 │ 录取概率│ 匹配指数│ 推荐指数 │
├────────────┼────────────┼──────────┼─────────┼─────────┼───────────┤
"""
for idx, vol in enumerate(volunteer_list, 1):
v_type = vol.get("type", "")
emoji = {"": "🔴", "": "🟡", "": "🟢"}.get(v_type, "")
prob_bar = "" * int(vol.get("probability", 0) / 10) + "" * (
10 - int(vol.get("probability", 0) / 10)
)
match_score = vol.get("match_score", 0)
stars = "" * int(match_score / 20) + "" * (5 - int(match_score / 20))
table += f"""{emoji} {v_type:>2} {idx:>2}{vol.get("school", "待定"):<10}{vol.get("major", "待定"):<8}{prob_bar}{match_score:>3}{stars}
├────────────┼────────────┼──────────┼─────────┼─────────┼───────────┤
"""
table += """│ │
│ 图例: │
│ 录取概率 █████ 90%+ ████░ 80%+ ███░░ 60%+ ██░░░ 40%+ █░░░░ <20%
│ 匹配指数 90-100 完美 80-90 高度 70-80 中度 <70 需谨慎 │
└──────────────────────────────────────────────────────────────────┘
"""
return table
def generate_major_heatmap(majors):
"""
生成专业匹配度热力图
"""
heatmap = """
┌──────────────────────────────────────────────────────────────────────┐
│ 🔥 兴趣-专业匹配度热力图 │
├──────────────────────────────────────────────────────────────────────┤
│ │
"""
for major in majors:
name = major.get("name", "")
score = major.get("match_score", 0)
# 生成热力条
if score >= 90:
bar = "" * 20
status = "强烈推荐"
elif score >= 80:
bar = "" * 17 + "" * 3
status = "推荐选择"
elif score >= 70:
bar = "" * 14 + "" * 6
status = "可以考虑"
elif score >= 60:
bar = "" * 11 + "" * 9
status = "谨慎考虑"
elif score >= 40:
bar = "" * 8 + "" * 12
status = "不太建议"
else:
bar = "" * 5 + "" * 15
status = "不推荐"
heatmap += f"{name:<12} {bar} {score:>3}% {status:<12}\n"
heatmap += """│ │
│ 热力指数:████ 90-100% ████ 80-90% ███░ 70-80% ██░░ 60-70%
│ █░░░ 30-60% ░░░░ <30% (不推荐) │
└──────────────────────────────────────────────────────────────────────┘
"""
return heatmap
def detect_risks(student_profile, volunteer_list):
"""
智能风险检测
"""
risks = []
# 检查位次差距
for vol in volunteer_list:
if vol.get("type") == "":
if vol.get("probability", 0) < 30:
risks.append({
"level": "warning",
"item": f"{vol.get('school')}录取概率过低",
"desc": f"录取概率仅{vol.get('probability')}%,需要增加相近备选",
})
# 检查学科匹配
weak_subjects = student_profile.get("weak_subjects", [])
for vol in volunteer_list:
required = vol.get("required_subjects", [])
for subj in required:
if subj in weak_subjects:
risks.append({
"level": "danger",
"item": f"{vol.get('school')}-{vol.get('major')}学科不匹配",
"desc": f"该专业需要{subj},但考生{subj}为弱项",
})
# 检查梯度合理性
types_count = {"": 0, "": 0, "": 0}
for vol in volunteer_list:
v_type = vol.get("type", "")
types_count[v_type] = types_count.get(v_type, 0) + 1
total = len(volunteer_list)
if total > 0:
if types_count[""] / total < 0.2:
risks.append({
"level": "danger",
"item": "保底志愿不足",
"desc": f"保底志愿仅占{types_count[''] / total * 100:.0f}%建议至少30%",
})
# 生成风险报告
risk_report = """
┌──────────────────────────────────────────────────────────────────────┐
│ 🚦 志愿填报风险检测报告 │
├──────────────────────────────────────────────────────────────────────┤
│ │
"""
danger_list = [r for r in risks if r["level"] == "danger"]
warning_list = [r for r in risks if r["level"] == "warning"]
if danger_list:
risk_report += (
"│ 🔴 高风险项目(必须修改): │\n"
)
for risk in danger_list:
risk_report += f"│ ✗ {risk['item']:<30}\n"
risk_report += f"│ → {risk['desc']:<50}\n"
risk_report += "│ │\n"
if warning_list:
risk_report += (
"│ 🟡 中风险项目(建议调整): │\n"
)
for risk in warning_list:
risk_report += f"│ ⚠ {risk['item']:<30}\n"
risk_report += f"│ → {risk['desc']:<50}\n"
risk_report += "│ │\n"
if not risks:
risk_report += (
"│ 🟢 恭喜!未检测到高风险项目,当前方案可以安全填报 │\n"
)
risk_report += """│ │
└──────────────────────────────────────────────────────────────────────┘
"""
return risk_report, risks
def generate_html_report(student_data, volunteer_list, output_file=None):
"""
生成HTML可视化报告
"""
if not HAS_JINJA:
print("错误: 需要安装 jinja2 才能生成HTML报告")
print("运行: pip3 install --user jinja2")
return None
# 计算雷达图数据
radar_data = {
"interest": student_data.get("interest_match", 0),
"ability": student_data.get("ability_match", 0),
"employment": student_data.get("employment_match", 0),
"family": student_data.get("family_match", 0),
}
radar_data["average"] = sum(radar_data.values()) / 4
# HTML模板
html_template = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高考志愿填报方案 - {{ name }}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
text-align: center;
}
.header h1 { font-size: 2.5em; margin-bottom: 10px; }
.header .subtitle { font-size: 1.2em; opacity: 0.9; }
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
padding: 30px;
background: #f8f9fa;
}
.info-card {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.info-card .label { color: #666; font-size: 0.9em; margin-bottom: 5px; }
.info-card .value { font-size: 1.5em; font-weight: bold; color: #667eea; }
.section {
padding: 40px;
border-bottom: 1px solid #eee;
}
.section:last-child { border-bottom: none; }
.section-title {
font-size: 1.8em;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.chart-container {
background: #f8f9fa;
padding: 30px;
border-radius: 12px;
margin: 20px 0;
}
.school-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.school-table th, .school-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #eee;
}
.school-table th {
background: #667eea;
color: white;
}
.school-table tr:hover { background: #f8f9fa; }
.badge {
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.85em;
font-weight: bold;
}
.badge-red { background: #ffebee; color: #c62828; }
.badge-yellow { background: #fff3e0; color: #ef6c00; }
.badge-green { background: #e8f5e9; color: #2e7d32; }
.heatmap { display: flex; flex-direction: column; gap: 10px; }
.heat-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.heat-bar {
flex: 1;
height: 20px;
background: linear-gradient(90deg, #ff6b6b 0%, #ffd93d 50%, #6bcf7f 100%);
border-radius: 10px;
position: relative;
}
.heat-marker {
position: absolute;
top: -5px;
width: 30px;
height: 30px;
background: #667eea;
border-radius: 50%;
transform: translateX(-50%);
box-shadow: 0 2px 10px rgba(102,126,234,0.5);
}
.risk-list { list-style: none; }
.risk-item {
padding: 15px;
margin: 10px 0;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
}
.risk-danger { background: #ffebee; border-left: 4px solid #c62828; }
.risk-warning { background: #fff3e0; border-left: 4px solid #ef6c00; }
.risk-safe { background: #e8f5e9; border-left: 4px solid #2e7d32; }
.footer {
background: #333;
color: white;
padding: 30px;
text-align: center;
}
@media print {
body { background: white; }
.container { box-shadow: none; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎓 高考志愿填报方案</h1>
<p class="subtitle">{{ name }} | {{ province }} | {{ score }}分 | 位次: {{ rank }}</p>
<p style="margin-top: 10px; opacity: 0.8;">生成时间: {{ generated_at }}</p>
</div>
<div class="info-grid">
<div class="info-card">
<div class="label">考生姓名</div>
<div class="value">{{ name }}</div>
</div>
<div class="info-card">
<div class="label">高考总分</div>
<div class="value">{{ score }}分</div>
</div>
<div class="info-card">
<div class="label">全省位次</div>
<div class="value">{{ rank }}</div>
</div>
<div class="info-card">
<div class="label">综合匹配</div>
<div class="value">{{ radar.average }}/10</div>
</div>
</div>
<div class="section">
<h2 class="section-title">📊 考生画像分析</h2>
<div class="chart-container">
<canvas id="radarChart" width="400" height="300"></canvas>
</div>
</div>
<div class="section">
<h2 class="section-title">🎯 志愿方案对比</h2>
<table class="school-table">
<thead>
<tr>
<th>类型</th>
<th>院校</th>
<th>专业</th>
<th>录取概率</th>
<th>匹配度</th>
</tr>
</thead>
<tbody>
{% for vol in volunteers %}
<tr>
<td>
{% if vol.type == '' %}
<span class="badge badge-red">🔴 冲</span>
{% elif vol.type == '' %}
<span class="badge badge-yellow">🟡 稳</span>
{% else %}
<span class="badge badge-green">🟢 保</span>
{% endif %}
</td>
<td>{{ vol.school }}</td>
<td>{{ vol.major }}</td>
<td>
<div style="background: #eee; border-radius: 10px; overflow: hidden;">
<div style="background: linear-gradient(90deg, #ff6b6b, #ffd93d, #6bcf7f);
height: 20px; width: {{ vol.probability }}%;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 0.8em; font-weight: bold;">
{{ vol.probability }}%
</div>
</div>
</td>
<td>{{ vol.match_score }}分</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<h2 class="section-title">🔥 专业匹配度热力图</h2>
<div class="heatmap">
{% for major in majors %}
<div class="heat-item">
<span style="width: 120px; font-weight: bold;">{{ major.name }}</span>
<div class="heat-bar">
<div class="heat-marker" style="left: {{ major.match_score }}%;"></div>
</div>
<span style="width: 60px; text-align: right; font-weight: bold;">{{ major.match_score }}%</span>
</div>
{% endfor %}
</div>
</div>
<div class="section">
<h2 class="section-title">🚦 风险检测报告</h2>
{% if risks %}
<ul class="risk-list">
{% for risk in risks %}
<li class="risk-item {% if risk.level == 'danger' %}risk-danger{% elif risk.level == 'warning' %}risk-warning{% else %}risk-safe{% endif %}">
{% if risk.level == 'danger' %}✗{% elif risk.level == 'warning' %}⚠{% else %}✓{% endif %}
<div>
<strong>{{ risk.item }}</strong>
<p style="color: #666; margin-top: 5px;">{{ risk.desc }}</p>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="risk-item risk-safe">
✓ <strong>恭喜!当前方案未检测到高风险,可以安全填报</strong>
</div>
{% endif %}
</div>
<div class="footer">
<p>© 2026 高考志愿填报智能系统</p>
<p style="margin-top: 10px; opacity: 0.7;">本报告仅供参考,请以官方信息为准</p>
</div>
</div>
<script>
// 雷达图
const ctx = document.getElementById('radarChart').getContext('2d');
new Chart(ctx, {
type: 'radar',
data: {
labels: ['兴趣匹配', '能力匹配', '就业匹配', '家庭适配'],
datasets: [{
label: '匹配度',
data: [{{ radar.interest }}, {{ radar.ability }}, {{ radar.employment }}, {{ radar.family }}],
backgroundColor: 'rgba(102, 126, 234, 0.2)',
borderColor: 'rgba(102, 126, 234, 1)',
borderWidth: 2,
pointBackgroundColor: 'rgba(102, 126, 234, 1)',
pointSize: 6
}]
},
options: {
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: {
stepSize: 20
}
}
},
plugins: {
legend: { display: false }
}
}
});
</script>
</body>
</html>"""
template = Template(html_template)
# 准备数据
majors = [
{"name": vol["major"], "match_score": vol["match_score"]}
for vol in volunteer_list
]
_, risks = detect_risks(student_data, volunteer_list)
html_content = template.render(
name=student_data.get("name", "考生"),
province=student_data.get("province", "未知"),
score=student_data.get("score", 0),
rank=student_data.get("rank", 0),
radar=radar_data,
volunteers=volunteer_list,
majors=majors,
risks=risks,
generated_at=datetime.now().strftime("%Y年%m月%d%H:%M"),
)
# 保存HTML
if output_file is None:
output_file = f"/tmp/gaokao_report_{student_data.get('name', 'unknown')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
with open(output_file, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"✓ HTML报告已生成: {output_file}")
return output_file
def generate_pdf_from_html(html_file, pdf_file=None):
"""
将HTML转换为PDF
"""
if not HAS_WEASYPRINT:
print("错误: 需要安装 weasyprint 才能生成PDF")
print("运行: pip3 install --user weasyprint")
return None
if pdf_file is None:
pdf_file = html_file.replace(".html", ".pdf")
try:
HTML(filename=html_file).write_pdf(pdf_file)
print(f"✓ PDF报告已生成: {pdf_file}")
return pdf_file
except Exception as e:
print(f"✗ PDF生成失败: {e}")
return None
def generate_visual_report(student_data, volunteer_list, output_format="all"):
"""
生成完整可视化报告(支持多种格式)
参数:
student_data: 考生数据
volunteer_list: 志愿列表
output_format: 输出格式 ('md', 'html', 'pdf', 'all')
返回:
生成的文件路径列表
"""
results = []
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = f"gaokao_report_{student_data.get('name', 'unknown')}_{timestamp}"
# 1. 生成Markdown报告
if output_format in ["md", "all"]:
md_content = f"""# 高考志愿填报方案报告
**生成时间**: {datetime.now().strftime("%Y年%m月%d%H:%M")}
**考生姓名**: {student_data.get("name", "考生")}
**考生省份**: {student_data.get("province", "未知")}
**高考总分**: {student_data.get("score", 0)}
**全省位次**: {student_data.get("rank", 0)}
---
## 一、考生画像雷达图
"""
radar_chart, weighted_score, scores = generate_student_radar(student_data)
md_content += radar_chart
md_content += f"\n**综合匹配指数**: {weighted_score:.1f}/10\n\n"
md_content += "## 二、志愿方案可视化对比\n\n"
md_content += generate_school_comparison(volunteer_list)
md_content += "## 三、专业匹配度热力图\n\n"
majors = [
{"name": vol["major"], "match_score": vol["match_score"]}
for vol in volunteer_list
]
md_content += generate_major_heatmap(majors)
md_content += "## 四、风险检测报告\n\n"
risk_report, _ = detect_risks(student_data, volunteer_list)
md_content += risk_report
md_file = f"/tmp/{base_name}.md"
with open(md_file, "w", encoding="utf-8") as f:
f.write(md_content)
print(f"✓ Markdown报告已生成: {md_file}")
results.append(md_file)
# 2. 生成HTML报告
html_file = None
if output_format in ["html", "pdf", "all"]:
html_file = f"/tmp/{base_name}.html"
html_file = generate_html_report(student_data, volunteer_list, html_file)
if html_file:
results.append(html_file)
# 3. 生成PDF报告
if output_format in ["pdf", "all"] and html_file:
pdf_file = f"/tmp/{base_name}.pdf"
pdf_file = generate_pdf_from_html(html_file, pdf_file)
if pdf_file:
results.append(pdf_file)
return results
# 示例使用
if __name__ == "__main__":
# 示例考生数据
student = {
"name": "李明",
"province": "浙江省",
"score": 612,
"rank": 15230,
"interest_match": 85,
"ability_match": 90,
"employment_match": 88,
"family_match": 95,
"weak_subjects": ["化学", "语文"],
}
# 示例志愿列表
volunteers = [
{
"school": "浙江大学",
"major": "计算机类",
"type": "",
"probability": 35,
"match_score": 95,
"required_subjects": ["数学", "物理"],
},
{
"school": "杭州电子科技大学",
"major": "计算机类",
"type": "",
"probability": 70,
"match_score": 92,
"required_subjects": ["数学", "物理"],
},
{
"school": "浙江工业大学",
"major": "软件工程",
"type": "",
"probability": 80,
"match_score": 88,
"required_subjects": ["数学"],
},
{
"school": "浙江理工大学",
"major": "软件工程",
"type": "",
"probability": 95,
"match_score": 82,
"required_subjects": ["数学"],
},
]
print("=" * 60)
print("高考志愿填报可视化报告生成器 V2.0")
print("=" * 60)
print()
# 生成所有格式
files = generate_visual_report(student, volunteers, output_format="all")
print()
print("=" * 60)
print("生成完成!文件列表:")
print("=" * 60)
for f in files:
print(f"{f}")
print()
print("提示:")
print(" • HTML文件可直接在浏览器中打开")
print(" • PDF文件可直接打印或分享")
print(" • Markdown文件可编辑和转换")