[Gemini API 實戰] 幫 LINE Bot 加一顆「詳細研究報告」按鈕:用 Google Search Grounding 把摘要變成有對照觀點的研究報告

前情提要 我的 LINE Bot 一直有個摘要功能:丟一個網址進去,它爬完內容、產一段摘要,附上社群貼文草稿跟儲存書籤的按鈕。這個功能從 2024 年就在了,但它解決的始終是「這篇在講什麼」,而我常常想知道的是另外三件事: 這篇文章講的東西,背景脈絡是什麼?它的說法有沒有其他人反駁過?裡面那些數字,是有出處的還是作者自己講的? 摘要回答不了這些,因為摘要的輸入就只有那篇文章本身。模型手上沒有其他材料,你叫它「批判性分析」,它只能在原文裡繞圈圈,或者開始編。 Google Search Grounding 剛好補的就是這一塊。我在 之前那篇文章 裡用它做過搜尋助手,那時候用途是回答問題;這次我想試的是另一種用法:把一篇既有的文章丟給模型,讓它自己去搜尋文章外的資訊,然後回頭審視這篇文章。 成果是摘要卡片上多了一顆「📄 詳細研究報告」按鈕,按下去大約一到兩分鐘後,Bot 會推一個網頁連結給你。 主要 Repo:https://github.com/kkdai/linebot-helper-python 為什麼是 Grounding,而不是自己串搜尋 在 Grounding 之前,要讓模型讀到網路上的即時資訊,得自己搭一條管線:先請模型從文章裡抽出關鍵字,拿關鍵字去打搜尋 API,把搜尋結果的網頁一個個爬回來,塞進 prompt,再請模型總結。三次以上的 API 呼叫,每一段都可能斷,而且關鍵字抽得好不好,直接決定後面撈到的東西有沒有用。 Grounding 把這整段收進模型內部。你在 GenerateContentConfig 裡掛一個 google_search 工具,剩下的模型自己處理:它自己決定要不要搜、搜什麼、搜幾次,搜完自己判斷哪些結果值得用。 對「研究報告」這個題目來說,模型自己決定搜什麼這件事特別有價值。我在寫 prompt 的時候並不知道使用者會丟什麼文章進來,自然也寫不出該搜哪些關鍵字。但模型讀完文章之後知道,它會去找這個主題的來龍去脈,也會去找有沒有人持相反意見。 另一個我很在意的優點是引用來源會跟著回來。模型回應的 grounding_metadata 裡帶著它實際參考過的網頁,標題跟網址都有。這代表報告裡「根據其他報導」那幾句話,不是模型憑印象講的,而是有對應網頁可以點過去查。做資訊類的產品,這個差別很大。 抽來源的程式碼在 loader/langtools.py,寫得防禦一點,因為沒有觸發搜尋時這些欄位整串都不存在: def _extract_grounding_sources(response) -> list: """從 grounding metadata 抽引用來源(同 chat_session 的作法)。""" sources = [] try: if getattr(response, 'candidates', None): candidate = response.candidates[0] metadata = getattr(candidate, 'grounding_metadata', None) chunks = getattr(metadata, 'grounding_chunks', None) if metadata else None for chunk in chunks or []: web = getattr(chunk, 'web', None) if web: sources.append({ 'title': getattr(web, 'title', '') or '', 'uri': getattr(web, 'uri', '') or '', }) except Exception as e: logging.warning(f"Failed to extract grounding sources: {e}") return sources 系統架構 整條流程從摘要卡片上的按鈕開始,中間經過一次重新爬取跟一次 grounding 呼叫,最後以臨時網頁收尾。 graph TD A[使用者傳送網址] -->|摘要 Flex Bubble| B[📄 詳細研究報告 按鈕] B -->|Postback 帶 bookmark doc id| C[驗證書籤所有權] C -->|立即 Reply 研究中| D[LINE 聊天室] C -->|背景任務| E[load_url 重新爬取原文] E --> F[Gemini...
繼續閱讀

[開發紀錄][Python] 丟一包影片照片進去,讓 Gemini 3.7 Flash 幫我剪成短影片:ReelCraft

