Automation7 min read
Automated Newsletter Summarizer with Gemini Search Grounding and Sentiment Analysis
A configurable topic-based news pipeline that retrieves current articles, generates sourced summaries with bullish/bearish signals, and outputs newsletter-ready Markdown — on Cloud Scheduler
- Gemini
- Vertex AI
- Google Search Grounding
- Cloud Scheduler
- Cloud Run
- Python
The information problem for technical teams is volume, not access: there's too much relevant news to track manually, and most AI-generated summaries hallucinate sources or pull from knowledge cutoffs that are months old. Google Search grounding in Vertex AI solves the cutoff problem — Gemini retrieves current articles before generating, and cites them explicitly in the output.
Pipeline Architecture
Config: topics.json (list of topic seeds)
↓
For each topic (parallel):
Gemini 1.5 Pro + Vertex AI Search Grounding
├── Retrieves 5–10 current articles via Google Search
├── Extracts: key facts, dates, entities, source URLs
├── Generates 2–3 sentence summary with attribution
└── Assigns sentiment: bullish | bearish | neutral | mixed
↓
Synthesis pass (Gemini)
├── Cross-topic patterns ("AI + energy sector converging")
└── Anomaly flags ("unusual coverage spike on topic X")
↓
Newsletter Assembly
├── Per-topic sections (Markdown H2)
├── Executive summary: top 3 developments
└── Full source list (all URLs cited)
↓
Outputs: CSV + JSON + Markdown
→ GCS archive + optional SMTP deliveryGemini with Search Grounding
The key is using Vertex AI's grounded generation — Gemini retrieves current web content before generating, so summaries reflect what was published this week, not what was in training data:
1import vertexai
2from vertexai.preview.generative_models import (
3 GenerativeModel,
4 Tool,
5 grounding,
6)
7
8vertexai.init(project=PROJECT_ID, location="us-central1")
9model = GenerativeModel("gemini-1.5-pro-002")
10
11# Google Search grounding tool
12search_tool = Tool.from_google_search_retrieval(
13 google_search_retrieval=grounding.GoogleSearchRetrieval()
14)
15
16def summarize_topic(topic: str) -> dict:
17 prompt = f"""You are a research analyst writing a newsletter section.
18
19Topic: {topic}
20
21Search for and summarize the most important news and developments on this topic
22from the past 7 days. For each key development:
231. State the fact clearly (who, what, when)
242. Cite the source (publication + URL)
253. Explain the significance
26
27After the summary, assign an overall sentiment:
28BULLISH if developments are positive/growth-indicating
29BEARISH if developments are negative/risk-indicating
30NEUTRAL if factual/informational
31MIXED if both positive and negative
32
33Format your response as:
34SUMMARY: [2-3 sentence narrative]
35SENTIMENT: [BULLISH|BEARISH|NEUTRAL|MIXED]
36REASON: [1 sentence explaining sentiment choice]
37SOURCES: [list of "title — url" pairs]
38"""
39 response = model.generate_content(
40 prompt,
41 tools=[search_tool],
42 generation_config={"temperature": 0.2}, # Lower temp for factual accuracy
43 )
44 return parse_response(response.text)Topic Configuration
1# config/topics.json — customize per client
2{
3 "topics": [
4 "artificial intelligence retail applications 2025",
5 "Google Cloud BigQuery announcements",
6 "energy grid forecasting machine learning Romania",
7 "Gemini API updates and new features",
8 "EU AI Act compliance enterprise"
9 ],
10 "output_format": "markdown",
11 "max_articles_per_topic": 5,
12 "language": "en",
13 "sentiment_analysis": true,
14 "delivery": {
15 "gcs_bucket": "gs://formare-ai-newsletters",
16 "smtp_to": ["team@example.com"],
17 "schedule": "daily"
18 }
19}Newsletter Assembly
1from datetime import datetime
2
3SENTIMENT_EMOJI = {
4 "BULLISH": "📈",
5 "BEARISH": "📉",
6 "NEUTRAL": "📊",
7 "MIXED": "⚖️",
8}
9
10def assemble_newsletter(summaries: list[dict], week_of: datetime) -> str:
11 lines = [
12 f"# Weekly AI & Tech Briefing — {week_of.strftime('%B %d, %Y')}",
13 "",
14 "## Executive Summary",
15 "",
16 *[f"- {s['key_takeaway']}" for s in sorted(
17 summaries, key=lambda x: x.get("significance_score", 0), reverse=True
18 )[:3]],
19 "",
20 ]
21
22 for summary in summaries:
23 sentiment = summary.get("sentiment", "NEUTRAL")
24 emoji = SENTIMENT_EMOJI.get(sentiment, "")
25 lines.extend([
26 f"## {summary['topic']} {emoji}",
27 "",
28 summary["summary_text"],
29 "",
30 f"**Sentiment:** {sentiment} — {summary.get('sentiment_reason', '')}",
31 "",
32 "**Sources:**",
33 *[f"- [{s['title']}]({s['url']})" for s in summary.get("sources", [])],
34 "",
35 "---",
36 "",
37 ])
38
39 return "\n".join(lines)Cloud Scheduler Deployment
1# Deploy as Cloud Run Job
2gcloud run jobs create newsletter-summarizer \
3 --image europe-west1-docker.pkg.dev/$PROJECT/newsletter/summarizer:latest \
4 --region europe-west1 \
5 --service-account newsletter-sa@$PROJECT.iam.gserviceaccount.com \
6 --set-env-vars PROJECT_ID=$PROJECT \
7 --set-secrets CONFIG_PATH=newsletter-config:latest
8
9# Schedule daily at 07:00 CET
10gcloud scheduler jobs create http newsletter-daily \
11 --schedule="0 7 * * *" \
12 --uri="https://europe-west1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/$PROJECT/jobs/newsletter-summarizer:run" \
13 --http-method=POST \
14 --oauth-service-account-email=newsletter-sa@$PROJECT.iam.gserviceaccount.com \
15 --time-zone="Europe/Bucharest"- News from Google Search (no knowledge cutoff)
- Current
- Sentiment signals (bullish/bearish/neutral/mixed)
- 4
- Cloud Scheduler runs
- Daily
- Output formats (CSV + JSON + Markdown)
- 3