feat: 军事科技每日资讯推送系统 - Docker部署 + 日志系统 + 数据目录重组
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
AI分析模块:四维度评分、智能分类、中文摘要、分类洞察、网摘生成
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from .config import SCORE_DIMENSIONS, WECHAT_SOURCES, AI_CONCURRENCY
|
||||
from .utils import call_ai
|
||||
from .logger import get_logger
|
||||
|
||||
|
||||
def score_and_classify_article(article, api_key, base_url, model, index=0, total=0):
|
||||
"""
|
||||
AI三合一分析:四维度评分 + 中文摘要 + 智能分类
|
||||
一次API调用完成三项任务,返回包含 scores/summary/category 的字典
|
||||
"""
|
||||
logger = get_logger()
|
||||
title = article['translated_title']
|
||||
content = article['translated_content'][:1000] if article['translated_content'] else article['translated_title']
|
||||
source = article['source']
|
||||
|
||||
dimensions_desc = '\n'.join(
|
||||
f'- {d["name"]} (权重 {int(d["weight"]*100)}%): {d["description"]}'
|
||||
for d in SCORE_DIMENSIONS
|
||||
)
|
||||
|
||||
prompt = f"""请对以下军事科技文章进行分析。
|
||||
|
||||
【文章标题】:{title}
|
||||
|
||||
【文章内容摘要】:{content}
|
||||
|
||||
【文章来源】:{source}
|
||||
|
||||
【评分维度】:
|
||||
{dimensions_desc}
|
||||
|
||||
【输出要求】:
|
||||
请严格按照以下 JSON 格式返回(不要其他文字说明):
|
||||
{{
|
||||
"scores": {{
|
||||
"维度名称1": 分数(1-10的整数),
|
||||
"维度名称2": 分数(1-10的整数)
|
||||
}},
|
||||
"summary": "2-3句话的高质量中文摘要,突出技术参数、战略影响、国际竞争格局",
|
||||
"category": "文章分类,必须是以下之一:装备动态/地区冲突/战略政策"
|
||||
}}
|
||||
|
||||
只输出 JSON,不要其他内容。"""
|
||||
|
||||
try:
|
||||
result = call_ai([
|
||||
{'role': 'system', 'content': '你是专业的军事科技分析专家,擅长武器装备分析、战略评估。'},
|
||||
{'role': 'user', 'content': prompt}
|
||||
], api_key, base_url, model, temperature=0.2, max_tokens=800, purpose="AI评分分类")
|
||||
|
||||
json_match = re.search(r'\{[\s\S]*\}', result)
|
||||
if not json_match:
|
||||
raise ValueError('未找到JSON输出')
|
||||
|
||||
parsed = json.loads(json_match[0])
|
||||
if index > 0 and total > 0:
|
||||
logger.info("AI分析完成 %d/%d: %s...", index, total, title[:30])
|
||||
return (article, parsed)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("AI处理失败 '%s...': %s", title[:30], e)
|
||||
default_scores = {d['name']: 5 for d in SCORE_DIMENSIONS}
|
||||
default_result = {
|
||||
'scores': default_scores,
|
||||
'summary': title,
|
||||
'category': '装备动态'
|
||||
}
|
||||
return (article, default_result)
|
||||
|
||||
|
||||
def batch_score_and_classify_articles(articles, api_key, base_url, model, max_concurrent=None):
|
||||
"""批量对文章进行AI评分与分类,并发执行
|
||||
返回处理后的文章列表,文章已包含ai_result、final_score、summary、category等字段
|
||||
"""
|
||||
logger = get_logger()
|
||||
if not articles:
|
||||
return []
|
||||
|
||||
if max_concurrent is None:
|
||||
max_concurrent = AI_CONCURRENCY
|
||||
|
||||
logger.info("开始批量AI分析,共%d篇文章,并发数:%d", len(articles), max_concurrent)
|
||||
processed_articles = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_concurrent) as executor:
|
||||
future_to_article = {
|
||||
executor.submit(score_and_classify_article, article, api_key, base_url, model, i+1, len(articles)): article
|
||||
for i, article in enumerate(articles)
|
||||
}
|
||||
|
||||
for future in as_completed(future_to_article):
|
||||
article, ai_result = future.result()
|
||||
article['ai_result'] = ai_result
|
||||
article['final_score'] = calculate_weighted_score(ai_result)
|
||||
article['summary'] = ai_result.get('summary', article['translated_title'])
|
||||
article['category'] = ai_result.get('category', '装备动态')
|
||||
article['scores'] = ai_result.get('scores', {})
|
||||
processed_articles.append(article)
|
||||
|
||||
logger.info("批量AI分析完成,共处理%d篇文章", len(processed_articles))
|
||||
return processed_articles
|
||||
|
||||
|
||||
def calculate_weighted_score(ai_result):
|
||||
"""根据四维度分数和权重计算加权总分"""
|
||||
scores = ai_result.get('scores', {})
|
||||
total = 0.0
|
||||
for dim in SCORE_DIMENSIONS:
|
||||
score = scores.get(dim['name'], 5)
|
||||
total += score * dim['weight']
|
||||
return round(total, 1)
|
||||
|
||||
|
||||
def generate_category_overview(category_name, articles, api_key, base_url, model):
|
||||
"""为某分类生成50字左右的总体情况介绍"""
|
||||
logger = get_logger()
|
||||
if not articles:
|
||||
return ""
|
||||
|
||||
article_titles = "\u3001".join(a['translated_title'][:15] for a in articles[:5])
|
||||
|
||||
prompt = f"""请为以下军事科技新闻分类生成50字左右的总体情况介绍。
|
||||
|
||||
【分类名称】:{category_name}
|
||||
|
||||
【本分类文章】:{article_titles}
|
||||
|
||||
【要求】:
|
||||
- 50字左右,简洁专业
|
||||
- 概括本分类今日核心看点或趋势
|
||||
- 军事科技专业风格,不要口语化
|
||||
|
||||
直接输出文字,不要其他格式。"""
|
||||
|
||||
try:
|
||||
result = call_ai([
|
||||
{'role': 'system', 'content': '你是专业的军事科技编辑,擅长撰写简洁专业的新闻综述。'},
|
||||
{'role': 'user', 'content': prompt}
|
||||
], api_key, base_url, model, temperature=0.3, max_tokens=150, purpose="分类介绍")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
logger.warning("生成分类介绍失败: %s", e)
|
||||
return ""
|
||||
|
||||
|
||||
def generate_webzine_for_article(article, api_key, base_url, model, max_retries=3):
|
||||
"""
|
||||
为单篇文章生成《参考消息》风格的网摘
|
||||
支持字数校验重试(正文280-320字、价值点35-45字),最多重试3次
|
||||
"""
|
||||
logger = get_logger()
|
||||
source_name = article['source']
|
||||
publish_time = article['published'].strftime('%Y年%m月%d日')
|
||||
|
||||
is_wechat = source_name in WECHAT_SOURCES
|
||||
source_prefix = f"据公众号 {source_name} 报道" if is_wechat else f"据 {source_name} 报道"
|
||||
|
||||
prompt = f"""文章标题:{article['translated_title']}
|
||||
文章来源:{source_name}
|
||||
文章发布时间:{publish_time}
|
||||
文章内容:{article.get('summary', article.get('content', '')[:800])}
|
||||
文章链接:{article['link']}
|
||||
|
||||
【重要警告:字数必须严格达标,否则视为不合格!】
|
||||
|
||||
请你严格按照 《参考消息》官方新闻报道格式、标题风格 ,对我提供的新闻内容进行改写,严格遵守以下所有规则:
|
||||
|
||||
标题要求
|
||||
简洁、客观、中性、信息密度高
|
||||
结构:主体 + 事件 + 核心态势
|
||||
不抒情、不夸张、不用网络用语
|
||||
|
||||
正文格式要求
|
||||
开头第一句必须加:{source_prefix}
|
||||
正文风格:客观、平实、严谨、书面化,类似外电编译稿
|
||||
必须充分展开:补充背景、说明意义、分析影响、展望前景,确保内容充实
|
||||
只保留核心信息:时间、地点、人物、事件、内容、前景
|
||||
全文一段到底,不分段
|
||||
|
||||
【字数强制要求 - 必须严格执行】
|
||||
正文字数严格控制在 280~320 字(含标点),一个字都不能少,一个字都不能多!
|
||||
300字左右的正文示例长度:大概5-6个完整的长句,涵盖事件背景、核心内容、技术细节、战略意义、未来影响五个层面。
|
||||
如果内容不够,请合理补充相关背景、行业态势、同类项目对比等专业内容,确保达到字数要求。
|
||||
|
||||
价值点要求
|
||||
正文结束后空一行,再写价值点
|
||||
价值点严格 35~45 字(含标点)
|
||||
句式结构:事件 - 影响 / 后果 - 值得关注
|
||||
凝练、客观、不发散
|
||||
|
||||
输出格式
|
||||
标题:新闻标题
|
||||
原标题:原文章标题
|
||||
发布日期:严格使用上面提供的「{publish_time}」,不要修改
|
||||
正文:改写后的正文内容
|
||||
价值点:价值点内容
|
||||
|
||||
注意:每一行都必须以「类别:内容」的格式输出,类别严格使用上面的5个名称,不要修改。"""
|
||||
|
||||
last_result = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = call_ai([
|
||||
{'role': 'system', 'content': '你是《参考消息》资深编辑,擅长撰写客观、严谨、专业的新闻报道,对字数控制极其精准。'},
|
||||
{'role': 'user', 'content': prompt}
|
||||
], api_key, base_url, model, temperature=0.4, max_tokens=800, purpose="网摘生成")
|
||||
|
||||
result = result.strip()
|
||||
last_result = result
|
||||
|
||||
lines = result.split('\n')
|
||||
body_text = ''
|
||||
for line in lines:
|
||||
if line.startswith('正文:'):
|
||||
body_text = line[3:].strip()
|
||||
break
|
||||
|
||||
body_len = len(body_text)
|
||||
if 280 <= body_len <= 320:
|
||||
if attempt > 0:
|
||||
logger.info("第%d次尝试成功,字数:%d", attempt + 1, body_len)
|
||||
return result
|
||||
else:
|
||||
logger.warning("第%d次尝试字数不达标:%d字(要求280-320),重试中...", attempt + 1, body_len)
|
||||
if body_len < 280:
|
||||
prompt += f"\n\n【上次反馈:正文只有{body_len}字,太少!请大幅增加内容,补充更多背景、细节和分析,确保达到280-320字!】"
|
||||
else:
|
||||
prompt += f"\n\n【上次反馈:正文有{body_len}字,太多!请精简内容,删除冗余描述,控制在280-320字以内!】"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("第%d次尝试失败: %s", attempt + 1, e)
|
||||
|
||||
logger.warning("重试%d次仍未达标,返回最后结果", max_retries)
|
||||
return last_result if last_result else ""
|
||||
Reference in New Issue
Block a user