bep40 commited on
Commit
27bddcf
·
verified ·
1 Parent(s): 444e536

Upload ai_ext.py

Browse files
Files changed (1) hide show
  1. ai_ext.py +0 -1164
ai_ext.py CHANGED
@@ -1,1164 +0,0 @@
1
- """VNEWS AI Extension - rewrite + auto short video generation.
2
- Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
3
- /api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
4
-
5
- Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
6
- TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
7
- """
8
- import os, re, json, time, random, html as html_lib, subprocess, asyncio
9
- from urllib.parse import quote_plus, quote, urlparse, urljoin
10
- from typing import Optional, List, Dict
11
- import requests
12
- from bs4 import BeautifulSoup
13
- from fastapi import Request, Query
14
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
-
16
- from main import app
17
-
18
- # Import wall store from main.py so we read/write the SAME file
19
- try:
20
- from main import _load_wall, _save_wall, _web_context # noqa: F401
21
- except ImportError:
22
- _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
23
- _wall_file = os.path.join(_data_dir, "wall_posts.json")
24
- def _load_wall():
25
- try:
26
- if os.path.exists(_wall_file):
27
- with open(_wall_file, "r", encoding="utf-8") as f:
28
- return json.load(f)
29
- except Exception:
30
- pass
31
- return []
32
- def _save_wall(posts):
33
- try:
34
- os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
35
- tmp = _wall_file + ".tmp"
36
- with open(tmp, "w", encoding="utf-8") as f:
37
- json.dump(posts[:100], f, ensure_ascii=False)
38
- os.replace(tmp, _wall_file)
39
- except Exception:
40
- pass
41
- def _web_context(topic):
42
- return ""
43
-
44
- try:
45
- from huggingface_hub import AsyncInferenceClient
46
- except Exception:
47
- AsyncInferenceClient = None
48
- try:
49
- from gtts import gTTS
50
- except Exception:
51
- gTTS = None
52
- try:
53
- from PIL import Image, ImageDraw, ImageFont
54
- except Exception:
55
- Image = ImageDraw = ImageFont = None
56
- try:
57
- import edge_tts
58
- except Exception:
59
- edge_tts = None
60
-
61
-
62
- def _hf_token():
63
- for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
64
- v = os.getenv(k, "").strip()
65
- if v:
66
- return v
67
- return ""
68
-
69
- HF_TOKEN = _hf_token()
70
- QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
71
- # Fast TEXT models for summaries that don't need vision (much faster than the VL model).
72
- QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
73
- "QWEN_TEXT_MODELS",
74
- "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
75
- ).split(",") if m.strip()]
76
- _WORKING_MODEL_TEXT = None # cached last-working text model
77
- _WORKING_MODEL_VL = None # cached last-working vision model
78
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
79
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
80
- HEADERS = {
81
- "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",
82
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
83
- }
84
- LAST_QWEN_ERROR = ""
85
-
86
- # ===== TTS VOICE CONFIG =====
87
- # Multilingual neural voices grouped by country/language
88
- # Format: key -> {id, gender, name, country, lang, flag}
89
- TTS_VOICES = {
90
- # === VIETNAM (edge-tts) ===
91
- "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
92
- "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
93
- # === gTTS (Google, tiếng Việt cơ bản) ===
94
- "gtts_vi": {"id": "gtts", "gender": "female", "name": "gTTS Google", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "gtts"},
95
- # === MULTILINGUAL (đa ngôn ngữ — đọc được tiếng Việt + nhiều thứ tiếng) ===
96
- "en_au_william": {"id": "en-AU-WilliamMultilingualNeural", "gender": "male", "name": "William (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
97
- "en_us_andrew": {"id": "en-US-AndrewMultilingualNeural", "gender": "male", "name": "Andrew (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
98
- "en_us_ava": {"id": "en-US-AvaMultilingualNeural", "gender": "female", "name": "Ava (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
99
- "en_us_brian": {"id": "en-US-BrianMultilingualNeural", "gender": "male", "name": "Brian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
100
- "en_us_emma": {"id": "en-US-EmmaMultilingualNeural", "gender": "female", "name": "Emma (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
101
- "fr_vivienne": {"id": "fr-FR-VivienneMultilingualNeural","gender": "female", "name": "Vivienne (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
102
- "fr_remy": {"id": "fr-FR-RemyMultilingualNeural", "gender": "male", "name": "Rémy (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
103
- "de_seraphina": {"id": "de-DE-SeraphinaMultilingualNeural","gender": "female","name": "Seraphina (Đa NN)","country": "Đa ngôn ngữ","lang": "multi", "flag": "🌐", "engine": "edge"},
104
- "de_florian": {"id": "de-DE-FlorianMultilingualNeural", "gender": "male", "name": "Florian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
105
- "it_giuseppe": {"id": "it-IT-GiuseppeMultilingualNeural","gender": "male", "name": "Giuseppe (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
106
- "ko_hyunsu": {"id": "ko-KR-HyunsuMultilingualNeural", "gender": "male", "name": "Hyunsu (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
107
- "pt_thalita": {"id": "pt-BR-ThalitaMultilingualNeural", "gender": "female", "name": "Thalita (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
108
- }
109
- TTS_DEFAULT_VOICE = "hoaimy"
110
- TTS_DEFAULT_SPEED = 1.2 # 1.2x speed for faster reading
111
-
112
- # Topic -> voice mapping (auto-detect based on topic keywords)
113
- TOPIC_VOICE_MAP = {
114
- # Sports -> male voice
115
- "bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
116
- "premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
117
- "serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
118
- "tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
119
- # Lifestyle/Health/Entertainment -> female voice
120
- "sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
121
- "âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
122
- "ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
123
- "tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
124
- # Tech/Science -> male voice
125
- "công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
126
- "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
127
- "laptop": "namminh", "game": "namminh",
128
- # News/Politics/Economy -> male voice
129
- "chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
130
- "chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
131
- "xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
132
- }
133
-
134
-
135
- def _detect_voice_for_topic(title: str, text: str) -> str:
136
- """Auto-detect the best voice based on topic keywords."""
137
- combined = (title + " " + text[:500]).lower()
138
- for keyword, voice_id in TOPIC_VOICE_MAP.items():
139
- if keyword in combined:
140
- return voice_id
141
- return TTS_DEFAULT_VOICE
142
-
143
-
144
- # ===== EMOTION (CẢM XÚC) FOR TTS =====
145
- # edge-tts does NOT support Azure express-as styles, so emotion is simulated
146
- # with pitch + rate(speed multiplier) + volume. Each preset is a delta.
147
- # rate_mul multiplies the user/auto speed; pitch is absolute Hz; volume is percent.
148
- EMOTION_PRESETS = {
149
- "vui": {"label": "Vui tươi", "emoji": "😊", "rate_mul": 1.06, "pitch": "+15Hz", "volume": "+6%"},
150
- "hao_hung": {"label": "Hào hứng", "emoji": "🔥", "rate_mul": 1.12, "pitch": "+24Hz", "volume": "+12%"},
151
- "nghiem": {"label": "Nghiêm túc", "emoji": "📰", "rate_mul": 1.00, "pitch": "-3Hz", "volume": "+0%"},
152
- "tram": {"label": "Trầm ấm", "emoji": "🌙", "rate_mul": 0.94, "pitch": "-10Hz", "volume": "+0%"},
153
- "buon": {"label": "Buồn/Xúc động", "emoji": "💧", "rate_mul": 0.88, "pitch": "-18Hz", "volume": "-4%"},
154
- "trung_tinh":{"label": "Trung tính", "emoji": "🎙️", "rate_mul": 1.00, "pitch": "+0Hz", "volume": "+0%"},
155
- }
156
- EMOTION_DEFAULT = "trung_tinh"
157
-
158
- # Topic keyword -> emotion. Checked in order; first match wins.
159
- TOPIC_EMOTION_MAP = {
160
- # Sports / wins -> excited
161
- "chiến thắng": "hao_hung", "vô địch": "hao_hung", "world cup": "hao_hung",
162
- "bóng đá": "hao_hung", "thể thao": "hao_hung", "ghi bàn": "hao_hung",
163
- "champions league": "hao_hung", "premier league": "hao_hung", "chung kết": "hao_hung",
164
- "olympic": "hao_hung", "kỷ lục": "hao_hung",
165
- # Entertainment / lifestyle / good news -> cheerful
166
- "giải trí": "vui", "âm nhạc": "vui", "phim": "vui", "lễ hội": "vui",
167
- "du lịch": "vui", "ẩm thực": "vui", "thời trang": "vui", "ra mắt": "vui",
168
- "khai trương": "vui", "tin vui": "vui", "hạnh phúc": "vui",
169
- # Sad / accidents / loss -> sad
170
- "tai nạn": "buon", "qua đời": "buon", "tử vong": "buon", "thiệt mạng": "buon",
171
- "động đất": "buon", "lũ lụt": "buon", "thiên tai": "buon", "cháy": "buon",
172
- "tang lễ": "buon", "mất tích": "buon", "thương tâm": "buon",
173
- # Health / science / calm -> calm warm
174
- "sức khỏe": "tram", "y t��": "tram", "bệnh": "tram", "dinh dưỡng": "tram",
175
- "tâm lý": "tram", "thiền": "tram", "giấc ngủ": "tram",
176
- # News / politics / economy / law -> serious
177
- "chính trị": "nghiem", "kinh tế": "nghiem", "tài chính": "nghiem",
178
- "chứng khoán": "nghiem", "pháp luật": "nghiem", "tòa án": "nghiem",
179
- "ngân hàng": "nghiem", "thị trường": "nghiem", "lạm phát": "nghiem",
180
- "công nghệ": "nghiem", "ai": "nghiem", "khoa học": "nghiem", "giáo dục": "nghiem",
181
- }
182
-
183
-
184
- def _detect_emotion_for_topic(title: str, text: str) -> str:
185
- """Auto-detect emotion (cảm xúc) from topic/content keywords."""
186
- combined = (title + " " + text[:600]).lower()
187
- for keyword, emo in TOPIC_EMOTION_MAP.items():
188
- if keyword in combined:
189
- return emo
190
- return EMOTION_DEFAULT
191
-
192
-
193
- def _detect_voice_emotion(title: str, text: str) -> tuple:
194
- """Return (voice_id, emotion_id) auto-chosen for the article's topic."""
195
- return _detect_voice_for_topic(title, text), _detect_emotion_for_topic(title, text)
196
-
197
-
198
- # ===== TEXT HELPERS =====
199
- def _clean_text(s: str) -> str:
200
- s = html_lib.unescape(s or "")
201
- return re.sub(r"\s+", " ", s).strip()
202
-
203
- def _domain(u):
204
- try:
205
- return urlparse(u).netloc.replace("www.", "")
206
- except Exception:
207
- return ""
208
-
209
- def _safe_name(s):
210
- return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
211
-
212
-
213
- # ===== CLEAN AI OUTPUT =====
214
- def _clean_ai_output(text: str) -> str:
215
- """Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
216
- if not text:
217
- return ""
218
- # Remove markdown headings, bold, italic, horizontal rules
219
- text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
220
- text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
221
- text = re.sub(r'\*([^*]+)\*', r'\1', text)
222
- text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
223
- text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
224
- # Remove common AI instruction leakage phrases (entire line)
225
- leakage = [
226
- r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
227
- r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
228
- r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
229
- r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
230
- r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
231
- r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
232
- ]
233
- for phrase in leakage:
234
- text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
235
- text = re.sub(r'\n{3,}', '\n\n', text)
236
- # --- Aggressive dedup: split into sentences, remove any that repeats ---
237
- # Normalize: collapse whitespace, strip
238
- def _norm(s):
239
- return re.sub(r'\s+', ' ', s.strip().lower())
240
-
241
- # Split by sentence-ending punctuation (keep delimiters)
242
- raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
243
- seen_sentences = set()
244
- unique_parts = []
245
- for part in raw_parts:
246
- n = _norm(part)
247
- # Skip near-duplicate: if >70% of an existing seen sentence matches
248
- is_dup = False
249
- if n:
250
- if n in seen_sentences:
251
- is_dup = True
252
- else:
253
- partial = re.sub(r'\W+', '', n)
254
- for seen in seen_sentences:
255
- seen_clean = re.sub(r'\W+', '', seen)
256
- # Check substring match for very similar sentences
257
- if partial and seen_clean and (
258
- partial in seen_clean or seen_clean in partial
259
- ):
260
- shorter = min(len(partial), len(seen_clean))
261
- longer = max(len(partial), len(seen_clean))
262
- if shorter > 20 and shorter / longer > 0.75:
263
- is_dup = True
264
- break
265
- if is_dup:
266
- continue
267
- if n:
268
- seen_sentences.add(n)
269
- unique_parts.append(part)
270
-
271
- result = ' '.join(unique_parts).strip()
272
- # Final pass: remove any remaining consecutive duplicate lines
273
- lines = result.split('\n')
274
- final_lines = []
275
- prev_line = ""
276
- for line in lines:
277
- stripped = line.strip()
278
- if stripped and stripped == prev_line:
279
- continue
280
- final_lines.append(line)
281
- prev_line = stripped
282
- result = '\n'.join(final_lines).strip()
283
- return result
284
-
285
-
286
- # ===== EXTRACT ALL IMAGES FROM ARTICLE =====
287
- def _extract_all_images(soup, base_url: str) -> List[Dict]:
288
- """Extract ALL content images from an article page using multi-strategy approach."""
289
- images = []
290
- seen_urls = set()
291
- skip_patterns = [
292
- "avatar", "icon", "logo", "button", "banner-ad", "tracking",
293
- "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
294
- "advertisement", "ads", "widget", "sidebar", "footer-logo",
295
- ]
296
-
297
- def _add_image(src: str, alt: str = "", source_tag: str = "img"):
298
- if not src or src.startswith("data:"):
299
- return
300
- abs_url = urljoin(base_url, src.strip())
301
- if abs_url in seen_urls:
302
- return
303
- # Skip non-content images by URL pattern
304
- if any(p in abs_url.lower() for p in skip_patterns):
305
- return
306
- # Skip very small images (likely icons)
307
- try:
308
- parsed = urlparse(abs_url)
309
- path = parsed.path.lower()
310
- if any(path.endswith(ext) for ext in ['.svg', '.ico', '.gif']):
311
- return
312
- except Exception:
313
- pass
314
- seen_urls.add(abs_url)
315
- images.append({"url": abs_url, "alt": alt, "source": source_tag})
316
-
317
- # Strategy 1: Standard <img> tags with all lazy-load attributes
318
- for img in soup.find_all("img"):
319
- src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
320
- img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
321
- _add_image(src, alt=img.get("alt", ""), source_tag="img")
322
-
323
- # Strategy 2: srcset on <img>
324
- for img in soup.find_all("img", srcset=True):
325
- for part in img["srcset"].split(","):
326
- part = part.strip()
327
- if part:
328
- _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
329
-
330
- # Strategy 3: <picture> with <source>
331
- for picture in soup.find_all("picture"):
332
- for source in picture.find_all("source"):
333
- srcset = source.get("srcset", "")
334
- for part in srcset.split(","):
335
- part = part.strip()
336
- if part:
337
- _add_image(part.split(" ")[0], source_tag="picture/srcset")
338
- fallback_img = picture.find("img")
339
- if fallback_img:
340
- _add_image(
341
- fallback_img.get("src") or fallback_img.get("data-src"),
342
- alt=fallback_img.get("alt", ""),
343
- source_tag="picture/img"
344
- )
345
-
346
- # Strategy 4: WordPress CMS patterns
347
- for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
348
- _add_image(img.get("data-src") or img.get("src"),
349
- alt=img.get("alt", ""), source_tag="wp-image")
350
-
351
- # Strategy 5: Background images in style attributes
352
- for tag in soup.find_all(style=re.compile(r"background-image")):
353
- for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
354
- _add_image(m, source_tag="background-style")
355
-
356
- # Strategy 6: og:image (featured/hero image)
357
- og_image = soup.find("meta", property="og:image")
358
- if og_image and og_image.get("content"):
359
- _add_image(og_image["content"], source_tag="og:image")
360
-
361
- # Strategy 7: twitter:image
362
- tw_image = soup.find("meta", attrs={"name": "twitter:image"})
363
- if tw_image and tw_image.get("content"):
364
- _add_image(tw_image["content"], source_tag="twitter:image")
365
-
366
- # Strategy 8: <figure> with <figcaption>
367
- for figure in soup.find_all("figure"):
368
- img = figure.find("img")
369
- if img:
370
- src = img.get("data-src") or img.get("src")
371
- figcaption = figure.find("figcaption")
372
- alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
373
- _add_image(src, alt=alt, source_tag="figure")
374
-
375
- # Strategy 9: <a> tags linking to images
376
- for a in soup.find_all("a", href=True):
377
- href = a["href"]
378
- if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
379
- _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
380
-
381
- return images
382
-
383
-
384
- # ===== JINA READER =====
385
- def _reader_url(target_url: str) -> str:
386
- safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
387
- return "https://r.jina.ai/http://" + safe
388
-
389
- def jina_reader_markdown(url: str) -> str:
390
- jr = _reader_url(url)
391
- r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
392
- r.raise_for_status()
393
- return r.text or ""
394
-
395
- def _parse_jina_markdown(md: str, url: str):
396
- lines = [x.rstrip() for x in (md or "").splitlines()]
397
- title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
398
- for ln in lines:
399
- if ln.startswith("Title:") and not title:
400
- title = _clean_text(ln.replace("Title:", "", 1)); continue
401
- if ln.startswith("URL Source:"):
402
- continue
403
- if ln.startswith("Markdown Content:"):
404
- in_content = True; continue
405
- # Extract ALL images from markdown ![alt](url)
406
- for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
407
- img_url = mimg.group(1)
408
- if img_url not in all_images:
409
- all_images.append(img_url)
410
- if not first_image:
411
- first_image = img_url
412
- if in_content or (title and not ln.startswith("Title:")):
413
- if ln.strip():
414
- content_lines.append(ln)
415
- text = "\n".join(content_lines)
416
- text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
417
- paras = []
418
- for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
419
- t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
420
- if len(t) >= 40:
421
- paras.append(t)
422
- if len(paras) >= 35:
423
- break
424
- if not title and paras:
425
- title = paras[0][:90]
426
- return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
427
- "text": "\n".join(paras), "image": first_image,
428
- "images": all_images, "via": "jina"}
429
-
430
-
431
- # ===== WEB SCRAPE (with full image extraction) =====
432
- def _best_content_block(soup):
433
- best, best_score = None, 0
434
- for el in soup.find_all(["article", "main", "section", "div"]):
435
- ps = el.find_all("p")
436
- txt = " ".join(p.get_text(" ", strip=True) for p in ps)
437
- score = len(ps) * 100 + len(txt)
438
- cls = " ".join(el.get("class", []))
439
- if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
440
- score += 800
441
- if score > best_score:
442
- best, best_score = el, score
443
- return best
444
-
445
- def scrape_any_url_direct(url: str):
446
- r = requests.get(url, headers=HEADERS, timeout=18)
447
- if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
448
- raise RuntimeError(f"blocked status {r.status_code}")
449
- r.encoding = "utf-8"
450
- soup = BeautifulSoup(r.text, "lxml")
451
- for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
452
- tag.decompose()
453
-
454
- # Title
455
- title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
456
- if not title:
457
- ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
458
- title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
459
-
460
- # Summary
461
- desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
462
- summary = desc_tag.get("content", "") if desc_tag else ""
463
-
464
- # Featured image (og:image)
465
- img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
466
- image = img_tag.get("content", "") if img_tag else ""
467
- if image and image.startswith("//"):
468
- image = "https:" + image
469
-
470
- # Extract ALL images from the article
471
- all_images = _extract_all_images(soup, url)
472
- image_urls = [img["url"] for img in all_images]
473
-
474
- # Ensure featured image is first
475
- if image and image not in image_urls:
476
- image_urls.insert(0, image)
477
- elif image in image_urls:
478
- image_urls.remove(image)
479
- image_urls.insert(0, image)
480
-
481
- # Content paragraphs
482
- block = _best_content_block(soup) or soup
483
- paras, seen_p = [], set()
484
- for p in block.find_all("p"):
485
- t = _clean_text(p.get_text(" ", strip=True))
486
- if len(t) >= 40 and t not in seen_p:
487
- seen_p.add(t)
488
- paras.append(t)
489
- if len(paras) >= 35:
490
- break
491
-
492
- if not title and paras:
493
- title = paras[0][:90]
494
-
495
- return {
496
- "url": url, "title": title or url, "summary": paras[0] if paras else "",
497
- "text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
498
- "images": image_urls, "via": _domain(url)
499
- }
500
-
501
- def scrape_any_url(url: str):
502
- """Try direct scrape first, fall back to Jina Reader."""
503
- data = scrape_any_url_direct(url)
504
- raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
505
- if len(raw_text) >= 120:
506
- return data
507
- try:
508
- md = jina_reader_markdown(url)
509
- if md:
510
- jr = _parse_jina_markdown(md, url)
511
- if jr.get("text"):
512
- if data.get("title") and data["title"] != url:
513
- jr["title"] = data["title"]
514
- if data.get("image"):
515
- jr["image"] = data["image"]
516
- if data.get("images"):
517
- jr["images"] = data["images"]
518
- jr["via"] = data.get("via", _domain(url)) + " + jina"
519
- return jr
520
- except Exception:
521
- pass
522
- return data
523
-
524
-
525
- # ===== POLLINATIONS IMAGE =====
526
- def pollinations_image_url(topic: str) -> str:
527
- prompt = "editorial illustration, Vietnamese news, " + topic
528
- return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
529
-
530
-
531
- @app.get("/api/ai/probe")
532
- async def api_ai_probe():
533
- """Diagnostic: test which chat models actually work on this token + their latency."""
534
- import time as _t
535
- tok = _hf_token()
536
- out = []
537
- extra = os.getenv("PROBE_MODELS", "").split(",")
538
- cand = [m.strip() for m in extra if m.strip()] + [
539
- "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct", "Qwen/Qwen2-VL-7B-Instruct",
540
- "Qwen/Qwen2.5-VL-72B-Instruct", "Qwen/Qwen3-8B", "Qwen/Qwen3-4B", "Qwen/Qwen3-32B",
541
- "Qwen/Qwen2.5-7B-Instruct-1M", "meta-llama/Llama-3.2-3B-Instruct"]
542
- seen = set()
543
- for m in cand:
544
- if not m or m in seen:
545
- continue
546
- seen.add(m)
547
- t0 = _t.time()
548
- try:
549
- c = AsyncInferenceClient(provider="auto", api_key=tok, timeout=40)
550
- r = await c.chat_completion(model=m, messages=[{"role": "user", "content": "Trả lời đúng 1 từ: xin chào"}], max_tokens=10)
551
- out.append({"model": m, "ok": True, "sec": round(_t.time() - t0, 1), "txt": (r.choices[0].message.content or "")[:30]})
552
- except Exception as e:
553
- out.append({"model": m, "ok": False, "sec": round(_t.time() - t0, 1), "err": (type(e).__name__ + ": " + str(e))[-300:]})
554
- return JSONResponse({"results": out})
555
-
556
-
557
- # ===== QWEN AI (strict, concise) =====
558
- async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
559
- global LAST_QWEN_ERROR, HF_TOKEN
560
- HF_TOKEN = _hf_token()
561
- if not HF_TOKEN:
562
- LAST_QWEN_ERROR = "Không tìm thấy token"
563
- return None
564
- if not AsyncInferenceClient:
565
- LAST_QWEN_ERROR = "Thiếu huggingface_hub"
566
- return None
567
- errors = []; models = []
568
- has_images = bool(image_urls) or bool(image_url)
569
- if has_images:
570
- # Vision needed -> VL models
571
- candidate = [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]
572
- else:
573
- # Text-only summary -> FAST text models first, VL only as last resort.
574
- candidate = QWEN_TEXT_MODELS + [QWEN_VL_MODEL]
575
- # Use the last-known-working model first to avoid wasting time on unavailable models.
576
- global _WORKING_MODEL_TEXT, _WORKING_MODEL_VL
577
- cached_ok = _WORKING_MODEL_VL if has_images else _WORKING_MODEL_TEXT
578
- if cached_ok and cached_ok in candidate:
579
- candidate = [cached_ok] + [m for m in candidate if m != cached_ok]
580
- for m in candidate:
581
- if m and m not in models:
582
- models.append(m)
583
- for model in models:
584
- try:
585
- client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=60)
586
- content = []
587
- # Collect all images: image_urls list takes priority, fall back to single image_url
588
- all_img_urls = []
589
- if image_urls:
590
- all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
591
- elif image_url:
592
- all_img_urls = [image_url]
593
- for img_u in all_img_urls:
594
- if img_u and img_u.startswith("http"):
595
- content.append({"type": "image_url", "image_url": {"url": img_u}})
596
- content.append({"type": "text", "text": prompt})
597
- messages = [
598
- {"role": "system", "content": (
599
- "Bạn là biên tập viên báo điện tử tiếng Việt. "
600
- "NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
601
- "QUY TẮC CỨNG: "
602
- "(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
603
- "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
604
- "(3) KHÔNG dùng Markdown (##, **, ---, *). "
605
- "(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
606
- "(5) KHÔNG bịa thông tin ngoài nguồn. "
607
- "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
608
- "(7) Tối đa 200 từ. Ngắn gọn, súc tích."
609
- )},
610
- {"role": "user", "content": content}
611
- ]
612
- resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
613
- txt = (resp.choices[0].message.content or "").strip()
614
- if txt:
615
- LAST_QWEN_ERROR = ""
616
- if has_images: _WORKING_MODEL_VL = model
617
- else: _WORKING_MODEL_TEXT = model
618
- return txt
619
- except Exception as e:
620
- errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
621
- LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
622
- print("[qwen errors]", LAST_QWEN_ERROR)
623
- return None
624
-
625
-
626
- # ===== TTS GENERATION =====
627
- async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str, emotion: str = None):
628
- """Generate TTS using edge-tts (or gTTS if engine=gtts) with voice, speed & emotion control.
629
-
630
- Emotion (cảm xúc) is simulated via pitch + rate + volume (edge-tts has no express-as).
631
- """
632
- vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
633
- # gTTS engine (no voice/speed/emotion control)
634
- if vcfg.get("engine") == "gtts":
635
- _generate_tts_gtts(text, out_path)
636
- return
637
- if edge_tts is None:
638
- raise RuntimeError("edge-tts chưa cài đặt")
639
- voice = vcfg["id"]
640
- emo = EMOTION_PRESETS.get(emotion or EMOTION_DEFAULT, EMOTION_PRESETS[EMOTION_DEFAULT])
641
- # Apply emotion rate multiplier on top of base speed
642
- eff_speed = speed * emo.get("rate_mul", 1.0)
643
- pct = int(round((eff_speed - 1.0) * 100))
644
- rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
645
- pitch = emo.get("pitch", "+0Hz")
646
- volume = emo.get("volume", "+0%")
647
- communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=volume)
648
- await communicate.save(out_path)
649
-
650
- def _generate_tts_gtts(text: str, out_path: str):
651
- """Fallback TTS using gTTS (no voice/speed control)."""
652
- if gTTS is None:
653
- raise RuntimeError("gTTS chưa cài đặt")
654
- gTTS(text, lang="vi").save(out_path)
655
-
656
-
657
- # ===== SHORT VIDEO GENERATION (multi-segment: each key point with its own image) =====
658
- def _download_image(url, fallback_topic, out_path):
659
- """Download an image (un-proxying our own /api/proxy/img). Falls back to generated image."""
660
- if url:
661
- u = url
662
- m = re.search(r'/api/proxy/img\?url=(.+)$', u)
663
- if m:
664
- from urllib.parse import unquote
665
- u = unquote(m.group(1))
666
- try:
667
- r = requests.get(u, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=15)
668
- if r.status_code == 200 and len(r.content) > 1000:
669
- with open(out_path, "wb") as f:
670
- f.write(r.content)
671
- if Image:
672
- Image.open(out_path).verify()
673
- return out_path
674
- except Exception:
675
- pass
676
- gen = pollinations_image_url(fallback_topic)
677
- try:
678
- r = requests.get(gen, headers=HEADERS, timeout=25)
679
- if r.status_code == 200 and len(r.content) > 1000:
680
- with open(out_path, "wb") as f:
681
- f.write(r.content)
682
- return out_path
683
- except Exception:
684
- pass
685
- if Image:
686
- Image.new("RGB", (1080, 980), (30, 55, 42)).save(out_path)
687
- return out_path
688
- raise RuntimeError("Không tạo được ảnh")
689
-
690
-
691
- def _split_keypoint_sentences(text, max_points=6):
692
- """Split summary text into key points: prefer bullet markers, else sentences."""
693
- text = _clean_text(text)
694
- parts = re.split(r'\s*•\s*', text)
695
- good = [p.strip() for p in parts if len(p.strip()) > 20]
696
- if len(good) >= 2:
697
- # Explicit bullet points: keep each one as-is (never merge).
698
- return good[:max_points]
699
- # Fallback: split into sentences and merge orphan short fragments.
700
- pts = [p.strip() for p in re.split(r'(?<=[.!?])\s+', text) if len(p.strip()) > 20]
701
- out = []
702
- for p in pts:
703
- if out and len(p) < 40:
704
- out[-1] = (out[-1] + " " + p).strip()
705
- else:
706
- out.append(p)
707
- return out[:max_points] if out else ([text] if text else [])
708
-
709
-
710
- def _build_keypoints(post, max_points=6):
711
- """Return [{text, image}] pairing each key point with its own image."""
712
- slides = post.get("slides") or []
713
- images = post.get("images") or ([post.get("img")] if post.get("img") else [])
714
- images = [i for i in images if i]
715
- if slides:
716
- kps = []
717
- for i, s in enumerate(slides[:max_points]):
718
- t = _clean_text(s.get("text", ""))
719
- img = s.get("image") or (images[i] if i < len(images) else (images[-1] if images else ""))
720
- if t:
721
- kps.append({"text": t, "image": img})
722
- if kps:
723
- return kps
724
- points = _split_keypoint_sentences(post.get("text", ""), max_points)
725
- kps = []
726
- for i, t in enumerate(points):
727
- img = images[i] if i < len(images) else (images[-1] if images else "")
728
- kps.append({"text": t, "image": img})
729
- if not kps:
730
- kps = [{"text": _clean_text(post.get("title", "")) or "VNEWS", "image": images[0] if images else ""}]
731
- return kps
732
-
733
-
734
- def _wrap_text(draw, text, font, max_w):
735
- words = text.split()
736
- lines, cur = [], ""
737
- for w in words:
738
- test = (cur + " " + w).strip()
739
- if draw.textlength(test, font=font) <= max_w:
740
- cur = test
741
- else:
742
- if cur:
743
- lines.append(cur)
744
- cur = w
745
- if cur:
746
- lines.append(cur)
747
- return lines
748
-
749
-
750
- def _make_segment_frame(title, point_text, img_path, idx, total, out_path):
751
- """Render a 1080x1920 vertical frame: image on top, key point text below."""
752
- if Image is None:
753
- raise RuntimeError("Pillow chưa sẵn sàng")
754
- W, H = 1080, 1920
755
- IMG_H = 980
756
- bg = Image.new("RGB", (W, H), (12, 14, 18))
757
- try:
758
- im = Image.open(img_path).convert("RGB")
759
- tr = W / IMG_H
760
- ir = im.width / im.height
761
- if ir > tr:
762
- nh = IMG_H; nw = int(nh * ir)
763
- else:
764
- nw = W; nh = int(nw / ir)
765
- im = im.resize((nw, nh))
766
- left = (nw - W) // 2; top = (nh - IMG_H) // 2
767
- im = im.crop((left, top, left + W, top + IMG_H))
768
- bg.paste(im, (0, 0))
769
- except Exception:
770
- pass
771
- draw = ImageDraw.Draw(bg)
772
- draw.rectangle((0, IMG_H, W, H), fill=(12, 14, 18))
773
- try:
774
- f_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
775
- f_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 46)
776
- f_point = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 52)
777
- except Exception:
778
- f_label = f_title = f_point = ImageFont.load_default()
779
- draw.text((54, IMG_H + 24), "VNEWS · Tường AI", fill=(92, 184, 122), font=f_label)
780
- cnt = f"{idx + 1}/{total}"
781
- draw.text((W - 54 - draw.textlength(cnt, font=f_label), IMG_H + 24), cnt, fill=(240, 192, 64), font=f_label)
782
- y = IMG_H + 86
783
- for ln in _wrap_text(draw, _clean_text(title), f_title, W - 108)[:2]:
784
- draw.text((54, y), ln, fill=(255, 255, 255), font=f_title)
785
- y += 56
786
- y += 16
787
- for ln in _wrap_text(draw, _clean_text(point_text), f_point, W - 108)[:11]:
788
- draw.text((54, y), ln, fill=(225, 230, 235), font=f_point)
789
- y += 64
790
- bg.save(out_path, quality=92)
791
- return out_path
792
-
793
-
794
- def _ffmpeg_bin():
795
- return os.environ.get("FFMPEG_BIN", "ffmpeg")
796
-
797
-
798
- def _audio_duration(path):
799
- try:
800
- out = subprocess.run([_ffmpeg_bin(), "-i", path], capture_output=True, text=True, timeout=30).stderr
801
- m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", out)
802
- if m:
803
- h, mi, s = m.groups()
804
- return int(h) * 3600 + int(mi) * 60 + float(s)
805
- except Exception:
806
- pass
807
- return 0.0
808
-
809
-
810
- async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None, emotion: str = None) -> str:
811
- """Generate a multi-segment MP4 short: each key point shown with its OWN image + narration."""
812
- try:
813
- os.makedirs(SHORTS_DIR, exist_ok=True)
814
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
815
- if os.path.exists(out_mp4) and voice_id is None and speed is None and emotion is None:
816
- return "/api/ai/short-file/" + post_id
817
-
818
- work = os.path.join(SHORTS_DIR, _safe_name(post_id) + "_work")
819
- os.makedirs(work, exist_ok=True)
820
-
821
- title = _clean_text(post.get("title", "")) or "VNEWS"
822
- kps = _build_keypoints(post)
823
-
824
- # Resolve voice + emotion (auto from topic, or from post, or explicit args)
825
- auto_voice, auto_emotion = _detect_voice_emotion(post.get("title", ""), post.get("text", ""))
826
- if voice_id is None:
827
- voice_id = post.get("voice") or auto_voice
828
- if emotion is None:
829
- emotion = post.get("emotion") or auto_emotion
830
- if speed is None:
831
- speed = TTS_DEFAULT_SPEED
832
- vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
833
-
834
- seg_files = []
835
- ff = _ffmpeg_bin()
836
- for i, kp in enumerate(kps):
837
- img_path = os.path.join(work, f"img{i}.jpg")
838
- frame_path = os.path.join(work, f"frame{i}.jpg")
839
- audio_path = os.path.join(work, f"voice{i}.mp3")
840
- seg_mp4 = os.path.join(work, f"seg{i}.mp4")
841
- _download_image(kp.get("image", ""), title, img_path)
842
- _make_segment_frame(title, kp["text"], img_path, i, len(kps), frame_path)
843
- narration = (title + ". " + kp["text"]) if i == 0 else kp["text"]
844
- try:
845
- await _generate_tts_edge(narration, voice_id, speed, audio_path, emotion=emotion)
846
- except Exception as e:
847
- print(f"[TTS edge-tts error] {e}, falling back to gTTS")
848
- if gTTS:
849
- _generate_tts_gtts(narration, audio_path)
850
- else:
851
- return ""
852
- dur = _audio_duration(audio_path)
853
- if dur < 1.0:
854
- dur = 2.0
855
- cmd = [ff, "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
856
- "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
857
- "-t", f"{dur + 0.4:.2f}", "-c:a", "aac", "-b:a", "128k", "-ar", "44100",
858
- "-vf", "scale=1080:1920", "-r", "25", seg_mp4]
859
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
860
- seg_files.append(seg_mp4)
861
-
862
- if not seg_files:
863
- return ""
864
- if len(seg_files) == 1:
865
- os.replace(seg_files[0], out_mp4)
866
- return "/api/ai/short-file/" + post_id
867
-
868
- listfile = os.path.join(work, "concat.txt")
869
- with open(listfile, "w", encoding="utf-8") as f:
870
- f.write("\n".join(f"file '{s}'" for s in seg_files))
871
- cmd = [ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile,
872
- "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", out_mp4]
873
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
874
- return "/api/ai/short-file/" + post_id
875
- except Exception as e:
876
- print(f"[short video error] {e}")
877
- return ""
878
-
879
- import threading as _threading
880
- def _spawn_background_video(post):
881
- """Generate the short video in a BACKGROUND thread so the rewrite/topic endpoint
882
- can return immediately. When done, persist the video URL onto the wall post.
883
- This is the main fix for 'rewrite tu cac nguon tin qua lau' — the user gets the
884
- text post instantly; the video appears shortly after (or via the 'Tao Video' button)."""
885
- pid = post.get("id")
886
- if not pid:
887
- return
888
- def _run():
889
- try:
890
- loop = asyncio.new_event_loop()
891
- asyncio.set_event_loop(loop)
892
- video_url = loop.run_until_complete(_generate_short_video(post, pid))
893
- loop.close()
894
- if video_url:
895
- posts = _load_wall()
896
- for i, p in enumerate(posts):
897
- if str(p.get("id")) == str(pid):
898
- posts[i]["video"] = video_url
899
- break
900
- _save_wall(posts)
901
- print(f"[bg-video] done {pid} -> {video_url}")
902
- except Exception as e:
903
- print(f"[bg-video] error {pid}: {e}")
904
- _threading.Thread(target=_run, daemon=True).start()
905
-
906
- # ===== MAKE POST =====
907
- def make_post(title, text, image, source_url, kind, sources=None, images=None, voice=None, emotion=None):
908
- # Auto-pick voice + emotion from the article topic when not provided
909
- auto_voice, auto_emotion = _detect_voice_emotion(title or "", text or "")
910
- return {
911
- "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
912
- "title": title, "text": text, "img": image, "url": source_url,
913
- "kind": kind, "sources": sources or [], "video": "",
914
- "images": images or [], "ts": int(time.time()),
915
- "voice": voice or auto_voice,
916
- "emotion": emotion or auto_emotion,
917
- }
918
-
919
-
920
- # ===== SHARED PROMPT BUILDER =====
921
- def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
922
- image_info = ""
923
- if images:
924
- num = len(images)
925
- if num == 1:
926
- image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
927
- else:
928
- image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
929
-
930
- return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.
931
-
932
- QUY TẮC BẮT BUỘC:
933
- 1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.
934
- 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
935
- 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
936
- 4. KHÔNG dùng Markdown (##, **, ---, *).
937
- 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".
938
- 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
939
- 7. Giữ sự thật, KHÔNG bịa thông tin.
940
- 8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}
941
-
942
- Tiêu đề gốc: {title}
943
-
944
- Nội dung gốc:
945
- {raw[:14000]}"""
946
-
947
-
948
- def _build_topic_prompt(topic: str, ctx: str) -> str:
949
- return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
950
-
951
- QUY TẮC BẮT BUỘC:
952
- 1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.
953
- 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
954
- 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
955
- 4. KHÔNG dùng Markdown (##, **, ---, *).
956
- 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
957
- 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.
958
- 7. Giữ sự thật, KHÔNG bịa.
959
- 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
960
-
961
- Nguồn thực tế:
962
- {ctx[:12000]}"""
963
-
964
-
965
- # ===== WRITE ENDPOINTS =====
966
- @app.post("/api/rewrite_share")
967
- async def api_rewrite_share(request: Request):
968
- body = await request.json()
969
- url = _clean_text(body.get("url", ""))
970
- if not url.startswith("http"):
971
- return JSONResponse({"error": "missing url"}, status_code=400)
972
- try:
973
- data = scrape_any_url(url)
974
- except Exception as e:
975
- return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
976
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
977
- if len(raw) < 60:
978
- return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
979
-
980
- images = data.get("images", [])
981
- prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
982
- # Text-only summary for SPEED (images are kept on the post for display + short video).
983
- text = await qwen_generate(prompt, max_tokens=500)
984
- if not text:
985
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
986
- text = _clean_ai_output(text)
987
- post = make_post(data.get("title") or "Bài viết", text,
988
- images[0] if images else data.get("image", ""),
989
- url, "rewrite", images=images)
990
-
991
- # Save post and return IMMEDIATELY; generate the short video in the background
992
- # (so rewrite is fast). Video appears on the wall when ready / via 'Tao Video' button.
993
- posts = _load_wall()
994
- posts.insert(0, post)
995
- _save_wall(posts)
996
- _spawn_background_video(post)
997
- return JSONResponse({"post": post})
998
-
999
-
1000
- @app.post("/api/url_wall")
1001
- async def api_url_wall(request: Request):
1002
- body = await request.json()
1003
- url = _clean_text(body.get("url", ""))
1004
- if not url.startswith("http"):
1005
- return JSONResponse({"error": "missing url"}, status_code=400)
1006
- try:
1007
- data = scrape_any_url(url)
1008
- except Exception as e:
1009
- return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
1010
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
1011
- if len(raw) < 60:
1012
- return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
1013
-
1014
- images = data.get("images", [])
1015
- prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
1016
- # Text-only summary for SPEED (images are kept on the post for display + short video).
1017
- text = await qwen_generate(prompt, max_tokens=500)
1018
- if not text:
1019
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
1020
- text = _clean_ai_output(text)
1021
- post = make_post(data.get("title") or "Bài viết", text,
1022
- images[0] if images else data.get("image", ""),
1023
- url, "url", images=images)
1024
-
1025
- posts = _load_wall()
1026
- posts.insert(0, post)
1027
- _save_wall(posts)
1028
- _spawn_background_video(post)
1029
- return JSONResponse({"post": post})
1030
-
1031
-
1032
- @app.post("/api/topic_post")
1033
- async def api_topic_post(request: Request):
1034
- body = await request.json()
1035
- topic = _clean_text(body.get("topic", ""))
1036
- if not topic:
1037
- return JSONResponse({"error": "missing topic"}, status_code=400)
1038
-
1039
- ctx = _web_context(topic)
1040
- if not ctx:
1041
- return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
1042
-
1043
- image = pollinations_image_url(topic)
1044
- prompt = _build_topic_prompt(topic, ctx)
1045
- # NOTE: do NOT pass the decorative pollinations image to the VL model — feeding an
1046
- # image makes Qwen2.5-VL much slower (it must download+process it) with no benefit for
1047
- # a text summary. We keep the image only for display on the post. This is a major
1048
- # speed-up for 'rewrite tong hop'. (Text-only inference is several times faster.)
1049
- text = await qwen_generate(prompt, max_tokens=500)
1050
- if not text:
1051
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
1052
- text = _clean_ai_output(text)
1053
- post = make_post(topic, text, image, "", "topic")
1054
-
1055
- posts = _load_wall()
1056
- posts.insert(0, post)
1057
- _save_wall(posts)
1058
- _spawn_background_video(post)
1059
- return JSONResponse({"post": post})
1060
-
1061
-
1062
- # ===== WALL ENDPOINTS =====
1063
- @app.get("/api/ai_wall")
1064
- def api_ai_wall():
1065
- return JSONResponse({"posts": _load_wall()[:80]})
1066
-
1067
- @app.get("/api/wall")
1068
- def api_wall():
1069
- return JSONResponse({"posts": _load_wall()[:80]})
1070
-
1071
-
1072
- # ===== SHORT VIDEO ENDPOINT (with voice + speed params) =====
1073
- @app.post("/api/ai/short/{post_id}")
1074
- async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None), emotion: str = Query(default=None)):
1075
- """Generate (or retrieve cached) short video for a wall post.
1076
-
1077
- Query params:
1078
- - voice: 'hoaimy' (female) | 'namminh' (male) | auto-detect if not specified
1079
- - speed: float (default 1.2), e.g. 1.0=normal, 1.2=fast, 0.8=slow
1080
- - emotion: 'vui'|'hao_hung'|'nghiem'|'tram'|'buon'|'trung_tinh' | auto by topic
1081
- """
1082
- posts = _load_wall()
1083
- post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
1084
- if not post:
1085
- return JSONResponse({"error": "post not found"}, status_code=404)
1086
-
1087
- os.makedirs(SHORTS_DIR, exist_ok=True)
1088
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
1089
-
1090
- # If cached and no custom voice/speed/emotion requested, return cached
1091
- if os.path.exists(out_mp4) and voice is None and speed is None and emotion is None:
1092
- video_url = "/api/ai/short-file/" + post_id
1093
- for i, p in enumerate(posts):
1094
- if str(p.get("id")) == str(post_id):
1095
- posts[i]["video"] = video_url
1096
- break
1097
- _save_wall(posts)
1098
- return JSONResponse({"video": video_url})
1099
-
1100
- # Validate params
1101
- if voice is not None and voice not in TTS_VOICES:
1102
- return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
1103
- if emotion is not None and emotion not in EMOTION_PRESETS:
1104
- return JSONResponse({"error": f"emotion không hợp lệ. Chọn: {list(EMOTION_PRESETS.keys())}"}, status_code=400)
1105
-
1106
- video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed, emotion=emotion)
1107
- if video_url:
1108
- for i, p in enumerate(posts):
1109
- if str(p.get("id")) == str(post_id):
1110
- posts[i]["video"] = video_url
1111
- if voice:
1112
- posts[i]["voice"] = voice
1113
- if emotion:
1114
- posts[i]["emotion"] = emotion
1115
- break
1116
- _save_wall(posts)
1117
- return JSONResponse({"video": video_url})
1118
- return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
1119
-
1120
-
1121
- @app.get("/api/ai/short-file/{post_id}")
1122
- def api_ai_short_file(post_id: str):
1123
- path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
1124
- if not os.path.exists(path):
1125
- return JSONResponse({"error": "not found"}, status_code=404)
1126
- return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
1127
-
1128
-
1129
- @app.get("/api/ai/status")
1130
- def api_ai_status():
1131
- return JSONResponse({
1132
- "has_token": bool(_hf_token()),
1133
- "client_imported": AsyncInferenceClient is not None,
1134
- "model": QWEN_VL_MODEL,
1135
- "last_error": LAST_QWEN_ERROR,
1136
- "tts_ready": gTTS is not None or edge_tts is not None,
1137
- "tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
1138
- "tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
1139
- "tts_voice_count": len(TTS_VOICES),
1140
- "tts_default_speed": TTS_DEFAULT_SPEED,
1141
- })
1142
-
1143
-
1144
- @app.get("/api/ai/voices")
1145
- def api_ai_voices():
1146
- """Return available TTS voices with country/group info."""
1147
- voices_out = {}
1148
- for k, v in TTS_VOICES.items():
1149
- voices_out[k] = {
1150
- "name": v["name"],
1151
- "gender": v["gender"],
1152
- "country": v["country"],
1153
- "lang": v["lang"],
1154
- "flag": v["flag"],
1155
- "label": f"{v['flag']} {v['name']} ({v['gender']})",
1156
- }
1157
- return JSONResponse({
1158
- "voices": voices_out,
1159
- "default_voice": TTS_DEFAULT_VOICE,
1160
- "default_speed": TTS_DEFAULT_SPEED,
1161
- "topic_voice_map": TOPIC_VOICE_MAP,
1162
- "emotions": {k: {"label": v["label"], "emoji": v["emoji"]} for k, v in EMOTION_PRESETS.items()},
1163
- "default_emotion": EMOTION_DEFAULT,
1164
- })