Uncategorized

Xây Dựng AI News Agent: Lọc Tin Gmail, RAG & Voice Tự Động

<!– WordPress SEO Article Title (Exactly 58 characters

Xây Dựng AI News Agent: Lọc Tin Gmail, RAG & Voice Tự Động

Tóm tắt kỹ thuật (Meta Description): Hướng dẫn toàn diện từng bước xây dựng ứng dụng AI News Agent tự động quét email Gmail theo lịch, cào nội dung báo chí, dùng LLM (Structured Outputs) bóc tách thực thể và loại bỏ tin cũ trùng lặp, lưu trữ trên PostgreSQL kết hợp pgvector để tìm kiếm ngữ nghĩa RAG và phát giọng đọc Text-to-Speech tự động cuộn trang (Auto-Scroll Karaoke) cho người bận rộn.


Giao diện tổng hợp tin tức số hóa và luồng xử lý dữ liệu tự động của hệ thống AI News Agent

Hệ thống AI News Agent kết hợp RAG và Text-to-Speech giúp tự động hóa hoàn toàn quy trình lọc bản tin email, khử trùng lặp và phát thanh điểm tin rảnh tay.

1. Bài Toán Điểm Tin Cho Người Bận Rộn & Sơ Đồ Kiến Trúc Tổng Thể

Mỗi ngày, giới doanh nhân, nhà đầu tư tài chính và quản lý cấp cao phải đối mặt với hàng chục bản tin điện tử (Google Alerts, Newsletter tài chính, báo cáo ngành) gửi về hộp thư Gmail. Việc phải tự mở từng email, nhấp vào hàng loạt đường dẫn (link) báo chí, đọc lướt qua các bài viết “xào nấu” lại cùng một sự kiện cũ gây lãng phí hàng giờ đồng hồ và dẫn đến tình trạng quá tải thông tin (Information Overload).

Để giải quyết triệt để nỗi đau này, chúng ta có thể xây dựng một hệ thống AI News Agent kết hợp RAG (Retrieval-Augmented Generation) và Text-to-Speech (TTS) hoàn toàn tự động. Ứng dụng sẽ tự động đăng nhập Gmail theo khung giờ định sẵn, cào toàn bộ bài báo trong liên kết, dùng Mô hình Ngôn ngữ Lớn (LLM) nhận diện đối tượng (con người, công ty, tổ chức) và đối chiếu lịch sử để loại bỏ hoàn toàn tin cũ hoặc trùng lặp, chỉ giữ lại những diễn biến mới nhất. Khi người dùng cần tra cứu hoặc nghe điểm tin buổi sáng, giao diện Web sẽ thực hiện tìm kiếm ngữ nghĩa bằng Vector và đọc giọng nói tự nhiên kèm hiệu ứng tự động cuộn màn hình (Auto-Scroll) và tô sáng câu chữ (Highlight) theo thời gian thực.

Sơ Đồ Kiến Trúc 4 Module Cốt Lõi (System Architecture)

[Module 1: Data Ingestion] Lập lịch Cron/Celery ➔ Quét Gmail (OAuth2) ➔ Trích xuất Link ➔ Cào bài báo (Scraper)
        │
        ▼
[Module 2: AI Processing] Trích xuất Thực thể (NER) ➔ Đối chiếu Ngữ cảnh Lịch sử & Khử trùng (LLM JSON) ➔ Vector hóa (Embedding)
        │
        ▼
[Module 3: Hybrid Storage] PostgreSQL (Lưu Thực thể, Thời gian, Bài báo) + pgvector (Lưu Vector 1536 chiều & HNSW Index)
        │
        ▼
[Module 4: Web UI & Voice] Tìm kiếm Ngữ nghĩa (Semantic RAG) + Trình phát Điểm tin TTS + Tự động cuộn & Highlight Karaoke
Phân Hệ (Module) Chức Năng Trọng Tâm Công Nghệ Khuyến Nghị
1. Data Ingestion (Thu thập) Lập lịch quét hộp thư đến, lọc thư chưa đọc, bóc tách URL báo chí và cào nội dung văn bản thô. Python, google-api-python-client (OAuth2), imaplib, BeautifulSoup4, Playwright / Scrapy.
2. AI Processing (Xử lý thông minh) Nhận diện thực thể (NER), so sánh ngữ cảnh lịch sử để loại bỏ tin cũ/xào nấu, sinh tóm tắt và tạo Vector. OpenAI gpt-4o-mini / Gemini 1.5 Flash, Pydantic (Structured Outputs), text-embedding-3-small.
3. Hybrid Storage (Lưu trữ lai) Lưu trữ dữ liệu có cấu trúc (quan hệ Nhiều-Nhiều giữa bài báo và thực thể) kết hợp chỉ mục tìm kiếm Vector. PostgreSQL 15+, tiện ích mở rộng pgvector (chỉ mục HNSW), psycopg2 / asyncpg.
4. User Interface (Giao diện & Voice) Tìm kiếm ngữ nghĩa RAG, hiển thị luồng tin tức, phát âm thanh điểm tin đồng bộ cuộn màn hình tự động. FastAPI (Backend), ReactJS / NextJS (Frontend), Web Speech API hoặc FPT.AI / Viettel AI TTS (MP3 + Timestamps).

2. Thiết Kế Cơ Sở Dữ Liệu Lai (Hybrid Database: PostgreSQL + pgvector)

Để hệ thống vừa có thể lọc tin tức chính xác theo từng mốc thời gian và từng đối tượng cụ thể (ví dụ: tất cả tin tức về một tập đoàn hoặc một nhân vật trong 7 ngày qua), vừa có thể tìm kiếm theo ý nghĩa câu hỏi tự nhiên của người dùng, chúng ta cần một kiến trúc Hybrid Database (Cơ sở dữ liệu kép).

Thay vì phải vận hành tách biệt một hệ quản trị SQL truyền thống và một Vector Database bên thứ ba tốn kém, giải pháp tối ưu nhất là sử dụng PostgreSQL tích hợp tiện ích mở rộng pgvector. Cách tiếp cận này giúp quản lý các bảng thực thể (Entities), bài báo (Articles) và không gian vector 1536 chiều ngay trong cùng một giao dịch (Transaction) nhất quán.

Hệ thống máy chủ cơ sở dữ liệu PostgreSQL và chỉ mục không gian vector tốc độ cao

Kết hợp PostgreSQL và tiện ích mở rộng pgvector với chỉ mục HNSW giúp hợp nhất dữ liệu quan hệ (SQL) và truy vấn tương đồng Cosine trong một hạ tầng duy nhất.

Cấu Trúc Bảng Dữ Liệu & Chỉ Mục Vector (SQL Schema)

Bạn khởi tạo cơ sở dữ liệu news_intelligence trên PostgreSQL và thực thi kịch bản SQL dưới đây để tạo đầy đủ 3 bảng cốt lõi cùng chỉ mục tìm kiếm tốc độ cao HNSW (Hierarchical Navigable Small World):

-- 1. Kích hoạt tiện ích mở rộng pgvector trên PostgreSQL
CREATE EXTENSION IF NOT EXISTS vector;

-- 2. Bảng entities: Lưu trữ thông tin đối tượng nhắm tới (Con người, Công ty, Tổ chức)
CREATE TABLE IF NOT EXISTS entities (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) UNIQUE NOT NULL,
    type VARCHAR(50) CHECK (type IN ('COMPANY', 'PERSON', 'ORGANIZATION')),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 3. Bảng articles: Lưu nội dung bài báo thô, bản tóm tắt và Vector Embedding
CREATE TABLE IF NOT EXISTS articles (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    url TEXT UNIQUE NOT NULL,
    content TEXT NOT NULL,
    summary TEXT,
    published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    -- Kích thước 1536 chiều tương thích chuẩn với model 'text-embedding-3-small' của OpenAI
    embedding VECTOR(1536),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 4. Bảng trung gian article_entities: Liên kết Nhiều-Nhiều (Many-to-Many) giữa Bài báo và Đối tượng
CREATE TABLE IF NOT EXISTS article_entities (
    article_id INT REFERENCES articles(id) ON DELETE CASCADE,
    entity_id INT REFERENCES entities(id) ON DELETE CASCADE,
    PRIMARY KEY (article_id, entity_id)
);

-- 5. Tạo chỉ mục HNSW giúp tăng tốc độ truy vấn khoảng cách Cosine khi dữ liệu lớn
CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);

3. Module 1: Tự Động Lấy Email Gmail (OAuth2 / IMAP) & Cào Nội Dung Báo Chí

Ở tầng thu thập dữ liệu (Data Ingestion), hệ thống cần tự động truy cập vào hộp thư Gmail cá nhân, lọc ra những email mới chưa đọc (UNSEEN / is:unread), quét mã HTML của bức thư để trích xuất các đường dẫn bài báo và cào toàn bộ nội dung văn bản về xử lý.

Phương Án A: Kết Nối Nhanh Qua Giao Thức IMAP & Cào Web Bằng BeautifulSoup4

Trong giai đoạn kiểm thử luồng cào dữ liệu cơ bản, bạn có thể sử dụng thư viện imaplib kết hợp email và BeautifulSoup4 của Python để bóc tách các thẻ <a href="..."> và thu thập nội dung từ các trang báo lớn:

import imaplib
import email
from bs4 import BeautifulSoup
import requests

def fetch_latest_emails():
    # Kết nối tới máy chủ Gmail qua giao thức IMAP SSL
    mail = imaplib.IMAP4_SSL("imap.gmail.com")
    mail.login("your_email@gmail.com", "your_app_password")
    mail.select("inbox")
    
    # Tìm kiếm toàn bộ các email chưa đọc trong hộp thư đến
    status, search_data = mail.search(None, 'UNSEEN')
    articles_to_process = []
    
    for num in search_data[0].split():
        status, data = mail.fetch(num, '(RFC822)')
        raw_email = data[0][1]
        msg = email.message_from_bytes(raw_email)
        
        # Trích xuất các liên kết từ phần thân HTML của email
        if msg.is_multipart():
            for part in msg.walk():
                if part.get_content_type() == "text/html":
                    html_content = part.get_payload(decode=True).decode('utf-8', errors='ignore')
                    soup = BeautifulSoup(html_content, 'html.parser')
                    # Quét toàn bộ thẻ  chứa đường dẫn bài báo
                    for link in soup.find_all('a', href=True):
                        url = link['href']
                        if "baomoi.com" in url or "vnexpress.net" in url or "vietnamnet.vn" in url:
                            articles_to_process.append(url)
    return list(set(articles_to_process)) # Khử trùng lặp URL ngay trong cùng đợt quét

def scrape_article(url: str):
    # Gửi HTTP Request giả lập trình duyệt để lấy mã nguồn trang báo
    response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
    soup = BeautifulSoup(response.text, 'html.parser')
    
    # Bóc tách tiêu đề chính (H1) và ghép nối toàn bộ các đoạn văn (P)
    title = soup.find('h1').get_text(strip=True) if soup.find('h1') else "Bản tin tổng hợp"
    paragraphs = soup.find_all('p')
    content = " ".join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
    
    return {"title": title, "url": url, "content": content}

Phương Án B (Chuẩn Production): Xác Thực An Toàn Bằng Gmail API & OAuth2 Refresh Token

Do Google đã siết chặt bảo mật và hạn chế đăng nhập bằng mật khẩu đơn thuần đối với các tác vụ chạy ngầm dài hạn, giải pháp chuẩn mực nhất cho môi trường Production là sử dụng Gmail API chính thức thông qua cơ chế OAuth2 Refresh Token.

Quy trình 4 bước lấy file xác thực trên Google Cloud Console:

  1. Truy cập vào Google Cloud Console, khởi tạo một Project mới dành riêng cho hệ thống AI News Agent.
  2. Vào mục APIs & Services > Library, tìm kiếm và kích hoạt (Enable) dịch vụ Gmail API.
  3. Vào mục OAuth consent screen, cấu hình loại ứng dụng là External và thêm địa chỉ Gmail của bạn vào danh sách Test users.
  4. Vào mục Credentials > Create Credentials > OAuth client ID, chọn Desktop app (hoặc Web application), tải file cấu hình JSON về máy và đổi tên thành credentials.json.

Trong lần khởi chạy đầu tiên trên máy cá nhân, đoạn mã dưới đây sẽ mở trình duyệt để bạn cấp quyền và sinh ra file token.json chứa refresh_token. Từ lần thứ hai trở đi, hệ thống tự động gia hạn ngầm vĩnh viễn mà không cần sự can thiệp của con người, đồng thời tự động gỡ nhãn UNREAD sau khi đã trích xuất xong link báo:

import os
import base64
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from bs4 import BeautifulSoup

# Cấp quyền đọc và cập nhật trạng thái nhãn (Label) của email
SCOPES = ['https://www.googleapis.com/auth/gmail.modify']

def get_gmail_service():
    creds = None
    # Kiểm tra file token.json lưu trữ Access Token & Refresh Token
    if os.path.exists('token.json'):
        creds = Credentials.from_authorized_user_file('token.json', SCOPES)
        
    # Tự động làm mới (Refresh) nếu token hết hạn hoặc yêu cầu đăng nhập lần đầu
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
        with open('token.json', 'w') as token:
            token.write(creds.to_json())

    return build('gmail', 'v1', credentials=creds)

def fetch_links_from_unread_emails():
    try:
        service = get_gmail_service()
        # Truy vấn toàn bộ thư chưa đọc trong hộp thư đến
        results = service.users().messages().list(userId='me', q='is:unread').execute()
        messages = results.get('messages', [])
        
        extracted_urls = []
        
        for msg in messages:
            msg_data = service.users().messages().get(userId='me', id=msg['id'], format='full').execute()
            payload = msg_data.get('payload', {})
            parts = payload.get('parts', [])
            
            html_content = ""
            if not parts and payload.get('body', {}).get('data'):
                html_content = base64.urlsafe_b64decode(payload['body']['data']).decode('utf-8', errors='ignore')
            else:
                for part in parts:
                    if part.get('mimeType') == 'text/html' and part.get('body', {}).get('data'):
                        html_content = base64.urlsafe_b64decode(part['body']['data']).decode('utf-8', errors='ignore')
                        break
            
            if html_content:
                soup = BeautifulSoup(html_content, 'html.parser')
                for link in soup.find_all('a', href=True):
                    url = link['href']
                    if "vnexpress.net" in url or "vietnamnet.vn" in url or "cafef.vn" in url:
                        extracted_urls.append(url)
                        
            # Đánh dấu email là ĐÃ ĐỌC (xóa nhãn UNREAD) để chu kỳ sau không quét trùng
            service.users().messages().batchModify(
                userId='me',
                body={'ids': [msg['id']], 'removeLabelIds': ['UNREAD']}
            ).execute()
            
        return list(set(extracted_urls))
    except Exception as e:
        print(f"Lỗi khi truy xuất Gmail API: {e}")
        return []

4. Module 2: Trích Xuất Thực Thể (NER) & Khử Trùng Lặp Thông Minh Bằng LLM

Điểm khác biệt cốt lõi giữa một công cụ đọc RSS thông thường và một AI News Agent nằm ở khả năng nhận thức ngữ nghĩa. Các tờ báo thường đăng tải lại cùng một sự kiện (ví dụ: kết quả kinh doanh quý hoặc một thương vụ M&A) dưới nhiều tiêu đề và cách hành văn (paraphrase) khác nhau. Nếu chỉ kiểm tra trùng lặp bằng từ khóa hay URL, cơ sở dữ liệu sẽ nhanh chóng bị ngập bởi các thông tin cũ lặp đi lặp lại.

Giải pháp triệt để là sử dụng tính năng Structured Outputs (Pydantic Schema) của các dòng mô hình tốc độ cao, chi phí rẻ như gpt-4o-mini hoặc Gemini 1.5 Flash. Trước khi lưu bài báo mới, hệ thống truy xuất các bản tóm tắt gần nhất của đối tượng đó trong Database làm Ngữ cảnh lịch sử (Historical Context) và yêu cầu LLM thẩm định:

Kịch Bản Cơ Bản: Phân Loại Thực Thể & Kiểm Tra Tin Cũ

from openai import OpenAI
from pydantic import BaseModel
from typing import List

client = OpenAI(api_key="your_openai_api_key")

# Định nghĩa Schema dữ liệu đầu ra chuẩn xác bằng Pydantic
class ArticleAnalysis(BaseModel):
    is_duplicate_or_old: bool
    reason: str
    entities: List[str]
    entity_types: List[str] # ["COMPANY", "PERSON", "ORGANIZATION"]
    summary: str

def analyze_and_filter_with_llm(new_article_content: str, history_context_summary: str) -> ArticleAnalysis:
    prompt = f"""
    Bạn là một trợ lý AI phân tích tin tức thông minh. 
    Nhiệm vụ của bạn là đọc bài báo mới và so sánh với ngữ cảnh lịch sử các tin đã lưu để xem nó có bị trùng lặp thông tin hoặc chỉ chứa thông tin cũ không.

    [Ngữ cảnh lịch sử các tin đã có về đối tượng này]:
    {history_context_summary}

    [Nội dung bài báo mới cần kiểm tra]:
    {new_article_content}
    
    Hãy phân tích và trả về kết quả theo đúng định dạng được yêu cầu. Nếu bài báo mang lại thông tin MỚI, cập nhật mới về con người, sự kiện, doanh nghiệp, hãy đặt is_duplicate_or_old = false.
    """
    
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are a precise data extraction assistant."},
            {"role": "user", "content": prompt},
        ],
        response_format=ArticleAnalysis,
    )
    
    return completion.choices[0].message.parsed

