bep40 commited on
Commit
c726397
·
verified ·
1 Parent(s): ea1ac49

Upload ai_ext.py

Browse files
Files changed (1) hide show
  1. ai_ext.py +11 -385
ai_ext.py CHANGED
@@ -96,27 +96,20 @@ def _domain(url: str) -> str:
96
 
97
 
98
  async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
99
- """Generate text using Qwen models via Hugging Face Inference API.
100
 
101
- This function provides a resilient implementation that:
102
- 1. First tries the SDK-based inference client if available
103
- 2. Falls back to REST API calls to HF router endpoint
104
- 3. Returns a fallback summary if all else fails
105
  """
106
  token = _hf_token()
107
  errors = []
108
 
109
- # Try HF router API with multiple models
110
  if token:
111
  models = [
112
  os.getenv("QWEN_VL_MODEL", ""),
 
113
  "Qwen/Qwen2.5-VL-7B-Instruct",
114
- "Qwen/Qwen2.5-VL-3B-Instruct",
115
- "Qwen/Qwen2.5-7B-Instruct",
116
- "Qwen/Qwen2.5-3B-Instruct",
117
- "Qwen/Qwen2.5-1.5B-Instruct",
118
  "Qwen/Qwen2.5-72B-Instruct",
119
- "meta-llama/Llama-3.3-70B-Instruct",
120
  ]
121
  # Deduplicate while preserving order
122
  seen = set()
@@ -138,12 +131,12 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
138
  payload = {
139
  "model": model,
140
  "messages": [
141
- {"role": "system", "content": "Bạn là nhà báo chuyên nghiệp tiếng Việt. Hãy viết bài phân tích dựa trên quan điểm cá nhân được cung cấp, kết hợp với nguồn tin tham khảo. Ưu tiên QUAN ĐIỂM CÁ NHÂN hơn là sao chép nguồn tin."},
142
  {"role": "user", "content": user_content},
143
  ],
144
- "max_tokens": min(int(max_tokens or 2500), 3000),
145
- "temperature": 0.85,
146
- "top_p": 0.85,
147
  }
148
 
