un2266 commited on
Commit
b9619ff
·
1 Parent(s): 090658c

修進工作區後左側導覽列消失、技術執行紀錄空白缺標題、下載捷徑提早缺件三個 bug

Browse files

- result_nav_col/canvas_exec_col 第一次從 visible=False 翻 True 時,
Gradio 前端只認觸發它的 generator 第一個 yield 才會真的掛進 DOM;
on_start_analysis_show 原本延到第二個 yield 才設,導致「開始研究
分析」後左側導覽列只剩「回首頁」、硬點技術執行紀錄也是空白。搬到
第一個 yield,比照 on_scenario_pick 已驗證可行的寫法。
- 技術執行紀錄頁補上獨立的靜態標題,不再依賴逐行累加的 log 內容。
- 下載研究報告/下載完整分析資料兩顆導覽列捷徑,改成等 LLM 報告階段
真正跑到終態(done/failed)才生成與顯示,避免報告還在寫的時候就
把缺件的 zip 遞出去。

Files changed (1) hide show
  1. app_agent.py +74 -26
app_agent.py CHANGED
@@ -1668,18 +1668,23 @@ _ZIP_GROUPS = [
1668
  ]
1669
 
1670
 
1671
- def _make_result_zips(output_dir: Path, grouped: dict) -> dict:
1672
  """把各組產出打包成 zip,回傳 {group_key: zip 路徑字串}。
1673
 
1674
  grouped:{group_key: [檔案路徑, ...]},不含 "all"(由本函式自己合併)。
1675
  只打包真實存在的檔案;某組完全沒有檔案就不產生該包(對應的下載捷徑
1676
  也就不會出現),例如 LLM 報告失敗時「研究報告」那包不該憑空生出來。
 
 
 
 
 
1677
  """
1678
  targets = {k: [f for f in v if Path(f).exists()] for k, v in grouped.items()}
1679
 
1680
  # 整包 = 各組去重合併。只有一組有東西時,整包跟那一組內容完全一樣,
1681
  # 多給一顆按鈕只會讓人猶豫該點哪個,這種情況不另外產生整包。
1682
- if sum(1 for v in targets.values() if v) >= 2:
1683
  seen = set()
1684
  targets["all"] = [f for v in targets.values() for f in v
1685
  if not (f in seen or seen.add(f))]
@@ -3119,6 +3124,14 @@ def create_app():
3119
  # 跟其他三個結果頁一樣的 visible=True/False 語意,交給
3120
  # applyActivePage() JS 決定要不要顯示。
3121
  with gr.Column(visible=False, elem_id="canvas_exec", elem_classes=["canvas-result"]) as canvas_exec_col:
 
 
 
 
 
 
 
 
3122
  exec_log = gr.Markdown(
3123
  value="",
3124
  elem_id="exec_log",
@@ -3303,19 +3316,30 @@ def create_app():
3303
  # 下載清單只留敘述統計技術報告跟 LLM 輔助報告兩份,複製成使用者
3304
  # 指定的檔名(保留原始檔案不動,避免影響其他邏輯依賴原檔名);
3305
  # 原始 .txt 不提供下載,僅用於上方 R[18] 的文字顯示
 
 
 
 
 
 
 
 
 
 
3306
  dl_rpt = []
3307
- if final_rpts:
3308
- ts = final_rpts[0].stem.split("Final_Report-")[-1]
3309
- stats_dst = output_dir / f"TransPrism_stats_{ts}.docx"
3310
- if not stats_dst.exists():
3311
- shutil.copy(str(final_rpts[0]), str(stats_dst))
3312
- dl_rpt.append(str(stats_dst))
3313
- if docx_files:
3314
- ts = docx_files[0].stem.split("LLM_Analysis-")[-1]
3315
- llm_dst = output_dir / f"TransPrism_LLM Report_{ts}.docx"
3316
- if not llm_dst.exists():
3317
- shutil.copy(str(docx_files[0]), str(llm_dst))
3318
- dl_rpt.append(str(llm_dst))
 
3319
  if dl_rpt:
3320
  R[19] = gr.update(value=dl_rpt, visible=True)
3321
 
@@ -3323,11 +3347,16 @@ def create_app():
3323
  # 刻意沿用上面已經整理好的清單變數,不另外去 output_dir 掃檔案:
3324
  # 掃出來的會含 LLM_Analysis-*.txt 這種只供畫面顯示、不提供下載的
3325
  # 中間產物,也含前一次打包留下的 .zip 自己。
 
 
 
 
 
3326
  zips = _make_result_zips(output_dir, {
3327
  "idx": [str(csv_path)] + [str(p) for p in xlsx_list[-1:]],
3328
  "stat": [str(p) for p in [desc_path, diff_path, box_path]] + imp_dl + cluster_dl,
3329
  "report": dl_rpt,
3330
- })
3331
  state["zip_paths"] = zips
3332
  for _i, _key in ((26, "idx"), (27, "stat"), (28, "report"), (29, "all")):
3333
  if zips.get(_key):
@@ -3606,6 +3635,24 @@ def create_app():
3606
  # yield 1:workspace_row 從 visible=False 翻回 True 的那一批(祖先
3607
  # 翻轉),scenario_select_row 本來就要在這時候顯示 True。子元件的
3608
  # 補償刻意留到下一個 yield,不跟這批同時處理(理由見上方大段註解)。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3609
  yield (
3610
  gr.update(visible=False), # homepage_col
3611
  gr.update(visible=True), # workspace_row
@@ -3613,8 +3660,8 @@ def create_app():
3613
  gr.update(value=_build_header_html(current_step=1)), # app_header_html:站上第1步「研究選擇」
3614
  state,
3615
  gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), # upload_row/metadata_row/canvas_idx/stat/report 留到下一個 yield
3616
- gr.update(), # result_nav_col 留到下一個 yield
3617
- gr.update(), # canvas_exec_col 留到下一個 yield
3618
  gr.update(), # nav_dl_group 留到第三個 yield
3619
  )