Kỹ Thuật Viết Prompt Tiếng Việt Chuyên Sâu: Bóc Tách Luận Điểm Mới (`new_facts`)

Để nâng cao độ chính xác ở cấp độ doanh nghiệp (Production), chúng ta thiết kế cấu trúc NewsFilterOutput bổ sung thang điểm tin cậy (confidence_score) và danh sách các tình tiết mới (new_facts). Nhờ vậy, ngay cả khi một bài báo dài có 80% là thông tin tổng hợp lại quá khứ và 20% là diễn biến mới phát sinh, AI vẫn tách riêng được phần thông tin mới đắt giá để lưu trữ:

import json
from openai import OpenAI
from pydantic import BaseModel
from typing import List

class NewsFilterOutput(BaseModel):
    is_new_information: bool
    confidence_score: float       # Thang điểm 0.0 - 1.0 về độ tin cậy của quyết định
    extracted_entities: List[str] # Ví dụ: ["Ông Phạm Nhật Vượng", "Tập đoàn Vingroup"]
    new_facts: List[str]          # Các tình tiết, số liệu mới mà bài báo cung cấp
    reasoning: str                # Giải thích lý do giữ lại hoặc loại bỏ bài báo

def verify_news_with_llm(new_article_content: str, history_context_summary: str) -> NewsFilterOutput:
    client = OpenAI(api_key="your_openai_api_key")
    
    prompt = f"""
    Bạn là một chuyên gia phân tích dữ liệu tin tức cao cấp. Nhiệm vụ của bạn là đọc một bài báo MỚI thu thập được và đối chiếu với [NGỮ CẢNH LỊCH SỬ] các tin tức đã lưu trước đó của cùng một đối tượng để quyết định xem bài báo này có cung cấp THÔNG TIN MỚI hay không.

    [NGỮ CẢNH LỊCH SỬ CÁC TIN ĐÃ LƯU]:
    """
    {history_context_summary}
    """

    [NỘI DUNG BÀI BÁO MỚI CẦN KIỂM TRA]:
    """
    {new_article_content}
    """

    QUY TẮC PHÂN LOẠI NGHIÊM NGẶT:
    1. Đặt `is_new_information` thành `true` NẾU VÀ CHỈ NẾU bài báo mới cung cấp thêm các tình tiết mới, diễn biến mới, số liệu mới, hoặc hành động mới chưa từng xuất hiện trong Ngữ cảnh lịch sử.
    2. Đặt `is_new_information` thành `false` nếu bài báo mới chỉ xào nấu lại, tổng hợp lại, hoặc viết lại (paraphrase) cùng một nội dung sự kiện cũ bằng câu chữ khác mà không bổ sung thêm bất kỳ giá trị thông tin mới nào.
    3. Trích xuất chính xác các thực thể (Tên người, tên công ty, tên tổ chức) xuất hiện nổi bật trong bài báo mới vào mục `extracted_entities`.
    4. Ghi rõ các luận điểm mới thu thập được vào `new_facts`. Nếu không có thông tin mới, hãy để trống mục này.
    """

    completion = client.beta.chat.completions.parse(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Bạn là một AI phân tích tin tức chuyên nghiệp, luôn trả về kết quả dạng JSON chính xác theo cấu trúc yêu cầu bằng tiếng Việt."},
            {"role": "user", "content": prompt}
        ],
        response_format=NewsFilterOutput
    )
    
    return completion.choices[0].message.parsed

