Spaces:
Running
Running
| """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 |