149
  r = requests.post(
@@ -183,20 +176,18 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
183
  text = re.sub(r"https?://\S+", "", text)
184
  text = re.sub(r"\s+", " ", text).strip()
185
 
186
- # Split into sentences - extract ALL valid sentences, not just first few
187
  sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
188
  units = []
189
  for s in sentences:
190
  s = _clean_text(s)
191
- if len(s) >= 30: # Lower threshold to capture more content
192
  units.append(s)
193
 
194
  if units:
195
- # Take up to max_units valid sentences
196
  result_units = units[:max_units]
197
  return "\n".join("• " + u for u in result_units)
198
  if text:
199
- # Fallback: take chunks if no sentence boundaries found
200
  chunks = []
201
  for i in range(0, min(len(text), max_units * 300), 280):
202
  chunk = _clean_text(text[i:i+300])
@@ -206,369 +197,4 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
206
  break
207
  if chunks:
208
  return "\n".join("• " + c for c in chunks)
209
- return "• Không có đủ nội dung để tóm tắt."
210
-
211
-
212
- HF_TOKEN = _hf_token()
213
- QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
214
- QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
215
- "QWEN_TEXT_MODELS",
216
- "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
217
- ).split(",") if m.strip()]
218
- _WORKING_MODEL_TEXT = None
219
- _WORKING_MODEL_VL = None
220
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
221
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
222
- HEADERS = {
223
- "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",
224
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
225
- }
226
- LAST_QWEN_ERROR = ""
227
-
228
-
229
- # ===== MULTILINGUAL VOICES FOR TTS =====
230
- # Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
231
- MULTILINGUAL_VOICES = {
232
- # Vietnamese - Native voices
233
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
234
- "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
235
- "hoaimy": "vi-VN-HoaiMyNeural",
236
- "namminh": "vi-VN-NamMinhNeural",
237
- "vi_female": "vi-VN-HoaiMyNeural",
238
- "vi_male": "vi-VN-NamMinhNeural",
239
- "nu": "vi-VN-HoaiMyNeural",
240
- "male": "vi-VN-NamMinhNeural",
241
- "female": "vi-VN-HoaiMyNeural",
242
- "mien-nam": "vi-VN-HoaiMyNeural",
243
- # English - Multilingual
244
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
245
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
246
- "en_andrew": "en-US-AndrewMultilingualNeural",
247
- "andrew": "en-US-AndrewMultilingualNeural",
248
- "en_jenny": "en-US-AndrewMultilingualNeural",
249
- "jenny": "en-US-AndrewMultilingualNeural",
250
- # Portuguese - Thalita Multilingual ONLY
251
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
252
- "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
253
- "thalita": "pt-BR-ThalitaMultilingualNeural",
254
- "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
255
- "pt": "pt-BR-ThalitaMultilingualNeural",
256
- # French - Multilingual
257
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
258
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
259
- "fr_denise": "fr-FR-VivienneMultilingualNeural",
260
- "denise": "fr-FR-VivienneMultilingualNeural",
261
- "fr": "fr-FR-VivienneMultilingualNeural",
262
- # German - Multilingual
263
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
264
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
265
- "de_katja": "de-DE-SeraphinaMultilingualNeural",
266
- "katja": "de-DE-SeraphinaMultilingualNeural",
267
- "de": "de-DE-SeraphinaMultilingualNeural",
268
- # Korean - Hyunsu Multilingual (NOT SunHee)
269
- "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
270
- "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
271
- "sunhee": "ko-KR-HyunsuMultilingualNeural",
272
- "ko": "ko-KR-HyunsuMultilingualNeural",
273
- # Italian - Multilingual
274
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
275
- # Spanish (fallback to English multilingual)
276
- "es_ela": "en-US-AndrewMultilingualNeural",
277
- "ela": "en-US-AndrewMultilingualNeural",
278
- "es_carlos": "en-US-AndrewMultilingualNeural",
279
- "es": "en-US-AndrewMultilingualNeural",
280
- # Japanese (fallback to English multilingual)
281
- "ja_nanami": "en-US-AndrewMultilingualNeural",
282
- "nanami": "en-US-AndrewMultilingualNeural",
283
- "ja": "en-US-AndrewMultilingualNeural",
284
- # Chinese (fallback to English multilingual)
285
- "zh_xiaochen": "en-US-AndrewMultilingualNeural",
286
- "xiaochen": "en-US-AndrewMultilingualNeural",
287
- "zh": "en-US-AndrewMultilingualNeural",
288
- }
289
-
290
-
291
- def _detect_voice_emotion(title, text):
292
- """Detect appropriate voice and emotion based on content for multilingual TTS."""
293
- content = ((title or "") + " " + (text or "")).lower()
294
-
295
- # World Cup / Football content - use Andrew multilingual
296
- if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
297
- return ("andrew", "excited")
298
-
299
- # News categories - choose appropriate voice
300
- if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
301
- return ("jenny", "calm")
302
- if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
303
- return ("thalita", "serious")
304
- if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
305
- return ("ela", "happy")
306
- if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
307
- return ("katja", "excited")
308
-
309
- # Default Vietnamese
310
- return ("hoaimy", "trung_tinh")
311
-
312
-
313
- def _safe_name(s: str) -> str:
314
- """Create safe filename from string."""
315
- s = re.sub(r"[^\w\-.]", "_", s)
316
- return s[:100] if len(s) > 100 else s
317
-
318
-
319
- def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
320
- """Download image from URL to path."""
321
- if not url:
322
- return False
323
- try:
324
- r = requests.get(url, headers=HEADERS, timeout=15)
325
- if r.status_code == 200:
326
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
327
- with open(out_path, "wb") as f:
328
- f.write(r.content)
329
- return True
330
- except Exception:
331
- pass
332
- return False
333
-
334
-
335
- def pollination_image_url(topic: str) -> str:
336
- """Generate image URL from Pollinations.ai."""
337
- return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
338
-
339
-
340
- # Use the same wall file as app_v2_entry.py for consistency
341
- WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
342
-
343
- def _load_ai_wall():
344
- """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
345
- try:
346
- if os.path.exists(WALL_FILE):
347
- with open(WALL_FILE, "r", encoding="utf-8") as f:
348
- return json.load(f)
349
- except Exception:
350
- pass
351
- return []
352
-
353
-
354
- def _save_ai_wall(posts):
355
- """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
356
- try:
357
- os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
358
- tmp = WALL_FILE + ".tmp"
359
- with open(tmp, "w", encoding="utf-8") as f:
360
- json.dump(posts[:100], f, ensure_ascii=False)
361
- os.replace(tmp, WALL_FILE)
362
- except Exception:
363
- pass
364
-
365
-
366
- # Helper functions for wall operations
367
- def _load_wall_posts():
368
- """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
369
- return _load_ai_wall()
370
-
371
-
372
- def _save_wall_posts(posts):
373
- """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
374
- return _save_ai_wall(posts)
375
-
376
-
377
- def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
378
- """Create a post dict with standard fields."""
379
- return {
380
- "id": str(int(time.time() * 1000)),
381
- "title": title,
382
- "text": text,
383
- "img": img,
384
- "url": url,
385
- "kind": kind,
386
- "sources": sources or [],
387
- "ts": int(time.time())
388
- }
389
-
390
-
391
- def _short_script(post) -> str:
392
- """Extract clean text for TTS from post."""
393
- text = post.get("text", "") or post.get("title", "")
394
- text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
395
- text = re.sub(r"\s*\n\s*", ". ", text)
396
- return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
397
-
398
-
399
- # ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
400
- def scrape_any_url(url: str) -> dict:
401
- """Scrape any URL and extract article content.
402
-
403
- Returns dict with: title, summary, text, image, og_image, via (domain)
404
- """
405
- try:
406
- r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
407
- r.encoding = 'utf-8'
408
- soup = BeautifulSoup(r.text, 'lxml')
409
-
410
- # Remove scripts, styles, nav, footer
411
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
412
- tag.decompose()
413
-
414
- # Extract title
415
- h1 = soup.find('h1')
416
- ogt = soup.find('meta', property='og:title')
417
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
418
-
419
- # Extract OG image
420
- ogi = soup.find('meta', property='og:image')
421
- og_image = ogi.get('content', '') if ogi else ''
422
-
423
- # Extract article body
424
- block = None
425
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
426
- el = soup.select_one(sel)
427
- if el and len(el.find_all('p')) >= 2:
428
- block = el
429
- break
430
- if not block:
431
- block = soup.body or soup
432
-
433
- # Extract text from paragraphs
434
- paragraphs = []
435
- for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
436
- t = _clean_text(el.get_text(strip=True))
437
- if t and len(t) > 40:
438
- paragraphs.append(t)
439
-
440
- # Extract images
441
- images = []
442
- for el in block.find_all(['figure', 'img'], recursive=True):
443
- im = el if el.name == 'img' else el.find('img')
444
- if im:
445
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
446
- if src and 'base64' not in src:
447
- if src.startswith('//'):
448
- src = 'https:' + src
449
- images.append(src)
450
-
451
- # Prefer OG image as main image
452
- image = og_image or (images[0] if images else '')
453
-
454
- return {
455
- 'title': title,
456
- 'summary': paragraphs[0] if paragraphs else '',
457
- 'text': '\n'.join(paragraphs),
458
- 'image': image,
459
- 'og_image': og_image,
460
- 'via': _domain(url),
461
- 'images': images
462
- }
463
- except Exception as e:
464
- return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
465
-
466
-
467
- def web_context(topic: str, limit: int = 5) -> tuple:
468
- """Get web context for a topic. Returns (context_text, sources_list)."""
469
- sources = []
470
- try:
471
- # Try Google News RSS
472
- rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
473
- r = requests.get(rss_url, headers=HEADERS, timeout=15)
474
- r.encoding = 'utf-8'
475
- soup = BeautifulSoup(r.text, 'xml')
476
- for it in soup.find_all('item')[:limit]:
477
- title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
478
- link = it.find('link').get_text(strip=True) if it.find('link') else ''
479
- if title and link:
480
- sources.append({'title': title, 'url': link, 'via': _domain(link)})
481
- except Exception:
482
- pass
483
-
484
- context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
485
- return context, sources
486
-
487
-
488
- # ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
489
- def _make_short_frame(post, img_path, out_path):
490
- """Create a short video frame from post and image.
491
-
492
- Called by ai_patch.py _make_short_frame_full when Image is available.
493
- """
494
- if Image is None:
495
- # Create a minimal frame without PIL - just return success
496
- # The caller should handle this case
497
- return False
498
-
499
- W, H = 1080, 1920
500
- bg = Image.new("RGB", (W, H), (14, 14, 14))
501
-
502
- try:
503
- im = Image.open(img_path).convert("RGB")
504
- target = (1080, 760)
505
- im_ratio = im.width / max(1, im.height)
506
- target_ratio = target[0] / target[1]
507
-
508
- if im_ratio > target_ratio:
509
- new_h = target[1]
510
- new_w = int(new_h * im_ratio)
511
- else:
512
- new_w = target[0]
513
- new_h = int(new_w / im_ratio)
514
-
515
- im = im.resize((new_w, new_h))
516
- left = (new_w - target[0]) // 2
517
- top = (new_h - target[1]) // 2
518
- im = im.crop((left, top, left + target[0], top + target[1]))
519
- bg.paste(im, (0, 0))
520
- except Exception:
521
- pass
522
-
523
- draw = ImageDraw.Draw(bg)
524
-
525
- try:
526
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
527
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
528
- except Exception:
529
- font_title = font_body = None
530
-
531
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
532
- margin = 48
533
- maxw = W - margin * 2
534
-
535
- y = 830
536
- for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
537
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
538
- y += 66
539
-
540
- y += 18
541
- text = post.get("text", "")
542
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
543
- body_lines = _wrap_text(draw, text, font_body, maxw, 14)
544
- for ln in body_lines:
545
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
546
- y += 50
547
- if y > 1640:
548
- break
549
-
550
- bg.save(out_path, quality=92)
551
- return True
552
-
553
-
554
- def _wrap_text(draw, text, font, max_width, max_lines):
555
- """Helper for wrapping text in frames."""
556
- words = _clean_text(text).split()
557
- lines, cur = [], ""
558
- for w in words:
559
- test = (cur + " " + w).strip()
560
- try:
561
- width = draw.textbbox((0, 0), test, font=font)[2]
562
- except Exception:
563
- width = len(test) * 20
564
- if width <= max_width:
565
- cur = test
566
- else:
567
- if cur:
568
- lines.append(cur)
569
- cur = w
570
- if len(lines) >= max_lines:
571
- break
572
- if cur and len(lines) < max_lines:
573
- lines.append(cur)
574
- return lines
 
