VNEWS / ai_ext.py
bep40's picture 29faf24 verified
Raw
History Blame Contribute Delete
21.1 kB
"""VNEWS AI Extension - rewrite + auto short video generation.
Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
/api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
"""
import os, re, json, time, random, html as html_lib, subprocess, asyncio
from urllib.parse import quote_plus, quote, urlparse, urljoin
from typing import Optional, List, Dict
import requests
from bs4 import BeautifulSoup
from fastapi import Request, Query
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
# Try to import main app, but don't fail if it doesn't exist
try:
from main import app
except ImportError:
# Create a minimal FastAPI app for standalone testing
try:
from fastapi import FastAPI
app = FastAPI()
except Exception:
app = None
# Import wall store from main.py so we read/write the SAME file
try:
from main import _load_wall, _save_wall, _web_context # noqa: F401
except ImportError:
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
_wall_file = os.path.join(_data_dir, "wall_posts.json")
def _load_wall():
try:
if os.path.exists(_wall_file):
with open(_wall_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def _save_wall(posts):
try:
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
tmp = _wall_file + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(posts[:100], f, ensure_ascii=False)
os.replace(tmp, _wall_file)
except Exception:
pass
def _web_context(topic):
return ""
# ai_ext alias for backward compatibility
_load_ai_wall = _load_wall
_save_ai_wall = _save_wall
try:
from huggingface_hub import AsyncInferenceClient
except Exception:
AsyncInferenceClient = None
try:
from gtts import gTTS
except Exception:
gTTS = None
try:
from PIL import Image, ImageDraw, ImageFont
except Exception:
Image = ImageDraw = ImageFont = None
try:
import edge_tts
except Exception:
edge_tts = None
def _hf_token():
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
v = os.getenv(k, "").strip()
if v:
return v
return ""
def _clean_text(s: str) -> str:
"""Clean text for processing."""
s = html_lib.unescape(s or "")
s = re.sub(r"\s+", " ", s)
return s.strip()
def _domain(url: str) -> str:
"""Extract domain from URL."""
try:
return urlparse(url or "").netloc.replace("www.", "")
except Exception:
return ""
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
"""Generate text using Qwen models via Hugging Face Inference API.
This function provides a resilient implementation that:
1. First tries the SDK-based inference client if available
2. Falls back to REST API calls to HF router endpoint
3. Returns a fallback summary if all else fails
"""
token = _hf_token()
errors = []
# Try HF router API with multiple models
if token:
models = [
os.getenv("QWEN_VL_MODEL", ""),
"Qwen/Qwen2.5-VL-7B-Instruct",
"Qwen/Qwen2.5-VL-3B-Instruct",
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2.5-3B-Instruct",
"Qwen/Qwen2.5-1.5B-Instruct",
"Qwen/Qwen2.5-72B-Instruct",
"meta-llama/Llama-3.3-70B-Instruct",
]
# Deduplicate while preserving order
seen = set()
models = [m for m in models if m and m not in seen and not seen.add(m)]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
for model in models:
try:
is_vl = "VL" in model and image_url
if is_vl:
user_content = [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": prompt}
]
else:
user_content = prompt
payload = {
"model": model,
"messages": [
{"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
{"role": "user", "content": user_content},
],
"max_tokens": min(int(max_tokens or 900), 1400),
"temperature": 0.35,
"top_p": 0.85,
}
r = requests.post(
"https://router.huggingface.co/v1/chat/completions",
headers=headers,
json=payload,
timeout=95
)
if r.status_code >= 300:
errors.append(f"{model}: HTTP {r.status_code}")
continue
j = r.json()
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
if txt:
return txt
errors.append(f"{model}: empty response")
except Exception as e:
errors.append(f"{model}: {type(e).__name__}")
# Fallback: extractive summary from prompt
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
return _fallback_summary_from_prompt(prompt, max_units=6)
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
"""Generate a simple fallback summary when AI is unavailable."""
text = prompt or ""
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
if marker in text:
text = text.split(marker, 1)[1]
break
text = re.sub(r"https?://\S+", "", text)
text = re.sub(r"\s+", " ", text).strip()
# Split into sentences - extract ALL valid sentences, not just first few
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
units = []
for s in sentences:
s = _clean_text(s)
if len(s) >= 30: # Lower threshold to capture more content
units.append(s)
if units:
# Take up to max_units valid sentences
result_units = units[:max_units]
return "\n".join("• " + u for u in result_units)
if text:
# Fallback: take chunks if no sentence boundaries found
chunks = []
for i in range(0, min(len(text), max_units * 300), 280):
chunk = _clean_text(text[i:i+300])
if chunk and chunk not in chunks:
chunks.append(chunk)
if len(chunks) >= max_units:
break
if chunks:
return "\n".join("• " + c for c in chunks)
return "• Không có đủ nội dung để tóm tắt."
HF_TOKEN = _hf_token()
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
"QWEN_TEXT_MODELS",
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
).split(",") if m.strip()]
_WORKING_MODEL_TEXT = None
_WORKING_MODEL_VL = None
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
}
LAST_QWEN_ERROR = ""
# ===== MULTILINGUAL VOICES FOR TTS =====
# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
MULTILINGUAL_VOICES = {
# Vietnamese - Native voices
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
"vi-vn-namminhneural": "vi-VN-NamMinhNeural",
"hoaimy": "vi-VN-HoaiMyNeural",
"namminh": "vi-VN-NamMinhNeural",
"vi_female": "vi-VN-HoaiMyNeural",
"vi_male": "vi-VN-NamMinhNeural",
"nu": "vi-VN-HoaiMyNeural",
"male": "vi-VN-NamMinhNeural",
"female": "vi-VN-HoaiMyNeural",
"mien-nam": "vi-VN-HoaiMyNeural",
# English - Multilingual
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
"en_andrew": "en-US-AndrewMultilingualNeural",
"andrew": "en-US-AndrewMultilingualNeural",
"en_jenny": "en-US-AndrewMultilingualNeural",
"jenny": "en-US-AndrewMultilingualNeural",
# Portuguese - Thalita Multilingual ONLY
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
"thalita": "pt-BR-ThalitaMultilingualNeural",
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
"pt": "pt-BR-ThalitaMultilingualNeural",
# French - Multilingual
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
"fr_denise": "fr-FR-VivienneMultilingualNeural",
"denise": "fr-FR-VivienneMultilingualNeural",
"fr": "fr-FR-VivienneMultilingualNeural",
# German - Multilingual
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
"de_katja": "de-DE-SeraphinaMultilingualNeural",
"katja": "de-DE-SeraphinaMultilingualNeural",
"de": "de-DE-SeraphinaMultilingualNeural",
# Korean - Hyunsu Multilingual (NOT SunHee)
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
"sunhee": "ko-KR-HyunsuMultilingualNeural",
"ko": "ko-KR-HyunsuMultilingualNeural",
# Italian - Multilingual
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
# Spanish (fallback to English multilingual)
"es_ela": "en-US-AndrewMultilingualNeural",
"ela": "en-US-AndrewMultilingualNeural",
"es_carlos": "en-US-AndrewMultilingualNeural",
"es": "en-US-AndrewMultilingualNeural",
# Japanese (fallback to English multilingual)
"ja_nanami": "en-US-AndrewMultilingualNeural",
"nanami": "en-US-AndrewMultilingualNeural",
"ja": "en-US-AndrewMultilingualNeural",
# Chinese (fallback to English multilingual)
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
"xiaochen": "en-US-AndrewMultilingualNeural",
"zh": "en-US-AndrewMultilingualNeural",
}
def _detect_voice_emotion(title, text):
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
content = ((title or "") + " " + (text or "")).lower()
# World Cup / Football content - use Andrew multilingual
if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
return ("andrew", "excited")
# News categories - choose appropriate voice
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
return ("jenny", "calm")
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
return ("thalita", "serious")
if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
return ("ela", "happy")
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
return ("katja", "excited")
# Default Vietnamese
return ("hoaimy", "trung_tinh")
def _safe_name(s: str) -> str:
"""Create safe filename from string."""
s = re.sub(r"[^\w\-.]", "_", s)
return s[:100] if len(s) > 100 else s
def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
"""Download image from URL to path."""
if not url:
return False
try:
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code == 200:
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as f:
f.write(r.content)
return True
except Exception:
pass
return False
def pollination_image_url(topic: str) -> str:
"""Generate image URL from Pollinations.ai."""
return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
# Use the same wall file as app_v2_entry.py for consistency
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
def _load_ai_wall():
"""Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
try:
if os.path.exists(WALL_FILE):
with open(WALL_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def _save_ai_wall(posts):
"""Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
try:
os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
tmp = WALL_FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(posts[:100], f, ensure_ascii=False)
os.replace(tmp, WALL_FILE)
except Exception:
pass
# Helper functions for wall operations
def _load_wall_posts():
"""Alias for _load_ai_wall for consistency with app_v2_entry.py."""
return _load_ai_wall()
def _save_wall_posts(posts):
"""Alias for _save_ai_wall for consistency with app_v2_entry.py."""
return _save_ai_wall(posts)
def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
"""Create a post dict with standard fields."""
return {
"id": str(int(time.time() * 1000)),
"title": title,
"text": text,
"img": img,
"url": url,
"kind": kind,
"sources": sources or [],
"ts": int(time.time())
}
def _short_script(post) -> str:
"""Extract clean text for TTS from post."""
text = post.get("text", "") or post.get("title", "")
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
text = re.sub(r"\s*\n\s*", ". ", text)
return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
def scrape_any_url(url: str) -> dict:
"""Scrape any URL and extract article content.
Returns dict with: title, summary, text, image, og_image, via (domain)
"""
try:
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
r.encoding = 'utf-8'
soup = BeautifulSoup(r.text, 'lxml')
# Remove scripts, styles, nav, footer
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
tag.decompose()
# Extract title
h1 = soup.find('h1')
ogt = soup.find('meta', property='og:title')
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
# Extract OG image
ogi = soup.find('meta', property='og:image')
og_image = ogi.get('content', '') if ogi else ''
# Extract article body
block = None
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
el = soup.select_one(sel)
if el and len(el.find_all('p')) >= 2:
block = el
break
if not block:
block = soup.body or soup
# Extract text from paragraphs
paragraphs = []
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
t = _clean_text(el.get_text(strip=True))
if t and len(t) > 40:
paragraphs.append(t)
# Extract images
images = []
for el in block.find_all(['figure', 'img'], recursive=True):
im = el if el.name == 'img' else el.find('img')
if im:
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
if src and 'base64' not in src:
if src.startswith('//'):
src = 'https:' + src
images.append(src)
# Prefer OG image as main image
image = og_image or (images[0] if images else '')
return {
'title': title,
'summary': paragraphs[0] if paragraphs else '',
'text': '\n'.join(paragraphs),
'image': image,
'og_image': og_image,
'via': _domain(url),
'images': images
}
except Exception as e:
return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
def web_context(topic: str, limit: int = 5) -> tuple:
"""Get web context for a topic. Returns (context_text, sources_list)."""
sources = []
try:
# Try Google News RSS
rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
r = requests.get(rss_url, headers=HEADERS, timeout=15)
r.encoding = 'utf-8'
soup = BeautifulSoup(r.text, 'xml')
for it in soup.find_all('item')[:limit]:
title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
link = it.find('link').get_text(strip=True) if it.find('link') else ''
if title and link:
sources.append({'title': title, 'url': link, 'via': _domain(link)})
except Exception:
pass
context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
return context, sources
# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
def _make_short_frame(post, img_path, out_path):
"""Create a short video frame from post and image.
Called by ai_patch.py _make_short_frame_full when Image is available.
"""
if Image is None:
# Create a minimal frame without PIL - just return success
# The caller should handle this case
return False
W, H = 1080, 1920
bg = Image.new("RGB", (W, H), (14, 14, 14))
try:
im = Image.open(img_path).convert("RGB")
target = (1080, 760)
im_ratio = im.width / max(1, im.height)
target_ratio = target[0] / target[1]
if im_ratio > target_ratio:
new_h = target[1]
new_w = int(new_h * im_ratio)
else:
new_w = target[0]
new_h = int(new_w / im_ratio)
im = im.resize((new_w, new_h))
left = (new_w - target[0]) // 2
top = (new_h - target[1]) // 2
im = im.crop((left, top, left + target[0], top + target[1]))
bg.paste(im, (0, 0))
except Exception:
pass
draw = ImageDraw.Draw(bg)
try:
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
except Exception:
font_title = font_body = None
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
margin = 48
maxw = W - margin * 2
y = 830
for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
y += 66
y += 18
text = post.get("text", "")
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
body_lines = _wrap_text(draw, text, font_body, maxw, 14)
for ln in body_lines:
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
y += 50
if y > 1640:
break
bg.save(out_path, quality=92)
return True
def _wrap_text(draw, text, font, max_width, max_lines):
"""Helper for wrapping text in frames."""
words = _clean_text(text).split()
lines, cur = [], ""
for w in words:
test = (cur + " " + w).strip()
try:
width = draw.textbbox((0, 0), test, font=font)[2]
except Exception:
width = len(test) * 20
if width <= max_width:
cur = test
else:
if cur:
lines.append(cur)
cur = w
if len(lines) >= max_lines:
break
if cur and len(lines) < max_lines:
lines.append(cur)
return lines