前言: 起點是一個誤會。 我看到 Gemini API 文件多了一頁 Omni,介紹一個叫 Gemini Omni Flash 的模型,寫著「原生多模態,同時處理文字、圖片、聲音與影片」。我的第一個念頭很直接:那我把手機裡一整個資料夾的影片跟照片丟進去,讓它先看懂每個素材在拍什麼,再用一句話叫它剪成一支短影片,不就是一個剪片 App 了? 翻完文件發現我理解錯了,而且錯的剛好是最關鍵的那一點。但繞過那個限制之後,剩下的部分是真的做得出來的,成品是 ReelCraft:一支 Python CLI,把一包影片照片丟進去,Gemini 3.7 Flash 逐個看懂素材、提出剪輯建議,我確認過剪輯清單之後,ffmpeg 剪成 9:16 直式短影片,配樂用 Lyria 3 生成,字幕自動燒上去。 中間有三個問題是 ffmpeg 跟 Gemini 都回報成功、輸出卻是錯的,那種只有真的把影片播出來看才會發現的類型。 TL;DR 本篇文章會依序介紹: Omni Flash 不是我以為的那個東西 繞過限制:逐檔理解,再用文字彙整 把 edl.yaml 當成人工確認點 換上 Gemini 3.7 Flash 之後差多少 配樂:Lyria 3 走的是另一套 API ffmpeg 會安靜地剪錯給你看 字幕:兩個只有真的燒出來才看得到的問題 其他幾個坑 結論 參考連結 Omni Flash 不是我以為的那個東西 Gemini Omni Flash(gemini-omni-flash-preview)是影片生成與編輯模型,走的是 Interactions API,可以用自然語言對一支影片做效果編輯,例如「當人碰到鏡子時,讓鏡子像液體一樣漂亮地波動」。它不是拿來「看懂一堆影片」的工具。 限制那一段寫得很明白: Referencing or reasoning across multiple videos is not supported. Attempting multi-video prompting may result in degraded model performance or unexpected outputs. 另外還有一條: Video references up to 3 seconds in duration are accepted by the API schema but are not correctly processed by the model at this time. 所以「丟一堆影片進去讓它自己看懂再剪」這條路,在 Omni Flash 上直接被堵死。真正能做多影片理解的是一般的 Gemini 模型:2.5 之後單次請求最多可以帶 10 支影片,1M context 在預設解析度下大約吃得下一小時的長度,而且可以逐秒 tokenize,輸出帶時間戳的場景描述。 這個誤會花掉的時間不算浪費。查證的過程剛好把「哪件事該由哪個模型做」分清楚了,架構也就跟著定了。 繞過限制:逐檔理解,再用文字彙整 整條 pipeline 拆成五個階段,狀態全部落在檔案上: [素材資料夾] │ poc ingest 掃描影片/照片 → catalog.json ▼ │ poc analyze 每個檔案各自呼叫 Gemini → analysis/*.json ▼ │ poc plan 彙整所有分析結果,一次呼叫產生剪輯建議 ▼ →...
繼續閱讀

[Claude Code 實戰] 重新盤點終端機工作流:從 zsh 自動完成到搜尋比對工具鏈

痛點:每打一個指令,都要重新來一次 平常用 Claude Code 處理事情的時候,一直有兩個小摩擦一直在,只是還沒認真處理過。 第一個是 shell 本身太陽春:~/.zshrc 裡除了兩行 PATH 什麼都沒有,沒有自動完成、沒有指令記憶,翻歷史只能一路按上鍵慢慢找,找到類似指令還得手動改。第二個是跟 Claude Code 協作時,一些明明是唯讀、完全不會有副作用的指令(列檔案、查版本、curl 個 README),每次都要跳出來按一次允許,一來一回打斷節奏。 這篇記錄的就是一次把這兩件事一次盤點掉的過程:怎麼選工具、踩到什麼坑、以及最後怎麼跟 Claude Code 的權限系統對接起來。 解法一:先讓 zsh 自己記得你打過什麼 沒有裝 oh-my-zsh,也不想為了兩個功能扛一整套框架,所以直接挑最小夠用的兩個套件: zsh-autosuggestions:打字時用灰色文字提示之前打過的類似指令,按 Ctrl+空白鍵 或 → 接受 zsh-completions:補強 tab 自動完成的涵蓋範圍 brew install zsh-autosuggestions zsh-completions 再搭配歷史紀錄設定,讓上下鍵可以依照目前已輸入的內容去篩選歷史,而不是整批往前翻: # --- 指令歷史紀錄設定 --- HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=10000 setopt SHARE_HISTORY # 多個終端機視窗共用歷史紀錄 setopt HIST_IGNORE_DUPS setopt HIST_IGNORE_ALL_DUPS setopt HIST_FIND_NO_DUPS setopt INC_APPEND_HISTORY # 指令一輸入就馬上寫入歷史檔 autoload -Uz up-line-or-beginning-search down-line-or-beginning-search zle -N up-line-or-beginning-search zle -N down-line-or-beginning-search bindkey "^[[A" up-line-or-beginning-search bindkey "^[[B" down-line-or-beginning-search 裝完套件、加完設定,理論上重開終端機就能用了——實際上沒有這麼順利。 解法二:補顏色,讓終端機看得更快 自動完成裝好之後,順手把顏色也一起補了: zsh-syntax-highlighting:打指令時即時上色,有效指令綠色、無效指令紅色 ls -G:資料夾、執行檔、連結各自不同顏色 grep --color=auto:比對到的關鍵字直接標紅 export CLICOLOR=1 export LSCOLORS=GxFxCxDxBxegedabagaced alias grep='grep --color=auto' source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh 字體也一起處理:Ghostty 的設定檔(~/Library/Application Support/com.mitchellh.ghostty/config.ghostty)原本沒指定字體大小,吃系統預設的 13,直接加一行 font-size = 16 解決。 解法三:把 ls、cat、cd 也換成更聰明的版本 顏色跟自動完成算是基礎建設,接著補了三個常用指令的現代化替代品,選擇標準只有一個:單一執行檔、沒有背景常駐程序,不會拖慢啟動速度。 指令 替代品 換來什麼 ls eza --icons 彩色、圖示、樹狀結構(lt) cat bat --paging=never 語法高亮、行號 cd(輔助) zoxide 記住常去的資料夾,z proj 直接跳過去 fzf 這次評估後沒有裝——目前的使用習慣還沒有到需要模糊搜尋歷史/檔案的程度,等真的有感覺卡再補。 解法四:讓 Claude Code 搜尋、比對檔案更快 前面幾項是「人用得爽」,這一項是「Claude Code 用得快」。Claude Code 內建的搜尋工具本身就是用 ripgrep(rg)在跑,jq 也已經裝了,所以缺的是這三個: fd:取代 find,語法簡單、速度快,列檔案清單特別有感 ast-grep:結構化程式碼搜尋,不是純文字比對,而是看語法樹(AST)。可以搜「所有呼叫某函式且第一個參數是字串的地方」,對大範圍重構或精準搜尋特定寫法比 regex 準確很多 difftastic(difft):語法感知的 diff,函式搬位置也看得出來只是移動,不是整段被砍掉重寫 brew install fd ast-grep difftastic git-delta git-delta 主要是給人看 git diff...
繼續閱讀

[開發紀錄][Node.js] Feedly Classic 回不去了,我自己動手做了一個 FeedFlow(一)

前言: Feedly 改版之後,介面我一直不習慣,尤其是懷念舊版 Feedly Classic 那種一欄式、資訊密度高、不拖泥帶水的閱讀節奏。訂閱的來源裡有不少英文、日文、韓文的技術部落格,每次要嘛開分頁丟去翻譯、要嘛乾脆跳過,久而久之這些來源就變成已讀不回。 與其繼續將就,我花了一個週末,自己刻了一個 RSS 閱讀器:FeedFlow。手機瀏覽器優先、深色主題、多欄位檢視模式,這部分是在向 Feedly Classic 致敬;資料存在 Google Firestore,帳號綁 LINE Login;訂閱到的非中文文章,背景會自動用 Gemini 2.5 Flash 翻成繁體中文。目前已經部署在 Cloud Run 上面,自己每天在用。 這個 repo 會持續開發下去,這篇是系列文章的第一篇,先把整個專案的骨架、幾個關鍵決策的來龍去脈記錄下來。 TL;DR 本篇文章會依序介紹: 為什麼要自己刻一個 RSS 閱讀器 LINE Login 在這個專案裡代表的意義 RSS 閱讀器的開發過程:從單機 MVP 到多使用者雲端同步 為什麼選 Gemini 當翻譯引擎,中間踩了哪些坑 前端介面:四種檢視模式與行動裝置優先設計 意外的插曲:把洩漏的密鑰從 git 歷史裡挖乾淨 目前進度與下一篇的方向 結論 參考連結 為什麼要自己刻一個 RSS 閱讀器 市面上不缺 RSS 閱讀器,但我要的東西很具體:手機上開起來要快、介面資訊密度要高(不要每篇文章都用一張大圖佔掉整個螢幕)、然後外文來源要能就地翻譯,不用切換到別的分頁。這三個條件湊在一起,現成的服務沒有一個完全符合,尤其是「非中文文章自動翻譯」這件事,幾乎沒有閱讀器把它當一等公民做。 專案取名 FeedFlow,核心就三塊:後端一支 Express(server.js),負責抓 RSS、解析內容、呼叫 Gemini、讀寫 Firestore;前端是純 Vanilla JS 的 ES Modules(app.js、store.js、api.js、i18n.js),沒有套框架;資料庫用 Firestore,帳號則是 LINE Login。整個 MVP 第一版就把訂閱管理、資料夾分類、四種檢視模式、深色主題全部生出來了,後面的每一版都是在這個骨架上加東西。 LINE Login 在這個專案裡代表的意義 我之前寫過 如何透過 Golang 開發 OAuth2 的 PKCE,講的是 LINE Login 導入 PKCE 的實作細節。那時候是把 LINE Login 當研究對象在拆解協定;這次在 FeedFlow 裡,LINE Login 換了一個角色——它是整個多使用者架構能不能成立的關鍵。 FeedFlow 最早其實沒有帳號系統,資料就存在瀏覽器的 localStorage,換一台裝置訂閱就不見了。要做雲端同步,第一件事是要有一個穩定的使用者身份,而且這個身份要能在 Firestore 裡當 document 路徑的 key(users/{userId}/...)。比起自己刻一套帳號密碼系統,直接用 LINE Login 換來的 LINE User ID(sub claim)當這把 key,省掉了整套密碼、驗證信、忘記密碼流程,對一個個人專案來說划算很多。 這個決定也不是一次到位的。最早的版本其實是偷懶版:讓使用者自己貼一段 LINE UID 字串進去,後端只是拿去當 Firestore key,沒有做任何身份驗證——這樣的「登入」等於任何人都能冒充任何一個 UID。下一版才換成正規的 LINE OpenID Connect:標準的 OAuth 2.1 授權碼流程,拿到 id_token 之後用 LINE 的 /oauth2/v2.1/verify 驗證簽章,session 存在 HTTP-only cookie,而不是塞在網址參數裡到處跑。再之後又補上 state 跟 nonce 檢查,擋掉 CSRF。這個「先求有、再求對」的順序,某種程度上也反映了個人專案常見的節奏:先把功能兜起來確認方向對不對,安全性的坑等看得到需求了再回頭補。 因為主要使用情境是在 LINE 裡分享連結、在 LINE 內建瀏覽器打開,所以除了標準的 OAuth 重導向,也整合了 LIFF SDK,讓使用者如果是在 LINE App 裡開啟,可以直接用 LIFF 的 SSO...
繼續閱讀

[學習心得][Golang] AI Agent 時代的授權難題:ID-JAG 是什麼?為什麼我用 Go 重新實作了一次

前言: 這半年多讓 AI Agent 直接接上內部系統、幫忙做事,已經不是什麼新鮮事了。但只要仔細想一步:Agent 要用「誰的身份」去呼叫那些 API?如果它拿到的權限跟人一樣大,一旦被騙去執行了不該做的操作,後果可能比人自己手滑還嚴重。 這正是 ID-JAG(Identity Assertion JWT Authorization Grant)想解的問題。我最近把這套機制的原理整理了一遍,也照著 athenz-community/id-jag-the-hard-way 這個教學 repo,把裡面的 MCP Server 用 Go 重新實作了一遍:kkdai/id-jag-mcp。這篇文章想把 ID-JAG 的技術原理講清楚:它建立在哪些 RFC 標準上、跟我之前寫過的 OAuth2 / PKCE 有什麼不一樣、實際的 token 交換流程長什麼樣,最後也會示範怎麼把這個 Go 專案跑起來、怎麼測試。 TL;DR 本篇文章會依序介紹: 什麼是 ID-JAG?為什麼需要它? 從 OAuth2、PKCE,到 Agent 時代的新問題 兩塊 RFC 基石:Token Exchange 與 JWT Bearer ID-JAG 的完整 token 交換流程 每一跳都窄化權限:最小權限原則怎麼落地 為什麼要用 Go 重新實作這個 MCP Server? 動手玩:安裝、執行與測試 結論 參考文章 什麼是 ID-JAG?為什麼需要它? ID-JAG 是一種讓 AI Agent 代替使用者去存取受保護資源的授權機制,重點是「代替」這兩個字。目前它還是 IETF 的 Internet-Draft,還沒成為正式 RFC,但已經有 LY Corporation(在 Athenz 授權系統上)跟 Okta 等單位開始實作,MCP(Model Context Protocol)規範也已經引用了這份草案。 傳統的服務對服務授權,通常是兩種極端:要嘛整個服務共用一把萬用鑰匙(API Key、Service Account),要嘛乾脆把使用者的 session 或長效 token 直接借給程式用。前者權限太大,後者不僅沒有審計軌跡,一旦洩漏,攻擊者幾乎可以完全冒充使用者,而且很難被偵測出來。等到 AI Agent 開始自己決定要呼叫哪些工具、串接哪些內部 API 的時候,這兩種做法的風險都被放大了:Agent 可能因為 prompt injection 或幻覺,做出使用者根本沒打算做的操作,而如果 Agent 手上握的是一把萬用鑰匙,後果就是全公司資料任其存取。真實場景裡也常常是「Orchestrator Agent 呼叫 Sub-Agent」的多層架構,風險是一路往下傳遞的。 ID-JAG 想做到的是:Agent 每一次行動,都必須能證明「這是某個特定使用者,在某個特定當下,授權我做這一件特定的事」,而且這個授權範圍要盡可能窄、有效期要盡可能短。它建立在 OAuth2 的 token exchange(RFC 8693)之上,多蓋了一層「這個 token 是從人的身份主張衍生出來的」證明機制。這也是為什麼它同時被列在 OAuth.net 的 Cross-App Access(XAA)頁面 上——這正是 Agent 時代才會冒出來的新問題。 順帶一提,ID-JAG 也解決了一個很實際的使用體驗問題:如果每次 Agent 要存取一個新服務,都得跳出瀏覽器視窗讓使用者手動按「同意」,這種體驗很快就會讓人疲乏、乾脆什麼都按同意。ID-JAG 把授權動作收斂到使用者透過 SSO 登入的那一刻,之後 Agent 需要新的存取權限時,是拿著已經核發的身份主張去跟授權伺服器換 token,不需要使用者再跳出來按一次。 從 OAuth2、PKCE,到 Agent 時代的新問題 我之前寫過 如何透過 Golang 開發 OAuth2 的 PKCE,內容是 LINE Login 導入 PKCE 的實作經驗。那篇文章解的問題,跟 ID-JAG 解的問題其實完全是兩個層次,拿出來對比一下會更清楚 ID-JAG 到底新在哪裡。 PKCE 解的是「client...
繼續閱讀

[GCP 實戰] LINE 名片 Bot 再進化:用 Gemini 一次搞定名片正反面辨識合併

痛點:一張中文名片,其實是兩張名片 台灣的名片有個很常見的設計:正面印中文,背面印英文(或反過來)。我們的 LINE 名片 Bot 原本的邏輯很單純:收到一張圖片,OCR 一次,存一筆資料。 問題就出在這裡。使用者傳正面,Bot 存了一筆只有中文姓名的資料;如果使用者接著把背面也傳過去,Bot 會把它當成「另一張新名片」,同一個人在資料庫裡就會出現兩筆各缺一半資訊的紀錄。使用者得自己手動比對、刪除重複資料,體驗非常糟。 這篇文章記錄我們如何讓 Bot 學會「這是同一張名片的兩面」,並且把正反面資訊合併成一筆完整資料。 解法:先問一句「還有背面嗎?」 比起自己寫規則去猜兩張圖片是不是同一張名片,我們選了一個更直接的做法:問使用者。 流程設計如下: 使用者傳送名片正面,Bot 照常 OCR。 OCR 完成後,Bot 不馬上存檔,而是回覆一句「📇 已辨識正面資料,這張名片還有背面嗎?」,並附上兩顆 Quick Reply 按鈕。 使用者按「沒有,直接儲存」→ 照原本流程存檔,結束。 使用者按「有背面」→ Bot 記住剛剛那張正面圖片,等待下一張圖片進來。 背面圖片一到,兩張圖片一起送進 Gemini,合併成一筆資料再存檔。 這個等待狀態我們用專案原本就有的 user_states 記憶體字典來管理,並且加上 5 分鐘的逾時:使用者按了「有背面」卻不理它、跑去做別的事,5 分鐘一過就當作放棄,不會卡住整個流程。 user_states[user_id] = { 'action': 'pending_backside_confirm', 'card_obj': card_obj, 'front_image_bytes': image_content, 'expires_at': time.time() + PENDING_BACKSIDE_TIMEOUT_SECONDS } 核心:讓 Gemini 一次看兩張圖,自己做合併 最關鍵的技術決定是:要不要分兩次辨識正面、背面,再自己寫程式合併?我們選擇了另一條路,把正反面圖片包在同一次 generate_content 請求裡,直接交給 Gemini 判斷。 原因很簡單:中英文姓名要合併成「王大明 David Wang」這種格式,靠字串規則去兜很容易兜得又醜又不準;但這種語意層級的整合,靠規則去湊反而更容易出錯,交給 Gemini 直接判斷比較省事。 在 app/gemini_utils.py 中新增了 generate_json_from_two_images,沿用既有的 NAMECARD_SCHEMA 結構化輸出,只是這次 contents 塞了兩個圖片 Part: def generate_json_from_two_images( front_img: PIL.Image.Image, back_img: PIL.Image.Image, prompt: str) -> object: model = GenerativeModel( "gemini-3-flash-preview", generation_config={ "response_mime_type": "application/json", "response_schema": NAMECARD_SCHEMA }, ) front_part = Part.from_data( data=pil_to_bytes(front_img), mime_type="image/jpeg") back_part = Part.from_data( data=pil_to_bytes(back_img), mime_type="image/jpeg") response = model.generate_content( [prompt, front_part, back_part], stream=False, labels={"client_id": "namecard"} ) return response Prompt 也只是在原本的 IMGAGE_PROMPT 後面多加一段合併指示: DOUBLE_SIDED_IMAGE_PROMPT = IMGAGE_PROMPT + """ 這兩張圖片是同一張名片的正面與背面,請整合成一筆完整資料。 若同一欄位中英文都有出現(如姓名、公司),請合併呈現 (例如「王大明 David Wang」); 若某欄位只有一面出現,直接採用該面的值;忽略明顯重複的資訊。 """ 一次 API 呼叫,換來的是完全不用自己寫合併規則、也不用維護那套規則。 容易忽略的兩個小坑 功能上線前的整體 code review,抓到兩個很容易被忽略、但真的會咬人的細節。 坑一:重複檢查的時機點 原本的重複檢查(比對 email 是否已存在)是 OCR 完馬上做。但雙面辨識上線後,如果正面剛好跟舊資料的 email 重複,這時候就提早判定「已存在」並結束流程,那背面圖片裡如果帶有新的 email,就再也沒有機會被看到了。...
繼續閱讀