Spaces:
Running
Running
Update AI runtime to produce opinion posts instead of summaries
Browse files- ai_runtime.py +126 -27
ai_runtime.py
CHANGED
|
@@ -86,6 +86,50 @@ def _scrape_url_with_images(url):
|
|
| 86 |
return data
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def rich_context(topic, limit=5):
|
| 90 |
try: ctx,sources=base.web_context(topic, limit=limit)
|
| 91 |
except Exception: ctx,sources='',[]
|
|
@@ -122,35 +166,36 @@ _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share
|
|
| 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 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
@app.post('/api/url_wall')
|
| 126 |
async def url_wall_only(request:Request):
|
| 127 |
body=await request.json();url=base._clean_text(body.get('url',''))
|
|
|
|
| 128 |
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 129 |
try:data=_scrape_url_with_images(url)
|
| 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 để
|
| 133 |
-
|
| 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)
|
| 149 |
-
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}]
|
| 150 |
-
if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src)
|
| 151 |
-
images=_collect_all_images(data)
|
| 152 |
-
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)
|
| 153 |
-
post['images']=images
|
| 154 |
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 155 |
return JSONResponse({'post':post})
|
| 156 |
|
|
@@ -166,8 +211,62 @@ async def ai_url_compat(request:Request):
|
|
| 166 |
|
| 167 |
|
| 168 |
@app.post('/api/topic_post')
|
| 169 |
-
async def
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
|
| 173 |
def split_segments(post,max_segments=8):
|
|
@@ -341,10 +440,10 @@ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'&l
|
|
| 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
|
| 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('Đã
|
| 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;
|
|
|
|
| 86 |
return data
|
| 87 |
|
| 88 |
|
| 89 |
+
def _opinion_template(title, source, raw):
|
| 90 |
+
return f"""Bạn là nhà bình luận tiếng Việt của VNEWS. Nhiệm vụ là viết một bài quan điểm cá nhân đăng lên Tường AI, không phải tóm tắt tin tức.
|
| 91 |
+
|
| 92 |
+
YÊU CẦU BẮT BUỘC:
|
| 93 |
+
- Viết theo quan điểm cá nhân rõ ràng, có lập trường/thesis ngay từ đầu.
|
| 94 |
+
- Không viết bài kiểu bản tin/tóm tắt, không liệt kê 5 ý chính, không recap nguồn.
|
| 95 |
+
- Nguồn bên dưới chỉ là bằng chứng/gợi ý để lập luận, không phải nội dung chính cần lặp lại.
|
| 96 |
+
- Nếu nguồn không đủ mạnh để bảo vệ quan điểm, hãy nói rõ giới hạn đó thay vì bịa thêm chi tiết.
|
| 97 |
+
- Không sao chép nguyên văn câu từ của nguồn.
|
| 98 |
+
- Giọng văn tự nhiên như một bài opinion trên mạng xã hội: trực diện, có góc nhìn, có lý lẽ.
|
| 99 |
+
- Kết quả cuối cùng chỉ gồm 1 tiêu đề ngắn và 2-4 đoạn văn. Không dùng gạch đầu dòng.
|
| 100 |
+
|
| 101 |
+
Tiêu đề nguồn: {title}
|
| 102 |
+
Nguồn tham khảo: {source}
|
| 103 |
+
|
| 104 |
+
Nội dung nguồn dùng làm bằng chứng:
|
| 105 |
+
{raw[:16000]}"""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _opinion_title(text, fallback):
|
| 109 |
+
text = clean(text)
|
| 110 |
+
if not text:
|
| 111 |
+
return fallback
|
| 112 |
+
first = text.split('\n', 1)[0]
|
| 113 |
+
if len(first) <= 95 and not any(first.endswith(x) for x in ['.', '!', '?']):
|
| 114 |
+
return first[:95]
|
| 115 |
+
return fallback
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _postprocess_opinion(text):
|
| 119 |
+
text = clean(text)
|
| 120 |
+
if not text:
|
| 121 |
+
return text
|
| 122 |
+
text = re.sub(r'^(Tôi nghĩ|Theo tôi|Mình cho rằng|Mình tin|Rất cần nói lại|Cần nhìn lại|Không nên|Phải|Nên)\b.*$', '', text, flags=re.I).strip()
|
| 123 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 124 |
+
text = re.sub(r'\n{2,}', '\n\n', text)
|
| 125 |
+
# Keep paragraphs but avoid bullet-style output.
|
| 126 |
+
paras = [p.strip() for p in text.split('\n\n') if p.strip()]
|
| 127 |
+
if len(paras) > 4:
|
| 128 |
+
paras = paras[:4]
|
| 129 |
+
text = '\n\n'.join(paras)
|
| 130 |
+
return text
|
| 131 |
+
|
| 132 |
+
|
| 133 |
def rich_context(topic, limit=5):
|
| 134 |
try: ctx,sources=base.web_context(topic, limit=limit)
|
| 135 |
except Exception: ctx,sources='',[]
|
|
|
|
| 166 |
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)]
|
| 167 |
|
| 168 |
|
| 169 |
+
async def _make_opinion_post(title, raw, url, source_url, source_name, image_url=None, opinion_text=''):
|
| 170 |
+
source = f"{source_name or _domain(source_url or url) or 'nguồn VNEWS'}"
|
| 171 |
+
prompt = _opinion_template(title, source, raw)
|
| 172 |
+
if opinion_text:
|
| 173 |
+
prompt += "\n\nQuan điểm ban đầu của người dùng:\n" + clean(opinion_text)
|
| 174 |
+
text = await base.qwen_generate(prompt, image_url=(image_url or None), max_tokens=1100)
|
| 175 |
+
if not text:
|
| 176 |
+
text = old._fallback_summary_from_prompt(prompt, max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
|
| 177 |
+
text = _postprocess_opinion(text)
|
| 178 |
+
if len(text) < 120:
|
| 179 |
+
text = clean(raw[:900])
|
| 180 |
+
src=[{'title':title, 'url':url, 'excerpt':clean(raw)[:500], 'via':source_name or _domain(url)}]
|
| 181 |
+
if 'Nguồn tham khảo:' not in text:
|
| 182 |
+
text += '\n\n' + source_line(src)
|
| 183 |
+
images=_collect_all_images({'image':image_url, 'images':[]})
|
| 184 |
+
post=base.make_post(_opinion_title(text, title or 'Bài quan điểm'), text, images[0] if images else (image_url or ''), url, 'opinion', sources=src)
|
| 185 |
+
post['images']=images
|
| 186 |
+
return post
|
| 187 |
+
|
| 188 |
+
|
| 189 |
@app.post('/api/url_wall')
|
| 190 |
async def url_wall_only(request:Request):
|
| 191 |
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 192 |
+
opinion_text=base._clean_text(body.get('opinion') or body.get('opinion_text') or '')
|
| 193 |
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 194 |
try:data=_scrape_url_with_images(url)
|
| 195 |
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 196 |
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 197 |
+
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để viết quan điểm'},status_code=422)
|
| 198 |
+
post=await _make_opinion_post(data.get('title') or 'Bài viết', raw, url, url, data.get('via') or _domain(url), data.get('image') or None, opinion_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 200 |
return JSONResponse({'post':post})
|
| 201 |
|
|
|
|
| 211 |
|
| 212 |
|
| 213 |
@app.post('/api/topic_post')
|
| 214 |
+
async def topic_post(request:Request):
|
| 215 |
+
body=await request.json();topic=base._clean_text(body.get('topic',''))
|
| 216 |
+
opinion_text=base._clean_text(body.get('opinion') or body.get('opinion_text') or '')
|
| 217 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 218 |
+
try:ctx,sources=rich_context(topic, limit=5)
|
| 219 |
+
except Exception:ctx,sources=('',[])
|
| 220 |
+
if not sources:return JSONResponse({'error':'Không lấy được bài viết nguồn cho chủ đề này.'},status_code=422)
|
| 221 |
+
raw_parts=[];src=[];seen=set()
|
| 222 |
+
for s in sources[:5]:
|
| 223 |
+
url=s.get('url') or ''
|
| 224 |
+
if not url.startswith('http') or url in seen:continue
|
| 225 |
+
seen.add(url)
|
| 226 |
+
try:
|
| 227 |
+
data=base.scrape_any_url(url)
|
| 228 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 229 |
+
if len(raw)<120:continue
|
| 230 |
+
title=data.get('title') or s.get('title') or url
|
| 231 |
+
via=data.get('via') or s.get('via') or _domain(url)
|
| 232 |
+
raw_parts.append(f"### {title} ({via})\n{raw[:3000]}")
|
| 233 |
+
src.append({'title':title, 'url':url, 'excerpt':raw[:500], 'via':via})
|
| 234 |
+
except Exception:
|
| 235 |
+
continue
|
| 236 |
+
if not raw_parts:return JSONResponse({'error':'Không lấy được nội dung đủ mạnh cho chủ đề này.'},status_code=422)
|
| 237 |
+
post=await _make_opinion_post(topic, '\n\n'.join(raw_parts), '', '', 'chủ đề '+topic, opinion_text=opinion_text)
|
| 238 |
+
post['sources']=src
|
| 239 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 240 |
+
return JSONResponse({'post':post})
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
async def _make_personal_opinion_post(topic, opinion_text, image_url=None):
|
| 244 |
+
if not opinion_text:
|
| 245 |
+
return None
|
| 246 |
+
opinion_text=clean(opinion_text)
|
| 247 |
+
if len(opinion_text)<20:return None
|
| 248 |
+
prompt=_opinion_template(topic, 'ý kiến người dùng và bối cảnh chung', opinion_text)
|
| 249 |
+
text=await base.qwen_generate(prompt, image_url=(image_url or None), max_tokens=1100)
|
| 250 |
+
if not text:text=opinion_text[:1100]
|
| 251 |
+
text=_postprocess_opinion(text)
|
| 252 |
+
if len(text)<120:text=opinion_text
|
| 253 |
+
src=[{'title':topic, 'url':'', 'excerpt':opinion_text[:500], 'via':'người dùng'}]
|
| 254 |
+
if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src)
|
| 255 |
+
post=base.make_post(_opinion_title(text, topic), text, image_url or '', '', 'personal_opinion', sources=src)
|
| 256 |
+
post['images']=[image_url] if image_url else []
|
| 257 |
+
return post
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@app.post('/api/personal_opinion')
|
| 261 |
+
async def personal_opinion_post(request:Request):
|
| 262 |
+
body=await request.json();topic=base._clean_text(body.get('topic','')) or base._clean_text(body.get('title',''))
|
| 263 |
+
opinion_text=base._clean_text(body.get('opinion') or body.get('opinion_text') or '')
|
| 264 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 265 |
+
if not opinion_text:return JSONResponse({'error':'missing opinion'},status_code=400)
|
| 266 |
+
post=await _make_personal_opinion_post(topic, opinion_text, body.get('image') or '')
|
| 267 |
+
if not post:return JSONResponse({'error':'Quan điểm chưa đủ dài'},status_code=422)
|
| 268 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 269 |
+
return JSONResponse({'post':post})
|
| 270 |
|
| 271 |
|
| 272 |
def split_segments(post,max_segments=8):
|
|
|
|
| 440 |
function hideTopicControls(){
|
| 441 |
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';});
|
| 442 |
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';});
|
| 443 |
+
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 viết quan điểm cá nhân và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
|
| 444 |
}
|
| 445 |
+
window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết để AI viết quan điểm cá nhân.');};
|
| 446 |
+
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('Đã viết bài quan điểm cá nhân, 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'));};
|
| 447 |
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>';}
|
| 448 |
function patchReaders(){
|
| 449 |
let oldRead=window.aiReadWallPatched||window.aiReadWall;
|