5. Module 3: Vector Hóa Dữ Liệu & Tìm Kiếm Ngữ Nghĩa Bằng Mô Hình RAG

Khi người dùng bận rộn gõ vào thanh tìm kiếm một câu hỏi tự nhiên như: “Tuần này công ty X có dự án bất động sản hay biến động nhân sự cấp cao nào mới không?”, phương pháp tìm kiếm khớp từ khóa truyền thống (LIKE '%từ_khóa%') sẽ thất bại hoàn toàn vì câu hỏi không trùng khớp từng chữ với bài báo.

Bằng cách chuyển đổi nội dung bài báo đã qua kiểm duyệt thành chuỗi Vector 1536 chiều bằng model text-embedding-3-small và sử dụng toán tử khoảng cách Cosine <=> trong pgvector, hệ thống sẽ tìm ra chính xác các bài báo có ý nghĩa gần nhất, sau đó nạp vào LLM để tổng hợp thành một bản tin mạch lạc chuẩn bị cho bộ phát giọng nói:

import psycopg2
from openai import OpenAI

client = OpenAI(api_key="your_openai_api_key")

def get_embedding(text: str):
    """Chuyển đổi văn bản thành mảng Vector 1536 chiều"""
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=[text]
    )
    return response.data[0].embedding

def insert_article(title: str, url: str, content: str, summary: str):
    """Lưu bài báo mới đã qua kiểm duyệt kèm Vector Embedding vào PostgreSQL"""
    embedding = get_embedding(content)
    
    conn = psycopg2.connect("dbname=news_intelligence user=admin password=secure_password_123")
    cur = conn.cursor()
    
    cur.execute(
        """
        INSERT INTO articles (title, url, content, summary, embedding)
        VALUES (%s, %s, %s, %s, %s)
        ON CONFLICT (url) DO NOTHING;
        """,
        (title, url, content, summary, str(embedding))
    )
    conn.commit()
    cur.close()
    conn.close()

