File size: 31,099 Bytes
08a20c7
 
 
 
 
 
 
 
 
 
 
470d42f
 
08a20c7
 
 
c477af3
08a20c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44bc4c0
 
 
470d42f
44bc4c0
 
 
 
 
08a20c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470d42f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08a20c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470d42f
 
08a20c7
 
 
 
 
 
 
 
 
470d42f
 
 
 
 
 
08a20c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1fed82e
 
 
 
 
 
 
 
 
 
70f4dc4
1fed82e
 
 
 
70f4dc4
08a20c7
 
1fed82e
 
 
08a20c7
 
 
 
 
 
 
 
 
 
 
 
 
 
1fed82e
 
 
 
08a20c7
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
# 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
import base64
import threading
import plotly.graph_objects as go


# 环境判断:本地运行(DEBUG=True)用纯 Gradio,线上用 FastAPI+Gradio 整合
DEBUG = os.getenv("HF_SPACE_REPO_ID") is None  # HF 环境会自动设置该环境变量
print(f"DEBUG是{DEBUG}")

# ================== 全局配置(关键!统一URL和路径)==================
PLUGIN_DOMAIN = "https://egisinsight.top"  # 你的域名(无端口,若用默认443端口)
PLUGIN_PORT = 7860  # 你的插件部署端口    
IMAGE_SAVE_DIR = "./temp_images"  # 图片保存目录(绝对路径更稳妥)
# 拼接完整的图片访问前缀(含端口和路径分隔符)
IMAGE_BASE_URL = f"{PLUGIN_DOMAIN}/temp_images/" # :{PLUGIN_PORT}

# 确保图片目录存在(递归创建,避免目录不存在报错)
os.makedirs(IMAGE_SAVE_DIR, exist_ok=True)
print(f"✅ 图片保存目录:{os.path.abspath(IMAGE_SAVE_DIR)}")
print(f"✅ 图片访问前缀:{IMAGE_BASE_URL}")


# ================== 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"<pre style='background:#f8f9fa; padding:20px; border-radius:12px; white-space: pre-wrap; font-family: Microsoft YaHei; line-height:1.6;'>{advice}</pre>"

# ================== LLM 教学建议---测试版 ==================
def generate_teaching_advice(sankey_fig, cluster_stats):
    # 临时替换LLM调用(30秒内必完成)
    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. 课后作业
