{"slug": "gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-to", "title": "[Gemini API in Action] Adding a \"Detailed Research Report\" Button to a LINE Bot: Using Google Search Grounding to Turn Summaries", "summary": "A developer has enhanced a LINE bot with a 'Detailed Research Report' button powered by Google Search Grounding in the Gemini API. The feature lets the model autonomously search for external context, counter-arguments, and source verification, returning citations for transparency. The implementation is open-sourced in the linebot-helper-python repository.", "body_md": "My LINE Bot has always had a summary feature: you drop a URL in, it crawls the content, generates a summary, and attaches a social media post draft along with a button to save it as a bookmark. This feature has been around since 2024, but it has always only solved the \"what is this about\" problem. I often find myself wanting to know three other things:\n\nWhat is the background context of the things discussed in this article? Have there been counter-arguments from others? Are the numbers mentioned sourced, or are they just the author's own claims?\n\nSummaries can't answer these because the input for a summary is only the article itself. The model has no other materials; if you ask it for a \"critical analysis,\" it can only circle around the original text or start hallucinating.\n\nGoogle Search Grounding fills exactly this gap. I used it as a search assistant in [a previous article](https://dev.to/evanlin/gemini-30google-search-building-a-news-and-information-assistant-with-google-search-grounding-36hp); back then, the purpose was to answer questions. This time, I wanted to try another approach: give an existing article to the model, let it search for information outside the article on its own, and then look back to review the article.\n\nThe result is a new \"📄 Detailed Research Report\" button on the summary card. About one to two minutes after clicking it, the Bot pushes a web link to you.\n\nMain Repo: [https://github.com/kkdai/linebot-helper-python](https://github.com/kkdai/linebot-helper-python)\n\nBefore Grounding, to let a model read real-time information from the web, you had to build a pipeline yourself: first, ask the model to extract keywords from the article, use those keywords to call a search API, crawl the search result pages one by one, stuff them into the prompt, and then ask the model to summarize. This involves three or more API calls, any of which could fail, and the quality of the extracted keywords directly determines whether the retrieved information is useful.\n\nGrounding integrates this entire process into the model. You simply attach a `google_search`\n\ntool in the `GenerateContentConfig`\n\n, and the model handles the rest: it decides whether to search, what to search for, how many times to search, and judges which results are worth using.\n\nFor the \"Research Report\" topic, the model deciding what to search for is particularly valuable. When writing the prompt, I don't know what article the user will provide, so I naturally can't write the specific keywords to search. But after the model reads the article, it knows; it will look for the context of the topic and check if there are opposing views.\n\nAnother advantage I care about is that **citations are returned**. The `grounding_metadata`\n\nin the model's response contains the actual web pages it referenced, including titles and URLs. This means phrases like \"according to other reports\" in the report aren't just the model speaking from memory; there are corresponding web pages you can click to verify. For information-based products, this makes a huge difference.\n\nThe code to extract sources is in `loader/langtools.py`\n\n, written defensively because these fields don't exist at all if no search was triggered:\n\n``` php\ndef _extract_grounding_sources(response) -> list:\n    \"\"\"Extract citations from grounding metadata (same approach as chat_session).\"\"\"\n    sources = []\n    try:\n        if getattr(response, 'candidates', None):\n            candidate = response.candidates[0]\n            metadata = getattr(candidate, 'grounding_metadata', None)\n            chunks = getattr(metadata, 'grounding_chunks', None) if metadata else None\n            for chunk in chunks or []:\n                web = getattr(chunk, 'web', None)\n                if web:\n                    sources.append({\n                        'title': getattr(web, 'title', '') or '',\n                        'uri': getattr(web, 'uri', '') or '',\n                    })\n    except Exception as e:\n        logging.warning(f\"Failed to extract grounding sources: {e}\")\n    return sources\n```\n\nThe entire flow starts from the button on the summary card, goes through a re-crawl and a grounding call, and ends with a temporary webpage.\n\n``` php\ngraph TD\n    A[User sends URL] -->|Summary Flex Bubble| B[📄 Detailed Research Report Button]\n    B -->|Postback with bookmark doc id| C[Verify bookmark ownership]\n    C -->|Immediate Reply: Researching| D[LINE Chatroom]\n    C -->|Background Task| E[load_url: Re-crawl original text]\n    E --> F[Gemini + Google Search Grounding]\n    F -->|Markdown + Citations| G[render_report_page to HTML]\n    G -->|Store in memory ReportStore| H[Get uuid report_id]\n    H -->|Push Link| I[GET /reports/:id Temporary Webpage]\n```\n\nThe button carries the bookmark's document ID, not the URL itself. This follows the existing \"Save Bookmark\" mechanism. The benefit is that using the doc ID allows verifying that the bookmark actually belongs to the user before generating the report. Conversely, if Firestore isn't connected or the doc ID can't be retrieved, this button won't appear.\n\nThe key to `generate_research_report()`\n\nisn't the code, but the prompt. I explicitly ask the model to search proactively and require it to label which information comes from the search and which comes from the original text:\n\n```\n    prompt = f\"\"\"You are a rigorous research analyst. Please write a detailed research report based on the following article content,\nin Traditional Chinese (Taiwan usage), Markdown format (starting from ## level, do not include the main article title).\n\nRequired Structure:\n## Executive Summary (3-5 sentences explaining what this is about and why it matters)\n## Background Context (The history and context of this topic, combined with relevant information you searched for)\n## Core Arguments & Evidence (Organize the article's claims and supporting evidence point by point, labeling the strength of evidence)\n## Data & Fact Summary (Key numbers, dates, people, and organizations from the text, using tables or lists)\n## Counter-perspectives & Critique (Search for related reports, compare other viewpoints; point out blind spots, assumptions, or controversies in the article)\n## Further Questions (3-5 questions worth investigating further)\n\nRequirements:\n- Please proactively search for supplementary background and comparative information outside the article, and label in the text whether the information comes from search or the original text.\n- Be specific rather than abstract; clearly label unsupported inferences as \"speculation\".\n- Use full-width punctuation, avoid AI-sounding clichés.\n\nOriginal URL: {url}\n\nArticle Content:\n{text}\"\"\"\n```\n\nThe phrases \"label the strength of evidence\" and \"clearly label unsupported inferences as speculation\" are the parts of the prompt I care about most. Without them, every sentence in the report would sound equally confident, and the reader wouldn't be able to distinguish what the article said, what the model added from search results, and what it inferred itself.\n\nThe part for attaching the tool is very short; whether `tools`\n\nis provided or not is the difference between having grounding or not:\n\n``` python\n    def _call(with_grounding: bool):\n        client = _get_vertex_client()\n        tools = [types.Tool(google_search=types.GoogleSearch())] if with_grounding else None\n        return client.models.generate_content(\n            model=\"gemini-3.1-flash-lite\",\n            contents=prompt,\n            config=types.GenerateContentConfig(\n                temperature=0.4,\n                tools=tools,\n                max_output_tokens=16384,\n                labels={\"client_id\": \"info_helper\"},\n            )\n        )\n\n    try:\n        try:\n            response = _call(with_grounding=True)\n        except Exception as e:\n            logging.warning(\n                f\"Grounded research call failed, retrying without tools: {e}\")\n            response = _call(with_grounding=False)\n```\n\nI used two layers of `try`\n\nbecause grounding involves external searches, so the failure rate is naturally higher than pure text generation. When the tool call fails, instead of returning \"Generation failed,\" it's better to retry once with the same prompt but without the tool. In this case, the user gets a pure article analysis without comparative views or sources, but at least they have something. This degradation is intentional, not an accident.\n\nThe report is a full Markdown document, often thousands of words long, which can't fit into a LINE message. Making it a Flex Message isn't suitable either because it contains tables and multi-level headings. So, I turned it into a webpage.\n\nBut then I had to decide: should these reports be stored in a database?\n\nI chose not to. The reports are only stored in memory and disappear as soon as the Cloud Run instance is recycled:\n\n```\nclass ReportStore:\n    def __init__ (self, ttl_seconds: float = DEFAULT_REPORT_TTL_SECONDS):\n        self.ttl = ttl_seconds\n        self._reports: Dict[str, dict] = {}\n        self._lock = Lock()\n\n    def put(self, html: str) -> str:\n        report_id = uuid.uuid4().hex\n        with self._lock:\n            self._purge_expired()\n            self._reports[report_id] = {\n                \"html\": html,\n                \"created_at\": time.time(),\n            }\n        return report_id\n```\n\nThe `report_id`\n\nuses `uuid.uuid4().hex`\n\nbecause this URL has no login protection; anyone with the link can open it, so the ID must be unguessable. The page itself also includes `<meta name=\"robots\" content=\"noindex\">`\n\nto prevent search engines from indexing people's reading history.\n\nSince reports disappear, \"link expiration\" is not an exception but the normal end for every report. So the route is written like this:\n\n``` python\n@app.get(\"/reports/{report_id}\")\ndef serve_research_report(report_id: str):\n    \"\"\"Temporary research report page: returns expired page (404) after expiration or instance restart.\"\"\"\n    html = report_store.get(report_id)\n    if html:\n        return HTMLResponse(html)\n    return HTMLResponse(render_expired_page(), status_code=404)\n```\n\nI also made it clear in the message pushed to the user, not pretending it's a permanent link:\n\n```\n⏳ This is a temporary page, kept for about 24 hours (invalidated after the service sleeps). Please copy the content if you need to save it.\n```\n\nI encountered this pitfall earlier when building a map restaurant search. At that time, I naturally thought: since I want to get a restaurant list from the model, I'll use structured output, attach `response_mime_type=\"application/json\"`\n\nand `response_schema`\n\n, and get correctly typed data directly to save myself from parsing it.\n\nThe result was an immediate API error. The fix then was to remove the schema (commit `a2c8745`\n\n) and instead ask the model to output JSON text, which I then parsed myself.\n\n**Reason and Solution**\n\nGoogle Search Grounding and structured output are mutually exclusive. When the model is performing grounding, it needs to freely intersperse searching, thinking, and citing; this process cannot be simultaneously constrained to a fixed JSON schema.\n\nSo when making the research report, I abandoned the idea of \"returning a structured object\" from the start and let the model output Markdown plain text directly, writing the structure into the \"Required Structure\" section of the prompt instead of the schema.\n\nLooking back, this limitation actually made things simpler. The report is meant to be a long-form text for humans to read; Markdown is its most natural form. If forced into JSON fields, it would just have to be stitched back into an article during rendering anyway. The only fields that truly need a strict structure are the citations, and those can be taken from `grounding_metadata`\n\n, which never needed a schema to begin with.\n\nLINE Webhook requires an HTTP 200 response within three seconds, but this feature needs to re-crawl the original text and then wait for the grounding call to finish, which takes one to two minutes in total.\n\nLet's talk about the part fewer people notice. `client.models.generate_content()`\n\nis a synchronous blocking call. Even if you wrap it in an `async def`\n\n, it will still block the entire event loop. While one user is generating a report for those two minutes, messages from other users will also be blocked.\n\n**Reason and Solution**\n\nSplit it into two parts: reply and push, each with its own responsibility:\n\n```\n    url = doc.get(\"url\", \"\")\n    await line_bot_api.reply_message(\n        event.reply_token,\n        [TextSendMessage(text=\"🔬 Starting in-depth research on this article (approx. 1-2 mins). I'll send you the report link once finished.\")])\n\n    try:\n        crawled_text = await load_url(url)\n        # Gemini call is synchronous and blocking; offload to a thread to avoid blocking other tasks on the event loop\n        result = await asyncio.to_thread(generate_research_report, crawled_text, url)\n```\n\nFirst, use `reply_message`\n\nto say \"Starting research,\" finishing the webhook request within three seconds. The heavy lifting is moved to the background, and the synchronous Gemini call is offloaded to a thread using `asyncio.to_thread`\n\n. Once finished, use `push_message`\n\nto proactively send the link.\n\nThe phrase \"approx. 1-2 mins\" is also intentional. If a user clicks a button and nothing happens for thirty seconds, they'll start to suspect it's broken and click it again. Explaining how long to wait upfront is cheaper than explaining it afterward.\n\nThe button carries the bookmark's doc ID. If I simply used this ID to query data and generate a report, anyone who could construct a postback could read the content of bookmarks saved by others.\n\n**Reason and Solution**\n\nVerify ownership during the query. Both parameters for `get_bookmark(user_id, doc_id)`\n\nare required. If not found, treat it as expired without telling the user \"this exists but doesn't belong to you\":\n\n```\n    doc = svc.get_bookmark(user_id, doc_id) if (doc_id and svc.available) else None\n    if not doc:\n        await line_bot_api.reply_message(\n            event.reply_token,\n            [TextSendMessage(text=\"⚠️ Data has expired. Please send the URL again and try once more.\")])\n        return\n```\n\nIn practice, this button changes more than just \"making the summary longer.\"\n\n**Summaries and research reports answer different questions.** A summary tells you what the piece is about, suitable for quickly deciding whether to read it. A research report tells you if the piece is correct, how others view it, and which numbers are sourced. That's why I made it two layers instead of making the summary longer: the cheap layer runs every time, and the expensive layer is there for when you really want to dive deep.\n\n**Citations make the report verifiable.** At the bottom of the report is a \"📚 References\" list, all from `grounding_metadata`\n\n, which are the actual web pages the model read. If you see something in the \"Counter-perspectives & Critique\" section that differs from the original text, you can click directly to the original report. This is why I think grounding is more worthwhile than building your own search API pipeline: what you save isn't just code, but the traceability of \"where did this sentence come from.\"\n\n**There's still something to see even if search fails.** After degrading to pure article analysis, the report will lack background context and comparative views, but the executive summary, core arguments, and data organization sections will still be there.\n\n**Temporary webpages save more than expected.** No need to set up a database, no need to write cleanup schedules, and no need to design a report list page. A dict plus a lock is all it takes, with security maintained by unguessable UUIDs. The trade-off is that links will expire, which I've made clear in the push message. Reading behavior is usually concentrated in the few minutes after receiving a link; I don't think it's worth the overhead of a full persistence system for the few cases where someone wants to save it long-term.\n\nIf long-term storage is really needed later, the current architecture isn't hard to change: the `ReportStore`\n\ninterface only has `put`\n\nand `get`\n\nmethods. Replacing it with a Firestore implementation wouldn't require any changes to the upper layers.\n\nThe entire feature adds up to about five hundred lines, a third of which are tests. The code is at [kkdai/linebot-helper-python](https://github.com/kkdai/linebot-helper-python), and the design document is in `docs/superpowers/specs/2026-08-15-research-report-design.md`\n\n. Feel free to check it out if you're interested.", "url": "https://wpnews.pro/news/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-to", "canonical_source": "https://dev.to/gde/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-using-google-2cd0", "published_at": "2026-08-20 14:00:07+00:00", "updated_at": "2026-08-20 14:16:21.929946+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "ai-products", "ai-tools", "developer-tools"], "entities": ["LINE", "Google Search Grounding", "Gemini API", "kkdai", "linebot-helper-python"], "alternates": {"html": "https://wpnews.pro/news/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-to", "markdown": "https://wpnews.pro/news/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-to.md", "text": "https://wpnews.pro/news/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-to.txt", "jsonld": "https://wpnews.pro/news/gemini-api-in-action-adding-a-detailed-research-report-button-to-a-line-bot-to.jsonld"}}