3620
  # yield 2(4-C 補償,緊接著上一個 yield,同一個 generator/連線):
@@ -3622,7 +3669,8 @@ def create_app():
3622
  # (要等對應結果產出才 enable,見 create_app 的 interactive=False
3623
  # 與 _populate_tabs 的閘門,這批不動那個閘門邏輯),改成「進工作區
3624
  # 就顯示」只是讓使用者一開始就看得到完整的流程地圖,不是讓他們能
3625
- # 提前點。
 
3626
  yield (
3627
  gr.update(), gr.update(), gr.update(), gr.update(), state,
3628
  gr.update(visible=False), # upload_row
@@ -3630,18 +3678,18 @@ def create_app():
3630
  gr.update(visible=False), # canvas_idx_col
3631
  gr.update(visible=False), # canvas_stat_col
3632
  gr.update(visible=False), # canvas_report_col
3633
- gr.update(visible=True), # result_nav_col(4-a:一進工作區就顯示)
3634
- gr.update(visible=False), # canvas_exec_col
3635
  gr.update(), # nav_dl_group 留到下一個 yield(見下方說明)
3636
  )
3637
  # yield 3:nav_dl_group(下載捷徑那組分隔線+四顆按鈕)住在
3638
  # result_nav_col 裡面,是比 result_nav_col 更深一層的子元件。
3639
- # result_nav_col 剛在上一個 yield 翻成 visible=True,若 nav_dl_group
3640
- # 在「同一個」yield 裡也跟著壓 False,會被同一種 still-mounted-
3641
- # snapback 蓋回舊值(跟 metadata_row 那組是同一條地雷,只是換了
3642
- # 一層祖先/子元件);所以要等 result_nav_col 的翻轉在上一個 yield
3643
- # 沉澱完,這裡才安全,之後有 zip 產出時 _populate_tabs 會自己把它
3644
- # 翻回 True,不受影響。
3645
  yield (
3646
  gr.update(), gr.update(), gr.update(), gr.update(), state,
3647
  gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
 
1668
  ]
1669
 
1670
 
1671
+ def _make_result_zips(output_dir: Path, grouped: dict, allow_all: bool = True) -> dict:
1672
  """把各組產出打包成 zip,回傳 {group_key: zip 路徑字串}。
1673
 
1674
  grouped:{group_key: [檔案路徑, ...]},不含 "all"(由本函式自己合併)。
1675
  只打包真實存在的檔案;某組完全沒有檔案就不產生該包(對應的下載捷徑
1676
  也就不會出現),例如 LLM 報告失敗時「研究報告」那包不該憑空生出來。
1677
+
1678
+ allow_all:呼叫端還有「必然會出現、但目前尚未跑完」的組別時傳
1679
+ False,即使目前已有 ≥2 組非空也先不合併「整包」——避免「完整分析
1680
+ 資料」在報告撰寫階段還沒結束前,就用 idx+stat 兩組拼出一包名不副實
1681
+ 的「完整」資料(2026-08-06 踩坑,見呼叫端 report_stage_done 註解)。
1682
  """
1683
  targets = {k: [f for f in v if Path(f).exists()] for k, v in grouped.items()}
1684
 
1685
  # 整包 = 各組去重合併。只有一組有東西時,整包跟那一組內容完全一樣,
1686
  # 多給一顆按鈕只會讓人猶豫該點哪個,這種情況不另外產生整包。
1687
+ if allow_all and sum(1 for v in targets.values() if v) >= 2:
1688
  seen = set()