"""
    return prompt, f"<pre style='background:#f8f9fa; padding:20px; border-radius:12px; white-space: pre-wrap; font-family: Microsoft YaHei; line-height:1.6;'>{prompt}</pre>"

    # ================== 主分析函数 ==================
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)
        prompt, msgs = generate_teaching_advice(sankey_fig, cluster_stats)
        print(f"{sankey_fig}{sb64}生成成功!")
        print(f"{cluster_fig}{cb64}生成成功!")
        
        # 统计表格
        stats_table = """
            <h3 style="color:#1976d2; text-align:center; margin:40px 0 15px;">聚类主题统计</h3>
            <table border="1" style="width:100%; max-width:900px; margin:0 auto; border-collapse: collapse; text-align:center; font-size:14px;">
                <tr style="background:#f0f0f0;"><th>聚类</th><th>主题关键词</th><th>反馈数</th><th>占比</th><th>代表句</th></tr>
        """
        for s in cluster_stats:
            stats_table += f"""
                <tr>
                    <td>{s['cluster_id']}</td>
                    <td><strong>{s['keyword']}</strong></td>
                    <td>{s['size']}</td>
                    <td>{s['ratio']:.1%}</td>
                    <td style="text-align:left; max-width:400px;">{s['rep_sentence'][:80]}...</td>
                </tr>
            """.format(s=s)
        stats_table += "</table>"

        # 最终报告(只放文字部分)
        html_report = f"""
            <div style="font-family:'Microsoft YaHei',sans-serif; max-width:1000px; margin:40px auto; padding:20px;">
                <h1 style="text-align:center; color:#1e88e5;">EGISInsight</h1>
                <p style="text-align:center; color:#555; font-size:17px;">GIS 教学智能体 · 循证教学优化</p>
                <hr style="border:1px solid #eee; margin:30px 0;">
                {stats_table}
                <div style="padding:25px; background:#f8f9fa; border-radius:12px; margin-top:30px;">
                    {msgs}
                </div>
                <p style="text-align:center; color:#999; margin-top:50px; font-size:13px;">
                    EGISInsight © 2025 | 从数据到教学内容改革
                </p>
            </div>
        """
        return prompt, html_report, sankey_fig, cluster_fig, cluster_stats, sb64, cb64

    except Exception as e:
        # 问题1修复:异常逻辑也返回6个值(补全空字符串)
        error_html = f"<p style='color:red; text-align:center;'>分析失败:{str(e)}</p>"
        return "", error_html, None, None, None, "", ""

def analyze_report_plugin(excel_path):
    if not excel_path:
        return "", "请上传 Excel 文件", None, "", ""

    try:
        sankey_fig, sb64 = analyze_teacher_dashboard(excel_path=excel_path)
        cluster_fig, cb64, cluster_stats  = cluster_and_visualize(excel_path=excel_path)
        prompt, msgs = generate_teaching_advice(sankey_fig, cluster_stats)
        print(f"{sankey_fig}{sb64}生成成功!")
        print(f"{cluster_fig}{cb64}生成成功!")
        
        # 统计表格
        stats_table = """
            <h3 style="color:#1976d2; text-align:center; margin:40px 0 15px;">聚类主题统计</h3>
            <table border="1" style="width:100%; max-width:900px; margin:0 auto; border-collapse: collapse; text-align:center; font-size:14px;">
                <tr style="background:#f0f0f0;"><th>聚类</th><th>主题关键词</th><th>反馈数</th><th>占比</th><th>代表句</th></tr>
        """
        for s in cluster_stats:
            stats_table += f"""
                <tr>
                    <td>{s['cluster_id']}</td>
                    <td><strong>{s['keyword']}</strong></td>
                    <td>{s['size']}</td>
                    <td>{s['ratio']:.1%}</td>
                    <td style="text-align:left; max-width:400px;">{s['rep_sentence'][:80]}...</td>
                </tr>
            """.format(s=s)
        stats_table += "</table>"

        # 最终报告(只放文字部分)
        html_report = f"""
            <div style="font-family:'Microsoft YaHei',sans-serif; max-width:1000px; margin:40px auto; padding:20px;">
                <h1 style="text-align:center; color:#1e88e5;">EGISInsight</h1>
                <p style="text-align:center; color:#555; font-size:17px;">GIS 教学智能体 · 循证教学优化</p>
                <hr style="border:1px solid #eee; margin:30px 0;">
                {stats_table}
                <div style="padding:25px; background:#f8f9fa; border-radius:12px; margin-top:30px;">
                    {msgs}
                </div>
                <p style="text-align:center; color:#999; margin-top:50px; font-size:13px;">
                    EGISInsight © 2025 | 从数据到教学内容改革
                </p>
            </div>
        """
        return prompt, html_report, cluster_stats, sb64, cb64

    except Exception as e:
        # 问题1修复:异常逻辑也返回6个值(补全空字符串)
        error_html = f"<p style='color:red; text-align:center;'>分析失败:{str(e)}</p>"
        return "", error_html, 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

# ================== 完美兼容版界面(老版本也能居中 + 控制高度)==================
def create_demo():
    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('<h2 style="text-align:center; color:#1976d2; margin:40px 0 10px;">1. 学生反馈流向分析(交互桑基图)</h2>')
        sankey_plot = gr.Plot(elem_id="sankey-plot")
    
        # 标题 + 聚类图
        gr.HTML('<h2 style="text-align:center; color:#388e3c; margin:50px 0 10px;">2. 学生反馈主题聚类可视化</h2>')
        cluster_plot = gr.Plot(elem_id="cluster-plot")
    
        # 新增:接收 prompt 的组件(如果需要显示,用 gr.Textbox;不需要则隐藏)
        prompt_output = gr.Textbox(visible=True, label="提示词",
                                   lines=5,          # 核心:设置默认显示行数(越大高度越高)
                                   max_lines=10,     # 滚动前最大可显示行数
                                   # size="lg",        # 宽度:sm(小)、md(中,默认)、lg(大)、xl(超大)
                                   elem_id="prompt-textbox", 
                                   # font_size=16,     # 字体大小(可选,默认14px)
                                   interactive=False # 若只是展示,设为不可编辑(避免误操作)
                                  )  # 第1个返回值
    
        # 文字报告(第2个返回值)
        html_report = gr.HTML()
    
        # 聚类统计数据(第5个返回值,对应 cluster_stats)
        stats_json = gr.JSON(visible=False)
    
        # 新增:接收 sb64 和 cb64 的组件(如果不需要显示,隐藏)
        sankey_b64 = gr.Textbox(visible=False, label="桑基图base64")  # 第6个返回值
        cluster_b64 = gr.Textbox(visible=False, label="聚类图base64")  # 第7个返回值
    
        # 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;
                    }
                     /* 新增:控制提示词文本框宽度和样式 */
                    /* 提示词文本框样式(完全用 CSS 控制,兼容低版本) */
                    #prompt-textbox {
                    width: 100% !important;
                    max-width: 1100px !important;  /* 和图表同宽,居中显示 */
                    margin: 0 auto 30px !important; /* 居中 + 底部间距 */
                    font-size: 17px !important;    /* 字体大小(核心需求) */
                    line-height: 1.6 !important;   /* 行间距,更易读 */
                    padding: 15px !important;      /* 内边距,不拥挤 */
                    border-radius: 8px !important; /* 圆角,视觉更友好 */
                    border: 1px solid #eee !important; /* 边框,区分区域 */
                    }

                    /* 文本框标签样式(可选,让标签也变大) */
                    #prompt-textbox + label {
                    font-size: 18px !important;
                    font-weight: 600 !important;
                    color: #1976d2 !important;
                    margin-bottom: 10px !important;
                    display: block !important;
                    text-align: center !important;
                    }
                `;
                document.head.appendChild(style);
            }
            """
        )
    
        # 关键修复:outputs 数量=7,与函数返回值顺序一一对应
        file_input.change(
            fn=analyze_report,
            inputs=file_input,
            outputs=[
                prompt_output,        # 1. prompt
                html_report,          # 2. html_report
                sankey_plot,          # 3. sankey_fig
                cluster_plot,         # 4. cluster_fig
                stats_json,           # 5. cluster_stats
                sankey_b64,           # 6. sb64
                cluster_b64           # 7. cb64
            ]
        )
    
        gr.Markdown("---")
        gr.Markdown("<p style='text-align:center; color:#666;'>通义千问大模型实时生成教学优化方案</p>")
    return demo