def semantic_search_rag(user_query: str):
    """Tìm kiếm ngữ nghĩa bằng Vector và tổng hợp câu trả lời điểm tin bằng RAG"""
    # 1. Vector hóa câu hỏi tự nhiên của người dùng
    query_vector = get_embedding(user_query)
    
    # 2. Truy vấn Top 3 bài viết có khoảng cách Cosine (<=>) gần nhất trong pgvector
    conn = psycopg2.connect("dbname=news_intelligence user=admin password=secure_password_123")
    cur = conn.cursor()
    
    cur.execute("""
        SELECT title, summary, url 
        FROM articles 
        ORDER BY embedding <=> %s::vector 
        LIMIT 3;
    """, (str(query_vector),))
    
    related_articles = cur.fetchall()
    
    # 3. Đóng gói Ngữ cảnh (Context) từ kết quả truy xuất
    context = ""
    for article in related_articles:
        context += f"Tiêu đề: {article[0]}nTóm tắt: {article[1]}nNguồn: {article[2]}n---n"
        
    # 4. Đưa Ngữ cảnh vào LLM để biên tập bản điểm tin ngắn gọn, tối ưu cho phát thanh Audio
    prompt = f"""
    Dựa vào các thông tin ngữ cảnh được cung cấp dưới đây, hãy trả lời câu hỏi của người dùng một cách chính xác, ngắn gọn và mạch lạc để chuẩn bị đọc audio.
    
    [Ngữ cảnh]:
    {context}
    
    [Câu hỏi]: {user_query}
    """
    
    completion = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}]
    )
    
    cur.close()
    conn.close()
    return completion.choices[0].message.content

