All posts

    Building an AI News Summarizer in Go with Gemini

    May 10, 20267 min read
    Go
    AI
    Gemini
    Backend

    Building an AI News Summarizer in Go with Gemini

    I wanted to build something useful with Go and AI — not a demo, but an actual tool I'd use. BriefMe came out of that: paste any article URL or type a topic, get a structured summary back, then ask follow-up questions about it. The whole thing runs as a deployed Go API on Railway.

    Why Go for This

    My first instinct was NestJS because that's what I reach for most when building APIs. But this project was compute-light and I/O-heavy — lots of waiting on HTTP calls to NewsAPI, Gemini, and scraped URLs. Go's goroutines are a better fit for that, and I wanted the practice. No ORM, no decorators, just the standard library and a handful of focused packages.

    Architecture

    Three services wire together in cmd/main.go and get injected into the handlers:

    GeminiService  ← wraps google.golang.org/genai
    NewsAPIService ← fetches articles by topic
    ScraperService ← scrapes article HTML from a URL
    

    The two handlers (/api/summarize and /api/chat) depend on these services. Summarize decides whether the input is a URL or a topic keyword, routes to the right source, then passes the content to Gemini. Chat takes the article context and a conversation history and continues the thread.

    The Scraping Challenge

    URL-based summarization means scraping HTML I don't control. Every news site structures its markup differently. My approach was to strip noise first, then try known article selectors before falling back to <body>:

    var blockedTags = []string{
        "script", "style", "nav", "header",
        "footer", "aside", "iframe", "noscript",
    }
    
    articleSelectors := []string{
        "article",
        "[role='main']",
        ".article-body",
        ".article-content",
        ".story-body",
        ".post-content",
        ".entry-content",
        "main",
    }
    

    After stripping noise and extracting the article node, the text gets cleaned (collapsed whitespace, empty lines removed) and truncated to 12,000 characters before being sent to Gemini. You can't send an entire scraped page to an LLM — you'll hit token limits and the noise degrades the output.

    One thing that matters: the User-Agent header. Without it, a lot of sites return 403s or serve bot-detection pages. Setting it to something reasonable gets you past most of them.

    Prompting for Structured Output

    This was the most important decision in the project. I wanted a consistent JSON response every time — not markdown, not prose with a JSON block somewhere inside it. The prompt enforces this explicitly:

    func summarizePrompt(content string) string {
        return fmt.Sprintf(`You are BriefMe, an expert news summarizer. Analyze the following article and respond with ONLY a valid JSON object — no markdown, no backticks, no explanation outside the JSON.
    
    The JSON must follow this exact structure:
    {
      "title": "A clear, concise title",
      "summary": "A 2-3 sentence summary",
      "key_points": ["point one", "point two", "point three"],
      "sentiment": "positive | neutral | negative",
      "reading_time_saved": "X mins"
    }
    
    Rules:
    - key_points must have exactly 3 items
    - sentiment must be exactly one of: positive, neutral, negative
    - reading_time_saved estimates read time at 200 wpm
    
    Article content:
    %s`, content)
    }
    

    Even with that prompt, Gemini occasionally wraps the JSON in a code block. I strip those prefixes/suffixes before unmarshalling:

    raw = strings.TrimPrefix(raw, "```json")
    raw = strings.TrimPrefix(raw, "```")
    raw = strings.TrimSuffix(raw, "```")
    raw = strings.TrimSpace(raw)
    

    In hindsight I'd use structured output (the ResponseSchema field in the SDK) to enforce the shape at the API level rather than sanitising text. But this works reliably.

    Multi-Turn Chat with Context

    After a user gets a summary, they can ask follow-up questions. The challenge is that Gemini doesn't have memory between API calls — you have to reconstruct the full conversation on every request.

    My solution: the client sends the article context and the full conversation history on each /api/chat call. The server builds a Contents slice where the first two messages inject the article as a system context, then appends the history and the new message:

    // Inject article context as the opening exchange
    contents = append(contents, &genai.Content{
        Role:  "user",
        Parts: []*genai.Part{{Text: systemContext}},
    })
    contents = append(contents, &genai.Content{
        Role:  "model",
        Parts: []*genai.Part{{Text: "Understood. I have read the article and I am ready to answer your questions about it."}},
    })
    
    // Append history + new message
    for _, msg := range history {
        contents = append(contents, &genai.Content{
            Role:  msg.Role,
            Parts: []*genai.Part{{Text: msg.Content}},
        })
    }
    

    This means the first token of every chat request re-processes the article context. It costs more tokens per call than server-side session management would, but it's stateless — no session storage, no expiry logic, and the API scales horizontally without coordination.

    Rate Limits

    Gemini's free tier has generous limits but they're real. I handle RESOURCE_EXHAUSTED errors specifically and surface a clear message instead of a generic 500:

    if strings.Contains(err.Error(), "429") ||
        strings.Contains(err.Error(), "RESOURCE_EXHAUSTED") {
        return nil, fmt.Errorf("rate limit reached — please wait a moment and try again")
    }
    

    What I'd Do Differently

    1. Use structured output — The SDK supports ResponseSchema to enforce JSON shape at the API level. I'd use that instead of prompt engineering + text sanitisation.
    2. Cache summaries by URL — Right now every request re-scrapes and re-calls Gemini. A short-lived Redis cache keyed on URL would cut costs significantly and improve response times for popular articles.
    3. Pagination from NewsAPI — The current implementation fetches the first page of results. For a topic with hundreds of articles, surfacing more would require pagination handling I skipped.

    BriefMe is deployed on Railway. The source is on GitHub if you want to read through it.