def create_demo_plugin():
    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('<h2 style="text-align:center; color:#1976d2; margin:40px 0 10px;">1. 学生反馈流向分析(交互桑基图)</h2>')
        sankey_plot = gr.Plot(elem_id="sankey-plot")          # 去掉 height,加 elem_id
    
        # 标题 + 聚类图
        gr.HTML('<h2 style="text-align:center; color:#388e3c; margin:50px 0 10px;">2. 学生反馈主题聚类可视化</h2>')
        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("<p style='text-align:center; color:#666;'>通义千问大模型实时生成教学优化方案</p>")
    return demo

# if __name__ == "__main__":
#    demo.launch(server_name="0.0.0.0", share=True)

# ================== 大赛平台专用 API(最终修正版)==================
from fastapi import FastAPI, File, UploadFile, Form
from fastapi.responses import FileResponse
import uvicorn
from io import BytesIO

app = FastAPI()
# 配置临时图片目录(确保存在)
IMAGE_DIR = "./temp_images"
os.makedirs(IMAGE_DIR, exist_ok=True)

# ================== 1. 图片访问接口(修复路径匹配)==================
@app.get("/temp_images/{img_name}")
async def get_temp_image(img_name: str):
    """通过 URL 访问图片(确保路径正确)"""
    # 用绝对路径查找图片(避免相对路径混乱)
    img_path = os.path.abspath(os.path.join(IMAGE_SAVE_DIR, img_name))
    print(f"🔍 正在查找图片:{img_path}")
    
    # 检查图片是否存在
    if not os.path.exists(img_path):
        print(f"❌ 图片不存在:{img_path}")
        return Response(status_code=404, content="图片不存在")
    
    # 检查文件是否是有效图片(避免目录遍历攻击)
    if not img_name.endswith((".png", ".jpg", ".jpeg")):
        return Response(status_code=403, content="无效的图片格式")
    
    # 返回图片文件
    return FileResponse(img_path, media_type="image/png")

