# main.py import gradio as gr from analyzer import analyze_teacher_dashboard from cluster_insight import cluster_and_visualize from qwen_api import call_qwen import pandas as pd import re import tempfile import os # ================== LLM 教学建议 ================== def generate_teaching_advice1(sankey_fig, cluster_stats): prompt = """ 你是一名GIS实验教学专家,基于以下分析结果,生成教学优化方案: 【桑基图分析】 - 学生反馈从 s1→s4 的主要流向:核密度 → 参数设置 → 应用场景 - 最粗路径:核密度分析 → 搜索半径选择 → 城市规划应用 【聚类分析】 """ for s in cluster_stats[:3]: prompt += f"- 聚类 {s['cluster_id']}:{s['keyword']}({s['size']}条,占{s['ratio']:.1%})\n" prompt += f" 代表句:{s['rep_sentence'][:100]}\n" prompt += """ 【要求】 1. 诊断核心教学痛点(3条) 2. 提出针对性优化措施(微课/演示/作业) 3. 设计 1 个 2 分钟微课脚本(标题+3步演示) 4. 建议 1 个课后作业(验证学生掌握) 【输出格式】 # 教学优化方案 ## 1. 核心痛点 ## 2. 优化措施 ## 3. 微课脚本 ## 4. 课后作业 """ advice = call_qwen(prompt) return f"
{advice}
" # ================== LLM 教学建议---测试版 ================== def generate_teaching_advice(sankey_fig, cluster_stats): # 临时替换LLM调用(30秒内必完成) advice = """ # 教学优化方案 ## 1. 核心痛点 1. 核密度分析的搜索半径参数设置缺乏实操指导 2. 空间连接功能的应用场景与实操步骤脱节 3. 栅格计算器的公式编写逻辑讲解不清晰 ## 2. 优化措施 1. 制作参数设置微课,结合案例演示不同场景下的取值标准 2. 增加空间连接功能的分步实操视频,配套场景化习题 3. 提供栅格计算器常用公式模板,附详细注释 ## 3. 微课脚本 ### 标题:3分钟掌握核密度分析搜索半径设置 1. (0-30秒)明确搜索半径的核心作用:影响密度场平滑度 2. (30-90秒)演示城市POI数据的半径设置(500米):工具位置→参数面板→取值依据 3. (90-180秒)对比不同半径效果(300米/500米/1000米),总结取值规律 ## 4. 课后作业 基于提供的城市餐饮POI数据,分别设置300米、500米、1000米搜索半径,生成3张核密度图,分析不同半径对结果的影响并提交报告 """ return f"
{advice}
" # ================== 主分析函数 ================== def analyze_report(excel_path): if not excel_path: return "请上传 Excel 文件", None, None, None, "", "" try: sankey_fig, sb64 = analyze_teacher_dashboard(excel_path=excel_path) cluster_fig, cb64, cluster_stats = cluster_and_visualize(excel_path=excel_path) advice = generate_teaching_advice(sankey_fig, cluster_stats) print(f"{sankey_fig}和{sb64}生成成功!") print(f"{cluster_fig}和{cb64}生成成功!") # 统计表格 stats_table = """

聚类主题统计

""" for s in cluster_stats: stats_table += f""" """.format(s=s) stats_table += "
聚类主题关键词反馈数占比代表句
{s['cluster_id']} {s['keyword']} {s['size']} {s['ratio']:.1%} {s['rep_sentence'][:80]}...
" # 最终报告(只放文字部分) html_report = f"""

EGISInsight

GIS 教学智能体 · 循证教学优化


{stats_table}
{advice}

EGISInsight © 2025 | 从数据到教学内容改革

