bep40 commited on
Commit
cc8a3ab
·
verified ·
1 Parent(s): ffa275a

Restore ai_runtime*.py chain from c93b544 (part 1: ai_runtime.py + ai_runtime_final.py)

Browse files
Files changed (1) hide show
  1. ai_runtime.py +2 -206
ai_runtime.py CHANGED
@@ -24,8 +24,7 @@ def _domain(url):
24
 
25
 
26
  def _strip_bullet_prefix(s):
27
- # remove bullets, numbered prefixes, leading dots commonly produced by AI summaries
28
- return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
29
 
30
 
31
  def source_line(sources):
@@ -60,7 +59,6 @@ def _collect_all_images(data):
60
 
61
  def _scrape_url_with_images(url):
62
  data=base.scrape_any_url(url)
63
- # extra pass: collect every useful image from original HTML, because some readers only return one image
64
  try:
65
  import requests
66
  from bs4 import BeautifulSoup
@@ -71,7 +69,6 @@ def _scrape_url_with_images(url):
71
  src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
72
  if src.startswith('//'):src='https:'+src
73
  if src and 'base64' not in src and src not in extra:
74
- # skip tiny icons/logos as much as possible
75
  low=src.lower()
76
  if any(x in low for x in ['logo','icon','avatar','sprite']):
77
  continue
@@ -113,11 +110,9 @@ def postprocess(text):
113
  out=old._postprocess_ai_text(text, max_units=7)
114
  else:
115
  out=clean(text)
116
- # keep wall text readable, but ensure short generation later won't show bullets
117
  return out
118
 
119
 
120
- # Remove old routes we must override.
121
  _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
122
  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)]
123
 
@@ -130,19 +125,7 @@ async def url_wall_only(request:Request):
130
  except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
131
  raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
132
  if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
