bep40 commited on
Commit
b6f0232
·
verified ·
1 Parent(s): 6505e8d

Add AI wall topic/url Qwen2.5-VL extension

Browse files
Files changed (1) hide show
  1. ai_ext.py +258 -0
ai_ext.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, re, json, time, random, html as html_lib
2
+ from urllib.parse import quote_plus
3
+ from typing import Optional
4
+ import requests
5
+ from bs4 import BeautifulSoup
6
+ from fastapi import Query, Request
7
+ from fastapi.responses import HTMLResponse, JSONResponse
8
+
9
+ from main import app
10
+
11
+ try:
12
+ from huggingface_hub import AsyncInferenceClient
13
+ except Exception:
14
+ AsyncInferenceClient = None
15
+
16
+ HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
17
+ QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
18
+ WALL_FILE = "/data/ai_wall_posts.json" if os.path.isdir("/data") else "/app/ai_wall_posts.json"
19
+ HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
20
+
21
+ def _load_ai_wall():
22
+ try:
23
+ if os.path.exists(WALL_FILE):
24
+ with open(WALL_FILE, "r", encoding="utf-8") as f:
25
+ return json.load(f)
26
+ except Exception:
27
+ pass
28
+ return []
29
+
30
+ def _save_ai_wall(posts):
31
+ try:
32
+ os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
33
+ tmp = WALL_FILE + ".tmp"
34
+ with open(tmp, "w", encoding="utf-8") as f:
35
+ json.dump(posts[:100], f, ensure_ascii=False)
36
+ os.replace(tmp, WALL_FILE)
37
+ except Exception:
38
+ pass
39
+
40
+ def _clean_text(s: str) -> str:
41
+ s = html_lib.unescape(s or "")
42
+ return re.sub(r"\s+", " ", s).strip()
43
+
44
+ def _best_content_block(soup):
45
+ best, best_score = None, 0
46
+ for el in soup.find_all(["article", "main", "section", "div"]):
47
+ ps = el.find_all("p")
48
+ txt = " ".join(p.get_text(" ", strip=True) for p in ps)
49
+ score = len(ps) * 100 + len(txt)
50
+ cls = " ".join(el.get("class", []))
51
+ if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
52
+ score += 800
53
+ if score > best_score:
54
+ best, best_score = el, score
55
+ return best
56
+
57
+ def scrape_any_url(url: str):
58
+ r = requests.get(url, headers=HEADERS, timeout=18)
59
+ r.encoding = "utf-8"
60
+ soup = BeautifulSoup(r.text, "lxml")
61
+ for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
62
+ tag.decompose()
63
+ title = ""
64
+ if soup.find("h1"):
65
+ title = soup.find("h1").get_text(" ", strip=True)
66
+ if not title:
67
+ ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name":"title"})
68
+ title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
69
+ desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name":"description"})
70
+ summary = desc_tag.get("content", "") if desc_tag else ""
71
+ img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name":"twitter:image"})
72
+ image = img_tag.get("content", "") if img_tag else ""
73
+ if image and image.startswith("//"):
74
+ image = "https:" + image
75
+ block = _best_content_block(soup) or soup
76
+ paras, images, seen_p, seen_i = [], [], set(), set()
77
+ for p in block.find_all("p"):
78
+ t = _clean_text(p.get_text(" ", strip=True))
79
+ if len(t) >= 40 and t not in seen_p:
80
+ seen_p.add(t); paras.append(t)
81
+ if len(paras) >= 35:
82
+ break
83
+ for im in block.find_all("img"):
84
+ src = im.get("data-src") or im.get("data-original") or im.get("src") or ""
85
+ if src.startswith("//"):
86
+ src = "https:" + src
87
+ if src and "base64" not in src and src not in seen_i:
88
+ seen_i.add(src); images.append(src)
89
+ if len(images) >= 8:
90
+ break
91
+ if not image and images:
92
+ image = images[0]
93
+ return {"url": url, "title": _clean_text(title), "summary": _clean_text(summary), "text": "\n".join(paras), "image": image, "images": images}
94
+
95
+ def pollinations_image_url(topic: str, width=1024, height=576):
96
+ prompt = f"editorial illustration, topic: {topic}, modern clean cinematic news image, high quality, no text, no watermark"
97
+ return f"https://image.pollinations.ai/prompt/{quote_plus(prompt)}?width={width}&height={height}&seed={random.randint(1,999999)}&nologo=true&private=true"
98
+
99
+ def web_context(topic: str, limit=6):
100
+ try:
101
+ url = "https://duckduckgo.com/html/?q=" + quote_plus(topic + " tin tức kiến thức bối cảnh")
102
+ r = requests.get(url, headers=HEADERS, timeout=10)
103
+ soup = BeautifulSoup(r.text, "lxml")
104
+ snippets = []
105
+ for res in soup.select(".result")[:limit]:
106
+ title = _clean_text(res.select_one(".result__title").get_text(" ", strip=True) if res.select_one(".result__title") else "")
107
+ body = _clean_text(res.select_one(".result__snippet").get_text(" ", strip=True) if res.select_one(".result__snippet") else "")
108
+ if title or body:
109
+ snippets.append(f"- {title}: {body}")
110
+ return "\n".join(snippets)
111
+ except Exception:
112
+ return ""
113
+
114
+ async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 1700):
115
+ if not (HF_TOKEN and AsyncInferenceClient):
116
+ return None
117
+ try:
118
+ client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90)
119
+ content = []
120
+ if image_url:
121
+ content.append({"type":"image_url", "image_url":{"url": image_url}})
122
+ content.append({"type":"text", "text": prompt})
123
+ messages = [
124
+ {"role":"system", "content":"Bạn là biên tập viên AI tiếng Việt. Viết tự nhiên, mạch lạc, đầy đủ ý, không bịa chi tiết, không cắt cụt nội dung."},
125
+ {"role":"user", "content": content},
126
+ ]
127
+ resp = await client.chat_completion(model=QWEN_VL_MODEL, messages=messages, max_tokens=max_tokens, temperature=0.65, top_p=0.9)
128
+ return (resp.choices[0].message.content or "").strip()
129
+ except Exception as e:
130
+ print("[qwen fallback]", type(e).__name__, str(e)[:200])
131
+ return None
132
+
133
+ def fallback_rewrite(title, raw_text):
134
+ text = _clean_text(raw_text)
135
+ chunks = []
136
+ while text and len(chunks) < 7:
137
+ part = text[:620]
138
+ cut = max(part.rfind(". "), part.rfind("! "), part.rfind("? "))
139
+ if cut > 220:
140
+ part = part[:cut+1]
141
+ chunks.append(part.strip())
142
+ text = text[len(part):].strip()
143
+ return f"{title}\n\n" + "\n\n".join(chunks)
144
+
145
+ def make_post(title, text, image, source_url, kind):
146
+ return {"id": str(int(time.time()*1000)) + str(random.randint(100,999)), "title": title, "text": text, "img": image, "url": source_url, "kind": kind, "ts": int(time.time())}
147
+
148
+ @app.get("/api/ai_wall")
149
+ def api_ai_wall():
150
+ return JSONResponse({"posts": _load_ai_wall()[:80]})
151
+
152
+ @app.post("/api/ai/topic")
153
+ async def api_ai_topic(request: Request):
154
+ body = await request.json()
155
+ topic = _clean_text(body.get("topic", ""))
156
+ if not topic:
157
+ return JSONResponse({"error":"missing topic"}, status_code=400)
158
+ ctx = web_context(topic)
159
+ image = pollinations_image_url(topic)
160
+ prompt = f"""
161
+ Hãy viết một dàn ý bài viết tiếng Việt tự nhiên cho chủ đề: {topic}
162
+
163
+ Yêu cầu:
164
+ - Dựa trên kiến thức thực tế và bối cảnh internet dưới đây nếu có.
165
+ - Có tiêu đề hấp dẫn nhưng không giật tít.
166
+ - 1 đoạn mở bài ngắn.
167
+ - 5-7 luận điểm chính, mỗi luận điểm có 1-2 câu giải thích.
168
+ - 1 đoạn kết luận gợi ý góc nhìn cho độc giả.
169
+ - Văn phong tự nhiên, dễ đọc.
170
+
171
+ Bối cảnh tìm được trên internet:
172
+ {ctx or 'Không có snippet đáng tin cậy; hãy viết theo kiến thức nền phổ quát, tránh khẳng định số liệu cụ thể.'}
173
+ """
174
+ text = await qwen_generate(prompt, image_url=image, max_tokens=1400)
175
+ if not text:
176
+ text = f"{topic}\n\nMở bài: Chủ đề này đang nhận được sự quan tâm vì liên quan trực tiếp tới đời sống, công nghệ và cách con người ra quyết định.\n\nDàn ý:\n• Bối cảnh chung của chủ đề.\n• Vì sao chủ đề này đáng chú ý ở hiện tại.\n• Các tác động chính tới người dùng hoặc xã hội.\n• Những lợi ích tiềm năng nếu được triển khai đúng cách.\n• Các rủi ro hoặc hiểu lầm cần tránh.\n• Gợi ý hướng tiếp cận cân bằng.\n\nKết luận: Nên nhìn chủ đề này như một xu hướng cần theo dõi liên tục, thay vì một hiện tượng nhất thời."
177
+ post = make_post(topic, text, image, "", "topic")
178
+ posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
179
+ return JSONResponse({"post": post})
180
+
181
+ @app.post("/api/ai/url")
182
+ async def api_ai_url(request: Request):
183
+ body = await request.json()
184
+ url = _clean_text(body.get("url", ""))
185
+ if not url.startswith("http"):
186
+ return JSONResponse({"error":"missing url"}, status_code=400)
187
+ try:
188
+ data = scrape_any_url(url)
189
+ except Exception:
190
+ return JSONResponse({"error":"Không scrape được URL"}, status_code=422)
191
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
192
+ prompt = f"""
193
+ Hãy đọc nội dung scrape dưới đây và viết lại thành một bài ngắn gọn đưa lên Tường AI VNEWS.
194
+
195
+ Yêu cầu:
196
+ - Không cắt khúc, không bỏ mất ý chính.
197
+ - 3-6 đoạn ngắn, văn phong tự nhiên.
198
+ - Giữ sự kiện, nhân vật, bối cảnh đúng theo nguồn.
199
+ - Không thêm chi tiết không có trong nguồn.
200
+ - Có thể dùng ảnh như bối cảnh thị giác nếu ảnh được cung cấp.
201
+
202
+ Tiêu đề gốc: {data.get('title','')}
203
+ Nội dung gốc:
204
+ {raw[:15000]}
205
+ """
206
+ text = await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1700)
207
+ if not text:
208
+ text = fallback_rewrite(data.get("title", "Bài viết"), raw)
209
+ post = make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "url")
210
+ posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
211
+ return JSONResponse({"post": post})
212
+
213
+ @app.post("/api/rewrite_share")
214
+ async def api_rewrite_share(request: Request):
215
+ body = await request.json()
216
+ url = _clean_text(body.get("url", ""))
217
+ if not url.startswith("http"):
218
+ return JSONResponse({"error":"missing url"}, status_code=400)
219
+ try:
220
+ data = scrape_any_url(url)
221
+ except Exception:
222
+ return JSONResponse({"error":"Không đọc được bài viết"}, status_code=422)
223
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
224
+ prompt = f"Viết lại bài sau bằng tiếng Việt tự nhiên, đầy đủ ý, không cắt cụt, 4-7 đoạn:\n\nTiêu đề: {data.get('title','')}\n\nNội dung:\n{raw[:15000]}"
225
+ text = await qwen_generate(prompt, image_url=data.get("image") or None, max_tokens=1700)
226
+ if not text:
227
+ text = fallback_rewrite(data.get("title", "Bài viết"), raw)
228
+ post = make_post(data.get("title") or "Bài viết", text, data.get("image") or "", url, "rewrite")
229
+ posts = _load_ai_wall(); posts.insert(0, post); _save_ai_wall(posts)
230
+ return JSONResponse({"post": post})
231
+
232
+ # Override index route to inject AI composer/wall without modifying large index.html.
233
+ app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
234
+
235
+ AI_INJECT = r'''
236
+ <style>
237
+ .ai-compose{margin:6px 4px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;color:#5cb87a;font-weight:800;margin-bottom:8px}.ai-row{display:flex;gap:6px;margin-bottom:6px}.ai-row input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px}.ai-row button{background:#2d8659;border:0;color:white;border-radius:8px;padding:8px 10px;font-size:12px;font-weight:700}.ai-row button.secondary{background:#333}.ai-wall-extra{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-card button{margin-top:8px;width:100%;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px}</style>
238
+ <script>
239
+ (function(){
240
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
241
+ let aiWall=[];
242
+ async function refreshAiWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();aiWall=j.posts||[];renderAiPanel();}catch(e){}}
243
+ function renderAiPanel(){const home=document.getElementById('view-home');if(!home)return;let old=document.getElementById('ai-compose-block');if(old)old.remove();let oldw=document.getElementById('ai-wall-block');if(oldw)oldw.remove();let box=document.createElement('div');box.id='ai-compose-block';box.className='ai-compose';box.innerHTML='<div class="ai-compose-title">🤖 Tạo bài AI</div><div class="ai-row"><input id="ai-topic-input" placeholder="Nhập gợi ý chủ đề bài viết..."><button onclick="aiCreateTopic()">Tạo dàn ý</button></div><div class="ai-row"><input id="ai-url-input" placeholder="Dán URL để AI scrape và tóm tắt lên tường..."><button class="secondary" onclick="aiCreateUrl()">Chèn URL</button></div><div id="ai-compose-status" style="font-size:11px;color:#888"></div>';home.prepend(box);if(aiWall.length){let wrap=document.createElement('div');wrap.id='ai-wall-block';wrap.className='ai-wall-extra';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track">';aiWall.slice(0,20).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><button onclick="aiReadWall(${i})">Xem đầy đủ</button></div>`});h+='</div>';wrap.innerHTML=h;box.after(wrap);}}
244
+ window.aiReadWall=function(i){const p=aiWall[i];if(!p)return;showView('view-article');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>${p.img?`<img class="article-img" src="${p.img}">`:''}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
245
+ async function postJson(url,data){const st=document.getElementById('ai-compose-status');if(st)st.textContent='AI đang xử lý...';const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(st)st.textContent='Đã đăng lên Tường AI';await refreshAiWall();return j;}
246
+ window.aiCreateTopic=function(){const v=document.getElementById('ai-topic-input')?.value.trim();if(!v)return alert('Nhập chủ đề');postJson('/api/ai/topic',{topic:v}).catch(e=>alert(e.message));};
247
+ window.aiCreateUrl=function(){const v=document.getElementById('ai-url-input')?.value.trim();if(!v)return alert('Dán URL');postJson('/api/ai/url',{url:v}).catch(e=>alert(e.message));};
248
+ const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){await oldLoad.apply(this,arguments);setTimeout(refreshAiWall,50);};}
249
+ setTimeout(refreshAiWall,1200);
250
+ })();
251
+ </script>
252
+ '''
253
+
254
+ @app.get("/")
255
+ async def index_ai():
256
+ with open("/app/static/index.html", "r", encoding="utf-8") as f:
257
+ html = f.read()
258
+ return HTMLResponse(html.replace("</body>", AI_INJECT + "\n</body>"))