bep40 commited on
Commit
d05bd1b
·
verified ·
1 Parent(s): ca8c13b

Restore ai_patch.py from c93b544

Browse files
Files changed (1) hide show
  1. ai_patch.py +712 -0
ai_patch.py ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ import random
5
+ import json
6
+ import html as html_lib
7
+ import subprocess
8
+ import requests
9
+ import ai_ext as base
10
+ from ai_ext import app
11
+ from fastapi import Request
12
+ from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
13
+ from bs4 import BeautifulSoup
14
+ from urllib.parse import quote_plus
15
+
16
+ try:
17
+ from PIL import Image, ImageDraw, ImageFont
18
+ except Exception:
19
+ Image = ImageDraw = ImageFont = None
20
+
21
+
22
+ def _clean(s):
23
+ s = html_lib.unescape(s or "")
24
+ s = re.sub(r"[ \t]+", " ", s)
25
+ s = re.sub(r"\n{3,}", "\n\n", s)
26
+ return s.strip()
27
+
28
+
29
+ def _norm(s):
30
+ s = s.lower()
31
+ s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
32
+ s = re.sub(r"\s+", " ", s).strip()
33
+ return s
34
+
35
+
36
+ def _similar(a, b):
37
+ ta = set(_norm(a).split())
38
+ tb = set(_norm(b).split())
39
+ if not ta or not tb:
40
+ return False
41
+ return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
42
+
43
+
44
+ def _dedupe_units(units, max_units=7):
45
+ out, seen = [], set()
46
+ for u in units:
47
+ u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
48
+ if len(u) < 18:
49
+ continue
50
+ nu = _norm(u)
51
+ if nu in seen:
52
+ continue
53
+ if any(_similar(u, old) for old in out):
54
+ continue
55
+ seen.add(nu)
56
+ out.append(u)
57
+ if len(out) >= max_units:
58
+ break
59
+ return out
60
+
61
+
62
+ def _postprocess_ai_text(text, max_units=7):
63
+ text = _clean(text)
64
+ if not text:
65
+ return text
66
+ drop_prefixes = (
67
+ "dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
68
+ "tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
69
+ )
70
+ raw_lines = []
71
+ for line in re.split(r"\n+", text):
72
+ line = _clean(line)
73
+ if not line:
74
+ continue
75
+ low = line.lower().strip()
76
+ if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
77
+ continue
78
+ raw_lines.append(line)
79
+ units = []
80
+ for line in raw_lines:
81
+ if len(line) > 260:
82
+ units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
83
+ else:
84
+ units.append(line)
85
+ units = _dedupe_units(units, max_units=max_units)
86
+ if not units:
87
+ return text[:900]
88
+ title = ""
89
+ if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
90
+ title = raw_lines[0]
91
+ units = [u for u in units if not _similar(u, title)]
92
+ body = "\n".join("• " + u for u in units[:max_units])
93
+ return (title + "\n\n" + body).strip() if title else body
94
+
95
+
96
+ def _fallback_summary_from_prompt(prompt, max_units=6):
97
+ text = prompt or ""
98
+ 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:"]:
99
+ if marker in text:
100
+ text = text.split(marker, 1)[1]
101
+ break
102
+ text = re.sub(r"https?://\S+", "", text)
103
+ text = re.sub(r"\s+", " ", text).strip()
104
+ sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
105
+ candidates = []
106
+ for s in sentences:
107
+ s = _clean(s)
108
+ if 45 <= len(s) <= 260:
109
+ candidates.append(s)
110
+ units = _dedupe_units(candidates, max_units=max_units)
111
+ if units:
112
+ return "\n".join("• " + u for u in units)
113
+ if text:
114
+ return "• " + text[:700].rsplit(" ", 1)[0]
115
+ return "• Không có đủ nội dung nguồn để tóm tắt."
116
+
117
+
118
+ def _source_line(sources):
119
+ names = []
120
+ for s in (sources or [])[:5]:
121
+ via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
122
+ if via and via not in names:
123
+ names.append(via)
124
+ return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
125
+
126
+
127
+ def _make_summary_prompt(title, raw, source_hint=""):
128
+ return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
129
+
130
+ NHIỆM VỤ BẮT BUỘC:
131
+ - Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
132
+ - Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
133
+ - Không thêm thông tin ngoài nguồn.
134
+ - Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
135
+ - Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
136
+ - Không viết phần mở bài dài, không viết văn kể lại.
137
+
138
+ Tiêu đề nguồn: {title}
139
+ Nguồn: {source_hint}
140
+
141
+ Nội dung nguồn:
142
+ {raw[:14000]}
143
+ """
144
+
145
+
146
+ def _direct_news_rss(topic, limit=10):
147
+ out = []
148
+ try:
149
+ url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
150
+ r = requests.get(url, headers=base.HEADERS, timeout=15)
151
+ r.encoding = "utf-8"
152
+ soup = BeautifulSoup(r.text, "xml")
153
+ for it in soup.find_all("item")[:limit]:
154
+ title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
155
+ link = it.find("link").get_text(strip=True) if it.find("link") else ""
156
+ src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
157
+ if title and link:
158
+ out.append({"title": title, "url": link, "via": src, "excerpt": title})
159
+ except Exception:
160
+ pass
161
+ return out
162
+
163
+
164
+ def _topic_source_articles(topic, limit=5):
165
+ """Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
166
+ try:
167
+ _ctx, sources = base.web_context(topic, limit=limit)
168
+ except Exception:
169
+ sources = []
170
+ if not sources:
171
+ sources = _direct_news_rss(topic, limit=10)
172
+ out, seen = [], set()
173
+ for s in (sources or [])[:limit * 3]:
174
+ url = s.get("url") or ""
175
+ if not url.startswith("http") or url in seen:
176
+ continue
177
+ seen.add(url)
178
+ try:
179
+ page = base.scrape_any_url(url)
180
+ raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
181
+ if len(raw) < 180:
182
+ continue
183
+ title = page.get("title") or s.get("title") or url
184
+ via = page.get("via") or s.get("via") or base._domain(url)
185
+ out.append({
186
+ "title": title,
187
+ "url": url,
188
+ "raw": raw,
189
+ "image": page.get("image") or "",
190
+ "via": via,
191
+ "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
192
+ })
193
+ if len(out) >= limit:
194
+ break
195
+ except Exception:
196
+ continue
197
+ if not out:
198
+ for s in (sources or _direct_news_rss(topic, 6))[:limit]:
199
+ title = s.get("title") or topic
200
+ excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
201
+ url = s.get("url", "")
202
+ via = s.get("via") or base._domain(url)
203
+ out.append({
204
+ "title": title,
205
+ "url": url,
206
+ "raw": excerpt,
207
+ "image": base.pollinations_image_url(title),
208
+ "via": via,
209
+ "source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
210
+ })
211
+ return out[:limit]
212
+
213
+
214
+ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
215
+ errors = []
216
+ token = base._hf_token()
217
+ try:
218
+ original = getattr(base, "_original_qwen_generate", None)
219
+ if original:
220
+ txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
221
+ if txt:
222
+ base.LAST_QWEN_ERROR = ""
223
+ return txt
224
+ if getattr(base, "LAST_QWEN_ERROR", ""):
225
+ errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
226
+ except Exception as e:
227
+ errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
228
+ if token:
229
+ models = []
230
+ for m in [
231
+ os.getenv("QWEN_VL_MODEL", ""),
232
+ "Qwen/Qwen2.5-VL-7B-Instruct",
233
+ "Qwen/Qwen2.5-VL-3B-Instruct",
234
+ "Qwen/Qwen2.5-7B-Instruct",
235
+ "Qwen/Qwen2.5-3B-Instruct",
236
+ "Qwen/Qwen2.5-1.5B-Instruct",
237
+ ]:
238
+ if m and m not in models:
239
+ models.append(m)
240
+ headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
241
+ for model in models:
242
+ try:
243
+ is_vl = "VL" in model and bool(image_url)
244
+ user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
245
+ payload = {
246
+ "model": model,
247
+ "messages": [
248
+ {"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."},
249
+ {"role": "user", "content": user_content},
250
+ ],
251
+ "max_tokens": min(int(max_tokens or 900), 1400),
252
+ "temperature": 0.35,
253
+ "top_p": 0.85,
254
+ }
255
+ r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
256
+ if r.status_code >= 300:
257
+ errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
258
+ continue
259
+ j = r.json()
260
+ txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
261
+ if txt:
262
+ base.LAST_QWEN_ERROR = ""
263
+ return txt
264
+ errors.append(f"{model}: empty response")
265
+ except Exception as e:
266
+ errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
267
+ else:
268
+ errors.append("missing HF_TOKEN")
269
+ base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
270
+ print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
271
+ return _fallback_summary_from_prompt(prompt, max_units=6)
272
+
273
+
274
+ if not hasattr(base, "_original_qwen_generate"):
275
+ base._original_qwen_generate = base.qwen_generate
276
+ base.qwen_generate = qwen_generate_resilient
277
+
278
+
279
+ @app.get('/api/wall')
280
+ def compat_wall():
281
+ return JSONResponse({'posts': base._load_ai_wall()[:80]})
282
+
283
+
284
+ _PATCHED_PATHS = {
285
+ ('/api/topic_post', 'POST'),
286
+ ('/api/url_wall', 'POST'),
287
+ ('/api/rewrite_share', 'POST'),
288
+ ('/api/ai/short/{post_id}', 'POST'),
289
+ }
290
+ app.router.routes = [
291
+ r for r in app.router.routes
292
+ if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
293
+ ]
294
+
295
+
296
+ @app.post('/api/topic_post')
297
+ async def compat_topic_post(request: Request):
298
+ body = await request.json()
299
+ topic = base._clean_text(body.get('topic', ''))
300
+ if not topic:
301
+ return JSONResponse({'error': 'missing topic'}, status_code=400)
302
+ articles = _topic_source_articles(topic, limit=4)
303
+ if not articles:
304
+ return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
305
+ new_posts = []
306
+ posts = base._load_ai_wall()
307
+ for art in articles:
308
+ prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
309
+
310
+ Chủ đề lọc: {topic}
311
+ Tiêu đề bài nguồn: {art['title']}
312
+ Nguồn: {art['via']}
313
+
314
+ Yêu cầu bắt buộc:
315
+ - Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
316
+ - Không trộn với bài khác.
317
+ - Không viết lại toàn bộ bài.
318
+ - Không lặp ý.
319
+ - 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
320
+ - Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
321
+
322
+ Nội dung bài:
323
+ {art['raw'][:14000]}"""
324
+ text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
325
+ text = _postprocess_ai_text(text, max_units=6)
326
+ src = [art['source']]
327
+ if 'Nguồn tham khảo:' not in text:
328
+ text += "\n\n" + _source_line(src)
329
+ 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)
330
+ new_posts.append(post)
331
+ posts = new_posts + posts
332
+ base._save_ai_wall(posts)
333
+ return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
334
+
335
+
336
+ @app.post('/api/url_wall')
337
+ async def compat_url_wall(request: Request):
338
+ body = await request.json()
339
+ url = base._clean_text(body.get('url', ''))
340
+ if not url.startswith('http'):
341
+ return JSONResponse({'error': 'missing url'}, status_code=400)
342
+ try:
343
+ data = base.scrape_any_url(url)
344
+ except Exception as e:
345
+ return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
346
+ raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
347
+ if len(raw) < 120:
348
+ return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
349
+ prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
350
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
351
+ text = _postprocess_ai_text(text, max_units=6)
352
+ src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
353
+ if 'Nguồn tham khảo:' not in text:
354
+ text += "\n\n" + _source_line(src)
355
+ post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
356
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
357
+ return JSONResponse({'post': post})
358
+
359
+
360
+ @app.post('/api/rewrite_share')
361
+ async def compat_rewrite_share(request: Request):
362
+ body = await request.json()
363
+ url = base._clean_text(body.get('url', ''))
364
+ if not url.startswith('http'):
365
+ return JSONResponse({'error': 'missing url'}, status_code=400)
366
+ try:
367
+ data = base.scrape_any_url(url)
368
+ except Exception as e:
369
+ return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
370
+ raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
371
+ if len(raw) < 120:
372
+ return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
373
+ prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
374
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
375
+ text = _postprocess_ai_text(text, max_units=6)
376
+ src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
377
+ if 'Nguồn tham khảo:' not in text:
378
+ text += "\n\n" + _source_line(src)
379
+ post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
380
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
381
+ return JSONResponse({'post': post})
382
+
383
+
384
+ def _emotion_script(text, emotion):
385
+ text = _clean(text)
386
+ if emotion == 'urgent':
387
+ return 'Tin nhanh. ' + text
388
+ if emotion == 'warm':
389
+ return 'Câu chuyện đáng chú ý. ' + text
390
+ if emotion == 'serious':
391
+ return 'Bản tin nghiêm túc. ' + text
392
+ if emotion == 'energetic':
393
+ return 'Cập nhật nổi bật. ' + text
394
+ return text
395
+
396
+
397
+ def _tts_script_smart(post, emotion):
398
+ raw = base._short_script(post)
399
+ raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
400
+ raw = re.sub(r"\s*\n\s*", ". ", raw)
401
+ raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
402
+ raw = re.sub(r"\n{2,}", "\n", raw).strip()
403
+ raw = _emotion_script(raw, emotion)
404
+ if len(raw) > 1000:
405
+ raw = raw[:1000]
406
+ cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
407
+ if cut > 350:
408
+ raw = raw[:cut + 1]
409
+ return raw
410
+
411
+
412
+ def _split_subtitle_sentences(script):
413
+ parts = []
414
+ for line in script.splitlines():
415
+ line = _clean(line)
416
+ if not line:
417
+ continue
418
+ for s in re.split(r"(?<=[\.\!\?])\s+", line):
419
+ s = _clean(s)
420
+ if 8 <= len(s) <= 140:
421
+ parts.append(s)
422
+ return parts[:12]
423
+
424
+
425
+ def _srt_time(sec):
426
+ ms = int((sec - int(sec)) * 1000)
427
+ sec = int(sec)
428
+ h = sec // 3600
429
+ m = (sec % 3600) // 60
430
+ s = sec % 60
431
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
432
+
433
+
434
+ def _write_srt(script, path, total_duration=30):
435
+ subs = _split_subtitle_sentences(script)
436
+ if not subs:
437
+ subs = [script[:120]]
438
+ dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
439
+ cur = 0.3
440
+ with open(path, 'w', encoding='utf-8') as f:
441
+ for i, s in enumerate(subs, 1):
442
+ start = cur
443
+ end = cur + dur
444
+ cur = end + 0.15
445
+ f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
446
+
447
+
448
+ def _wrap_text_px(draw, text, font, max_width, max_lines):
449
+ words = _clean(text).split()
450
+ lines, cur = [], ""
451
+ for w in words:
452
+ test = (cur + " " + w).strip()
453
+ try:
454
+ width = draw.textbbox((0, 0), test, font=font)[2]
455
+ except Exception:
456
+ width = len(test) * 20
457
+ if width <= max_width:
458
+ cur = test
459
+ else:
460
+ if cur:
461
+ lines.append(cur)
462
+ cur = w
463
+ if len(lines) >= max_lines:
464
+ break
465
+ if cur and len(lines) < max_lines:
466
+ lines.append(cur)
467
+ return lines
468
+
469
+
470
+ def _make_short_frame_full(post, img_path, out_path):
471
+ if Image is None:
472
+ return base._make_short_frame(post, img_path, out_path)
473
+ W, H = 1080, 1920
474
+ bg = Image.new("RGB", (W, H), (14, 14, 14))
475
+ try:
476
+ im = Image.open(img_path).convert("RGB")
477
+ target = (1080, 760)
478
+ im_ratio = im.width / im.height
479
+ target_ratio = target[0] / target[1]
480
+ if im_ratio > target_ratio:
481
+ new_h = target[1]
482
+ new_w = int(new_h * im_ratio)
483
+ else:
484
+ new_w = target[0]
485
+ new_h = int(new_w / im_ratio)
486
+ im = im.resize((new_w, new_h))
487
+ left = (new_w - target[0]) // 2
488
+ top = (new_h - target[1]) // 2
489
+ im = im.crop((left, top, left + target[0], top + target[1]))
490
+ bg.paste(im, (0, 0))
491
+ except Exception:
492
+ pass
493
+ draw = ImageDraw.Draw(bg)
494
+ try:
495
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
496
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
497
+ font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
498
+ except Exception:
499
+ font_title = font_body = font_label = None
500
+ draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
501
+ margin = 48
502
+ maxw = W - margin * 2
503
+ draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
504
+ y = 830
505
+ for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
506
+ draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
507
+ y += 66
508
+ y += 18
509
+ text = post.get("text", "")
510
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
511
+ body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
512
+ for ln in body_lines:
513
+ draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
514
+ y += 50
515
+ if y > 1640:
516
+ break
517
+ bg.save(out_path, quality=92)
518
+
519
+
520
+
521
+
522
+ def _summary_segments_from_post(post, max_segments=7):
523
+ raw = _clean(post.get('text') or post.get('title') or '')
524
+ raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
525
+ raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
526
+ lines=[]
527
+ for ln in raw.splitlines():
528
+ ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
529
+ if not ln: continue
530
+ low=ln.lower()
531
+ if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
532
+ if len(ln)>=18: lines.append(ln)
533
+ if len(lines)<2:
534
+ lines=[]
535
+ for s in re.split(r'(?<=[\.\!\?])\s+', raw):
536
+ s=_clean(s)
537
+ if len(s)>=25: lines.append(s)
538
+ segs=_dedupe_units(lines, max_units=max_segments)
539
+ return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
540
+
541
+
542
+ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
543
+ if Image is None:
544
+ return _make_short_frame_full(post, img_path, out_path)
545
+ W,H=1080,1920
546
+ bg=Image.new('RGB',(W,H),(10,10,10))
547
+ try:
548
+ im=Image.open(img_path).convert('RGB')
549
+ ratio=im.width/max(1,im.height); target=W/H
550
+ if ratio>target:
551
+ nh=H; nw=int(nh*ratio)
552
+ else:
553
+ nw=W; nh=int(nw/ratio)
554
+ cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
555
+ cover=cover.crop((left,top,left+W,top+H))
556
+ bg.paste(cover,(0,0))
557
+ bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
558
+ hero_h=720; target=W/hero_h
559
+ if ratio>target:
560
+ nh=hero_h; nw=int(nh*ratio)
561
+ else:
562
+ nw=W; nh=int(nw/ratio)
563
+ hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
564
+ hero=hero.crop((left,top,left+W,top+hero_h))
565
+ bg.paste(hero,(0,0))
566
+ except Exception:
567
+ pass
568
+ draw=ImageDraw.Draw(bg)
569
+ try:
570
+ font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
571
+ font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
572
+ font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
573
+ font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
574
+ except Exception:
575
+ font_brand=font_small=font_seg=font_title=None
576
+ draw.rectangle((0,680,W,H), fill=(12,12,12))
577
+ dot_x=48; dot_y=742
578
+ for i in range(total):
579
+ fill=(92,184,122) if i==idx else (70,70,70)
580
+ draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
581
+ draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
582
+ draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
583
+ draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
584
+ y=940; maxw=W-96
585
+ for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
586
+ draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
587
+ y+=74
588
+ if y>1500: break
589
+ y2=1640
590
+ draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
591
+ for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
592
+ draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
593
+ y2+=46
594
+ bg.save(out_path, quality=92)
595
+
596
+
597
+ def _estimate_audio_duration(path, fallback=4.0):
598
+ try:
599
+ pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
600
+ return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
601
+ except Exception:
602
+ return fallback
603
+
604
+
605
+ @app.post('/api/ai/short/{post_id}')
606
+ async def patched_ai_short(post_id: str, request: Request):
607
+ try:
608
+ body = await request.json()
609
+ except Exception:
610
+ body = {}
611
+ voice = str(body.get('voice', 'nu')).strip().lower()
612
+ emotion = str(body.get('emotion', 'neutral')).strip().lower()
613
+ speed = float(body.get('speed', 1.2) or 1.2)
614
+ speed = max(0.85, min(1.35, speed))
615
+
616
+ posts = base._load_ai_wall()
617
+ post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
618
+ if not post:
619
+ return JSONResponse({'error': 'post not found'}, status_code=404)
620
+
621
+ segments = _summary_segments_from_post(post, max_segments=7)
622
+ seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
623
+ os.makedirs(base.SHORTS_DIR, exist_ok=True)
624
+ suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
625
+ out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
626
+ if os.path.exists(out_mp4):
627
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
628
+ post['short_voice'] = voice
629
+ post['short_emotion'] = emotion
630
+ post['short_speed'] = speed
631
+ post['short_segments'] = segments
632
+ post['short_subtitles'] = False
633
+ base._save_ai_wall(posts)
634
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
635
+ if base.gTTS is None:
636
+ return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
637
+
638
+ work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
639
+ os.makedirs(work, exist_ok=True)
640
+ img = os.path.join(work, 'image.jpg')
641
+ try:
642
+ base._download_image(post.get('img'), post.get('title', 'AI news'), img)
643
+ edge_voice = {
644
+ 'nam': 'vi-VN-NamMinhNeural',
645
+ 'male': 'vi-VN-NamMinhNeural',
646
+ 'nu': 'vi-VN-HoaiMyNeural',
647
+ 'female': 'vi-VN-HoaiMyNeural',
648
+ 'mien-nam': 'vi-VN-HoaiMyNeural',
649
+ }.get(voice, 'vi-VN-HoaiMyNeural')
650
+ part_files=[]
651
+ for idx, seg in enumerate(segments):
652
+ frame=os.path.join(work,f'frame_{idx:02d}.jpg')
653
+ aud=os.path.join(work,f'voice_{idx:02d}.mp3')
654
+ aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
655
+ part=os.path.join(work,f'part_{idx:02d}.mp4')
656
+ _make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
657
+ spoken=_emotion_script(seg, emotion)
658
+ try:
659
+ subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
660
+ except Exception:
661
+ tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
662
+ try:
663
+ base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
664
+ except TypeError:
665
+ base.gTTS(spoken, lang='vi', slow=False).save(aud)
666
+ subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
667
+ dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
668
+ 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)
669
+ part_files.append(part)
670
+ concat=os.path.join(work,'concat.txt')
671
+ with open(concat,'w',encoding='utf-8') as f:
672
+ for p in part_files:
673
+ f.write("file '" + p.replace("'", "'\\''") + "'\n")
674
+ subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
675
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
676
+ post['short_voice'] = voice
677
+ post['short_emotion'] = emotion
678
+ post['short_speed'] = speed
679
+ post['short_segments'] = segments
680
+ post['short_subtitles'] = False
681
+ base._save_ai_wall(posts)
682
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
683
+ except Exception as e:
684
+ return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
685
+
686
+
687
+ @app.get('/api/ai/short-file/{file_id}')
688
+ def patched_ai_short_file(file_id: str):
689
+ path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
690
+ if not os.path.exists(path):
691
+ return JSONResponse({'error': 'not found'}, status_code=404)
692
+ return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
693
+
694
+
695
+ @app.get('/api/ai_shorts')
696
+ def api_ai_shorts():
697
+ posts = [p for p in base._load_ai_wall() if p.get('video')]
698
+ return JSONResponse({'posts': posts[:80]})
699
+
700
+
701
+ app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
702
+
703
+ PATCH_INJECT = r'''
704
+ <style>.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.ai-short-card{flex:0 0 145px}.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}.ai-short-video video{width:100%;height:100%;object-fit:cover}.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}.ai-short-progress.active{display:flex}.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}.ai-short-box h3{color:#5cb87a;margin-bottom:10px}.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}</style>
705
+ <div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
706
+ <script>
707
+ (function(){
708
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
709
+ let patchedWall=[];let aiShorts=[];
710
+ function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
711
+ function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
712
+ function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}