6. Module 4: Giao Diện Web Tự Động Cuộn (Auto-Scroll) & Đọc Voice Karaoke

Đây là phân hệ tạo nên trải nghiệm khác biệt nhất cho người dùng bận rộn: vừa nghe điểm tin rảnh tay khi đang lái xe, tập thể dục hay ăn sáng, vừa có thể liếc nhìn màn hình bất cứ lúc nào và thấy ngay câu đang được phát âm thanh nằm chính giữa tầm mắt.

Micro thu âm phòng thu và hệ thống tổng hợp giọng nói trí tuệ nhân tạo Text-to-Speech

Đồng bộ hóa sự kiện âm thanh với thao tác cuộn DOM (scrollIntoView) giúp biến trình đọc tin văn bản thành một studio phát thanh trực quan.

Cấp Độ 1: Đồng Bộ Miễn Phí Bằng Web Speech API (`onboundary` Event)

Mọi trình duyệt hiện đại đều tích hợp sẵn window.speechSynthesis. Thuật toán phía Frontend (ReactJS) sẽ dùng biểu thức chính quy (Regex) tách đoạn văn bản thành mảng từng câu riêng biệt, bọc mỗi câu trong một thẻ <span id="sentence-i">. Khi giọng đọc phát đến từ hoặc câu mới, sự kiện utterance.onboundary trả về vị trí ký tự charIndex, từ đó kích hoạt highlight và gọi hàm element.scrollIntoView({ behavior: 'smooth', block: 'center' }):

import React, { useState, useEffect, useRef } from 'react';