# ================== 2. 辅助函数:base64转图片并生成URL(修复URL拼接)==================
def base64_to_public_url(base64_str: str, img_name: str) -> str:
    """
    功能:将base64字符串保存为图片,返回可公网访问的URL
    修复:正确拼接端口和路径分隔符
    """
    if not base64_str:
        print("⚠️  空的base64字符串,跳过图片生成")
        return ""
    
    try:
        # 去掉base64前缀(如果有)
        if base64_str.startswith("data:image/png;base64,"):
            base64_str = base64_str.split(",")[1]
        
        # 解码base64(处理可能的空格/换行)
        # base64_str = base64_str.strip().replace("\n", "").replace(" ", "")
        # img_data = base64.b64decode(base64_str)
        processed = (
        base64_str.strip().replace("\n", "").replace("\r", "").replace(" ", "").replace("-", "+").replace("_", "/") )  # 兼容 URL 安全 Base64
        # 补全 Base64 填充符(避免 Incorrect padding 错误)
        padding = 4 - (len(processed) % 4)
        if padding != 4:
            processed += "=" * padding
        img_data = base64.b64decode(processed)
        
        # 保存图片到绝对路径
        img_path = os.path.abspath(os.path.join(IMAGE_SAVE_DIR, img_name))
        with open(img_path, "wb") as f:
            f.write(img_data)
        print(f"✅ 图片保存成功:{img_path}(大小:{len(img_data)/1024:.1f}KB)")
        
        # 生成正确的公网URL(含端口和路径分隔符)
        public_url = f"{IMAGE_BASE_URL}{img_name}"
        print(f"✅ 图片公网URL:{public_url}")
        return public_url
    
    except Exception as e:
        print(f"❌ 图片生成失败:{str(e)}")
        return ""


# ================== 桑基图转公网URL函数(无需传参,读取全局配置)==================
def plotly_sankey_to_url(fig: go.Figure,IMG_N: str) -> str:
    """
    无需传参!读取全局配置,将Plotly桑基图转公网可访问URL
    自动处理:中文字体修复、图片保存、URL生成
    参数:
        fig: Plotly桑基图对象(go.Figure)
    返回:
        成功:公网URL字符串;失败:None
    """
    # 1. 校验输入
    if not isinstance(fig, go.Figure):
        print(f"❌ 输入不是Plotly Figure对象(类型:{type(fig)})")
        return None

    try:
        # 2. 生成唯一图片名(防重复,用时间戳+固定前缀)
        import time
        timestamp = time.strftime("%Y%m%d%H%M%S", time.localtime())
        img_name = f"{IMG_N}_{timestamp}.png"  # 示例:sankey_gis_20251123153020.png

        # 3. 强制设置中文字体(解决中文方块问题)
        fig.update_layout(
            title=dict(
                text=fig.layout.title.text if fig.layout.title else "GIS实践教学改革方向捕捉",
                font=dict(family="SimHei", size=22)
            ),
            font=dict(family="SimHei", size=18),  # 全局字体
            autosize=False,
            width=1186,  # 匹配日志中的宽度
            height=798   # 匹配日志中的高度
        )
        # 单独设置桑基图节点标签字体(防止节点中文失效)
        for trace in fig.data:
            if hasattr(trace, "node"):
                trace.node.font = dict(family="SimHei", size=18)

        # 4. 保存图片(使用全局配置的目录)
        img_path = os.path.abspath(os.path.join(IMAGE_SAVE_DIR, img_name))  # 转绝对路径更稳妥
        fig.write_image(img_path, engine="kaleido")
        print(f"✅ 图片保存成功:{img_path}(大小:{os.path.getsize(img_path)/1024:.1f}KB)")

        # 5. 生成公网URL(读取全局配置的前缀,处理 '/' 拼接)
        public_url = f"{IMAGE_BASE_URL.rstrip('/')}/{img_name.lstrip('/')}"
        print(f"✅ 图片公网URL:{public_url}")
        return public_url

    except Exception as e:
        print(f"❌ 桑基图转URL失败:{str(e)}")
        # 针对常见错误给出提示
        if "font" in str(e).lower():
            print(f"📌 建议:尝试更换中文字体为 'WenQuanYi Zen Hei'(适配Linux环境)")
        elif "engine" in str(e).lower():
            print(f"📌 建议:将 engine 改为 'orca',并执行 conda install -c plotly plotly-orca 安装")
        return None
        
