bep40 commited on
Commit
3a998db
·
verified ·
1 Parent(s): d6fd7a7

Fix ai_fix2.py - restore from c93b544

Browse files
Files changed (1) hide show
  1. ai_fix2.py +159 -0
ai_fix2.py CHANGED
@@ -17,6 +17,7 @@ def _is_real_article_text(raw):
17
  raw = clean(raw)
18
  if len(raw) < 500:
19
  return False
 
20
  sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
21
  long_sentences = [s for s in sentences if len(s) > 45]
22
  return len(long_sentences) >= 5
@@ -84,11 +85,13 @@ def _topic_source_articles(topic, limit=5):
84
  candidates = []
85
  seen = set()
86
 
 
87
  for u in _ddg_article_urls(topic, limit=14):
88
  if u not in seen:
89
  seen.add(u)
90
  candidates.append({"url": u, "title": "", "via": base._domain(u)})
91
 
 
92
  try:
93
  _ctx, srcs = base.web_context(topic, limit=8)
94
  for s in srcs or []:
@@ -99,6 +102,7 @@ def _topic_source_articles(topic, limit=5):
99
  except Exception:
100
  pass
101
 
 
102
  for s in _rss_article_urls(topic, limit=10):
103
  u = s.get("url") or ""
104
  if u.startswith("http") and u not in seen:
@@ -148,6 +152,7 @@ def srt_time(sec):
148
 
149
 
150
  def parse_timecode(t):
 
151
  t = t.replace(',', '.')
152
  parts = t.split(':')
153
  if len(parts) == 3:
@@ -205,3 +210,157 @@ def write_weighted_srt(script, path, total_duration):
205
  f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
206
  if cur >= total_duration - 0.2:
207
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  raw = clean(raw)
18
  if len(raw) < 500:
19
  return False
20
+ # Reject search-result/title-only pages: need several real sentences.
21
  sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
22
  long_sentences = [s for s in sentences if len(s) > 45]
23
  return len(long_sentences) >= 5
 
85
  candidates = []
86
  seen = set()
87
 
88
+ # 1) DuckDuckGo actual result URLs are usually more directly scrapable.
89
  for u in _ddg_article_urls(topic, limit=14):
90
  if u not in seen:
91
  seen.add(u)
92
  candidates.append({"url": u, "title": "", "via": base._domain(u)})
93
 
94
+ # 2) Add base web_context sources.
95
  try:
96
  _ctx, srcs = base.web_context(topic, limit=8)
97
  for s in srcs or []:
 
102
  except Exception:
103
  pass
104
 
105
+ # 3) Google News RSS fallback last.
106
  for s in _rss_article_urls(topic, limit=10):
107
  u = s.get("url") or ""
108
  if u.startswith("http") and u not in seen:
 
152
 
153
 
154
  def parse_timecode(t):
155
+ # 00:00:01.234 or 00:00:01,234
156
  t = t.replace(',', '.')
157
  parts = t.split(':')
158
  if len(parts) == 3:
 
210
  f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
211
  if cur >= total_duration - 0.2:
212
  break
