VNEWS / ai_runtime.py
bep40's picture
Restore ai_runtime*.py chain from c93b544 (part 1: ai_runtime.py + ai_runtime_final.py)
cc8a3ab verified
Raw
History Blame
6.63 kB
import os, re, subprocess, json, time, hashlib
import ai_patch as old
from ai_patch import app
import ai_ext as base
from fastapi import Request
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
try:
from PIL import Image, ImageDraw, ImageFont
except Exception:
Image = ImageDraw = ImageFont = None
def clean(s):
import html as html_lib
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
def _domain(url):
try:
from urllib.parse import urlparse
return urlparse(url or '').netloc.replace('www.','')
except Exception:
return ''
def _strip_bullet_prefix(s):
return clean(re.sub(r'^[\s\•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
def source_line(sources):
names=[]
for s in (sources or [])[:5]:
via=s.get('via') or _domain(s.get('url','')) or s.get('title','')
if via and via not in names:names.append(via)
return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet'
def _source_badge(post):
sources=post.get('sources') or []
for s in sources:
via=s.get('via') or _domain(s.get('url',''))
if via:return via
return _domain(post.get('url','')) or post.get('source') or 'VNEWS'
def _collect_all_images(data):
imgs=[]
def add(u):
u=(u or '').strip()
if not u or u.startswith('data:') or 'base64' in u:return
if u.startswith('//'):u='https:'+u
if u not in imgs:imgs.append(u)
add(data.get('image') or data.get('og_image') or data.get('img'))
for u in data.get('images') or []:add(u)
for b in data.get('body') or []:
if isinstance(b,dict) and b.get('type')=='img':add(b.get('src'))
return imgs[:20]
def _scrape_url_with_images(url):
data=base.scrape_any_url(url)
try:
import requests
from bs4 import BeautifulSoup
r=requests.get(url,headers=base.HEADERS,timeout=18);r.encoding='utf-8'
soup=BeautifulSoup(r.text,'lxml')
extra=[]
for im in soup.find_all('img'):
src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
if src.startswith('//'):src='https:'+src
if src and 'base64' not in src and src not in extra:
low=src.lower()
if any(x in low for x in ['logo','icon','avatar','sprite']):
continue
extra.append(src)
if len(extra)>=20:break
data['images']=_collect_all_images(data)+[u for u in extra if u not in _collect_all_images(data)]
except Exception:
data['images']=_collect_all_images(data)
data['images']=_collect_all_images(data)
if data['images'] and not data.get('image'):
data['image']=data['images'][0]
return data
def rich_context(topic, limit=5):
try: ctx,sources=base.web_context(topic, limit=limit)
except Exception: ctx,sources='',[]
rich=[];rs=[];seen=set()
for s in (sources or [])[:limit*2]:
url=s.get('url') or ''
if not url.startswith('http') or url in seen:continue
seen.add(url)
try:
data=base.scrape_any_url(url)
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
if len(raw)<180:continue
title=data.get('title') or s.get('title') or url
via=data.get('via') or s.get('via') or _domain(url)
rich.append(f"### {title} ({via})\n{raw[:2600]}")
rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via})
if len(rich)>=limit:break
except Exception:continue
if rich:return '\n\n'.join(rich),rs
return ctx or f'Chủ đề: {topic}', sources or []
def postprocess(text):
if hasattr(old,'_postprocess_ai_text'):
out=old._postprocess_ai_text(text, max_units=7)
else:
out=clean(text)
return out
_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')}
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)]
@app.post('/api/url_wall')
async def url_wall_only(request:Request):
body=await request.json();url=base._clean_text(body.get('url',''))
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
try:data=_scrape_url_with_images(url)
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
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]}"
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
text=postprocess(text)
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}]
if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src)
images=_collect_all_images(data)
post=base.make_post(data.get('title') or 'Bài viết',text,images[0] if images else (data.get('image') or ''),url,'url',sources=src)
post['images']=images
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
return JSONResponse({'post':post})
@app.post('/api/rewrite_share')
async def rewrite_share_url_only(request:Request):
return await url_wall_only(request)
@app.post('/api/ai/url')
async def ai_url_compat(request:Request):
return await url_wall_only(request)
@app.post('/api/topic_post')
async def topic_disabled(request:Request):
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)