96
 
97
 
98
  async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
99
+ """Generate text using Llama/Qwen models via Hugging Face Inference API.
100
 
101
+ Prioritizes Llama-3.3-70B for better creative/opinion writing.
 
 
 
102
  """
103
  token = _hf_token()
104
  errors = []
105
 
106
+ # Try HF router API with multiple models - Llama FIRST for opinion writing
107
  if token:
108
  models = [
109
  os.getenv("QWEN_VL_MODEL", ""),
110
+ "meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis
111
  "Qwen/Qwen2.5-VL-7B-Instruct",
 
 
 
 
112
  "Qwen/Qwen2.5-72B-Instruct",
 
113
  ]
114
  # Deduplicate while preserving order
115
  seen = set()
 
131
  payload = {
132
  "model": model,
133
  "messages": [
134
+ {"role": "system", "content": "Bạn là nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."},
135
  {"role": "user", "content": user_content},
136
  ],
137
+ "max_tokens": min(int(max_tokens or 2000), 2500),
138
+ "temperature": 0.75,
139
+ "top_p": 0.9,
140
  }
141
 
142
  r = requests.post(
 
176
  text = re.sub(r"https?://\S+", "", text)
177
  text = re.sub(r"\s+", " ", text).strip()
178
 
179
+ # Split into sentences
180
  sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
181
  units = []
182
  for s in sentences:
183
  s = _clean_text(s)
184
+ if len(s) >= 30:
185
  units.append(s)
186
 
187
  if units:
 
188
  result_units = units[:max_units]
189
  return "\n".join("• " + u for u in result_units)
190
  if text:
 
191
  chunks = []
192
  for i in range(0, min(len(text), max_units * 300), 280):
193
  chunk = _clean_text(text[i:i+300])
 
197
  break
198
  if chunks:
199
  return "\n".join("• " + c for c in chunks)
200
+ return "• Không có đủ nội dung để tóm tắt."