213
+
214
+
215
+ def tts_script_full(post, emotion):
216
+ title = clean(post.get("title", ""))
217
+ text = clean(post.get("text", ""))
218
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
219
+ prefix = {
220
+ "urgent": "Tin nhanh.",
221
+ "warm": "Câu chuyện đáng chú ý.",
222
+ "serious": "Bản tin nghiêm túc.",
223
+ "energetic": "Cập nhật nổi bật.",
224
+ }.get(emotion, "")
225
+ script = f"{prefix} {title}. {text}".strip()
226
+ # Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
227
+ if len(script) > 3600:
228
+ tmp = script[:3600]
229
+ cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
230
+ script = tmp[:cut + 1] if cut > 1600 else tmp
231
+ script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
232
+ script = re.sub(r"\n{2,}", "\n", script).strip()
233
+ return script
234
+
235
+
236
+ _PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
237
+ app.router.routes = [r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
238
+
239
+
240
+ @app.post('/api/topic_post')
241
+ async def topic_post_aggregate(request: Request):
242
+ body = await request.json()
243
+ topic = base._clean_text(body.get('topic',''))
244
+ if not topic:
245
+ return JSONResponse({'error':'missing topic'}, status_code=400)
246
+ articles = _topic_source_articles(topic, limit=5)
247
+ if not articles:
248
+ return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
249
+ source_blocks = []
250
+ sources = []
251
+ image = ""
252
+ for i, art in enumerate(articles, 1):
253
+ raw = art.get('raw','')
254
+ source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
255
+ sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
256
+ if not image and art.get('image'):
257
+ image = art.get('image')
258
+ ctx = "\n\n".join(source_blocks)
259
+ prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
260
+
261
+ Chủ đề: {topic}
262
+
263
+ NHIỆM VỤ:
264
+ - Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
265
+ - Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
266
+ - Không tạo mỗi tiêu đề thành một bài riêng.
267
+ - Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
268
+ - Không lặp ý giữa các nguồn.
269
+ - Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
270
+ - Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
271
+ - Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
272
+
273
+ Nội dung nguồn:
274
+ {ctx[:16000]}"""
275
+ text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
276
+ text = prev._postprocess_ai_text(text, max_units=7)
277
+ if 'Nguồn tham khảo:' not in text:
278
+ text += '\n\n' + prev._source_line(sources)
279
+ post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
280
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
281
+ return JSONResponse({'post': post, 'count_sources': len(sources)})
282
+
283
+
284
+ @app.post('/api/ai/short/{post_id}')
285
+ async def ai_short_full(post_id: str, request: Request):
286
+ try:
287
+ body = await request.json()
288
+ except Exception:
289
+ body = {}
290
+ voice = str(body.get('voice','nu')).lower().strip()
291
+ emotion = str(body.get('emotion','neutral')).lower().strip()
292
+ speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
293
+ posts = base._load_ai_wall()
294
+ post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
295
+ if not post:
296
+ return JSONResponse({'error':'post not found'}, status_code=404)
297
+ os.makedirs(base.SHORTS_DIR, exist_ok=True)
298
+ suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
299
+ out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
300
+ if os.path.exists(out_mp4):
301
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
302
+ base._save_ai_wall(posts)
303
+ return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
304
+ work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
305
+ img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
306
+ try:
307
+ base._download_image(post.get('img'), post.get('title','AI news'), img)
308
+ prev._make_short_frame_full(post, img, frame)
309
+ script = tts_script_full(post, emotion)
310
+ edge_voice = {'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
311
+ used_edge = False
312
+ try:
313
+ subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
314
+ used_edge = True
315
+ except Exception:
316
+ tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
317
+ try:
318
+ base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
319
+ except TypeError:
320
+ base.gTTS(script, lang='vi', slow=False).save(audio)
321
+ subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
322
+ duration = 45.0
323
+ try:
324
+ pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
325
+ duration = float((pr.stdout or b'45').decode().strip() or 45)
326
+ except Exception:
327
+ pass
328
+ if used_edge and os.path.exists(vtt):
329
+ ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
330
+ if not ok:
331
+ write_weighted_srt(script, srt, duration)
332
+ else:
333
+ write_weighted_srt(script, srt, duration)
334
+ vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
335
+ cmd = ['ffmpeg','-y','-loop','1','-i',frame,'-i',audio_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf',vf,out_mp4]
336
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
337
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
338
+ post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
339
+ base._save_ai_wall(posts)
340
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
341
+ except Exception as e:
342
+ return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
343
+
344
+
345
+ @app.get('/api/ai/short-file/{file_id}')
346
+ def ai_short_file_full(file_id: str):
347
+ path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
348
+ if not os.path.exists(path):
349
+ return JSONResponse({'error':'not found'}, status_code=404)
350
+ return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
351
+
352
+
353
+ app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
354
+
355
+ @app.get('/')
356
+ async def index_fix2():
357
+ with open('/app/static/index.html','r',encoding='utf-8') as f:
358
+ html = f.read()
359
+ inject = prev.PATCH_INJECT + r'''
360
+ <script>
361
+ (function(){
362
+ window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){window.location.reload();alert('Đã tổng hợp NỘI DUNG các bài nguồn thành 1 bản tóm tắt trên Tường AI');}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
363
+ })();
364
+ </script>
365
+ '''
366
+ return HTMLResponse(html.replace('</body>', inject+'\n</body>'))