1.背景
花了整整兩天時間,本qiang~開發了一個關於AI新聞資訊的自動聚合及報告生成工具。
本篇記錄一下整體的框架和實現原理,並且本著它山之石可以攻玉,本qiang~開放了所有的原始碼,原始碼可見如下第5章節,感謝各位看官的大力支援。如有問題,可私信或留言溝通。
成品可以參考連結:《AI資訊每日速遞(2024.11.05)》
2.為什麼要做這件事?
深處AI時代,想要追趕前沿的一手技術與資訊,有一個工具能夠實時獲取每天的重點內容,包括諮詢和技術相關內容,並且能夠按照公司及內容的優先順序進行篩選,然後午後捧著一杯奶茶,點開自動生成的報告,豈不美哉美哉?
3.相關技術
- Crawl4ai: 一塊整合LLM的開源爬蟲工具
- Swarm: OpenAI釋出的Multi-Agent編排框架,可以參考本人先前的辛苦整理:《LLM應用實戰: OpenAI多代理框架-Swarm》
- Python-docx: word的操作工具
- Textdistance: 用於報告模組中資訊排序結果與原始資訊結果的對齊
- Gpt-4o-mini: 採用的大模型是gpt-4o-mini,每日免費呼叫200次,不夠用...
4.整體框架
整體框架分為三個模組:
4.1下載模組
下載模組的資料來源包括各大AI新聞網站及知名部落格,然後透過開源爬蟲工具crawl4ai進行爬取,爬取的維度包括標題、內容、圖片等。
4.2解析模組
解析模組是針對爬取的結果進行解析,採用OpenAi Swarm框架,包含4個Agent,其中Analysis Agent是主體Agent,遍歷下載的每一個資訊,將每條資訊分別同步給其他Agent完成具體的解析任務。其中Translator Agent主要功能是翻譯,將英文翻譯為中文;Classifier Agent主要功能是針對資訊進行分類,如涉及技術還是產品之類的;Modifier Agent主要功能是將資訊的標題和內容進行改寫,標題可以改寫更醒目一些,內容主要是提取摘要資訊。
Analysis Agent負責串聯其他3個Agent,每個Agent結束後均會返回到Analysis Agent,以便讓Analysis Agent決定下一步的操作。
4.3報告模組
報告模組包含Sorter Agent,主要功能是將解析後的資訊按照公司、內容等維度進行排序,然後篩選出其中相對排名較高的資訊。
經過排序Agent後,最終將結果儲存為word。
5.全部原始碼
5.1下載模組
採用crawl4ai工具進行網站爬取,示例的網站是https://www.aibase.com,網站存在中文及英文,但增加翻譯Agent是為了相容其他網站。
1. 檔案處理file_util.py
import json import hashlib def get_datas(file_path, json_flag=True, all_flag=False, mode='r'): """讀取文字檔案""" results = [] with open(file_path, mode, encoding='utf-8') as f: for line in f.readlines(): if json_flag: results.append(json.loads(line)) else: results.append(line.strip()) if all_flag: if json_flag: return json.loads(''.join(results)) else: return '\n'.join(results) return results def save_datas(file_path, datas, json_flag=True, all_flag=False, with_indent=False, mode='w'): """儲存文字檔案""" with open(file_path, mode, encoding='utf-8') as f: if all_flag: if json_flag: f.write(json.dumps(datas, ensure_ascii=False, indent= 4 if with_indent else None)) else: f.write(''.join(datas)) else: for data in datas: if json_flag: f.write(json.dumps(data, ensure_ascii=False) + '\n') else: f.write(data + '\n')
2. 網站爬取web_crawler.py
from crawl4ai import AsyncWebCrawler from crawl4ai.extraction_strategy import JsonCssExtractionStrategy import json from typing import Dict, Any, Union, List from bs4 import BeautifulSoup from file_util import * import os import datetime import re import requests class AbstractAICrawler(): def __init__(self) -> None: pass def crawl(): raise NotImplementedError() class AINewsCrawler(AbstractAICrawler): def __init__(self, domain) -> None: super().__init__() self.domain = domain self.file_path = f'data/{self.domain}.json' self.history = self.init() def init(self): if not os.path.exists(self.file_path): return {} return {ele['id']: ele for ele in get_datas(self.file_path)} def save(self, datas: Union[List, Dict]): if isinstance(datas, dict): datas = [datas] self.history.update({ele['id']: ele for ele in datas}) save_datas(self.file_path, datas=list(self.history.values())) async def crawl(self, url:str, schema: Dict[str, Any]=None): extraction_strategy = JsonCssExtractionStrategy(schema, verbose=True) if schema else None async with AsyncWebCrawler(verbose=True) as crawler: result = await crawler.arun( url=url, extraction_strategy=extraction_strategy, bypass_cache=True, ) assert result.success, "Failed to crawl the page" if schema: return json.loads(result.extracted_content) return result.cleaned_html class AIBasesCrawler(AINewsCrawler): def __init__(self) -> None: self.domain = 'aibase' super().__init__(self.domain) self.url = 'https://www.aibase.com' async def crawl_home(self, url='https://www.aibase.com/news'): schema = { 'name': 'ai base home page crawler', 'baseSelector': '.flex', 'fields': [ { 'name': 'link', 'selector': 'a[rel="noopener noreferrer"]', 'type': 'nested_list', 'fields': [ {'name': 'href', 'type': 'attribute', 'attribute':'href'} ] } ] } links = await super().crawl(url, schema) links = [link['href'] for ele in links for link in ele['link']] links = list(set([f'{self.url}{ele}' for ele in links if ele.startswith('/news')])) links = sorted(links, key=lambda x: x, reverse=True) return links async def crawl_newsletter_cn(self, url): html = await super().crawl(url) body = BeautifulSoup(html, 'html.parser') title = body.select_one('h1').get_text().replace('\u200b', '').strip() date = [ele.get_text().strip() for ele in body.find_all('span') if re.match(r'(\d{4}年\d{1,2}月\d{1,2}號)', ele.get_text().strip())][0] date = datetime.datetime.strptime(date, '%Y年%m月%d號 %H:%M').strftime("%Y-%m-%d") content = '\n'.join([ele.get_text().strip().replace('\n', '').replace(' ', '') for ele in body.find_all('p')]) content = content[:content.index('劃重點:')].strip() if '劃重點:' in content else content return { 'title': title, 'link': url, 'content': content, 'date': date } async def crawl_home_cn(self, url='https://www.aibase.com/zh/news'): schema = { 'name': 'ai base home page crawler', 'baseSelector': '.flex', 'fields': [ { 'name': 'link', 'selector': 'a[rel="noopener noreferrer"]', 'type': 'nested_list', 'fields': [ {'name': 'href', 'type': 'attribute', 'attribute':'href'} ] } ] } links = await super().crawl(url, schema) links = [link['href'] for ele in links for link in ele['link']] links = list(set([f'{self.url}{ele}' for ele in links if ele.startswith('/zh/news')])) links = sorted(links, key=lambda x: x, reverse=True) return links async def crawl_newsletter(self, url): html = await super().crawl(url) body = BeautifulSoup(html, 'html.parser') title = body.select_one('h1').get_text().replace('\u200b', '').strip() date = ';'.join([ele.get_text().strip() for ele in body.find_all('span')]) date = re.findall(r'(\b\w{3}\s+\d{1,2},\s+\d{4}\b)', date)[0] date = datetime.datetime.strptime(date, '%b %d, %Y').strftime("%Y-%m-%d") content = '\n'.join([ele.get_text().strip().replace('\n', '') for ele in body.find_all('p')]) content = content[:content.index('Key Points:')].strip() if 'Key Points:' in content else content pic_urls = [ele.get('src').strip() for ele in body.select('img') if ele.get('title')] pic_url = pic_urls[0] if pic_urls else '' pic_url = pic_url.replace('\\"', '') pic_path = '' if pic_url: pic_path = f'data/images/{md5(url)}.jpg' response = requests.get(pic_url) if response.status_code == 200: with open(pic_path, 'wb') as f: f.write(response.content) return { 'title': title, 'link': url, 'content': content, 'date': date, 'pic': pic_path, 'id': md5(url) } async def crawl(self): links = await self.crawl_home() results = [] for link in links: _id = md5(link) if _id in self.history: continue results.append({ 'id': _id, 'link': link, 'contents': await self.crawl_newsletter(link), 'time': datetime.datetime.now().strftime('%Y-%m-%d') }) self.save(results) return await self.get_last_day_data() async def get_last_day_data(self): last_day = (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d') datas = self.init() for v in datas.values(): v['contents']['id'] = v['id'] return [v['contents'] for v in datas.values() if v['contents']['date'] == last_day]
5.2解析模組
1. 解析提示語prompt.py
ANALYST = """你是一個AI領域的分析師,主要工作步驟如下: 1. 首先執行transform_to_translate_agent方法,切換到translate agent,執行翻譯任務; 2. 然後再執行transform_to_classifier_agent,呼叫classifier agent,針對內容進行分類; 3. 接著再執行transform_to_modifier_agent,呼叫modifier agent,針對內容進行改寫; 4. 前三步執行完畢後,意味著整個分析工作已經完成,最後呼叫finish方法,退出該整個工作流程。 需要注意的是:每個步驟必須執行完成後,才能執行後續的步驟,且同時只能有1個步驟在執行;如果modifier agent已經執行完畢,一定要呼叫finish退出整體工作流程。 """ TRANSLATE = """你現在是一個AI領域的翻譯專家,請將如下英文的標題和內容分別翻譯為中文。步驟及要求如下: 1. 首先呼叫translate方法進行翻譯,要求如下: a. 需要注意的標題和內容中如果包含公司名稱、產品名稱、技術名稱等專業詞彙,針對這些專業詞彙需要保留英文形式,其他非專業詞彙需要翻譯為中文,注意標題也必須翻譯; b. 輸出格式為 "標題: xxxxx\n內容: xxxxx",且需要保留換行符; c. 注意該translate方法沒有輸入引數,返回的結果只是需要翻譯的原始文字,需要你執行翻譯操作,然後返回翻譯結果; d. 該translate方法執行完成後,需要你執行具體的翻譯,等待翻譯完成後,才能開展下一個步驟,不能直接將原文作為引數傳給下一個步驟; 2. 抽取完成後,執行extract_translate_result方法,要求如下: a. 該extract_translate_result方法存在1個輸入引數,即執行1後得到的翻譯結果 3. 待步驟2執行完成後,執行transform_to_analysis_agent方法,切換至analysis agent,執行其他工作。 4. 步驟1,2,3必須按照順序執行,且同時只能有1個步驟在執行 5. 如果歷史記錄中已經執行了任何步驟,注意嚴格禁止再次重複執行,而要直接執行未執行的步驟, """ CLASSIFIER = """你是一個AI領域的分類器,請判斷輸入是否與AI的技術相關。步驟及要求如下: 1. 首先呼叫classify方法進行分類,要求如下: a. 輸入的內容包括標題和內容兩部分,重點基於內容進行判斷這條資訊是否與AI技術相關; b. 如果是相關技術細節、技術原理、程式碼說明、架構說明,則輸出"是",如果是與公司的最新資訊相關,如發行新產品、成立新部門、公司合作等非技術相關的,則輸出"否" c. 輸出的結果只能是"是"、"否"兩個選項中的一個,不要輸出其他內容,包括解釋資訊等。 d. 注意該classify方法沒有輸入引數,返回的結果只是需要分類的原始文字,需要你執行分類任務,然後返回分類結果; 2. 獲取到分類結果後,執行extract_classify_result方法,要求如下: a. 該extract_classify_result方法存在1個輸入引數,即執行1後得到的分類結果 3. 待步驟2執行完成後,執行transform_to_analysis_agent方法,切換至analysis agent,執行其他工作 4. 步驟1,2,3必須按照順序執行,且同時只能有1個步驟在執行 5. 如果歷史記錄中已經執行了任何步驟,注意嚴格禁止再次重複執行,而要直接執行未執行的步驟, """ MODIFIER = """你是一個AI新聞的改寫器,請基於輸入中的標題和內容進行改寫。步驟及要求如下: 1. 首先呼叫modify方法進行改寫,要求如下: a. 輸入的內容包括"標題"和"內容"兩部分,需要分別針對"標題"和"內容"進行改寫; b. "標題"的改寫目標是需要醒目且具有吸引力,能夠吸引讀者進一步閱讀,要求字數不能超過30字; c. "內容"需要摘要總結,需要準確提取主要內容,要求字數不超過200字; d. 輸出格式為 "標題: xxxx\n內容: xxxxx",且需要保留換行符,"標題"和"內容"需要以輸入的中文為準; e. 注意該modify方法沒有輸入引數,返回的結果是需要改寫的原始文字,需要你執行改寫任務,然後返回改寫結果; 2. 獲取到改寫結果後,執行extract_modify_result方法,要求如下: a. 該extract_modify_result方法存在1個輸入引數,即執行1後得到的改寫結果 3. 待步驟2執行完成後,執行transform_to_analysis_agent方法,切換至analysis agent,執行其他工作 4. 步驟1,2,3必須按照順序執行,且同時只能有1個步驟在執行 5. 如果歷史記錄中已經執行了任何步驟,注意嚴格禁止再次重複執行,而要直接執行未執行的步驟 """
2. 解析Agent整體流程agent.py
agent copy 2from swarm import Swarm, Agent from web_crawler import AIBasesCrawler import asyncio from prompt import * from file_util import * from tqdm import tqdm import datetime client = Swarm() def download(): return asyncio.run(AIBasesCrawler().crawl()) def transform_to_analysis_agent(): return analysis_agent def transform_to_translate_agent(): return translate_agent def transform_to_classifier_agent(): return classifier_agent def transform_to_modifier_agent(): return modifier_agent def translate(context_variables): return f'現在請按要求翻譯如下內容:\n標題: {context_variables["title"]}\n內容: {context_variables["content"]}' def extract_translate_result(result: str, context_variables: dict): """翻譯的結果進行抽取 Args: result (str): 翻譯結果 Returns: str: 翻譯結果提取結束標誌 """ context_variables['title_zh'] = result[result.index('標題:')+len('標題:'):result.index('內容:')] context_variables['content_zh'] = result[result.index('內容:')+len('內容:'):] return '翻譯結果提取任務已經完成,請繼續下一步操作。' def classify(context_variables): return f'現在請按要求針對以下內容進行分類,\n輸入:\n標題: {context_variables["title_zh"]}\n內容: {context_variables["content_zh"]},\n輸出:' def extract_classify_result(result: str, context_variables: dict): """分類的結果進行抽取 Args: result (str): 翻譯結果 Returns: str: 分類結果提取結束標誌 """ context_variables['classify'] = result return '分類結果提取任務已經完成,請繼續下一步操作。' def modify(context_variables): return f'現在請按要求針對以下內容進行改寫,\n輸入:\n標題: {context_variables["title_zh"]}\n內容: {context_variables["content_zh"]},\n輸出:' def extract_modify_result(result: str, context_variables: dict): """改寫的結果進行抽取 Args: result (str): 改寫結果 Returns: str: 改寫結果提取結束標誌 """ context_variables['title_modify'] = result[result.index('標題:')+len('標題:'):result.index('內容:')] context_variables['content_modify'] = result[result.index('內容:')+len('內容:'):] return '改寫結果提取任務已經完成,請繼續下一步操作。' def finish(): return '分析任務已經完成,請直接退出整個工作流程,直接輸出"退出"。' analysis_agent = Agent(name='analysis_agent', instructions=ANALYST, functions=[transform_to_translate_agent, transform_to_classifier_agent, transform_to_modifier_agent, finish]) translate_agent = Agent(name='translate_agent', instructions=TRANSLATE, functions=[translate, extract_translate_result, transform_to_analysis_agent]) classifier_agent = Agent(name='classifier_agent', instructions=CLASSIFIER, functions=[classify, extract_classify_result, transform_to_analysis_agent]) modifier_agent = Agent(name='modifier_agent', instructions=MODIFIER, functions=[modify, extract_modify_result, transform_to_analysis_agent]) output_file_pre = (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y.%m.%d') output_path = f'data/{output_file_pre}_final_results.json' results = get_datas(output_path) process_ids = [data['id'] for data in results] for data in tqdm(download()): if data['id'] in process_ids: continue context_variables = {'title': data['title'], 'content': data['content']} try: result = client.run(analysis_agent, messages=[{"role": "user", "content": "現在,請開始分析!"}], context_variables=context_variables, debug=True) context_variables = result.context_variables data['title_zh'] = context_variables['title_zh'] data['content_zh'] = context_variables['content_zh'] data['classify'] = context_variables['classify'] data['title_modify'] = context_variables['title_modify'] data['content_modify'] = context_variables['content_modify'] save_datas(output_path, [data], mode='a') except Exception as e: print(e) continue
5.3報告模組
1. 排序提示語prompt.py
SORTER = """你是一個AI新聞的排序助手,請給予輸入的新聞標題進行排序。要求如下: 1. 排序的規則是基於標題中所提及公司、組織機構的名氣和重要性進行排序,名氣和重要性是基於你所學的知識進行排序,名氣和重要性越高,排名越靠前; 2. 排序的結果只返回名氣最高的top10即可,輸出的格式為"1xxxxx\n2xxxxx\n3xxxxx...\n10xxxxx",注意一定要以"\n"進行換行; 3. 輸出的每個標題,需要和輸入中對應的標題保持完全一致,禁止更改; """
2. 排序流程agent.py
from swarm import Swarm, Agent from prompt import * from file_util import * from collections import defaultdict import re import textdistance from word_util import save_2_word import datetime import random client = Swarm() output_file_pre = (datetime.date.today() - datetime.timedelta(days=1)).strftime('%Y.%m.%d') output_path = f'data/{output_file_pre}_final_results.json' sort_agent = Agent(name='sort_agent', instructions=SORTER) datas = get_datas(output_path) for ele in datas: ele['title_modify'] = ele['title_modify'].strip() ele['content_modify'] = ele['content_modify'].strip() def get_most_similar(t1, texts): most_similarity = 0.0 most_similar_text = '' for ele in texts: similarity = textdistance.levenshtein.similarity(t1, ele) if similarity > most_similarity: most_similarity = similarity most_similar_text = ele return most_similar_text type_2_title = defaultdict(list) {type_2_title[ele['classify']].append(ele['title_modify']) for ele in datas} title_2_data = {ele['title_modify']: ele for ele in datas} final_results = defaultdict(list) for k, v in type_2_title.items(): content = "\n".join([ele for ele in v]) message = f'現在請根據你所學習的知識,按照要求對以下輸入進行排序,並且按照輸出格式進行輸出,\n輸入:\n{content},\n輸出:' result = client.run(sort_agent, messages=[{"role": "user", "content": message}], debug=True) sort_results = [ele['content'] for ele in result.messages[::-1] if 'content' in ele and ele['content'] and ele['content']] sort_results = sort_results[0].split('\n') if sort_results else random.sample(v, 10) sort_results = [re.sub(r'^\d+[\.,、\s]*', '', ele).strip() for ele in sort_results] final_results[k].extend([title_2_data[get_most_similar(ele, list(title_2_data.keys()))] for ele in sort_results]) sort_output = f'data/{output_file_pre}_sort_results.json' save_datas(sort_output, [final_results]) # 生成word save_2_word(final_results, output_file_pre)
3. 報告生成word_util.py
from docx import Document from docx.shared import Inches, Pt, RGBColor from docx.enum.text import WD_PARAGRAPH_ALIGNMENT import os def save_2_word(info_dict, file_pre): doc = Document() categories = ['否', '是'] category_color = 'FF5733' for category in categories: news = info_dict[category] category_paragraph = doc.add_paragraph() category = '技術' if category == '是' else '資訊' category_run = category_paragraph.add_run(category) category_run.bold = True category_run.font.size = Pt(25) category_run.font.color.rgb = RGBColor.from_string(category_color) category_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER for i, item in enumerate(news): title = item['title_modify'] doc.add_heading(f'{i+1}. {title}', level=1) pic = item['pic'] if 'pic' in item else '' if pic and os.path.exists(pic): pic_paragraph = doc.add_paragraph() pic_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER doc.add_picture(pic, width=Inches(5)) content = item['content_modify'] doc.add_paragraph(content) doc.save(f'data/AI資訊每日速遞({file_pre}).docx')
6.最佳化思考
1. 爬取模組目前是序列下載,且未增加反爬機制,後續可以增加多執行緒,且增加代理池機制。
2. 免費的gpt-4o-mini每日呼叫次數僅有200次,執行本任務遠遠不夠,因此後期嘗試切換為私有部署的Qwen2.5。
其實已經嘗試了Qwen2.5,以vllm部署,但與Swarm框架中的OpenAi介面存在少許不相容,例如不支援特定的引數,只能執行一輪。不過可以進一步最佳化Swarm框架來進行適配。
本次實驗本qiang~花費了30大洋,買了一個gpt-4o-mini,生成最終結果,直接耗費了其中的8個大洋,燒錢....
3. 資訊推送機制不支援,如一鍵同步到公眾號、CSDN、知乎,這塊如果有精力可以基於網站的開發介面,實現一鍵自動釋出文章。
7.總結
一句話足矣~
開發了一塊AI資訊的自動聚合及報告生成工具,包括具體的框架、實現原理以及完整原始碼,滿滿誠意,提供給各位看官。歡迎轉發、訂閱~
有問題可以私信或留言溝通!
8.參考
(1) Swarm: https://github.com/openai/swarm
(2) Crawl4ai: https://github.com/unclecode/crawl4ai
(3) 資訊網站: https://www.aibase.com/news