bep40 commited on
Commit
89e1bbf
·
verified ·
1 Parent(s): 7326af9

Upload ai_patch.py

Browse files
Files changed (1) hide show
  1. ai_patch.py +146 -879
ai_patch.py CHANGED
@@ -7,6 +7,7 @@ import html as html_lib
7
  import subprocess
8
  import requests
9
  import hashlib
 
10
  import ai_ext as base
11
  from ai_ext import app
12
  from fastapi import Request
@@ -19,55 +20,25 @@ try:
19
  except Exception:
20
  Image = ImageDraw = ImageFont = None
21
 
 
22
 
23
  def _clean(s):
24
- s = html_lib.unescape(s or "")
25
- # FIX: Remove malformed HTML artifacts (truncated tags without closing >)
26
- s = s.replace('<a href=" src="', '').replace("<a href=' src='", '')
27
- s = s.replace('<a href=" src=', '').replace("<a href=' src=", '')
28
- s = re.sub(r'<a\s+[^>]*href\s*=\s*"[^"]*"\s*src\s*=\s*"[^"]*"[^>]*', '', s, flags=re.I)
29
- # Remove HTML tags like <a href=...>, <img src=...>
30
- s = re.sub(r'<[^>]+>', '', s)
31
- s = re.sub(r"\s+", " ", s)
32
- s = re.sub(r"\n{3,}", "\n\n", s)
33
- return s.strip()
34
-
35
-
36
- def _norm(s):
37
- s = s.lower()
38
- s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
39
- s = re.sub(r"\s+", " ", s).strip()
40
  return s
41
 
42
-
43
- def _similar(a, b):
44
- ta = set(_norm(a).split())
45
- tb = set(_norm(b).split())
46
- if not ta or not tb:
47
- return False
48
- return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
49
-
50
-
51
- def _dedupe_units(units, max_units=25):
52
- """Deduplicate units - only skip exact matches to ensure all bullet points are read."""
53
- out, seen = [], set()
54
- for u in units:
55
- u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
56
- if len(u) < 18:
57
- continue
58
- nu = _norm(u)
59
- # Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
60
- if nu in seen:
61
- continue
62
- seen.add(nu)
63
- out.append(u)
64
- if len(out) >= max_units:
65
- break
66
- return out
67
-
68
-
69
  def _postprocess_ai_text(text, max_units=20):
70
  text = _clean(text)
 
 
 
 
 
 
 
 
71
  if not text:
72
  return text
73
  # Remove malformed HTML with both href and src attributes (e.g., <a href="..." src="...">)
@@ -78,856 +49,152 @@ def _postprocess_ai_text(text, max_units=20):
78
  text = re.sub(r'<[^>]+>', '', text) # Any remaining HTML tags
79
  text = re.sub(r'</a>', '', text, flags=re.I)