export default function AudioNewsPlayer({ newsText }) {
  const [sentences, setSentences] = useState([]);
  const [currentSentenceIndex, setCurrentSentenceIndex] = useState(-1);
  const [isPlaying, setIsPlaying] = useState(false);
  const synthRef = useRef(null);
  const utteranceRef = useRef(null);

  // 1. Tách văn bản thành mảng các câu ngắn dựa trên dấu câu (. ! ?)
  useEffect(() => {
    if (newsText) {
      const splitSentences = newsText.match(/[^.!?]+[.!?]+(s|$)/g) || [newsText];
      setSentences(splitSentences.map(s => s.trim()));
    }
    synthRef.current = window.speechSynthesis;
  }, [newsText]);

  // 2. Phát giọng đọc tiếng Việt và đồng bộ hóa vị trí câu đang đọc
  const handlePlay = () => {
    if (!synthRef.current || sentences.length === 0) return;

    if (synthRef.current.paused) {
      synthRef.current.resume();
      setIsPlaying(true);
      return;
    }

    const fullText = sentences.join(' ');
    const utterance = new SpeechSynthesisUtterance(fullText);
    utteranceRef.current = utterance;

    utterance.lang = 'vi-VN';
    utterance.rate = 1.0;

    // Bắt sự kiện chuyển câu/từ để tính toán câu hiện tại theo charIndex
    utterance.onboundary = (event) => {
      if (event.name === 'sentence' || event.name === 'word') {
        const charIndex = event.charIndex;
        
        let accumulatedLength = 0;
        for (let i = 0; i < sentences.length; i++) {
          accumulatedLength += sentences[i].length + 1;
          if (charIndex < accumulatedLength) {
            setCurrentSentenceIndex(i);
            
            // Tự động cuộn mượt mà đưa câu đang đọc vào chính giữa khung nhìn
            const element = document.getElementById(`sentence-${i}`);
            if (element) {
              element.scrollIntoView({
                behavior: 'smooth',
                block: 'center',
              });
            }
            break;
          }
        }
      }
    };

    utterance.onend = () => {
      setIsPlaying(false);
      setCurrentSentenceIndex(-1);
    };

    synthRef.current.speak(utterance);
    setIsPlaying(true);
  };

  const handlePause = () => {
    if (synthRef.current && synthRef.current.speaking) {
      synthRef.current.pause();
      setIsPlaying(false);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-6 bg-white shadow-lg rounded-xl">
      <div className="flex gap-4 mb-6 sticky top-0 bg-white py-2 border-b">
        {!isPlaying ? (
          <button onClick={handlePlay} className="px-4 py-2 bg-green-600 text-white rounded-lg font-semibold">
            ▶ Nghe Điểm Tin
          </button>
        ) : (
          <button onClick={handlePause} className="px-4 py-2 bg-amber-500 text-white rounded-lg font-semibold">
            ⏸ Tạm Dừng
          </button>
        )}
      </div>

      <div className="text-xl leading-relaxed text-gray-800 space-y-4 max-h-[500px] overflow-y-auto p-4 border rounded">
        {sentences.map((sentence, index) => (
          <span
            key={index}
            id={`sentence-${index}`}
            className={`transition-colors duration-300 block p-2 rounded ${
              index === currentSentenceIndex 
                ? 'bg-yellow-200 font-semibold text-black scale-105 transform origin-left duration-200' 
                : 'text-gray-600'
            }`}
          >
            {sentence}{' '}
          </span>
        ))}
      </div>
    </div>
  );
}

Cấp Độ 2: Đồng Bộ Karaoke Chuyên Nghiệp Với Giọng Đọc AI Cao Cấp (MP3 + Timestamps)

Để mang lại trải nghiệm âm thanh truyền cảm như biên tập viên thời sự chuyên nghiệp (thông qua các API như FPT.AI TTS, Viettel AI hoặc Google Cloud Text-to-Speech), Backend sẽ nhận về một file âm thanh .mp3 cùng một mảng JSON chứa mốc thời gian bắt đầu (start) và kết thúc (end) tính bằng giây của từng câu:

[
  {"text": "Hôm nay, tập đoàn Vingroup đã công bố báo cáo tài chính mới nhất", "start": 0.0, "end": 4.2},
  {"text": "với doanh thu tăng trưởng vượt bậc.", "start": 4.3, "end": 6.8},
  {"text": "Ông Phạm Nhật Vượng nhấn mạnh chiến lược tập trung vào xe điện.", "start": 6.9, "end": 11.5}
]

Trên giao diện ReactJS, chúng ta sử dụng thẻ <audio> chuẩn HTML5 và lắng nghe sự kiện onTimeUpdate để so khớp thời gian thực currentTime với mảng mốc thời gian, kích hoạt hiệu ứng tô sáng Karaoke và cuộn trang chính xác tới từng phần mười giây:

import React, { useState, useRef } from 'react';

const timelineData = [
  { text: "Hôm nay, tập đoàn Vingroup đã công bố báo cáo tài chính mới nhất", start: 0.0, end: 4.2 },
  { text: "với doanh thu tăng trưởng vượt bậc.", start: 4.3, end: 6.8 },
  { text: "Ông Phạm Nhật Vượng nhấn mạnh chiến lược tập trung vào xe điện toàn cầu.", start: 6.9, end: 12.5 },
  { text: "Trong khi đó, các công ty công nghệ đối thủ cũng đang rục rịch ra mắt sản phẩm mới.", start: 12.6, end: 18.0 }
];

export default function PremiumAudioPlayer() {
  const [currentIndex, setCurrentIndex] = useState(-1);
  const audioRef = useRef(null);

  // Kích hoạt liên tục mỗi khi trình phát audio cập nhật thời gian phát
  const handleTimeUpdate = () => {
    if (!audioRef.current) return;
    const currentTime = audioRef.current.currentTime;

    // Xác định câu tương ứng với mốc thời gian hiện tại
    const index = timelineData.findIndex(
      item => currentTime >= item.start && currentTime <= item.end
    );
    
    if (index !== -1 && index !== currentIndex) {
      setCurrentIndex(index);
      
      const element = document.getElementById(`premium-sentence-${index}`);
      if (element) {
        element.scrollIntoView({
          behavior: 'smooth',
          block: 'center'
        });
      }
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '640px', margin: '0 auto' }}>
      <h3 style={{ color: '#1e293b', fontWeight: 'bold', marginBottom: '12px' }}>
        Trình Phát Điểm Tin Thông Minh (Giọng Đọc AI Studio)
      </h3>
      
      <audio 
        ref={audioRef}
        src="https://your-storage-domain.com/audio/daily-briefing.mp3" 
        controls 
        onTimeUpdate={handleTimeUpdate}
        style={{ width: '100%', marginBottom: '20px' }}
      />

      <div style={{ fontSize: '18px', lineHeight: '2.0', textAlign: 'justify', marginTop: '20px' }}>
        {timelineData.map((item, index) => (
          <span
            key={index}
            id={`premium-sentence-${index}`}
            style={{
              backgroundColor: currentIndex === index ? '#ffc107' : 'transparent',
              color: currentIndex === index ? '#000000' : '#475569',
              fontWeight: currentIndex === index ? 'bold' : 'normal',
              transition: 'all 0.2s ease',
              padding: '4px 6px',
              borderRadius: '4px',
              display: 'inline-block',
              marginRight: '6px'
            }}
          >
            {item.text}
          </span>
        ))}
      </div>
    </div>
  );
}

7. Lập Lịch Vận Hành Tự Động 100% (APScheduler & GitHub Actions)

Để luồng công việc (Quét Gmail ➔ Cào bài báo ➔ LLM lọc trùng & bóc tách thực thể ➔ Lưu Postgres & Vector) diễn ra hoàn toàn tự động vào các khung giờ cố định trước khi bạn thức dậy hoặc nghỉ trưa, bạn có thể lựa chọn 1 trong 2 phương thức lập lịch sau:

Giải Pháp 1: Chạy Ngầm Trên VPS Với Thư Viện Python `APScheduler`

Nếu bạn triển khai ứng dụng trên một máy chủ VPS luôn bật, thư viện APScheduler cho phép cấu hình các mốc giờ cron ngay bên trong ứng dụng Python (ví dụ: 06:00 sáng, 12:00 trưa và 19:00 tối):

import time
from apscheduler.schedulers.background import BackgroundScheduler

def main_pipeline_job():
    print("[SCHEDULER] Kích hoạt luồng quét tin tức Gmail tự động...")
    # Bước 1: Lấy danh sách URL báo chí từ các email chưa đọc
    urls = fetch_links_from_unread_emails()
    
    # Bước 2: Duyệt từng URL -> Cào nội dung -> LLM kiểm duyệt -> Lưu Database
    for url in urls:
        article_data = scrape_article(url)
        print(f"Đang phân tích và lọc trùng bài báo: {article_data['title']}")
        # Gọi hàm verify_news_with_llm() và insert_article() tại đây

if __name__ == "__main__":
    scheduler = BackgroundScheduler()
    
    # Thiết lập lịch chạy cố định vào 06:00, 12:00 và 19:00 hàng ngày
    scheduler.add_job(main_pipeline_job, 'cron', hour=6, minute=0)
    scheduler.add_job(main_pipeline_job, 'cron', hour=12, minute=0)
    scheduler.add_job(main_pipeline_job, 'cron', hour=19, minute=0)
    
    scheduler.start()
    print("Hệ thống lập lịch điểm tin đã khởi động thành công. Đang chờ khung giờ cấu hình...")
    
    try:
        while True:
            time.sleep(2)
    except (KeyboardInterrupt, SystemExit):
        scheduler.shutdown()

