Initial commit: twitter-cli v0.1.0
This commit is contained in:
164
twitter_cli/summarizer.py
Normal file
164
twitter_cli/summarizer.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""AI summarization module.
|
||||
|
||||
Supports OpenAI-compatible (doubao, deepseek, openai) and Anthropic APIs.
|
||||
Uses urllib.request for zero extra dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .models import Tweet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_MESSAGE = "你是一个专业的 Twitter/X 信息流分析师,擅长提炼关键信息和发现趋势。"
|
||||
|
||||
|
||||
def _build_prompt(tweets, language="zh-CN"):
|
||||
# type: (List[Tweet], str) -> str
|
||||
"""Build the summarization prompt."""
|
||||
lines = []
|
||||
for i, t in enumerate(tweets):
|
||||
score_str = " [score: %.1f]" % t.score if t.score else ""
|
||||
rt = " (RT by @%s)" % t.retweeted_by if t.is_retweet and t.retweeted_by else ""
|
||||
media_str = ""
|
||||
if t.media:
|
||||
media_str = " [%s]" % ", ".join(m.type for m in t.media)
|
||||
url_str = ""
|
||||
if t.urls:
|
||||
url_str = "\n Links: %s" % ", ".join(t.urls)
|
||||
quoted = ""
|
||||
if t.quoted_tweet:
|
||||
qt = t.quoted_tweet
|
||||
quoted = "\n Quoting @%s: %s..." % (qt.author.screen_name, qt.text[:100].replace("\n", " "))
|
||||
|
||||
text_preview = t.text.replace("\n", " ")[:300]
|
||||
lines.append(
|
||||
'%d. @%s (%s)%s%s\n'
|
||||
' "%s"\n'
|
||||
' ❤️%d 🔄%d 💬%d 🔖%d 👁️%d%s%s%s'
|
||||
% (
|
||||
i + 1, t.author.screen_name, t.author.name, rt, score_str,
|
||||
text_preview,
|
||||
t.metrics.likes, t.metrics.retweets, t.metrics.replies,
|
||||
t.metrics.bookmarks, t.metrics.views,
|
||||
media_str, url_str, quoted,
|
||||
)
|
||||
)
|
||||
|
||||
tweet_summaries = "\n\n".join(lines)
|
||||
|
||||
if language.startswith("zh"):
|
||||
lang_inst = "请用中文输出。"
|
||||
else:
|
||||
lang_inst = "Please output in %s." % language
|
||||
|
||||
return (
|
||||
"你是一个 Twitter/X 信息流分析师。请对以下 %d 条推文进行摘要总结。\n\n"
|
||||
"要求:\n"
|
||||
"1. 按主题分组(如:AI & 编程、Crypto、工具推荐、生活观点等)\n"
|
||||
"2. 每组列出关键推文和核心观点,标注作者 @handle\n"
|
||||
"3. 标注数据亮点(高赞/高收藏推文用 🔥 标记)\n"
|
||||
"4. 最后用 2-3 句话总结今天 timeline 的整体趋势\n"
|
||||
"5. %s\n\n"
|
||||
"推文数据:\n\n%s"
|
||||
) % (len(tweets), lang_inst, tweet_summaries)
|
||||
|
||||
|
||||
def _call_openai(prompt, config):
|
||||
# type: (str, Dict[str, Any]) -> str
|
||||
"""Call OpenAI-compatible API."""
|
||||
url = config.get("base_url", "").rstrip("/")
|
||||
if not url.endswith("/chat/completions"):
|
||||
if not url.endswith("/v1"):
|
||||
url += "/v1"
|
||||
url += "/chat/completions"
|
||||
|
||||
payload = json.dumps({
|
||||
"model": config.get("model", ""),
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_MESSAGE},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Authorization", "Bearer %s" % config.get("api_key", ""))
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=120) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
return choices[0].get("message", {}).get("content", "")
|
||||
return ""
|
||||
|
||||
|
||||
def _call_anthropic(prompt, config):
|
||||
# type: (str, Dict[str, Any]) -> str
|
||||
"""Call Anthropic Messages API."""
|
||||
url = config.get("base_url", "").rstrip("/")
|
||||
if not url.endswith("/messages"):
|
||||
if not url.endswith("/v1"):
|
||||
url += "/v1"
|
||||
url += "/messages"
|
||||
|
||||
payload = json.dumps({
|
||||
"model": config.get("model", ""),
|
||||
"system": SYSTEM_MESSAGE,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=payload)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("x-api-key", config.get("api_key", ""))
|
||||
req.add_header("anthropic-version", "2023-06-01")
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=120) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
content_blocks = data.get("content", [])
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
return block.get("text", "")
|
||||
return ""
|
||||
|
||||
|
||||
def summarize(tweets, config):
|
||||
# type: (List[Tweet], Dict[str, Any]) -> str
|
||||
"""Summarize tweets using the configured AI provider.
|
||||
|
||||
Config keys: provider, api_key, model, base_url, language
|
||||
"""
|
||||
api_key = config.get("api_key", "")
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"AI API key not configured.\n"
|
||||
"Set ai.api_key in config.yaml or export AI_API_KEY=your_key"
|
||||
)
|
||||
|
||||
if not tweets:
|
||||
return "No tweets to summarize."
|
||||
|
||||
language = config.get("language", "zh-CN")
|
||||
prompt = _build_prompt(tweets, language)
|
||||
provider = config.get("provider", "openai")
|
||||
|
||||
logger.info("Calling AI (%s/%s)...", provider, config.get("model", ""))
|
||||
|
||||
if provider == "anthropic":
|
||||
return _call_anthropic(prompt, config)
|
||||
else:
|
||||
return _call_openai(prompt, config)
|
||||
Reference in New Issue
Block a user