133
- prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
134
-
135
- Yêu cầu bắt buộc:
136
- - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
137
- - Ngắn gọn, cụ thể, dễ hiểu.
138
- - Không lặp lại ý và không thêm chi tiết ngoài nguồn.
139
- - Tối đa 5 ý chính hoặc 2 đoạn ngắn.
140
- - Tránh dùng dấu đầu dòng nếu không thật cần thiết.
141
-
142
- Tiêu đề gốc: {data.get('title','')}
143
- Nguồn: {data.get('via','') or _domain(url)}
144
- Nội dung gốc:
145
- {raw[:16000]}"""
146
  text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
147
  if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
148
  text=postprocess(text)
@@ -168,190 +151,3 @@ async def ai_url_compat(request:Request):
168
  @app.post('/api/topic_post')
169
  async def topic_disabled(request:Request):
170
  return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
171
-
172
-
173
- def split_segments(post,max_segments=8):
174
- text=clean(post.get('text') or post.get('title') or '')
175
- text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
176
- lines=[]
177
- for ln in text.splitlines():
178
- ln=_strip_bullet_prefix(ln)
179
- if len(ln)>=18:lines.append(ln)
180
- if len(lines)<2:
181
- lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25]
182
- segs=[];cur=''
183
- for ln in lines:
184
- ln=_strip_bullet_prefix(ln)
185
- if not ln:continue
186
- if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
187
- else:
188
- if cur:segs.append(_strip_bullet_prefix(cur))
189
- cur=ln
190
- if cur:segs.append(_strip_bullet_prefix(cur))
191
- return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))]
192
-
193
-
194
- def wrap_text(draw,text,font,maxw,max_lines):
195
- words=clean(text).split();lines=[];cur=''
196
- for w in words:
197
- test=(cur+' '+w).strip()
198
- try:width=draw.textbbox((0,0),test,font=font)[2]
199
- except Exception:width=len(test)*20
200
- if width<=maxw:cur=test
201
- else:
202
- if cur:lines.append(cur)
203
- cur=w
204
- if len(lines)>=max_lines:break
205
- if cur and len(lines)<max_lines:lines.append(cur)
206
- return lines
207
-
208
-
209
- def _draw_center(draw, lines, font, y, fill, W, line_h):
210
- for ln in lines:
211
- try:
212
- box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
213
- except Exception:
214
- tw=len(ln)*24
215
- x=max(30,(W-tw)//2)
216
- draw.text((x,y),ln,fill=fill,font=font)
217
- y+=line_h
218
- return y
219
-
220
-
221
- def make_frame(post,seg,idx,total,img_path,out_path):
222
- if Image is None:raise RuntimeError('Pillow not ready')
223
- W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
224
- hero_h=760
225
- try:
226
- im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
227
- target=(W,hero_h);tr=target[0]/target[1]
228
- if ratio>tr:nh=target[1];nw=int(nh*ratio)
229
- else:nw=target[0];nh=int(nw/ratio)
230
- im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
231
- bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0))
232
- except Exception:pass
233
- draw=ImageDraw.Draw(bg)
234
- try:
235
- fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
236
- ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38)
237
- fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
238
- fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
239
- except Exception:fb=ft=fs=fsmall=None
240
- # source badge on top image corner
241
- badge='Nguồn: '+_source_badge(post)
242
- try:
243
- b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
244
- except Exception:
245
- bw=len(badge)*16;bh=34
246
- bx=W-bw-42;by=24
247
- draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170))
248
- draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
249
- # bottom text area
250
- draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
251
- # progress bars centered
252
- total_w=total*38-14;start=(W-total_w)//2
253
- for i in range(total):
254
- fill=(92,184,122) if i==idx else (70,70,70)
255
- draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
256
- brand='VNEWS AI SHORT'
257
- try:
258
- bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
259
- except Exception:tx=360
260
- draw.text((tx,870),brand,fill=(110,231,143),font=ft)
261
- clean_seg=_strip_bullet_prefix(seg)
262
- lines=wrap_text(draw,clean_seg,fb,W-120,8)
263
- block_h=len(lines)*74
264
- y=max(980, 1250-block_h//2)
265
- _draw_center(draw,lines,fb,y,(255,255,255),W,74)
266
- # small title centered near bottom
267
- title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
268
- y2=1640
269
- draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
270
- _draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
271
- bg.save(out_path,quality=92)
272
-
273
-
274
- def make_tts(text,voice,out_path):
275
- v={'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')
276
- text=_strip_bullet_prefix(text)
277
- try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
278
- except Exception:
279
- tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
280
- try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path)
281
- except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path)
282
-
283
-
284
- @app.post('/api/ai/short/{post_id}')
285
- async def short_segments(post_id:str,request:Request):
286
- try:body=await request.json()
287
- except Exception:body={}
288
- voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
289
- posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
290
- if not post:return JSONResponse({'error':'post not found'},status_code=404)
291
- segs=split_segments(post,8)
292
- os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet'
293
- out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
294
- if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
295
- work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
296
- img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img)
297
- clips=[]
298
- try:
299
- for i,seg in enumerate(segs):
300
- frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
301
- seg=_strip_bullet_prefix(seg)
302
- make_frame(post,seg,i,len(segs),img,frame)
303
- prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
304
- spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
305
- make_tts(spoken,voice,aud)
306
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
307
- subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
308
- clips.append(clip)
309
- lf=os.path.join(work,'list.txt')
310
- with open(lf,'w',encoding='utf-8') as f:
311
- for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n")
312
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
313
- post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
314
- return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
315
- except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500)
316
-
317
-
318
- @app.get('/api/ai/short-file/{file_id}')
319
- def short_file(file_id:str):
320
- path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
321
- if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404)
322
- return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
323
-
324
-
325
- # Rebuild / with old UI injection plus final UI overrides.
326
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
327
- @app.get('/')
328
- async def index_runtime():
329
- with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
330
- inject=getattr(old,'PATCH_INJECT','')+r'''
331
- <style>
332
- /* Hide old topic UI, keep URL input only */
333
- #ai-topic-input{display:none!important}
334
- #ai-topic-input,*[onclick*="createTopicPost"]{display:none!important}
335
- .ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
336
- .ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px}
337
- </style>
338
- <script>
339
- (function(){
340
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
341
- function hideTopicControls(){
342
- document.querySelectorAll('#ai-topic-input').forEach(e=>{let p=e.closest('.ai-compose,.ai-compose-topic,.topic-row,div'); if(p&&p.querySelector('#ai-url-input')) e.style.display='none'; else if(p) p.style.display='none';});
343
- document.querySelectorAll('button').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});
344
- let url=document.getElementById('ai-url-input'); if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết để AI tóm tắt và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
345
- }
346
- window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
347
- window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){if(typeof prependWallPost==='function')prependWallPost(j.post);if(window.patchedWall)window.patchedWall=[j.post].concat(window.patchedWall||[]);if(inp)inp.value='';alert('Đã tóm tắt URL, lấy ảnh trong bài và đăng lên Tường AI');location.reload();}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
348
- function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
349
- function patchReaders(){
350
- let oldRead=window.aiReadWallPatched||window.aiReadWall;
351
- window.aiReadWallPatched=window.aiReadWall=function(i){let arr=window.patchedWall||window.aiWall||[];let p=arr[i];if(!p&&oldRead)return oldRead(i);if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShortPatched?aiMakeShortPatched(${i}):aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0);};
352
- }
353
- setInterval(hideTopicControls,1000);setTimeout(hideTopicControls,300);setTimeout(patchReaders,1600);
354
- })();
355
- </script>
356
- '''
357
- return HTMLResponse(html.replace('</body>',inject+'\n</body>') if '</body>' in html else html+inject)
 
24
 
25
 
26
  def _strip_bullet_prefix(s):
27
+ return clean(re.sub(r'^[\s\•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
 
28
 
29
 
30
  def source_line(sources):
 
59
 
60
  def _scrape_url_with_images(url):
61
  data=base.scrape_any_url(url)
 
62
  try:
63
  import requests
64
  from bs4 import BeautifulSoup
 
69
  src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
70
  if src.startswith('//'):src='https:'+src
71
  if src and 'base64' not in src and src not in extra:
 
72
  low=src.lower()
73
  if any(x in low for x in ['logo','icon','avatar','sprite']):
74
  continue
 
110
  out=old._postprocess_ai_text(text, max_units=7)
111
  else:
112
  out=clean(text)
 
113
  return out
114
 
115
 
 
116
  _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
117
  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)]
118
 
 
125
  except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
126
  raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
127
  if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
128
+ prompt=f"Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.\n\nYêu cầu bắt buộc:\n- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.\n- Ngắn gọn, cụ thể, dễ hiểu.\n- Không lặp lại ý và không thêm chi tiết ngoài nguồn.\n- Tối đa 5 ý chính hoặc 2 đoạn ngắn.\n- Tránh dùng dấu đầu dòng nếu không thật cần thiết.\n\nTiêu đề gốc: {data.get('title','')}\nNguồn: {data.get('via','') or _domain(url)}\nNội dung gốc:\n{raw[:16000]}"
 
 
 
 
 
 
 
 
 
 
 
 
129
  text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
130
  if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
131
  text=postprocess(text)
 
151
  @app.post('/api/topic_post')
152
  async def topic_disabled(request:Request):
153
  return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)