Giải Pháp 2: Chạy Serverless Miễn Phí Bằng GitHub Actions (`daily_news.yml`)

Nếu bạn muốn tiết kiệm chi phí thuê máy chủ chạy nền 24/7, bạn có thể đẩy mã nguồn lên kho lưu trữ GitHub cá nhân (Private Repository) và cấu hình GitHub Actions tại đường dẫn .github/workflows/daily_news.yml để tự động khởi tạo máy ảo Ubuntu miễn phí đúng 06:00 sáng giờ Việt Nam (tức 23:00 UTC):

name: Daily News Aggregator

on:
  schedule:
    # Chạy tự động vào lúc 23:00 giờ UTC (tương đương 06:00 sáng giờ Việt Nam) hàng ngày
    - cron: '0 23 * * *'
  workflow_dispatch: # Cho phép bấm nút chạy thủ công bất cứ lúc nào

jobs:
  run-pipeline:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        
      - name: Set up Python 3.10
        uses: actions/setup-python@v4
        with:
          python-version: '3.10'
          
      - name: Install dependencies
        run: |
          pip install google-auth-oauthlib google-api-python-client beautifulsoup4 requests openai pydantic psycopg2-binary
          
      - name: Run AI News Pipeline
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
        run: python main_pipeline.py

8. Đóng Gói Toàn Hệ Thống Bằng Docker Compose & Triển Khai VPS 24/7

Khi đưa hệ thống lên môi trường máy chủ thực tế (Production), việc cài đặt thủ công PostgreSQL, biên dịch tiện ích pgvector, cấu hình Python và Node.js rất dễ phát sinh xung đột phiên bản. Sử dụng Docker và Docker Compose giúp đóng gói toàn bộ kiến trúc thành 3 Container độc lập, khởi chạy chỉ bằng một dòng lệnh duy nhất.

Màn hình thiết lập kiến trúc Docker Container và triển khai hệ thống tự động hóa trên máy chủ đám mây

Đóng gói ứng dụng với Docker Compose giúp chuẩn hóa đồng bộ cả 3 tầng: Cơ sở dữ liệu pgvector, Backend AI Pipeline và Giao diện ReactJS.

Cấu Trúc Thư Mục Dự Án Chuẩn (Project Directory Tree)

news-ai-agent/
│
├── backend/
│   ├── main.py          # REST API phục vụ tìm kiếm RAG và trả dữ liệu Audio
│   ├── pipeline.py      # Luồng quét Gmail OAuth2, cào báo, LLM lọc trùng
│   ├── scheduler.py     # Trình lập lịch chạy ngầm APScheduler
│   ├── token.json       # File lưu OAuth2 Refresh Token đã xác thực
│   ├── requirements.txt # Danh sách thư viện Python
│   └── Dockerfile
│
├── frontend/
│   ├── src/             # Mã nguồn giao diện ReactJS / NextJS
│   ├── package.json     # Quản lý thư viện Frontend
│   └── Dockerfile
│
└── docker-compose.yml   # File điều phối toàn bộ 3 dịch vụ

Cấu Hình `backend/Dockerfile` & `docker-compose.yml`

File backend/Dockerfile sử dụng image Python 3.10 gọn nhẹ và cài đặt trình biên dịch kết nối PostgreSQL:

FROM python:3.10-slim

WORKDIR /app

# Cài đặt các gói hệ thống cần thiết để biên dịch thư viện kết nối PostgreSQL
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Khởi chạy dịch vụ lập lịch và API Backend
CMD ["python", "scheduler.py"]

File docker-compose.yml ở thư mục gốc sử dụng trực tiếp image chính thức pgvector/pgvector:pg15 (đã tích hợp sẵn extension vector) và ánh xạ Volume giữ lại file token.json của Gmail API:

version: '3.8'

services:
  # 1. Dịch vụ Cơ sở dữ liệu PostgreSQL 15 tích hợp sẵn pgvector
  postgres_db:
    image: pgvector/pgvector:pg15
    container_name: news_postgres
    restart: always
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secure_password_123
      POSTGRES_DB: news_intelligence
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  # 2. Dịch vụ Backend xử lý AI, RAG và Lập lịch quét Gmail
  backend_api:
    build: ./backend
    container_name: news_backend
    restart: always
    depends_on:
      - postgres_db
    environment:
      - DATABASE_URL=postgresql://admin:secure_password_123@postgres_db:5432/news_intelligence
      - OPENAI_API_KEY=your_real_openai_api_key_here
    volumes:
      - ./backend:/app # Giữ bền vững file token.json xác thực Gmail OAuth2
    ports:
      - "8000:8000"

  # 3. Dịch vụ Giao diện Web Frontend (Auto-Scroll & Voice Reader)
  frontend_web:
    build: ./frontend
    container_name: news_frontend
    restart: always
    ports:
      - "3000:3000"
    depends_on:
      - backend_api

volumes:
  pgdata:

Khi triển khai lên máy chủ VPS Ubuntu, bạn chỉ cần xác thực Gmail lần đầu trên máy cá nhân để lấy file token.json, sao chép file này vào thư mục ./backend/ trên VPS và thực thi lệnh docker compose up -d --build để hệ thống vận hành tự động 24/7.

9. Kịch Bản Vận Hành Hàng Ngày & Lộ Trình Xây Dựng MVP Trong 4 Tuần

