Files
piano-plan/app/routes/plans.py
T

503 lines
18 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.
# 方案生成路由
import json
import os
from flask import (
request,
jsonify,
render_template,
send_file,
current_app,
Response,
stream_with_context,
session,
)
from app.routes import main_bp
from app.models import db, Student, PracticePlan
from app.services.plan_generator import generate_practice_plan, generate_ai_report
from app.services.pdf_generator import generate_pdf
from app.routes.auth import login_required_json
def sse_format(data):
"""格式化SSE数据"""
return f"data: {json.dumps(data)}\n\n"
@main_bp.route("/api/students/<int:student_id>/plans", methods=["GET"])
@login_required_json
def get_student_plans(student_id):
"""获取学员的练习方案列表"""
student = Student.query.get_or_404(student_id)
plans = student.plans.order_by(PracticePlan.created_at.desc()).all()
return jsonify([p.to_dict() for p in plans])
@main_bp.route("/api/generate-plan", methods=["POST"])
@login_required_json
def generate_plan():
"""生成练习方案 - 使用SSE实时推送进度"""
data = request.get_json()
student_id = data.get("student_id")
use_ai = data.get("use_ai", True)
template_id = data.get("template_id") # AI提示词模板ID
student = Student.query.get_or_404(student_id)
problems = student.problems.all()
if not problems:
return jsonify({"error": "请先记录学员的问题"}), 400
# 预先收集所有数据,避免在generator中访问数据库
problems_dir = current_app.config["PROBLEMS_DIR"]
# 学员的统一练习时间
practice_time = student.practice_time or "30-60分钟"
problem_data = []
for p in problems:
# problem_id 已经是完整标识(如 "01_手小"),直接用作文件名
problem_file = os.path.join(problems_dir, f"{p.problem_id}.md")
content = ""
if os.path.exists(problem_file):
with open(problem_file, "r", encoding="utf-8") as f:
content = f.read()
problem_data.append(
{
"problem_id": p.problem_id,
"problem_name": p.problem_name,
"severity": p.severity,
"level": p.level,
"content": content,
}
)
time_mapping = {
"15分钟": {"total": 15, "basic": 10, "tech": 2, "piece": 3},
"30分钟": {"total": 30, "basic": 15, "tech": 5, "piece": 10},
"45分钟": {"total": 45, "basic": 15, "tech": 10, "piece": 20},
"60分钟": {"total": 60, "basic": 20, "tech": 15, "piece": 25},
"90分钟": {"total": 90, "basic": 25, "tech": 25, "piece": 40},
"120分钟": {"total": 120, "basic": 30, "tech": 35, "piece": 55},
"150分钟以上": {"total": 150, "basic": 35, "tech": 45, "piece": 70},
}
time_config = time_mapping.get(practice_time, time_mapping["30分钟"])
# 加载API配置
from app.config import load_api_config
api_config = load_api_config(current_app.config)
@stream_with_context
def generate():
# Step 1: 收集问题数据
yield sse_format(
{"step": "collecting", "message": "正在收集问题数据...", "progress": 10}
)
# Step 2: 生成基础方案
yield sse_format(
{"step": "basic", "message": "正在生成基础练习方案...", "progress": 30}
)
plan_content = generate_practice_plan(
student_name=student.name,
problems=problem_data,
problems_dir=problems_dir,
practice_time=practice_time,
)
yield sse_format(
{"step": "basic_done", "message": "基础方案生成完成", "progress": 50}
)
# Step 3: 如果启用AI,生成AI报告
ai_report = None
if use_ai:
# 显示AI配置信息
yield sse_format(
{
"step": "ai_config",
"message": f"AI配置: {api_config.get('model', 'N/A')} @ {api_config.get('provider', 'N/A')}",
"progress": 55,
"detail": f"Endpoint: {api_config.get('base_url', '')}",
}
)
yield sse_format(
{
"step": "ai_start",
"message": "正在调用AI生成个性化报告...",
"progress": 60,
}
)
# 构建问题摘要用于显示
problems_summary = []
for p in problem_data[:3]: # 只显示前3个问题
level = p.get("level", "入门")
severity = p.get("severity", "中等")
problems_summary.append(f"- {p['problem_name']} [{level} | {severity}]")
yield sse_format(
{
"step": "ai_problems",
"message": "发送问题给AI:",
"progress": 62,
"detail": "\n".join(problems_summary),
}
)
yield sse_format(
{"step": "ai_request", "message": "等待AI响应...", "progress": 65}
)
# 先用dry_run模式获取提示词并显示给用户
prompt, _, error = generate_ai_report(
student_name=student.name,
wechat_nickname=student.wechat_nickname or "",
problems=problem_data,
practice_time=practice_time,
time_config=time_config,
template_id=template_id,
dry_run=True
)
# 发送提示词给前端显示
yield sse_format({
"step": "ai_prompt",
"message": "发送给AI的提示词:",
"progress": 60,
"detail": prompt if prompt else ""
})
if error:
yield sse_format(
{
"step": "ai_error",
"message": f"获取提示词失败: {error}",
"progress": 80,
"error": error,
}
)
return
yield sse_format(
{"step": "ai_generating", "message": "正在生成AI报告...", "progress": 70}
)
# 真正调用API生成报告
_, ai_report, error = generate_ai_report(
student_name=student.name,
wechat_nickname=student.wechat_nickname or "",
problems=problem_data,
practice_time=practice_time,
time_config=time_config,
template_id=template_id,
dry_run=False
)
if error:
yield sse_format(
{
"step": "ai_error",
"message": f"AI生成失败: {error}",
"progress": 80,
"error": error,
}
)
plan_content["ai_report_error"] = error
else:
# 显示AI返回的报告长度
report_lines = len(ai_report.split("\n")) if ai_report else 0
yield sse_format(
{
"step": "ai_response",
"message": f"AI报告已生成",
"progress": 75,
"detail": f"报告长度: {len(ai_report) if ai_report else 0} 字符, {report_lines}",
}
)
yield sse_format(
{
"step": "ai_done",
"message": "AI报告生成完成",
"progress": 80,
"has_ai": True,
}
)
plan_content["ai_report"] = ai_report
else:
yield sse_format(
{"step": "ai_skip", "message": "跳过AI生成", "progress": 80}
)
# Step 4: 保存方案
yield sse_format(
{"step": "saving", "message": "正在保存方案...", "progress": 90}
)
plan = None
try:
plan = PracticePlan(
student_id=student_id,
content=json.dumps(plan_content, ensure_ascii=False),
)
db.session.add(plan)
db.session.commit()
yield sse_format(
{
"step": "saved",
"message": f"方案已保存 (ID: {plan.id})",
"progress": 95,
}
)
except Exception as e:
db.session.rollback()
yield sse_format(
{
"step": "save_error",
"message": f"保存失败: {str(e)}",
"progress": 90,
"error": str(e),
}
)
# 确保 complete 消息被发送
try:
yield sse_format(
{
"step": "complete",
"message": "方案生成完成!",
"progress": 100,
"plan_id": plan.id if plan and hasattr(plan, 'id') else None,
"content": plan_content,
"ai_report": ai_report,
}
)
except Exception as e:
# 最后一道防护 - 即使出错也要发送 complete
yield sse_format(
{
"step": "complete",
"message": f"方案生成完成!(部分错误: {str(e)[:50]}",
"progress": 100,
"plan_id": None,
"error": str(e),
}
)
return Response(
generate(),
mimetype="text/event-stream",
headers={
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
}
)
@main_bp.route("/api/plans/<int:plan_id>", methods=["GET"])
@login_required_json
def get_plan(plan_id):
"""获取单个方案详情"""
plan = PracticePlan.query.get_or_404(plan_id)
content = json.loads(plan.content)
return jsonify(
{
"id": plan.id,
"student_id": plan.student_id,
"student_name": plan.student.name if plan.student else "",
"created_at": plan.created_at.strftime("%Y-%m-%d %H:%M"),
"content": content,
}
)
@main_bp.route("/api/plans/<int:plan_id>/pdf", methods=["GET"])
@login_required_json
def export_pdf(plan_id):
"""导出PDF - 使用模板"""
plan = PracticePlan.query.get_or_404(plan_id)
content = json.loads(plan.content)
student_name = plan.student.name if plan.student else "未知学员"
# 尝试使用数据库中的报告模板
report_template = None
try:
from app.models import Template
tmpl = Template.query.filter_by(type="report").first()
if tmpl:
report_template = tmpl.content
except:
pass
# 如果有模板,先渲染
rendered_report = None
if report_template:
rendered_report = report_template
rendered_report = rendered_report.replace("{student_name}", student_name)
rendered_report = rendered_report.replace("{practice_time}", content.get('practice_time', 'N/A'))
rendered_report = rendered_report.replace("{total_minutes}", str(content.get('total_daily_minutes', 0)))
rendered_report = rendered_report.replace("{generated_at}", content.get('generated_at', ''))
if content.get('ai_report'):
rendered_report = rendered_report.replace("{ai_report}", content['ai_report'])
else:
rendered_report = rendered_report.replace("{ai_report}", "(未生成AI报告)")
problem_tags = ""
for problem in content.get('problems', []):
problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n"
rendered_report = rendered_report.replace("{problem_tags}", problem_tags or "(无)")
schedule_table = "| 阶段 | 时长 | 内容 | 目的 |\n|------|------|------|------|\n"
for item in content.get('daily_schedule', []):
schedule_table += f"| {item.get('phase', '')} | {item.get('duration', '')} | {item.get('content', '')} | {item.get('purpose', '')} |\n"
rendered_report = rendered_report.replace("{schedule_table}", schedule_table)
pdf_path = generate_pdf(
plan_id=plan_id,
student_name=student_name,
content=content,
output_dir=current_app.config["PDF_OUTPUT_DIR"],
rendered_report=rendered_report, # 传递渲染后的报告
)
return send_file(
pdf_path, as_attachment=True, download_name=f"{student_name}_练习方案.pdf"
)
@main_bp.route("/api/plans/<int:plan_id>/md", methods=["GET"])
@login_required_json
def export_md(plan_id):
"""导出Markdown"""
plan = PracticePlan.query.get_or_404(plan_id)
content = json.loads(plan.content)
student_name = plan.student.name if plan.student else "未知学员"
template_id = request.args.get('template_id', type=int)
# 获取报告模板
report_template = None
try:
from app.models import Template
if template_id:
tmpl = Template.query.get(template_id)
if tmpl and tmpl.type == "report":
report_template = tmpl.content
else:
tmpl = Template.query.filter_by(type="report").order_by(Template.sort_order.asc()).first()
if tmpl:
report_template = tmpl.content
except:
pass
if report_template:
# 使用模板渲染
# 替换变量
rendered = report_template
rendered = rendered.replace("{student_name}", student_name)
rendered = rendered.replace("{practice_time}", content.get('practice_time', 'N/A'))
rendered = rendered.replace("{total_minutes}", str(content.get('total_daily_minutes', 0)))
rendered = rendered.replace("{generated_at}", content.get('generated_at', ''))
# AI报告
if content.get('ai_report'):
rendered = rendered.replace("{ai_report}", content['ai_report'])
else:
rendered = rendered.replace("{ai_report}", "(未生成AI报告)")
# 问题诊断标签
problem_tags = ""
for problem in content.get('problems', []):
problem_tags += f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n"
rendered = rendered.replace("{problem_tags}", problem_tags or "(无)")
# 每日计划表格
schedule_table = "| 阶段 | 时长 | 内容 | 目的 |\n|------|------|------|------|\n"
for item in content.get('daily_schedule', []):
schedule_table += f"| {item.get('phase', '')} | {item.get('duration', '')} | {item.get('content', '')} | {item.get('purpose', '')} |\n"
rendered = rendered.replace("{schedule_table}", schedule_table)
md_content = rendered
else:
# 兜底:生成完整内容
md_lines = [
f"# {student_name} - 个性化练习方案\n",
f"**练习时间**: {content.get('practice_time', 'N/A')} (共{content.get('total_daily_minutes', 0)}分钟)\n",
f"**生成时间**: {content.get('generated_at', '')}\n",
"\n---\n",
]
if content.get('ai_report'):
md_lines.append("## 📝 AI个性化报告\n")
md_lines.append(content['ai_report'])
md_lines.append("\n---\n")
md_lines.append("## 🔍 问题诊断\n")
for problem in content.get('problems', []):
md_lines.append(f"- **{problem.get('name', '')}** ({problem.get('severity', '')})\n")
md_lines.append("\n")
if content.get('problem_details'):
md_lines.append("## 📚 问题详解\n")
for detail in content.get('problem_details', []):
md_lines.append(f"### {detail.get('name', '')} ({detail.get('severity', '')})\n")
md_lines.append(f"{detail.get('content', '')}\n\n")
md_lines.append("## 📅 每日练习计划\n")
md_lines.append("| 阶段 | 时长 | 内容 | 目的 |\n|------|------|------|------|\n")
for item in content.get('daily_schedule', []):
md_lines.append(f"| {item.get('phase', '')} | {item.get('duration', '')} | {item.get('content', '')} | {item.get('purpose', '')} |\n")
md_content = ''.join(md_lines)
from urllib.parse import quote
filename = quote(f"{student_name}_练习方案.md")
return Response(
md_content,
mimetype='text/markdown; charset=utf-8',
headers={
'Content-Disposition': f"attachment; filename*=UTF-8''{filename}"
}
)
@main_bp.route("/plans/<int:plan_id>/wechat", methods=["GET"])
@login_required_json
def wechat_card(plan_id):
"""微信卡片展示页"""
plan = PracticePlan.query.get_or_404(plan_id)
content = json.loads(plan.content)
return render_template(
"wechat_card.html",
plan_id=plan_id,
student_name=plan.student.name if plan.student else "",
content=content,
)
@main_bp.route("/api/plans/<int:plan_id>", methods=["DELETE"])
@login_required_json
def delete_plan(plan_id):
"""删除方案"""
plan = PracticePlan.query.get_or_404(plan_id)
db.session.delete(plan)
db.session.commit()
return jsonify({"message": "删除成功"})
@main_bp.route("/api/plans/<int:plan_id>/content", methods=["PUT"])
@login_required_json
def update_plan_content(plan_id):
"""更新方案内容(用于编辑)"""
plan = PracticePlan.query.get_or_404(plan_id)
data = request.get_json()
# 更新content字段
if "content" in data:
plan.content = data["content"]
db.session.commit()
return jsonify({"message": "保存成功"})