1689
  targets["all"] = [f for v in targets.values() for f in v
1690
  if not (f in seen or seen.add(f))]
 
3124
  # 跟其他三個結果頁一樣的 visible=True/False 語意,交給
3125
  # applyActivePage() JS 決定要不要顯示。
3126
  with gr.Column(visible=False, elem_id="canvas_exec", elem_classes=["canvas-result"]) as canvas_exec_col:
3127
+ # 千卉回報(2026-08-06):這頁一直缺大標題文字。原因是
3128
+ # 另外三個結果頁(語言指標分析/敘述統計/研究報告)的
3129
+ # 「### 標題」都是烤進 _populate_tabs 每次重新賦值的內容
3130
+ # 字串裡(一次性結果,整段換新);但這頁是逐行往下累加
3131
+ # 的執行 log(見 _log_append),不會整段重寫,所以從沒
3132
+ # 機會把標題烤進 exec_log 的值裡。改成跟內容分開、獨立
3133
+ # 一顆靜態 gr.Markdown,不受 log 累加邏輯影響。
3134
+ gr.Markdown("### 技術執行紀錄")
3135
  exec_log = gr.Markdown(
3136
  value="",
3137
  elem_id="exec_log",
 
3316
  # 下載清單只留敘述統計技術報告跟 LLM 輔助報告兩份,複製成使用者
3317
  # 指定的檔名(保留原始檔案不動,避免影響其他邏輯依賴原檔名);
3318
  # 原始 .txt 不提供下載,僅用於上方 R[18] 的文字顯示
3319
+ #
3320
+ # 千卉發現的踩坑(2026-08-06):final_rpts(敘述統計技術報告)
3321
+ # 是統計階段一完成就存在,早於 LLM 報告撰寫階段。若不設防,
3322
+ # 「報告撰寫」子階段還在跑的當下,這裡就會把只有統計報告、缺
3323
+ # LLM 報告的清單當成「研究報告」打包出去——使用者這時點下載,
3324
+ # 拿到的檔案內容不完整。改成整段等 report 子階段真正跑到終態
3325
+ # (done 或 failed,不再是 running/pending)才組清單;報告若
3326
+ # 失敗,final_rpts 仍在,讓使用者至少能拿到統計報告,不會被
3327
+ # 卡死拿不到任何東西。
3328
+ report_stage_done = state["analysis"].get("report", {}).get("status") in ("done", "failed")
3329
  dl_rpt = []
3330
+ if report_stage_done:
3331
+ if final_rpts:
3332
+ ts = final_rpts[0].stem.split("Final_Report-")[-1]
3333
+ stats_dst = output_dir / f"TransPrism_stats_{ts}.docx"
3334
+ if not stats_dst.exists():
3335
+ shutil.copy(str(final_rpts[0]), str(stats_dst))
3336
+ dl_rpt.append(str(stats_dst))
3337
+ if docx_files:
3338
+ ts = docx_files[0].stem.split("LLM_Analysis-")[-1]
3339
+ llm_dst = output_dir / f"TransPrism_LLM Report_{ts}.docx"
3340
+ if not llm_dst.exists():
3341
+ shutil.copy(str(docx_files[0]), str(llm_dst))
3342
+ dl_rpt.append(str(llm_dst))
3343
  if dl_rpt:
3344
  R[19] = gr.update(value=dl_rpt, visible=True)
3345
 
 
3347
  # 刻意沿用上面已經整理好的清單變數,不另外去 output_dir 掃檔案:
3348
  # 掃出來的會含 LLM_Analysis-*.txt 這種只供畫面顯示、不提供下載的
3349
  # 中間產物,也含前一次打包留下的 .zip 自己。
3350
+ #
3351
+ # allow_all=report_stage_done:「完整分析資料」整包同理,report
3352
+ # 子階段還在跑時,即使 idx/stat 兩組都已完成(湊到 ≥2 組的舊
3353
+ # 門檻),也不該急著把「完整」兩字掛出來卻漏掉報告——見上面
3354
+ # report_stage_done 的踩坑說明。
3355
  zips = _make_result_zips(output_dir, {
3356
  "idx": [str(csv_path)] + [str(p) for p in xlsx_list[-1:]],
3357
  "stat": [str(p) for p in [desc_path, diff_path, box_path]] + imp_dl + cluster_dl,
3358
  "report": dl_rpt,
3359
+ }, allow_all=report_stage_done)
3360
  state["zip_paths"] = zips
3361
  for _i, _key in ((26, "idx"), (27, "stat"), (28, "report"), (29, "all")):
3362
  if zips.get(_key):
 
3635
  # yield 1:workspace_row 從 visible=False 翻回 True 的那一批(祖先
3636
  # 翻轉),scenario_select_row 本來就要在這時候顯示 True。子元件的
3637
  # 補償刻意留到下一個 yield,不跟這批同時處理(理由見上方大段註解)。
3638
+ #
3639
+ # result_nav_col/canvas_exec_col 例外:2026-08-06 千卉實測發現,
3640
+ # 「元件第一次從 visible=False 翻成 True(此生第一次掛進 DOM)」
3641
+ # 這件事,只有在觸發它的 generator 的第一個 yield 裡設定才會真的
3642
+ # 生效——on_scenario_pick 的 result_nav_col/canvas_exec_col 就是
3643
+ # 這樣,在第一個 yield 就設 visible=True(見該函式),所以按鈕跟
3644
+ # 內容區塊真的會出現。這裡原本學 4-C 的做法把兩者延到下一個 yield
3645
+ # 才設,結果變成「Python 端送出了 visible=True,前端卻從頭到尾
3646
+ # 沒把這批按鈕跟內容掛進 DOM」——使用者點「開始研究分析」進入
3647
+ # 研究選擇畫面後,左側導覽列只剩「回首頁」,AI對話/技術執行紀錄
3648
+ # 等全部消失;就算硬點技術執行紀錄,右邊內容區塊也是一片空白
3649
+ # (canvas_exec_col 同樣沒掛進 DOM,不是只缺標題文字)。4-C 的
3650
+ # 「延一個 yield」技巧解的是另一種問題(曾經 True 過的元件被自動
3651
+ # 還原),不適用在「這輩子第一次要 True」的元件上,兩者不能套
3652
+ # 同一招。canvas_exec_col 跟著 result_nav_col 一起搬進第一個
3653
+ # yield,也符合 nav_exec_btn 建構時刻意不設 interactive=False
3654
+ # 的原始設計意圖(見該按鈕附近註解:「技術執行紀錄……從第一秒
3655
+ # 就有內容」,代表它從一開始就該是可點、有內容可看的狀態)。
3656
  yield (
3657
  gr.update(visible=False), # homepage_col
3658
  gr.update(visible=True), # workspace_row
 
3660
  gr.update(value=_build_header_html(current_step=1)), # app_header_html:站上第1步「研究選擇」
3661
  state,
3662
  gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), # upload_row/metadata_row/canvas_idx/stat/report 留到下一個 yield
3663
+ gr.update(visible=True), # result_nav_col:第一次掛載必須跟祖先同一個 yield,見上方說明
3664
+ gr.update(visible=True), # canvas_exec_col:同上,且從第一秒就該有內容可看
3665
  gr.update(), # nav_dl_group 留到第三個 yield
3666
  )