@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)
        # 调用分析函数(传临时文件的真实路径)
        # prompt, html_report, stats, sb64, cb64 = analyze_report_plugin(temp_file_path)
        prompt, 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}")

        # 生成图片公网URL(关键修复:用正确的URL拼接)
        # sankey_url = base64_to_public_url(sb64, "sankey.png")
        # cluster_url = base64_to_public_url(cb64, "cluster.png")

        sankey_url = plotly_sankey_to_url(sankey_fig, "sankey")
        cluster_url = plotly_sankey_to_url(cluster_fig, "cluster")
        
        print(f"✅ 图片URL生成:桑基图={sankey_url},聚类图={cluster_url}")

        # 按平台要求返回结果
        # 按平台要求返回结果(用 sb64/cb64 替换 to_json())
        print(f"{sb64}生成成功!")
        print(f"{cb64}生成成功!")
        
         # 3. 格式化 stats 字段(严格匹配插件定义的子字段)
        formatted_stats = []
        if stats and isinstance(stats, list):  # 确保 stats 是数组
            for idx, s in enumerate(stats):
                # 严格匹配平台定义的5个字段:cluster_id、keyword、size、ratio、rep_sentence
                stat_item = {
                    "cluster_id": str(s.get("cluster_id", idx)),  # String 类型
                    "keyword": str(s.get("keyword", "无")),        # String 类型
                    "size": int(s.get("size", 0)),                # Number 类型
                    "ratio": f"{s.get('ratio', 0):.1%}" if s.get('ratio') is not None else "0.0%",  # String 类型(如 "30.5%")
                    "rep_sentence": str(s.get("rep_sentence", "无")[:200])  # String 类型,截取200字
                }
                # 过滤空值(必填字段确保有默认值)
                for key in list(stat_item.keys()):
                    if stat_item[key] == "无" and key in ["cluster_id", "keyword", "rep_sentence"]:
                        stat_item[key] = "无数据"
                    elif stat_item[key] == 0 and key == "size":
                        stat_item[key] = 0
                formatted_stats.append(stat_item)
        else:
            formatted_stats = []
                
        return {
            "code": "200",  # 插件定义为 String 类型
            "message": "success",  # 插件定义为 String 类型
            "data": {
                "html_report": html_report.strip() if html_report else "",  # 对应 html_report 字段
                "sankey_fig": sankey_url,  # 对应 sankey_fig 字段(图片URL,String类型)
                "cluster_fig": cluster_url,  # 对应 cluster_fig 字段(图片URL,String类型)
                "stats": formatted_stats,  # 对应 stats 字段(Array/Object类型)
                "prompt":prompt
            }
        }
    except Exception as e:
        error_msg = f"分析失败:{str(e)}"
        print(error_msg)
        return {
            "code": 500,
            "message": error_msg,
            "data": {
                "images": [],
                "html_report": f"<p style='color:red; text-align:center; font-size:18px;'>{error_msg}</p>",
                "stats": [],
                "teaching_advice": ""
            }
        }

# ================== 以下是你的原有函数(不变,确保正常调用)==================
# LLM教学建议函数(generate_teaching_advice1 / generate_teaching_advice)
# 主分析函数(analyze_report)
# 数据解析函数(parse_text_to_df)
# 桑基图生成函数(plot_sankey_from_df)
# Gradio界面配置(demo)

# if DEBUG:
#     # 本地模式:仅启动 Gradio 服务(不用 uvicorn)
#     if __name__ == "__main__":
#         demo = create_demo()
#         demo.launch(server_name="0.0.0.0", share=True)
# else:
#     # 线上 HF 模式:启动 FastAPI+Gradio 整合服务(用 uvicorn)
#     demo = create_demo_plugin()
#     app = gr.mount_gradio_app(app, demo, path="/gradio")
#     if __name__ == "__main__":
        
#         print(f"🚀 服务启动:{PLUGIN_DOMAIN}:{PLUGIN_PORT}")
#         print(f"📌 API路径:{PLUGIN_DOMAIN}:{PLUGIN_PORT}/api/plugin")
#         print(f"🖼️  图片访问路径:{PLUGIN_DOMAIN}:{PLUGIN_PORT}/temp_images/")
#         uvicorn.run(app, host="0.0.0.0", port=PLUGIN_PORT)


# 保留原界面
demo = create_demo_plugin()
app = gr.mount_gradio_app(app, demo, path="/hidden-gradio")
if __name__ == "__main__":
    
    # 启动 Gradio 原型(端口 7860,仅本地/你自己访问)
    # demo = create_demo()
    # demo.launch(server_name="0.0.0.0", share=True)
    # gradio_thread = threading.Thread(
    #     target=lambda: demo.launch(
    #         server_name="0.0.0.0",
    #         server_port=7860,  # 非大赛端口,避免冲突
    #         share=False  # 若需要公网访问,可设为 True,但会生成独立链接
    #     ),
    #     daemon=True  # 主程序退出时自动关闭
    # )
    # gradio_thread.start()
    
    print(f"🚀 服务启动:{PLUGIN_DOMAIN}:{PLUGIN_PORT}")
    print(f"📌 API路径:{PLUGIN_DOMAIN}:{PLUGIN_PORT}/api/plugin")
    print(f"🖼️  图片访问路径:{PLUGIN_DOMAIN}:{PLUGIN_PORT}/temp_images/")
    uvicorn.run(app, host="0.0.0.0", port=PLUGIN_PORT)