""" return html_report, sankey_fig, cluster_fig, cluster_stats, sb64, cb64 except Exception as e: # 问题1修复:异常逻辑也返回6个值(补全空字符串) error_html = f"

分析失败:{str(e)}

" return error_html, None, None, None, "", "" # 核心工具函数:把平台传递的纯文本 → DataFrame(不变) def parse_text_to_df(raw_text: str): """ 步骤: 1. 按制表符\t分割所有单元格(你的原有逻辑) 2. 过滤空单元格,得到纯数据列表 3. 按每行5列重新分组,不足5列的补None 4. 手动添加表头,生成DataFrame """ # 1. 清理原始文本(避免多余空字符干扰) cleaned_text = re.sub(r'\n+', '', raw_text.strip()) # 去掉所有换行(因为你按\t分割,换行没用) cleaned_text = re.sub(r'\t+', '\t', cleaned_text) # 合并连续制表符 cleaned_text = re.sub(r'^\t|\t$', '', cleaned_text) # 去掉首尾制表符 # 2. 按制表符分割,过滤空单元格(你的原有逻辑,稍作优化) lines = [] for cell in cleaned_text.split('\t'): cell_stripped = cell.strip() # 跳过空单元格(仅保留有内容的) if cell_stripped or cell_stripped == '0': # 避免把"0"当成空(如果有编号为0的情况) lines.append(cell_stripped) else: lines.append(None) # 空单元格用None填充,方便后续分组 # 关键修改:去掉第一个元素(sheet名,如ex01) if len(lines) > 0: lines = lines[1:] # 切片:保留从索引1开始的所有元素,去掉索引0(第一个元素) print(f"已去掉sheet名,剩余元素数量:{len(lines)}") else: raise Exception("分割后无有效数据,无法解析") # 关键修改2:再去掉第一个元素(原始表头 no/s1/s2/s3/s4) if len(lines) > 5 and lines[0] == 'no': # 确认第一个元素是表头,再删除 lines = lines[5:] else: # 若没有原始表头(极端情况),直接继续(后续用我们的固定表头) pass # 3. 核心:按每行5列重新分组(关键逻辑) header = ['no', 's1', 's2', 's3', 's4'] # 固定表头 data_rows = [] # 存储分组后的每行数据 # 计算需要分多少组(向上取整,避免遗漏最后几个元素) total_cells = len(lines[1:]) total_rows = (total_cells + 4) // 5 # 向上取整(比如11个元素→3行:5+5+1) # 按5个元素一组拆分,不足5列的补None for i in range(total_rows): # 截取当前行的5个元素(左闭右开区间) start_idx = i * 5 end_idx = start_idx + 5 row = lines[start_idx:end_idx] # 不足5列的补None(确保每行都是5列) while len(row) < 5: row.append(None) data_rows.append(row) # 4. 生成DataFrame,清理无效数据 df = pd.DataFrame(data_rows, columns=header) # 清理:去掉no列为空的行、s1-s4全为空的行 df = df.dropna(subset=['no'], how='any') df = df.dropna(subset=['s1', 's2', 's3', 's4'], how='all') return df # ================== 完美兼容版界面(老版本也能居中 + 控制高度)================== with gr.Blocks(theme=gr.themes.Soft(), title="EGISInsight") as demo: gr.Markdown("# GIS实验报告智能分析系统") gr.Markdown("**上传学生反馈 Excel → 1秒生成教学决策图 + AI教案**") file_input = gr.File(label="上传 ex02.xlsx(需含 s1-s4 列)", file_types=[".xlsx"]) # 标题 + 桑基图(完美居中 + 固定高度) gr.HTML('

1. 学生反馈流向分析(交互桑基图)

') sankey_plot = gr.Plot(elem_id="sankey-plot") # 去掉 height,加 elem_id # 标题 + 聚类图 gr.HTML('

2. 学生反馈主题聚类可视化

') cluster_plot = gr.Plot(elem_id="cluster-plot") # 去掉 height,加 elem_id # 文字报告 html_report = gr.HTML() # 隐藏统计 stats_json = gr.JSON(visible=False) # 关键:加一段 CSS 强制高度和居中 demo.load( None, None, None, js=""" () => { const style = document.createElement('style'); style.innerHTML = ` #sankey-plot, #cluster-plot { height: 560px !important; width: 100% !important; max-width: 1100px !important; margin: 0 auto !important; display: block !important; } #sankey-plot > div, #cluster-plot > div { height: 100% !important; } `; document.head.appendChild(style); } """ ) file_input.change( fn=analyze_report, inputs=file_input, outputs=[html_report, sankey_plot, cluster_plot, stats_json] ) gr.Markdown("---") gr.Markdown("

通义千问大模型实时生成教学优化方案

") # if __name__ == "__main__": # demo.launch(server_name="0.0.0.0", share=True) # ================== 大赛平台专用 API(最终修正版)================== from fastapi import FastAPI, File, UploadFile, Form import uvicorn from io import BytesIO app = FastAPI() @app.post("/api/plugin") async def plugin_api( file: str = Form(...), token: str = Form(...), timestamp: str = Form(...), signature: str = Form(...) ): try: # print(type(file), file) df = parse_text_to_df(file) print(f"解析出 {len(df)} 条有效反馈") # 关键:创建本地临时Excel文件(自动生成真实路径,用完删除) with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as temp_file: df.to_excel(temp_file, index=False) # 写入临时文件 temp_file_path = temp_file.name # 拿到临时文件的真实路径(比如:C:\Users\XXX\AppData\Local\Temp\tmpxxxx.xlsx) print(df) # 调用分析函数(传临时文件的真实路径) html_report, sankey_fig, cluster_fig, stats, sb64, cb64 = analyze_report(temp_file_path) print(f"{sankey_fig}生成成功!") print(f"{cluster_fig}生成成功!") # 用完删除临时文件(避免占用空间) os.remove(temp_file_path) print(f"临时文件已删除:{temp_file_path}") # 按平台要求返回结果 # 按平台要求返回结果(用 sb64/cb64 替换 to_json()) print(f"{sb64}生成成功!") print(f"{cb64}生成成功!") return { "code": 200, "message": "success", "data": { "html_report": html_report if html_report else "", # 直接用已有的base64字符串,不用再转json! "sankey_image": sb64 if sb64 else "", # 键名改成sankey_image(符合平台图片命名规范) "cluster_image": cb64 if cb64 else "", # 键名改成cluster_image "statistics": stats if stats else [] # 格式化后的统计数据 } } except Exception as e: error_msg = f"分析失败:{str(e)}" print(error_msg) return { "code": 500, "message": error_msg, "data": { "html_report": "", "sankey_fig": "", "cluster_fig": "", "stats": [] } } # 保留原界面 app = gr.mount_gradio_app(app, demo, path="/") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=7860)