80
  drop_prefixes = (
81
- "dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
82
- "tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  )
84
- raw_lines = []
85
- for line in re.split(r"\n+", text):
86
- line = _clean(line)
87
- if not line:
88
- continue
89
- low = line.lower().strip()
90
- if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
91
- continue
92
- raw_lines.append(line)
93
- units = []
94
- for line in raw_lines:
95
- # KEEP FULL bullet point - don't truncate or split into segments
96
- if len(line) >= 18:
97
- units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
98
- units = _dedupe_units(units, max_units=max_units)
99
- if not units:
100
- return text[:900]
101
- title = ""
102
- if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
103
- title = raw_lines[0]
104
- units = [u for u in units if not _similar(u, title)]
105
- body = "\n".join("• " + u for u in units[:max_units])
106
- return (title + "\n\n" + body).strip() if title else body
107
-
108
-
109
- def _fallback_summary_from_prompt(prompt, max_units=6):
110
- text = prompt or ""
111
- 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:"]:
112
- if marker in text:
113
- text = text.split(marker, 1)[1]
114
  break
115
- text = re.sub(r"https?://\S+", "", text)
116
- text = re.sub(r"\s+", " ", text).strip()
117
- sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
118
- candidates = []
119
- for s in sentences:
120
- s = _clean(s)
121
- if 45 <= len(s) <= 260:
122
- candidates.append(s)
123
- units = _dedupe_units(candidates, max_units=max_units)
124
- if units:
125
- return "\n".join("• " + u for u in units)
126
- if text:
127
- return "• " + text[:700].rsplit(" ", 1)[0]
128
- return "• Không có đủ nội dung nguồn để tóm tắt."
129
-
130
-
131
- def _source_line(sources):
132
- names = []
133
- for s in (sources or [])[:5]:
134
- via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
135
- if via and via not in names:
136
- names.append(via)
137
- return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
138
-
139
-
140
- def _make_summary_prompt(title, raw, source_hint=""):
141
- return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
142
-
143
- NHIỆM VỤ BẮT BUỘC:
144
- - Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
145
- - Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
146
- - Không thêm thông tin ngoài nguồn.
147
- - Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
148
- - Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
149
- - Không viết phần mở bài dài, không viết văn kể lại.
150
-
151
- Tiêu đề nguồn: {title}
152
- Nguồn: {source_hint}
153
-
154
- Nội dung nguồn:
155
- {raw[:14000]}
156
- """
157
-
158
-
159
- def _direct_news_rss(topic, limit=10):
160
  out = []
161
- try:
162
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
163
- r = requests.get(url, headers=base.HEADERS, timeout=15)
164
- r.encoding = "utf-8"
165
- soup = BeautifulSoup(r.text, "xml")
166
- for it in soup.find_all("item")[:limit]:
167
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
168
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
169
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
170
- if title and link:
171
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
172
- except Exception:
173
- pass
174
- return out
175
-
176
-
177
- def _topic_source_articles(topic, limit=5):
178
- """Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
179
- try:
180
- _ctx, sources = base.web_context(topic, limit=limit)
181
- except Exception:
182
- sources = []
183
- if not sources:
184
- sources = _direct_news_rss(topic, limit=10)
185
- out, seen = [], set()
186
- for s in (sources or [])[:limit * 3]:
187
- url = s.get("url") or ""
188
- if not url.startswith("http") or url in seen:
189
- continue
190
- seen.add(url)
191
- try:
192
- page = base.scrape_any_url(url)
193
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
194
- if len(raw) < 180:
195
- continue
196
- title = page.get("title") or s.get("title") or url
197
- via = page.get("via") or s.get("via") or base._domain(url)
198
- out.append({
199
- "title": title,
200
- "url": url,
201
- "raw": raw,
202
- "image": page.get("image") or "",
203
- "via": via,
204
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
205
- })
206
- if len(out) >= limit:
207
- break
208
- except Exception:
209
- continue
210
- if not out:
211
- for s in (sources or _direct_news_rss(topic, 6))[:limit]:
212
- title = s.get("title") or topic
213
- excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
214
- url = s.get("url", "")
215
- via = s.get("via") or base._domain(url)
216
- out.append({
217
- "title": title,
218
- "url": url,
219
- "raw": excerpt,
220
- "image": base.pollinations_image_url(title),
221
- "via": via,
222
- "source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
223
- })
224
- return out[:limit]
225
-
226
-
227
- async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
228
- errors = []
229
- token = base._hf_token()
230
- try:
231
- original = getattr(base, "_original_qwen_generate", None)
232
- if original:
233
- txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
234
- if txt:
235
- base.LAST_QWEN_ERROR = ""
236
- return txt
237
- if getattr(base, "LAST_QWEN_ERROR", ""):
238
- errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
239
- except Exception as e:
240
- errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
241
- if token:
242
- models = []
243
- for m in [
244
- os.getenv("QWEN_VL_MODEL", ""),
245
- "Qwen/Qwen2.5-VL-7B-Instruct",
246
- "Qwen/Qwen2.5-VL-3B-Instruct",
247
- "Qwen/Qwen2.5-7B-Instruct",
248
- "Qwen/Qwen2.5-3B-Instruct",
249
- "Qwen/Qwen2.5-1.5B-Instruct",
250
- ]:
251
- if m and m not in models:
252
- models.append(m)
253
- headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
254
- for model in models:
255
- try:
256
- is_vl = "VL" in model and bool(image_url)
257
- user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
258
- payload = {
259
- "model": model,
260
- "messages": [
261
- {"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
262
- {"role": "user", "content": user_content},
263
- ],
264
- "max_tokens": min(int(max_tokens or 900), 1400),
265
- "temperature": 0.35,
266
- "top_p": 0.85,
267
- }
268
- r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
269
- if r.status_code >= 300:
270
- errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
271
- continue
272
- j = r.json()
273
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
274
- if txt:
275
- base.LAST_QWEN_ERROR = ""
276
- return txt
277
- errors.append(f"{model}: empty response")
278
- except Exception as e:
279
- errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
280
- else:
281
- errors.append("missing HF_TOKEN")
282
- base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
283
- print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
284
- return _fallback_summary_from_prompt(prompt, max_units=12)
285
-
286
-
287
- if not hasattr(base, "_original_qwen_generate"):
288
- base._original_qwen_generate = base.qwen_generate
289
- base.qwen_generate = qwen_generate_resilient
290
-
291
-
292
- @app.get('/api/wall')
293
- def compat_wall():
294
- return JSONResponse({'posts': base._load_wall_posts()[:80]})
295
-
296
-
297
- _PATCHED_PATHS = {
298
- ('/api/topic_post', 'POST'),
299
- ('/api/url_wall', 'POST'),
300
- ('/api/rewrite_share', 'POST'),
301
- ('/api/ai/short/{post_id}', 'POST'),
302
- }
303
- app.router.routes = [
304
- r for r in app.router.routes
305
- if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
306
- ]
307
-
308
-
309
- @app.post('/api/topic_post')
310
- async def compat_topic_post(request: Request):
311
- body = await request.json()
312
- topic = base._clean_text(body.get('topic', ''))
313
- if not topic:
314
- return JSONResponse({'error': 'missing topic'}, status_code=400)
315
- articles = _topic_source_articles(topic, limit=4)
316
- if not articles:
317
- return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
318
- new_posts = []
319
- posts = base._load_ai_wall()
320
- for art in articles:
321
- prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
322
-
323
- Chủ đề lọc: {topic}
324
- Tiêu đề bài nguồn: {art['title']}
325
- Nguồn: {art['via']}
326
-
327
- Yêu cầu bắt buộc:
328
- - Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
329
- - Không trộn với bài khác.
330
- - Không viết lại toàn bộ bài.
331
- - Không lặp ý.
332
- - 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
333
- - Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
334
-
335
- Nội dung bài:
336
- {art['raw'][:14000]}"""
337
- text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
338
- text = _postprocess_ai_text(text, max_units=20)
339
- src = [art['source']]
340
- if 'Nguồn tham khảo:' not in text:
341
- text += "\n\n" + _source_line(src)
342
- post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
343
-
344
- # Generate slides for this post so they persist after page reload
345
- try:
346
- page_data = _scrape_article_images(art.get('url', ''))
347
- if page_data and page_data.get('paragraphs'):
348
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
349
- if key_points:
350
- relevant_imgs = page_data.get('images', [])
351
- if not relevant_imgs and page_data.get('og_img'):
352
- relevant_imgs = [page_data['og_img']]
353
- slides = []
354
- for i, point in enumerate(key_points):
355
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
356
- slides.append({'text': point, 'image': img, 'index': i + 1})
357
- post['slides'] = slides
358
- except Exception:
359
- pass
360
-
361
- new_posts.append(post)
362
- posts = new_posts + posts
363
- base._save_ai_wall(posts)
364
- return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
365
-
366
-
367
- @app.post('/api/url_wall')
368
- async def compat_url_wall(request: Request):
369
- body = await request.json()
370
- url = base._clean_text(body.get('url', ''))
371
- if not url.startswith('http'):
372
- return JSONResponse({'error': 'missing url'}, status_code=400)
373
- try:
374
- data = base.scrape_any_url(url)
375
- except Exception as e:
376
- return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
377
- raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
378
- if len(raw) < 120:
379
- return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
380
- prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
381
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
382
- text = _postprocess_ai_text(text, max_units=20)
383
- src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
384
- if 'Nguồn tham khảo:' not in text:
385
- text += "\n\n" + _source_line(src)
386
- post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
387
-
388
- # Generate slides so they persist after page reload
389
- slides = []
390
- try:
391
- page_data = _scrape_article_images(url)
392
- if page_data and page_data.get('paragraphs'):
393
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
394
- if key_points:
395
- relevant_imgs = page_data.get('images', [])
396
- if not relevant_imgs and page_data.get('og_img'):
397
- relevant_imgs = [page_data['og_img']]
398
- for i, point in enumerate(key_points):
399
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
400
- slides.append({'text': point, 'image': img, 'index': i + 1})
401
- except Exception:
402
- pass
403
- post['slides'] = slides
404
-
405
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
406
- return JSONResponse({'post': post, 'slides': slides})
407
-
408
-
409
- def _is_relevant_image(img_url, title, text):
410
- """Check if an image is relevant to the article content."""
411
- if not img_url:
412
- return False
413
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
414
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
415
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
416
- img_lower = img_url.lower()
417
- for p in skip_patterns:
418
- if p in img_lower:
419
- return False
420
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
421
- return False
422
- return True
423
-
424
-
425
- def _filter_relevant_images(images, title, text, max_images=8):
426
- """Filter and rank images by relevance to article content."""
427
- if not images:
428
- return []
429
  seen = set()
430
- relevant = []
431
- for img in images:
432
- if img in seen:
433
  continue
434
- seen.add(img)
435
- if _is_relevant_image(img, title, text):
436
- relevant.append(img)
437
- return relevant[:max_images]
438
-
439
-
440
- def _extract_key_points_for_slides(paragraphs, max_points=12):
441
- """Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
442
- points = []
443
- for p in paragraphs:
444
- if len(points) >= max_points:
445
- break
446
- p = _clean(p)
447
- if not p:
448
  continue
449
- # Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
450
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
451
- sentences = [s.strip() for s in sentences if s.strip()]
452
-
453
- for sentence in sentences:
454
- if len(points) >= max_points:
455
- break
456
- sentence = _clean(sentence)
457
- if len(sentence) < 30:
458
- continue
459
- if any(sentence[:60] in existing for existing in points):
460
- continue
461
- if not sentence.endswith(('.', '!', '?')):
462
- sentence = sentence + '.'
463
- points.append(sentence)
464
- return points
465
-
466
 
467
  def _scrape_article_images(url):
468
- """Scrape article page and return only relevant images."""
469
  try:
470
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
471
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
472
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
473
- r.encoding = 'utf-8'
474
- soup = BeautifulSoup(r.text, 'lxml')
475
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
476
- tag.decompose()
477
- h1 = soup.find('h1')
478
- ogt = soup.find('meta', property='og:title')
479
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
480
- ogi = soup.find('meta', property='og:image')
481
- og_img = ogi.get('content', '') if ogi else ''
482
- if og_img and og_img.startswith('//'):
483
- og_img = 'https:' + og_img
484
- block = None
485
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
486
- el = soup.select_one(sel)
487
- if el and len(el.find_all('p')) >= 2:
488
- block = el
489
- break
490
- if not block:
491
- block = soup.body or soup
492
- paragraphs = []
493
- all_images = []
494
- seen_imgs = set()
495
- if og_img and og_img not in seen_imgs:
496
- all_images.append(og_img)
497
- seen_imgs.add(og_img)
498
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
499
- if el.name == 'p':
500
- t = _clean(el.get_text(strip=True))
501
- if t and len(t) > 40:
502
- paragraphs.append(t)
503
- elif el.name in ('figure', 'img'):
504
- im = el if el.name == 'img' else el.find('img')
505
- if im:
506
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
507
- if src and 'base64' not in src:
508
- if src.startswith('//'):
509
- src = 'https:' + src
510
- if src not in seen_imgs:
511
- all_images.append(src)
512
- seen_imgs.add(src)
513
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
514
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
515
- except Exception:
516
- return None
517
-
518
-
519
- @app.post('/api/rewrite_share')
520
- async def compat_rewrite_share(request: Request):
521
- body = await request.json()
522
- url = base._clean_text(body.get('url', ''))
523
- if not url.startswith('http'):
524
- return JSONResponse({'error': 'missing url'}, status_code=400)
525
- try:
526
- data = base.scrape_any_url(url)
527
  except Exception as e:
528
- return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
529
- raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
530
- if len(raw) < 120:
531
- return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
532
- prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
533
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
534
- text = _postprocess_ai_text(text, max_units=20)
535
- src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
536
- if 'Nguồn tham khảo:' not in text:
537
- text += "\n\n" + _source_line(src)
538
- post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
539
-
540
- # Generate slides with relevant images only
541
- slides = []
542
- page_data = _scrape_article_images(url)
543
- if page_data and page_data.get('paragraphs'):
544
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
545
- if key_points:
546
- relevant_imgs = page_data.get('images', [])
547
- if not relevant_imgs and page_data.get('og_img'):
548
- relevant_imgs = [page_data['og_img']]
549
- for i, point in enumerate(key_points):
550
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
551
- slides.append({'text': point, 'image': img, 'index': i + 1})
552
-
553
- # FIX: Save slides into post so they persist after page reload
554
- post['slides'] = slides
555
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
556
 
557
- return JSONResponse({'post': post, 'slides': slides})
558
-
559
-
560
- def _emotion_script(text, emotion):
561
- """Prepend emotion-appropriate prefix to text based on emotion type.
562
-
563
- NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
564
- The emotion is still used for voice selection but content is read cleanly.
565
- """
566
- text = _clean(text)
567
- # REMOVED: No prefix added to keep content clean and natural
568
- return text
569
-
570
-
571
- def _tts_script_smart(post, emotion):
572
- raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
573
- raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
574
- raw = re.sub(r"\s*\n\s*", ". ", raw)
575
- raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
576
- raw = re.sub(r"\n{2,}", "\n", raw).strip()
577
- # REMOVED: _emotion_script call - read content cleanly without prefix
578
- # INCREASED to 3000 to read full content of all bullet points
579
- if len(raw) > 3000:
580
- raw = raw[:3000]
581
- cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
582
- if cut > 700:
583
- raw = raw[:cut + 1]
584
- return raw
585
-
586
-
587
- def _split_subtitle_sentences(script):
588
- parts = []
589
- for line in script.splitlines():
590
- line = _clean(line)
591
- if not line:
592
  continue
593
- for s in re.split(r"(?<=[\.\!\?])\s+", line):
594
- s = _clean(s)
595
- if 8 <= len(s) <= 140:
596
- parts.append(s)
597
- return parts[:12]
598
-
599
-
600
- def _srt_time(sec):
601
- ms = int((sec - int(sec)) * 1000)
602
- sec = int(sec)
603
- h = sec // 3600
604
- m = (sec % 3600) // 60
605
- s = sec % 60
606
- return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
607
-
608
-
609
- def _write_srt(script, path, total_duration=30):
610
- subs = _split_subtitle_sentences(script)
611
- if not subs:
612
- subs = [script[:120]]
613
- dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
614
- cur = 0.3
615
- with open(path, 'w', encoding='utf-8') as f:
616
- for i, s in enumerate(subs, 1):
617
- start = cur
618
- end = cur + dur
619
- cur = end + 0.15
620
- f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
621
-
622
-
623
- def _wrap_text_px(draw, text, font, max_width, max_lines):
624
- words = _clean(text).split()
625
- lines, cur = [], ""
626
- for w in words:
627
- test = (cur + " " + w).strip()
628
- try:
629
- width = draw.textbbox((0, 0), test, font=font)[2]
630
- except Exception:
631
- width = len(test) * 20
632
- if width <= max_width:
633
- cur = test
634
- else:
635
- if cur:
636
- lines.append(cur)
637
- cur = w
638
- if len(lines) >= max_lines:
639
- break
640
- if cur and len(lines) < max_lines:
641
- lines.append(cur)
642
- return lines
643
-
644
-
645
- def _make_short_frame_full(post, img_path, out_path):
646
- if Image is None:
647
- return base._make_short_frame(post, img_path, out_path)
648
- W, H = 1080, 1920
649
- bg = Image.new("RGB", (W, H), (14, 14, 14))
650
- try:
651
- im = Image.open(img_path).convert("RGB")
652
- target = (1080, 760)
653
- im_ratio = im.width / im.height
654
- target_ratio = target[0] / target[1]
655
- if im_ratio > target_ratio:
656
- new_h = target[1]
657
- new_w = int(new_h * im_ratio)
658
- else:
659
- new_w = target[0]
660
- new_h = int(new_w / im_ratio)
661
- im = im.resize((new_w, new_h))
662
- left = (new_w - target[0]) // 2
663
- top = (new_h - target[1]) // 2
664
- im = im.crop((left, top, left + target[0], top + target[1]))
665
- bg.paste(im, (0, 0))
666
- except Exception:
667
- pass
668
- draw = ImageDraw.Draw(bg)
669
- try:
670
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
671
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
672
- font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
673
- except Exception:
674
- font_title = font_body = font_label = None
675
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
676
- margin = 48
677
- maxw = W - margin * 2
678
- brand = 'Tin tóm tắt VNEWS 7h sáng, 13h trưa, 19h tối' if post.get('kind') == 'auto_scheduled' else 'VNEWS · Tường AI'
679
- draw.text((margin, 770), brand, fill=(92, 184, 122), font=font_label)
680
- y = 830
681
- for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
682
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
683
- y += 66
684
- y += 18
685
- text = post.get("text", "")
686
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
687
- body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
688
- for ln in body_lines:
689
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
690
- y += 50
691
- if y > 1640:
692
  break
693
- bg.save(out_path, quality=92)
694
-
695
-
696
-
697
-
698
- def _summary_segments_from_post(post, max_segments=25):
699
- raw = _clean(post.get('text') or post.get('title') or '')
700
- raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
701
- raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
702
- lines=[]
703
- for ln in raw.splitlines():
704
- ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
705
- if not ln: continue
706
- low=ln.lower()
707
- if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
708
- if len(ln)>=18: lines.append(ln)
709
- if len(lines)<3:
710
- lines=[]
711
- for s in re.split(r'(?<=[\.\!\?])\s+', raw):
712
- s=_clean(s)
713
- if len(s)>=25: lines.append(s)
714
- segs=_dedupe_units(lines, max_units=max_segments)
715
- return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
716
-
 
 
 
 
 
 
 
 
 
 
 
717
 
718
  def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
719
- if Image is None:
720
- return _make_short_frame_full(post, img_path, out_path)
721
- W,H=1080,1920
722
- bg=Image.new('RGB',(W,H),(10,10,10))
723
- try:
724
- im=Image.open(img_path).convert('RGB')
725
- ratio=im.width/max(1,im.height); target=W/H
726
- if ratio>target:
727
- nh=H; nw=int(nh*ratio)
728
- else:
729
- nw=W; nh=int(nw/ratio)
730
- cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
731
- cover=cover.crop((left,top,left+W,top+H))
732
- bg.paste(cover,(0,0))
733
- bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
734
- hero_h=720; target=W/hero_h
735
- if ratio>target:
736
- nh=hero_h; nw=int(nh*ratio)
737
- else:
738
- nw=W; nh=int(nw/ratio)
739
- hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
740
- hero=hero.crop((left,top,left+W,top+hero_h))
741
- bg.paste(hero,(0,0))
742
- except Exception:
743
- pass
744
- draw=ImageDraw.Draw(bg)
745
- try:
746
- font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
747
- font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
748
- font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
749
- font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
750
- except Exception:
751
- font_brand=font_small=font_seg=font_title=None
752
- draw.rectangle((0,680,W,H), fill=(12,12,12))
753
- dot_x=48; dot_y=742
754
- for i in range(total):
755
- fill=(92,184,122) if i==idx else (70,70,70)
756
- draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
757
- # Determine brand text: auto_scheduled uses special header, manual uses default
758
- brand = 'Tin tóm tắt VNEWS 7h sáng, 13h trưa, 19h tối' if post.get('kind') == 'auto_scheduled' else 'VNEWS AI SHORT'
759
- draw.text((48,780),brand,fill=(110,231,143),font=font_brand)
760
- draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
761
- draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
762
- y=940; maxw=W-96
763
- # INCREASED from 12 to 18 for full content display - each key point can span multiple lines
764
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
765
- draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
766
- y+=74
767
- if y>1500: break
768
- y2=1640
769
- draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
770
- for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
771
- draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
772
- y2+=46
773
- bg.save(out_path, quality=92)
774
-
775
-
776
- def _estimate_audio_duration(path, fallback=15.0):
777
- """Estimate audio duration with 15s minimum per segment for complete bullet reading."""
778
- try:
779
- pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
780
- return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
781
- except Exception:
782
- return fallback
783
-
784
-
785
- @app.post('/api/ai/short/{post_id}')
786
- async def patched_ai_short(post_id: str, request: Request):
787
- try:
788
- body = await request.json()
789
- except Exception:
790
- body = {}
791
- voice = str(body.get('voice', 'nu')).strip().lower()
792
- emotion = str(body.get('emotion', 'neutral')).strip().lower()
793
- speed = float(body.get('speed', 1.0) or 1.0)
794
- speed = max(0.85, min(1.35, speed))
795
-
796
- posts = base._load_ai_wall()
797
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
798
- if not post:
799
- return JSONResponse({'error': 'post not found'}, status_code=404)
800
-
801
- segments = _summary_segments_from_post(post, max_segments=25)
802
- seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
803
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
804
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
805
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
806
- if os.path.exists(out_mp4):
807
- post['video'] = '/api/ai/short-file/' + post_id + suffix
808
- post['short_voice'] = voice
809
- post['short_emotion'] = emotion
810
- post['short_speed'] = speed
811
- post['short_segments'] = segments
812
- post['short_subtitles'] = False
813
- base._save_ai_wall(posts)
814
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
815
- if base.gTTS is None:
816
- return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
817
-
818
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
819
- os.makedirs(work, exist_ok=True)
820
- img = os.path.join(work, 'image.jpg')
821
  try:
822
- base._download_image(post.get('img'), post.get('title', 'AI news'), img)
823
- edge_voice = {
824
- # Vietnamese
825
- 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
826
- 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
827
- 'hoaimy': 'vi-VN-HoaiMyNeural',
828
- 'namminh': 'vi-VN-NamMinhNeural',
829
- 'nam': 'vi-VN-NamMinhNeural',
830
- 'male': 'vi-VN-NamMinhNeural',
831
- 'nu': 'vi-VN-HoaiMyNeural',
832
- 'female': 'vi-VN-HoaiMyNeural',
833
- 'mien-nam': 'vi-VN-HoaiMyNeural',
834
- # English - Multilingual
835
- 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
836
- 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
837
- 'andrew': 'en-US-AndrewMultilingualNeural',
838
- 'en_andrew': 'en-US-AndrewMultilingualNeural',
839
- 'jenny': 'en-US-AndrewMultilingualNeural',
840
- 'en_jenny': 'en-US-AndrewMultilingualNeural',
841
- # Portuguese - Multilingual (ONLY Thalita)
842
- 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
843
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
844
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
845
- 'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
846
- 'pt': 'pt-BR-ThalitaMultilingualNeural',
847
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
848
- # French - Multilingual
849
- 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
850
- 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
851
- 'denise': 'fr-FR-VivienneMultilingualNeural',
852
- 'fr': 'fr-FR-VivienneMultilingualNeural',
853
- 'fr_denise': 'fr-FR-VivienneMultilingualNeural',
854
- # German - Multilingual
855
- 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
856
- 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
857
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
858
- 'de': 'de-DE-SeraphinaMultilingualNeural',
859
- 'de_katja': 'de-DE-SeraphinaMultilingualNeural',
860
- # Korean - Multilingual (Hyunsu, NOT SunHee)
861
- 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
862
- 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
863
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
864
- 'ko': 'ko-KR-HyunsuMultilingualNeural',
865
- 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
866
- # Italian - Multilingual
867
- 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
868
- # Spanish (keep for backward compat)
869
- 'ela': 'en-US-AndrewMultilingualNeural',
870
- 'es_ela': 'en-US-AndrewMultilingualNeural',
871
- 'es': 'en-US-AndrewMultilingualNeural',
872
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
873
- # Japanese (keep for backward compat)
874
- 'nanami': 'en-US-AndrewMultilingualNeural',
875
- 'ja': 'en-US-AndrewMultilingualNeural',
876
- 'ja_nanami': 'en-US-AndrewMultilingualNeural',
877
- # Chinese (keep for backward compat)
878
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
879
- 'zh': 'en-US-AndrewMultilingualNeural',
880
- 'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
881
- }.get(voice, 'vi-VN-HoaiMyNeural')
882
- part_files=[]
883
- for idx, seg in enumerate(segments):
884
- frame=os.path.join(work,f'frame_{idx:02d}.jpg')
885
- aud=os.path.join(work,f'voice_{idx:02d}.mp3')
886
- aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
887
- part=os.path.join(work,f'part_{idx:02d}.mp4')
888
- _make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
889
- spoken=_emotion_script(seg, emotion)
890
- try:
891
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
892
- except Exception:
893
- tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
894
- try:
895
- base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
896
- except TypeError:
897
- base.gTTS(spoken, lang='vi', slow=False).save(aud)
898
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
899
- dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
900
- subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
901
- part_files.append(part)
902
- concat=os.path.join(work,'concat.txt')
903
- with open(concat,'w',encoding='utf-8') as f:
904
- for p in part_files:
905
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
906
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
907
- post['video'] = '/api/ai/short-file/' + post_id + suffix
908
- post['short_voice'] = voice
909
- post['short_emotion'] = emotion
910
- post['short_speed'] = speed
911
- post['short_segments'] = segments
912
- post['short_subtitles'] = False
913
- base._save_ai_wall(posts)
914
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
915
- except Exception as e:
916
- return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
917
-
918
-
919
- @app.get('/api/ai/short-file/{file_id}')
920
- def patched_ai_short_file(file_id: str):
921
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
922
- if not os.path.exists(path):
923
- return JSONResponse({'error': 'not found'}, status_code=404)
924
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
925
-
926
-
927
- @app.get('/api/ai_shorts')
928
- def api_ai_shorts():
929
- posts = [p for p in base._load_ai_wall() if p.get('video')]
930
- return JSONResponse({'posts': posts[:80]})
931
-
932
 
933
- app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
 
 
 
7
  import subprocess
8
  import requests
9
  import hashlib
10
+ from datetime import datetime, timezone
11
  import ai_ext as base
12
  from ai_ext import app
13
  from fastapi import Request
 
20
  except Exception:
21
  Image = ImageDraw = ImageFont = None
22
 
23
+ _STOP = set('và của các những một được trong với cho tại sau trước khi không người vietnam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
24
 
25
  def _clean(s):
26
+ """Clean text - replace None with empty string, collapse whitespace."""
27
+ s = s or ''
28
+ s = s.strip()
29
+ s = re.sub(r'\s+', ' ', s)
 
 
 
 
 
 
 
 
 
 
 
 
30
  return s
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _postprocess_ai_text(text, max_units=20):
33
  text = _clean(text)
34
+ # Replace malformed HTML artifacts with time label
35
+ now = datetime.now(timezone.utc).hour + 7 # Convert to VN time
36
+ now = now % 24
37
+ if now == 7: tl = "7h sáng"
38
+ elif now == 13: tl = "13h trưa"
39
+ else: tl = "19h tối"
40
+ orig_label = '<a href=" src="'
41
+ text = text.replace(orig_label, f"Tin tóm tắt VNEWS {tl}")
42
  if not text:
43
  return text
44
  # Remove malformed HTML with both href and src attributes (e.g., <a href="..." src="...">)
 
49
  text = re.sub(r'<[^>]+>', '', text) # Any remaining HTML tags
50
  text = re.sub(r'</a>', '', text, flags=re.I)
51
  drop_prefixes = (
52
+ 'Bản tin VNEWS',
53
+ 'Bản tin AI',
54
+ 'Bản tin tổng hợp',
55
+ 'Tin tóm tắc VNEWS',
56
+ 'Bản tin tin tức',
57
+ 'Bản tin VnExpress',
58
+ 'Tóm tắc tin',
59
+ 'Tóm tắc nhanh',
60
+ 'Bản tin tóm tắc',
61
+ 'Tóm tắc hôm nay',
62
+ 'Tóm tắc nổi bật',
63
+ 'Bản tin nhanh',
64
+ 'Bản tin 24h',
65
+ 'Bản tin sáng',
66
+ 'Bản tin chiều',
67
+ 'Bản tin 7h',
68
+ 'Bản tin 13h',
69
+ 'Bản tin 19h',
70
+ 'Tóm tắc VNEWS',
71
+ 'Tóm tắc AI',
72
+ 'VNEWS - Bản tin',
73
+ 'VNEWS - Tóm tắc',
74
+ 'VNEWS Bản tin',
75
+ 'VNEWS Tóm tắc',
76
+ 'Tóm tắc AI VNEWS',
77
+ 'Bản tin tổng hợp VNEWS',
78
+ 'Bản tin VNEWS hàng ngày',
79
+ 'Tóm tắc nhanh hôm nay',
80
+ 'Bản tin hôm nay',
81
+ 'Tóm tắc 24h',
82
+ 'Tóm tắc nhanh VNEWS',
83
+ 'Bản tin phân tích',
84
+ 'Tóm tắc phân tích',
85
  )
86
+ for p in drop_prefixes:
87
+ if text.startswith(p):
88
+ text = text[len(p):].lstrip(' -\n')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  break
90
+ text = re.sub(r'^\s*[-•]\s*', '', text)
91
+ units = text.split(' ')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  out = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  seen = set()
94
+ for u in units:
95
+ u = _clean(u)
96
+ if not u:
97
  continue
98
+ w = tuple(re.findall(r"[\w']+", u.lower()))
99
+ if w in seen:
 
 
 
 
 
 
 
 
 
 
 
 
100
  continue
101
+ seen.add(w)
102
+ out.append(u)
103
+ if len(out) >= max_units:
104
+ break
105
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
  def _scrape_article_images(url):
108
+ sents = []; imgs = []; og = ''
109
  try:
110
+ if url:
111
+ r = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=8)
112
+ r.encoding = 'utf-8'
113
+ soup = BeautifulSoup(r.text, 'lxml')
114
+ og = soup.find('meta', property='og:image')
115
+ og = og.get('content', '') if og else ''
116
+ for p in soup.find_all('p'):
117
+ t = _clean(p.get_text())
118
+ if len(t) > 30:
119
+ sents.append(t)
120
+ for im in soup.find_all('img'):
121
+ src = im.get('src', '')
122
+ if src:
123
+ imgs.append(src)
124
+ return {'paragraphs': sents, 'images': imgs, 'og_img': og}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  except Exception as e:
126
+ return {'paragraphs': [], 'images': [], 'og_img': ''}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
+ def _extract_key_points_for_slides(paragraphs, max_points=8):
129
+ pts = []
130
+ for p in paragraphs:
131
+ if len(p) < 40:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  continue
133
+ s = _clean(p)
134
+ if s and s not in pts:
135
+ pts.append(s)
136
+ if len(pts) >= max_points:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  break
138
+ return pts
139
+
140
+ def _make_summary_prompt(title, body, source):
141
+ topic = source or "báo chính"
142
+ return f"""Tóm tắc tin tức: {title}
143
+ Nguồn: {topic}
144
+ ---
145
+ {body[:1200] if body else ''}
146
+ ---
147
+ Hãy viết tóm tắc ngắn gọn, súc tích bằng tiếng Việt. Dùng bullet point (•) để liệt kê điểm chính. Không dùng định dạng HTML. Không lặp lại tiêu đề. Không viết "Bản tin VNEWS" hay tiêu đề phụ."""
148
+
149
+ def _source_line(sources=None):
150
+ if not sources:
151
+ return "Nguồn tham khảo: VNEWS"
152
+ src = sources[0] if sources else {}
153
+ title = src.get('title', '')
154
+ url = src.get('url', '')
155
+ via = src.get('via', '')
156
+ if url and via:
157
+ return f"Nguồn: [{via}]({url})"
158
+ if url:
159
+ return f"Nguồn: {url}"
160
+ if via:
161
+ return f"Nguồn: {via}"
162
+ return "Nguồn tham khảo: VNEWS"
163
+
164
+ def _summary_segments_from_post(post, max_segments=15):
165
+ text = post.get('text', '')
166
+ segs = []
167
+ if text:
168
+ for line in text.split('\n'):
169
+ line = _clean(line)
170
+ if line:
171
+ segs.append(line)
172
+ return segs[:max_segments]
173
 
174
  def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
175
+ from PIL import Image, ImageDraw, ImageFont
176
+ sz = (1080, 1920)
177
+ bg = Image.new('RGB', sz, (15, 23, 38))
178
+ d = ImageDraw.Draw(bg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  try:
180
+ f = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 48)
181
+ fb = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 56)
182
+ except:
183
+ f = ImageFont.load_default()
184
+ fb = f
185
+ # Draw title bar
186
+ d.rectangle([0, 0, sz[0], 120], fill=(25, 118, 210))
187
+ title = post.get('title', '')[:60]
188
+ d.text((sz[0]//2, 60), title, fill='white', font=fb, anchor='mm')
189
+ # Draw segment text
190
+ y = 180
191
+ for line in segment[:120]:
192
+ y += 56
193
+ if y > sz[1] - 100:
194
+ break
195
+ d.text((80, y), line, fill='white', font=f)
196
+ bg.save(out_path, quality=85)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
+ @app.get('/health')
199
+ async def health():
200
+ return JSONResponse({'status': 'ok'})