Để hình dung rõ sự phối hợp nhịp nhàng giữa các thành phần khi đi vào hoạt động thực tế, dưới đây là chu kỳ vận hành tự động mỗi buổi sáng của hệ thống:

  • 06:00 Sáng (Thu thập tự động): Cronjob kích hoạt Backend ➔ Đăng nhập Gmail qua OAuth2 Refresh Token ➔ Trích xuất toàn bộ link báo chí trong các email nhận được từ đêm qua và đánh dấu đã đọc.
  • 06:05 Sáng (Cào dữ liệu & Đối sánh Vector): Scraper tải nội dung bài viết thô ➔ Gọi OpenAI Embedding tạo Vector ➔ Truy vấn pgvector tìm các bài viết cũ có nội dung tương đồng của cùng thực thể trong 7 ngày gần nhất.
  • 06:10 Sáng (Thẩm định bằng LLM): Đưa Ngữ cảnh lịch sử + Bài báo mới vào LLM kèm Prompt khử trùng lặp. Nếu là tin xào nấu lại ➔ Hủy bỏ. Nếu chứa tình tiết mới (is_new_information = true) ➔ Bóc tách tên Công ty/Nhân vật và lưu thông tin mới vào PostgreSQL.
  • 07:00 Sáng (Trải nghiệm Điểm tin rảnh tay): Người dùng thức dậy, mở Web App trên điện thoại và bấm “▶ Nghe Điểm Tin Sáng Nay” ➔ Hệ thống phát giọng đọc AI tự nhiên kèm hiệu ứng cuộn màn hình và tô sáng từng câu chữ giúp nắm bắt trọn vẹn thị trường trong 5 phút.

Lộ Trình 4 Tuần Triển Khai Phiên Bản MVP (Minimum Viable Product)

Giai Đoạn Mục Tiêu Cốt Lõi Hạng Mục Công Việc Cụ Thể
Tuần 1 Thu thập & Cào dữ liệu Khởi tạo Project trên Google Cloud, lấy token.json qua OAuth2, viết script Python lọc mail chưa đọc và cào văn bản từ 3-5 đầu báo lớn.
Tuần 2 Cơ sở dữ liệu & RAG Dựng Docker PostgreSQL + pgvector, tạo bảng dữ liệu và chỉ mục HNSW, viết hàm nhúng Vector và thử nghiệm truy vấn ngữ nghĩa Cosine.
Tuần 3 Trí tuệ nhân tạo LLM Tích hợp OpenAI / Gemini với Pydantic Structured Outputs để bóc tách thực thể (NER) và hoàn thiện Prompt đối chiếu khử trùng lặp tin cũ.
Tuần 4 Giao diện & Đọc Voice Xây dựng giao diện ReactJS, tích hợp trình phát TTS đồng bộ scrollIntoView Karaoke, cài đặt APScheduler và đưa lên VPS.

10. Câu Hỏi Thường Gặp (FAQ) Khi Xây Dựng AI News Agent

1. Chi phí gọi API OpenAI hàng ngày để lọc báo và tạo Vector có đắt không?

Rất rẻ. Với mô hình gpt-4o-mini (hoặc Gemini 1.5 Flash) kết hợp cùng text-embedding-3-small, việc xử lý và lọc trùng từ 50 đến 100 bài báo mỗi ngày chỉ tiêu tốn khoảng 0.02 – 0.05 USD/ngày (tương đương chưa tới 40.000 VNĐ/tháng).

2. Làm sao xử lý các trang báo sử dụng JavaScript Render (SPA) hoặc chặn Request thông thường?

Đối với các trang báo tải nội dung động bằng JavaScript mà requests + BeautifulSoup4 không lấy được thẻ <p>, bạn chỉ cần thay thế hàm cào bằng thư viện trình duyệt không giao diện Playwright (hoặc sử dụng thư viện chuyên bóc tách báo chí như newspaper3k / trafilatura).

3. Tại sao nên dùng chỉ mục HNSW trong pgvector thay vì IVFFlat?

Chỉ mục HNSW (Hierarchical Navigable Small World) mang lại tốc độ truy vấn nhanh hơn và độ chính xác (Recall) vượt trội so với IVFFlat, đặc biệt là không yêu cầu phải huấn luyện lại chỉ mục (re-clustering) khi bạn liên tục thêm mới bài báo vào bảng articles mỗi ngày.

11. Thiết Bị Phần Cứng & Âm Thanh Tối Ưu Cho Trải Nghiệm Điểm Tin AI

Để tự vận hành Home Server chạy Docker 24/7 tại nhà hoặc nâng cấp trải nghiệm nghe điểm tin rảnh tay chất lượng cao mỗi sáng, dưới đây là các thiết bị phần cứng được giới kỹ sư và doanh nhân ưa chuộng:


Tai Nghe Chống Ồn Không Dây Sony WF-1000XM5 Nghe Điểm Tin Rảnh Tay

Khả năng khử tiếng ồn chủ động hàng đầu thế giới cùng chất âm giọng nói trong trẻo, giúp bạn tập trung nghe bản điểm tin AI buổi sáng khi đang di chuyển hoặc tập luyện.

Loa Thông Minh Để Bàn Marshall Emberton II Phát Thanh Bản Tin Sáng

Thiết kế sang trọng đặt tại bàn làm việc hoặc phòng khách, kết nối Bluetooth tức thì với giao diện Web Điểm Tin để phát thanh tự động mỗi sáng khi bạn chuẩn bị ngày mới.

12. Tài Liệu & Nguồn Trích Xuất Tham Khảo

Toàn bộ kiến trúc hệ thống, quy chuẩn xác thực bảo mật và mã nguồn triển khai trong bài viết được tổng hợp từ dữ liệu đầu vào và các tài liệu kỹ thuật sau (dạng văn bản thuần):

  • developers.google.com/gmail/api/guides
  • github.com/pgvector/pgvector
  • platform.openai.com/docs/guides/structured-outputs
  • developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API
  • vnexpress.net
  • vietnamnet.vn
  • baomoi.com

Ban Biên Tập Kỹ Thuật Phần Mềm & Hệ Thống AI Agent

Bài viết được chuẩn hóa theo quy trình xây dựng sản phẩm SaaS thực tế, kiểm chứng khả năng vận hành bền bỉ với PostgreSQL pgvector, Gmail API OAuth2 và Docker Compose.

Contents hide
1 Xây Dựng AI News Agent: Lọc Tin Gmail, RAG & Voice Tự Động
Back to top button