3667
  # yield 2(4-C 補償,緊接著上一個 yield,同一個 generator/連線):
 
3669
  # (要等對應結果產出才 enable,見 create_app 的 interactive=False
3670
  # 與 _populate_tabs 的閘門,這批不動那個閘門邏輯),改成「進工作區
3671
  # 就顯示」只是讓使用者一開始就看得到完整的流程地圖,不是讓他們能
3672
+ # 提前點。result_nav_col/canvas_exec_col 本身已在上一個 yield
3673
+ # 掛載完成,��裡不再重複設定(重複設 visible=True 無害,但沒必要)。
3674
  yield (
3675
  gr.update(), gr.update(), gr.update(), gr.update(), state,
3676
  gr.update(visible=False), # upload_row
 
3678
  gr.update(visible=False), # canvas_idx_col
3679
  gr.update(visible=False), # canvas_stat_col
3680
  gr.update(visible=False), # canvas_report_col
3681
+ gr.update(), # result_nav_col:已在第一個 yield 掛載完成,這裡不再重複設定
3682
+ gr.update(), # canvas_exec_col:同上,這裡不再重複設定
3683
  gr.update(), # nav_dl_group 留到下一個 yield(見下方說明)
3684
  )
3685
  # yield 3:nav_dl_group(下載捷徑那組分隔線+四顆按鈕)住在
3686
  # result_nav_col 裡面,是比 result_nav_col 更深一層的子元件。
3687
+ # result_nav_col 已在第一個 yield 翻成 visible=True,若 nav_dl_group
3688
+ # 太早跟著壓 False,會被同一種 still-mounted-snapback 蓋回舊值
3689
+ # (跟 metadata_row 那組是同一條地雷,只是換了一層祖先/子元件);
3690
+ # 這裡隔了兩個 yield 才動 nav_dl_group,比原本的一個 yield 間隔
3691
+ # 更保守安全,之後有 zip 產出時 _populate_tabs 會自己把它翻回
3692
+ # True,不受影響。
3693
  yield (
3694
  gr.update(), gr.update(), gr.update(), gr.update(), state,
3695
  gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),