Code has been integrated into kb_workflow/deep_analyzer package. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
# KB Deep Analyzer Skill
|
||||
|
||||
**kb-deep-analyzer** - AI 深度内容分析工具。
|
||||
|
||||
## Features
|
||||
|
||||
- 多类型分析:视频(video)、文章(article)
|
||||
- 模板化输出
|
||||
- 质量评分
|
||||
- 自动标签
|
||||
- **多格式元数据支持**: 支持扁平结构 `{"title":"xxx"}` 和嵌套结构 `{"data":{"metadata":{...}}}`
|
||||
|
||||
## 内容类型
|
||||
|
||||
| 类型 | 参数 | 输出格式 |
|
||||
|------|------|----------|
|
||||
| 视频 | `--type video` | 摘要 + 主题 + 见解 + 时间轴 |
|
||||
| 文章 | `--type article` | 300字摘要 + 主题 + 原文 |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# 分析视频(默认)
|
||||
AI_API_KEY="your-api-key" AI_PROVIDER="zhipu" AI_MODEL="glm-5" \
|
||||
python3 -u deep_analyzer.py --metadata meta.json --content content.json --output analysis.md --type video
|
||||
|
||||
# 分析文章(300字摘要 + 原文)
|
||||
AI_API_KEY="your-api-key" AI_PROVIDER="zhipu" AI_MODEL="glm-5" \
|
||||
python3 -u deep_analyzer.py --metadata meta.json --content content.json --output analysis.md --type article
|
||||
|
||||
# 指定 AI 提供商
|
||||
python3 -u deep_analyzer.py -m meta.json -c content.json -o out.md --type video --provider zhipu --model glm-5
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
**必须设置以下环境变量**:
|
||||
|
||||
```bash
|
||||
export AI_API_KEY="your-api-key"
|
||||
export AI_PROVIDER="zhipu" # 或 openai, minimax
|
||||
export AI_MODEL="glm-5" # 模型名称
|
||||
export AI_BASE_URL="https://..." # 可选,自定义 API 地址
|
||||
```
|
||||
|
||||
### 重要提示
|
||||
|
||||
**必须使用 `-u` 参数运行 Python**,确保输出实时显示在终端:
|
||||
|
||||
```bash
|
||||
python3 -u deep_analyzer.py ...
|
||||
```
|
||||
|
||||
如果不使用 `-u` 参数,Python 的 stdout 缓冲会导致输出延迟或看不到进度。
|
||||
|
||||
## 执行方式选择
|
||||
|
||||
### 方式1: 并发执行(推荐,默认)
|
||||
- 使用 `batch_size=3`(默认)
|
||||
- 6个分片约 100 秒完成
|
||||
- 适合快速分析
|
||||
|
||||
```bash
|
||||
python3 -u deep_analyzer.py --metadata meta.json --content content.json -o out.md
|
||||
```
|
||||
|
||||
### 方式2: 顺序执行
|
||||
- 使用 `batch_size=1`
|
||||
- 6个分片约 735 秒完成
|
||||
- 适合调试和问题排查
|
||||
|
||||
```bash
|
||||
# 在代码中设置 batch_size=1
|
||||
# analyzer.analyze(metadata, content, "video", batch_size=1)
|
||||
```
|
||||
|
||||
## Metadata Format
|
||||
|
||||
支持两种元数据格式:
|
||||
|
||||
```json
|
||||
// 格式1: 扁平结构
|
||||
{
|
||||
"title": "视频标题",
|
||||
"author": "作者名",
|
||||
"duration": 619,
|
||||
"bvid": "BVxxx"
|
||||
}
|
||||
|
||||
// 格式2: 嵌套结构(kb-bilibili-metadata 输出)
|
||||
{
|
||||
"data": {
|
||||
"metadata": {
|
||||
"title": "视频标题",
|
||||
"author": "作者名",
|
||||
"duration": "10:19"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
会自动转换为扁平格式进行分析。
|
||||
Binary file not shown.
@@ -1,514 +0,0 @@
|
||||
"""Obsidian KB - AI客户端
|
||||
支持多个 AI 提供商和 fallback 机制
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from src.models import ContentType
|
||||
from src.config import get_config
|
||||
from src.content_chunker import ContentChunker, get_chunking_strategy, chunk_content
|
||||
|
||||
|
||||
class AIClient:
|
||||
"""
|
||||
AI客户端 - 支持分段分析长视频内容和 fallback 机制
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.chunker = ContentChunker()
|
||||
# 获取 AI 配置
|
||||
self._load_ai_config()
|
||||
|
||||
def _load_ai_config(self) -> None:
|
||||
"""加载 AI 配置(主 provider + fallbacks)"""
|
||||
self.ai_configs = []
|
||||
|
||||
# 加载主配置
|
||||
main_provider = self.config.get('ai.provider', 'openai')
|
||||
main_config = {
|
||||
'provider': main_provider,
|
||||
'api_key': self.config.get('ai.api_key'),
|
||||
'model': self.config.get('ai.model', 'gpt-4'),
|
||||
'base_url': self.config.get('ai.base_url'),
|
||||
'is_primary': True
|
||||
}
|
||||
self.ai_configs.append(main_config)
|
||||
|
||||
# 加载 fallback 配置
|
||||
fallbacks = self.config.get('ai.fallbacks', [])
|
||||
for fb in fallbacks:
|
||||
self.ai_configs.append({
|
||||
'provider': fb.get('provider'),
|
||||
'api_key': fb.get('api_key'),
|
||||
'model': fb.get('model'),
|
||||
'base_url': fb.get('base_url'),
|
||||
'is_primary': False
|
||||
})
|
||||
|
||||
def _call_api_with_fallback(self, prompt: str, max_tokens: int = 1000, response_format: str = 'text') -> Tuple[bool, str]:
|
||||
"""
|
||||
使用 fallback 机制调用 API
|
||||
|
||||
Args:
|
||||
prompt: 提示词
|
||||
max_tokens: 最大 token 数
|
||||
response_format: 响应格式 ('text' 或 'json')
|
||||
|
||||
Returns:
|
||||
(success, response) 元组
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for i, ai_config in enumerate(self.ai_configs):
|
||||
try:
|
||||
if not ai_config.get('api_key'):
|
||||
print(f"[AIClient] 跳过 {ai_config.get('provider')} - API Key 为空")
|
||||
continue
|
||||
|
||||
provider_type = ai_config.get('provider')
|
||||
print(f"[AIClient] 尝试使用 {provider_type} (优先级 {i+1}/{len(self.ai_configs)})...")
|
||||
|
||||
response = self._call_single_provider(ai_config, prompt, max_tokens, response_format)
|
||||
return True, response
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"[AIClient] {provider_type} 调用失败: {e}")
|
||||
continue
|
||||
|
||||
# 所有 provider 都失败了
|
||||
error_msg = f"所有 AI 提供商均失败,最后错误: {last_error}" if last_error else "没有可用的 AI 配置"
|
||||
print(f"[AIClient] {error_msg}")
|
||||
return False, self._mock_generate(prompt, max_tokens) # 返回 mock 响应作为兜底
|
||||
|
||||
def _call_single_provider(self, ai_config: Dict, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""
|
||||
调用单个 AI 提供商
|
||||
|
||||
Args:
|
||||
ai_config: AI 配置
|
||||
prompt: 提示词
|
||||
max_tokens: 最大 token 数
|
||||
response_format: 响应格式
|
||||
|
||||
Returns:
|
||||
API 响应文本
|
||||
"""
|
||||
provider = ai_config.get('provider')
|
||||
api_key = ai_config.get('api_key')
|
||||
model = ai_config.get('model')
|
||||
base_url = ai_config.get('base_url')
|
||||
|
||||
if provider == 'minimax':
|
||||
return self._call_minimax(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
elif provider == 'zhipu':
|
||||
return self._call_zhipu(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
elif provider == 'alibaba':
|
||||
return self._call_alibaba(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
elif provider == 'openai':
|
||||
return self._call_openai(api_key, model, base_url, prompt, max_tokens, response_format)
|
||||
else:
|
||||
raise ValueError(f"不支持的 provider: {provider}")
|
||||
|
||||
def _call_minimax(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用 MiniMax API (Anthropic Messages 兼容接口)"""
|
||||
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
if response_format == 'json':
|
||||
payload['messages'][0]['content'] = prompt + "\n\n请直接返回 JSON 格式,不要有其他文字。"
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# MiniMax 返回的是 Anthropic 格式,content 是一个数组
|
||||
# 需要找到 type='text' 的元素
|
||||
for item in data.get('content', []):
|
||||
if item.get('type') == 'text':
|
||||
return item.get('text', '')
|
||||
|
||||
# 如果没有找到 text 类型,返回第一个元素的值(兼容旧格式)
|
||||
return data.get('content', [{}])[0].get('text', '')
|
||||
|
||||
def _call_zhipu(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用智谱 AI API (OpenAI 兼容接口)"""
|
||||
# 确保 base_url 包含 /chat/completions
|
||||
if base_url and not base_url.endswith('/chat/completions'):
|
||||
url = f"{base_url}/chat/completions"
|
||||
else:
|
||||
url = base_url or "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
def _call_alibaba(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用阿里云通义千问 API (OpenAI 兼容接口)"""
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
def _call_openai(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
|
||||
"""调用 OpenAI API"""
|
||||
url = base_url or "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['choices'][0]['message']['content']
|
||||
|
||||
def quick_generate(self, prompt: str, max_tokens: int = 100) -> str:
|
||||
"""快速生成(用于轻量扫描)"""
|
||||
success, response = self._call_api_with_fallback(prompt, max_tokens, 'text')
|
||||
return response
|
||||
|
||||
def generate_json(self, prompt: str) -> Dict[str, Any]:
|
||||
"""生成JSON格式响应"""
|
||||
success, response = self._call_api_with_fallback(prompt, 1000, 'json')
|
||||
try:
|
||||
return json.loads(response)
|
||||
except:
|
||||
return self._parse_json_from_text(response)
|
||||
|
||||
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
C5.3: 深度分析入口,根据内容时长决定是否分段分析
|
||||
|
||||
Args:
|
||||
content_text: 内容文本
|
||||
content_type: 内容类型
|
||||
duration: 视频时长(秒)
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
"""
|
||||
duration = duration or 0
|
||||
|
||||
print(f"[AIClient] 深度分析: type={content_type}, duration={duration}s")
|
||||
|
||||
# 长视频(>=30分钟)且是视频类型,使用分段分析
|
||||
if duration >= 1800 and content_type == "video":
|
||||
print(f"[AIClient] 使用分段分析策略")
|
||||
return self._analyze_in_chunks(content_text, duration)
|
||||
|
||||
# 短视频直接分析
|
||||
print(f"[AIClient] 使用直接分析策略")
|
||||
return self._analyze_direct(content_text)
|
||||
|
||||
def _analyze_in_chunks(self, content_text: str, duration: int) -> Dict[str, Any]:
|
||||
"""
|
||||
C5.3: 分段分析长视频
|
||||
|
||||
Args:
|
||||
content_text: SRT字幕文本
|
||||
duration: 视频时长(秒)
|
||||
|
||||
Returns:
|
||||
合并后的分析结果
|
||||
"""
|
||||
# 1. 获取分段策略
|
||||
strategy = get_chunking_strategy(duration)
|
||||
print(f"[AIClient] 分段策略: {strategy['description']}")
|
||||
|
||||
# 2. 执行分片
|
||||
chunks = chunk_content(content_text, duration)
|
||||
print(f"[AIClient] 内容已分为 {len(chunks)} 段")
|
||||
|
||||
# 3. 逐段分析
|
||||
chunk_summaries = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
print(f"[AIClient] 分析第 {i+1}/{len(chunks)} 段...")
|
||||
summary = self._analyze_chunk(chunk.text, chunk.index)
|
||||
|
||||
# 处理时间格式(可能是float秒数或字符串)
|
||||
start_time = chunk.start_time
|
||||
end_time = chunk.end_time
|
||||
if isinstance(start_time, (int, float)):
|
||||
start_time = self._format_time(start_time)
|
||||
if isinstance(end_time, (int, float)):
|
||||
end_time = self._format_time(end_time)
|
||||
|
||||
chunk_summaries.append({
|
||||
'index': chunk.index,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'summary': summary
|
||||
})
|
||||
|
||||
# 4. 合并所有段摘要
|
||||
print(f"[AIClient] 合并 {len(chunk_summaries)} 段分析结果")
|
||||
final_result = self._merge_summaries(chunk_summaries, "video")
|
||||
|
||||
# 5. 构建完整时间轴
|
||||
final_result['timeline'] = self._build_timeline_from_chunks(chunk_summaries)
|
||||
|
||||
return final_result
|
||||
|
||||
def _analyze_chunk(self, chunk_text: str, chunk_index: int) -> str:
|
||||
"""
|
||||
C5.3: 分析单个内容分片
|
||||
|
||||
Args:
|
||||
chunk_text: 分片文本
|
||||
chunk_index: 分片索引
|
||||
|
||||
Returns:
|
||||
分片摘要(限制500字以内)
|
||||
"""
|
||||
# 截断文本避免过长
|
||||
text_sample = chunk_text[:3000] if len(chunk_text) > 3000 else chunk_text
|
||||
|
||||
prompt = f"""请对以下视频片段进行简要分析(限制在300字以内):
|
||||
|
||||
片段内容:
|
||||
{text_sample}
|
||||
|
||||
请提供:
|
||||
1. 这段的核心主题(50字以内)
|
||||
2. 关键要点(3-5条)
|
||||
3. 重要细节或引用
|
||||
|
||||
请用中文回答,控制在300字以内。"""
|
||||
|
||||
try:
|
||||
summary = self.quick_generate(prompt, max_tokens=500)
|
||||
# 确保不超过500字
|
||||
if len(summary) > 500:
|
||||
summary = summary[:497] + "..."
|
||||
return summary
|
||||
except Exception as e:
|
||||
print(f"[AIClient] 分片 {chunk_index} 分析失败: {e}")
|
||||
return f"第{chunk_index+1}段分析失败"
|
||||
|
||||
def _merge_summaries(self, chunk_summaries: List[Dict], content_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
C5.3: 合并所有段摘要,生成完整分析报告
|
||||
|
||||
Args:
|
||||
chunk_summaries: 各段摘要列表
|
||||
content_type: 内容类型
|
||||
|
||||
Returns:
|
||||
完整分析结果
|
||||
"""
|
||||
# 构建合并提示
|
||||
summaries_text = "\n\n".join([
|
||||
f"=== 第{s['index']+1}段 ({s['start_time']}-{s['end_time']}) ===\n{s['summary']}"
|
||||
for s in chunk_summaries
|
||||
])
|
||||
|
||||
prompt = f"""基于以下各段分析,生成完整的视频分析报告:
|
||||
|
||||
{summaries_text}
|
||||
|
||||
请提供以下格式的JSON:
|
||||
{{
|
||||
"summary": "整体摘要(200字以内)",
|
||||
"topics": ["主题1", "主题2", "主题3", "主题4"],
|
||||
"insights": ["深入见解1", "深入见解2", "深入见解3"],
|
||||
"actions": ["建议行动1", "建议行动2"],
|
||||
"full_analysis": "详细分析(500字以内)"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
result = self.generate_json(prompt)
|
||||
|
||||
# 确保必要字段存在
|
||||
if 'topics' not in result:
|
||||
result['topics'] = ["主题A", "主题B", "主题C"]
|
||||
if 'insights' not in result:
|
||||
result['insights'] = ["重要见解1", "重要见解2"]
|
||||
if 'actions' not in result:
|
||||
result['actions'] = []
|
||||
if 'summary' not in result:
|
||||
result['summary'] = "长视频分析完成"
|
||||
if 'full_analysis' not in result:
|
||||
result['full_analysis'] = summaries_text[:500]
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AIClient] 合并摘要失败: {e}")
|
||||
# 返回基础合并结果
|
||||
return {
|
||||
'summary': f'视频共分为{len(chunk_summaries)}个部分进行分析',
|
||||
'topics': [f"主题{i+1}" for i in range(min(4, len(chunk_summaries)))],
|
||||
'insights': ['从分段分析中综合得出'],
|
||||
'actions': [],
|
||||
'full_analysis': summaries_text[:800]
|
||||
}
|
||||
|
||||
def _analyze_direct(self, content_text: str) -> Dict[str, Any]:
|
||||
"""直接分析(不分段)"""
|
||||
# 截断文本
|
||||
text_sample = content_text[:5000] if len(content_text) > 5000 else content_text
|
||||
|
||||
prompt = f"""请分析以下内容,提供JSON格式结果:
|
||||
|
||||
{text_sample}
|
||||
|
||||
返回格式:
|
||||
{{
|
||||
"summary": "整体摘要(150字以内)",
|
||||
"topics": ["主题1", "主题2", "主题3"],
|
||||
"insights": ["见解1", "见解2"],
|
||||
"actions": ["行动建议1"],
|
||||
"timeline": [
|
||||
{{"start_time": "00:00", "end_time": "05:00", "title": "第一部分", "summary": "..."}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
return self.generate_json(prompt)
|
||||
except Exception as e:
|
||||
print(f"[AIClient] 直接分析失败: {e}")
|
||||
return {
|
||||
'summary': '分析失败',
|
||||
'topics': ['无法解析'],
|
||||
'insights': ['请检查内容格式'],
|
||||
'actions': [],
|
||||
'timeline': []
|
||||
}
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
"""格式化秒数为时间字符串"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
if hours > 0:
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes:02d}:{secs:02d}"
|
||||
|
||||
def _build_timeline_from_chunks(self, chunk_summaries: List[Dict]) -> List[Dict[str, Any]]:
|
||||
"""从分片摘要构建时间轴"""
|
||||
timeline = []
|
||||
for summary in chunk_summaries:
|
||||
timeline.append({
|
||||
'index': summary['index'],
|
||||
'start_time': summary['start_time'],
|
||||
'end_time': summary['end_time'],
|
||||
'title': f"第{summary['index']+1}部分",
|
||||
'summary': summary['summary'][:200] + "..." if len(summary['summary']) > 200 else summary['summary']
|
||||
})
|
||||
return timeline
|
||||
|
||||
def _mock_generate(self, prompt: str, max_tokens: int) -> str:
|
||||
"""模拟AI生成(实际实现应调用真实API)"""
|
||||
# 根据prompt内容返回模拟响应
|
||||
prompt_lower = prompt.lower()
|
||||
|
||||
if 'json' in prompt_lower or 'json格式' in prompt:
|
||||
if '主题' in prompt or 'topic' in prompt_lower:
|
||||
return json.dumps({
|
||||
'summary': '这是一个关于人工智能和技术发展的内容,涵盖了多个重要主题。',
|
||||
'topics': ['人工智能', '机器学习', '未来发展', '技术趋势'],
|
||||
'insights': [
|
||||
'AI正在改变我们的工作方式',
|
||||
'机器学习应用越来越广泛',
|
||||
'未来技术发展趋势值得关注'
|
||||
],
|
||||
'actions': ['深入研究相关技术', '关注行业动态'],
|
||||
'full_analysis': '详细分析内容...'
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({'result': '模拟JSON响应'})
|
||||
|
||||
if '摘要' in prompt or 'summary' in prompt_lower:
|
||||
return '这是一段关于人工智能发展的重要讨论,涵盖了技术趋势和未来应用。'
|
||||
|
||||
if '关键词' in prompt or 'keyword' in prompt_lower:
|
||||
return '人工智能, 机器学习, 深度学习, 神经网络, 自然语言处理'
|
||||
|
||||
return '模拟AI响应内容'
|
||||
|
||||
def _parse_json_from_text(self, text: str) -> Dict[str, Any]:
|
||||
"""从文本中解析JSON"""
|
||||
# 尝试提取JSON块
|
||||
import re
|
||||
json_match = re.search(r'\{[\s\S]*\}', text)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
except:
|
||||
pass
|
||||
return {'raw_response': text}
|
||||
|
||||
|
||||
class MultiAIClient:
|
||||
"""
|
||||
多AI客户端 - 支持多种AI提供商的客户端
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self.primary_client = AIClient()
|
||||
self.clients = {
|
||||
'openai': self.primary_client,
|
||||
'local': AIClient(), # 可以配置为本地模型
|
||||
}
|
||||
|
||||
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""使用主客户端进行深度分析"""
|
||||
return self.primary_client._deep_analyze(content_text, content_type, duration)
|
||||
|
||||
def quick_generate(self, prompt: str, max_tokens: int = 100, provider: str = None) -> str:
|
||||
"""快速生成"""
|
||||
client = self.clients.get(provider, self.primary_client)
|
||||
return client.quick_generate(prompt, max_tokens)
|
||||
@@ -1,650 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deep Analyzer - AI 深度内容分析工具
|
||||
|
||||
一次性调用 AI 生成完整深度分析报告。
|
||||
|
||||
Usage:
|
||||
python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md [--type video]
|
||||
python deep_analyzer.py --help
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# ============== 数据模型 ==============
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""分析结果"""
|
||||
summary: str = ""
|
||||
main_topics: List[str] = field(default_factory=list)
|
||||
key_insights: List[str] = field(default_factory=list)
|
||||
recommended_tags: List[str] = field(default_factory=list)
|
||||
full_analysis: str = ""
|
||||
original_text: str = "" # 文章类型的原文内容
|
||||
|
||||
|
||||
# ============== AI 客户端 ==============
|
||||
|
||||
class SimpleAIClient:
|
||||
"""AI 客户端"""
|
||||
|
||||
def __init__(self, api_key: str = None, provider: str = None, model: str = None, base_url: str = None):
|
||||
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
|
||||
self.provider = provider or os.environ.get("AI_PROVIDER", "")
|
||||
self.model = model or os.environ.get("AI_MODEL", "glm-5")
|
||||
self.base_url = base_url or os.environ.get("AI_BASE_URL", "")
|
||||
self.max_retries = 3
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int = 16000) -> str:
|
||||
"""生成文本"""
|
||||
api_key = os.environ.get("AI_API_KEY", self.api_key)
|
||||
provider = os.environ.get("AI_PROVIDER", self.provider)
|
||||
model = os.environ.get("AI_MODEL", self.model)
|
||||
base_url = os.environ.get("AI_BASE_URL", self.base_url)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("AI_API_KEY 未设置")
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
import time
|
||||
start_time = time.time()
|
||||
print(f"[AI Client] 开始调用 AI...")
|
||||
|
||||
if provider == "openai":
|
||||
result = self._call_openai(prompt, max_tokens, api_key, model, base_url)
|
||||
elif provider == "zhipu":
|
||||
result = self._call_zhipu(prompt, max_tokens, api_key, model, base_url)
|
||||
elif provider == "minimax":
|
||||
result = self._call_minimax(prompt, max_tokens, api_key, model, base_url)
|
||||
else:
|
||||
raise ValueError(f"不支持的 AI 提供商: {provider}")
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[AI Client] AI 调用完成,耗时: {elapsed:.2f}秒")
|
||||
return result
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries - 1:
|
||||
print(f"[AI Client] 第 {attempt + 1} 次调用失败,{self.max_retries - attempt - 1} 次重试剩余: {e}")
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
def _call_openai(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
|
||||
import requests
|
||||
url = f"{base_url}/chat/completions" if base_url else "https://api.openai.com/v1/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
return response.json()['choices'][0]['message']['content']
|
||||
|
||||
def _call_zhipu(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
|
||||
import requests
|
||||
base_url = base_url or "https://open.bigmodel.cn/api/coding/paas/v4"
|
||||
url = f"{base_url}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
payload = {"model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens}
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
message = data.get('choices', [{}])[0].get('message', {})
|
||||
content = message.get('content', '')
|
||||
reasoning = message.get('reasoning_content', '')
|
||||
if not content and reasoning:
|
||||
sentences = reasoning.strip().split('。')
|
||||
useful = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 10]
|
||||
if useful:
|
||||
return '。'.join(useful[-3:]) + '。'
|
||||
return reasoning.strip()[:500]
|
||||
return content
|
||||
|
||||
def _call_minimax(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
|
||||
import requests
|
||||
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
for item in data.get('content', []):
|
||||
if item.get('type') == 'text':
|
||||
return item.get('text', '')
|
||||
return str(data)
|
||||
|
||||
|
||||
# ============== 分析器 ==============
|
||||
|
||||
class DeepAnalyzer:
|
||||
"""深度内容分析器"""
|
||||
|
||||
def __init__(self, ai_client: SimpleAIClient = None):
|
||||
self.ai_client = ai_client or SimpleAIClient()
|
||||
|
||||
def analyze(self, metadata: Dict[str, Any], content: Dict[str, Any], content_type: str = "video") -> AnalysisResult:
|
||||
"""一次性调用 AI 生成完整分析报告
|
||||
|
||||
Args:
|
||||
metadata: 元数据字典
|
||||
content: 内容字典
|
||||
content_type: 内容类型,"video" 或 "article"
|
||||
"""
|
||||
|
||||
# 标准化元数据
|
||||
metadata = self._normalize_metadata(metadata)
|
||||
|
||||
title = metadata.get('title', '未知')
|
||||
duration = metadata.get('duration', 0)
|
||||
author = metadata.get('author', '')
|
||||
pubtime = metadata.get('pubtime', '')
|
||||
url = metadata.get('url', '')
|
||||
bvid = metadata.get('bvid', '')
|
||||
|
||||
# 推断 platform:优先使用元数据中的值,否则根据 URL 或 content_type 推断
|
||||
platform = metadata.get('platform', '')
|
||||
if not platform:
|
||||
if 'zhihu.com' in url or content_type in ('answer', 'zhuanlan', 'question'):
|
||||
platform = 'zhihu'
|
||||
elif 'bilibili.com' in url or bvid:
|
||||
platform = 'bilibili'
|
||||
else:
|
||||
platform = 'bilibili' # 默认值
|
||||
|
||||
# 清理 author 字段:移除零宽度空格和前后空白
|
||||
author = self._clean_author(author)
|
||||
|
||||
# 获取内容 - 支持多种字段名
|
||||
text = content.get('text', '') or content.get('subtitle', '') or content.get('subtitles', '') or content.get('description', '')
|
||||
|
||||
print(f"[DeepAnalyzer] 开始分析: {title}")
|
||||
print(f"[DeepAnalyzer] 类型: {content_type}, 内容长度: {len(text)} 字符")
|
||||
|
||||
# 构建 prompt(根据类型选择不同模板)
|
||||
prompt = self._build_prompt(
|
||||
title=title,
|
||||
duration=duration,
|
||||
duration_str=self._format_duration(duration),
|
||||
author=author,
|
||||
pubtime=pubtime,
|
||||
url=url,
|
||||
bvid=bvid,
|
||||
platform=platform,
|
||||
text=text,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
# 调用 AI
|
||||
try:
|
||||
full_analysis = self.ai_client.generate(prompt, max_tokens=16000)
|
||||
except Exception as e:
|
||||
print(f"[DeepAnalyzer] AI 调用失败: {e}")
|
||||
raise
|
||||
|
||||
# 解析结果
|
||||
result = self._parse_analysis(full_analysis, text, duration, content_type)
|
||||
|
||||
# 文章/回答类型:追加原文
|
||||
if content_type in ("article", "answer", "zhuanlan"):
|
||||
result.original_text = text
|
||||
|
||||
return result
|
||||
|
||||
def _build_prompt(self, title: str, duration: int, duration_str: str, author: str,
|
||||
pubtime: str, url: str, bvid: str, platform: str, text: str,
|
||||
content_type: str = "video") -> str:
|
||||
"""构建分析 prompt,根据内容类型选择不同模板"""
|
||||
if content_type in ("article", "answer", "zhuanlan"):
|
||||
return self._build_article_prompt(title, author, pubtime, url, platform, text)
|
||||
else:
|
||||
return self._build_video_prompt(title, duration, duration_str, author, pubtime, url, bvid, platform, text)
|
||||
|
||||
def _build_video_prompt(self, title: str, duration: int, duration_str: str, author: str,
|
||||
pubtime: str, url: str, bvid: str, platform: str, text: str) -> str:
|
||||
"""构建视频分析 prompt"""
|
||||
|
||||
# 计算分片数(按10分钟分片)
|
||||
num_chunks = max(1, (duration + 599) // 600)
|
||||
|
||||
# 截取适当长度的文本(20-30K 字符)
|
||||
max_chars = 30000
|
||||
if len(text) > max_chars:
|
||||
text_sample = text[:max_chars] + "\n\n[...]"
|
||||
else:
|
||||
text_sample = text
|
||||
|
||||
prompt = f"""你是一个专业的视频内容分析师。请根据以下信息生成深度分析报告:
|
||||
|
||||
【视频信息】
|
||||
标题:{title}
|
||||
作者:{author}
|
||||
时长:{duration_str}({duration}秒)
|
||||
发布日期:{pubtime}
|
||||
平台:{platform}
|
||||
URL:{url}
|
||||
|
||||
【内容】(完整字幕)
|
||||
{text_sample}
|
||||
|
||||
【输出要求】必须使用以下格式:
|
||||
|
||||
## 标题
|
||||
[自己总结的简洁标题,禁止使用原标题]
|
||||
|
||||
## 摘要
|
||||
[200-400字]
|
||||
|
||||
## 主要主题
|
||||
[从内容中提取不超过10个主题,每个不超过10字]
|
||||
- 主题1
|
||||
- 主题2
|
||||
...
|
||||
|
||||
## 关键见解
|
||||
[若干条深刻见解]
|
||||
- 见解1
|
||||
- 见解2
|
||||
...
|
||||
|
||||
## 标签
|
||||
[从内容中提取3-5个精炼标签,每个不超过10字]
|
||||
- 标签1
|
||||
- 标签2
|
||||
...
|
||||
|
||||
## 时间轴
|
||||
### 第 1 部分 (00:00 - 10:00)
|
||||
[详细分析,至少500字]
|
||||
|
||||
### 第 2 部分 (10:00 - 20:00)
|
||||
[详细分析,至少500字]
|
||||
|
||||
...(以此类推)
|
||||
|
||||
【重要】分析应足够详尽,读者阅读后可了解视频核心内容,无需再看原视频。"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _build_article_prompt(self, title: str, author: str, pubtime: str,
|
||||
url: str, platform: str, text: str) -> str:
|
||||
"""构建文章分析 prompt"""
|
||||
|
||||
# 文章保留完整内容(不截断,因为需要追加原文)
|
||||
# 但 prompt 中只取前 20000 字符供 AI 分析
|
||||
max_chars = 20000
|
||||
if len(text) > max_chars:
|
||||
text_sample = text[:max_chars] + "\n\n[...内容过长已截断,但原文会完整保留...]"
|
||||
else:
|
||||
text_sample = text
|
||||
|
||||
prompt = f"""你是一个专业的文章内容分析师。请根据以下信息生成摘要:
|
||||
|
||||
【文章信息】
|
||||
标题:{title}
|
||||
作者:{author}
|
||||
发布日期:{pubtime}
|
||||
平台:{platform}
|
||||
URL:{url}
|
||||
|
||||
【内容】
|
||||
{text_sample}
|
||||
|
||||
【输出要求】必须使用以下格式:
|
||||
|
||||
## 摘要
|
||||
[300字以内的摘要,描述文章的核心观点和主要内容]
|
||||
|
||||
## 主要主题
|
||||
[从内容中提取不超过5个主题,每个不超过10字]
|
||||
- 主题1
|
||||
- 主题2
|
||||
...
|
||||
|
||||
## 标签
|
||||
[从内容中提取3-5个精炼标签,每个不超过10字]
|
||||
- 标签1
|
||||
- 标签2
|
||||
...
|
||||
|
||||
【重要】
|
||||
1. 摘要应精炼概括文章核心观点,让读者快速了解文章主旨
|
||||
2. 不要输出时间轴
|
||||
3. 不要输出关键见解(除非是特别重要的观点)"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _parse_analysis(self, analysis_text: str, original_text: str, duration: int,
|
||||
content_type: str = "video") -> AnalysisResult:
|
||||
"""解析 AI 返回的分析结果"""
|
||||
result = AnalysisResult()
|
||||
result.full_analysis = analysis_text
|
||||
|
||||
# 提取各部分(使用正则)
|
||||
lines = analysis_text.split('\n')
|
||||
|
||||
# 简单解析:按 ## 标题分割
|
||||
current_section = ""
|
||||
in_section = None
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('##'):
|
||||
# 保存上一个 section
|
||||
if in_section:
|
||||
self._store_section(result, in_section, current_section)
|
||||
# 开始新 section
|
||||
section_name = line.lstrip('#').strip()
|
||||
in_section = section_name
|
||||
current_section = ""
|
||||
elif in_section:
|
||||
current_section += line + "\n"
|
||||
|
||||
# 存储最后一个 section
|
||||
if in_section:
|
||||
self._store_section(result, in_section, current_section)
|
||||
|
||||
# 如果解析失败,使用原始文本
|
||||
if not result.summary:
|
||||
result.summary = analysis_text[:500]
|
||||
|
||||
return result
|
||||
|
||||
def _store_section(self, result: AnalysisResult, section_name: str, content: str):
|
||||
"""存储解析到的 section"""
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
section_lower = section_name.lower()
|
||||
|
||||
if '摘要' in section_lower:
|
||||
result.summary = content[:500]
|
||||
elif '主题' in section_lower:
|
||||
# 提取主题
|
||||
topics = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip().lstrip('-').strip()
|
||||
if line and len(line) <= 15:
|
||||
topics.append(line)
|
||||
result.main_topics = topics[:10]
|
||||
elif '见解' in section_lower or '观点' in section_lower:
|
||||
# 提取见解
|
||||
insights = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip().lstrip('-').strip()
|
||||
if line and len(line) > 5:
|
||||
insights.append(line)
|
||||
result.key_insights = insights
|
||||
elif '标签' in section_lower:
|
||||
# 提取标签
|
||||
tags = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip().lstrip('-').strip()
|
||||
if line and len(line) <= 10:
|
||||
tags.append(line)
|
||||
result.recommended_tags = tags[:5]
|
||||
|
||||
def _normalize_metadata(self, metadata: Dict) -> Dict:
|
||||
"""标准化元数据"""
|
||||
if not metadata:
|
||||
return {}
|
||||
|
||||
# 如果已经扁平
|
||||
if 'title' in metadata:
|
||||
duration = metadata.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
return metadata
|
||||
|
||||
# 嵌套结构
|
||||
if 'metadata' in metadata:
|
||||
meta = metadata['metadata']
|
||||
normalized = {}
|
||||
for k, v in meta.items():
|
||||
normalized[k] = v
|
||||
# 处理 duration
|
||||
duration = normalized.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
normalized['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
return normalized
|
||||
|
||||
return metadata
|
||||
|
||||
def _format_duration(self, seconds: int) -> str:
|
||||
"""格式化时长"""
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
s = seconds % 60
|
||||
if h > 0:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
def _clean_author(self, author: str) -> str:
|
||||
"""清理 author 字段:移除零宽度空格和前后空白"""
|
||||
if not author:
|
||||
return ''
|
||||
# 移除零宽度空格 (U+200B) 和其他不可见字符
|
||||
invisible_chars = ['\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad']
|
||||
cleaned = author
|
||||
for char in invisible_chars:
|
||||
cleaned = cleaned.replace(char, '')
|
||||
# 移除前后空白和换行
|
||||
cleaned = cleaned.strip()
|
||||
# 如果包含换行符,只取第一行
|
||||
if '\n' in cleaned:
|
||||
cleaned = cleaned.split('\n')[0].strip()
|
||||
return cleaned
|
||||
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
|
||||
def load_json(file_path: str) -> Dict:
|
||||
"""加载 JSON 文件"""
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return {}
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def generate_frontmatter(metadata: Dict, result: AnalysisResult, content_type: str = "video") -> str:
|
||||
"""生成 Markdown frontmatter"""
|
||||
# 提取标签
|
||||
tags = result.recommended_tags or []
|
||||
|
||||
# 格式化日期
|
||||
pubtime = metadata.get('pubtime', '')
|
||||
if pubtime:
|
||||
date_str = str(pubtime)[:10]
|
||||
else:
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# 清理 author 字段
|
||||
author = metadata.get('author', '未知')
|
||||
author = _clean_string(author)
|
||||
|
||||
# 推断 platform
|
||||
url = metadata.get('url', '')
|
||||
platform = metadata.get('platform', '')
|
||||
if not platform:
|
||||
if 'zhihu.com' in url or content_type in ('answer', 'zhuanlan', 'question'):
|
||||
platform = 'zhihu'
|
||||
elif 'bilibili.com' in url or metadata.get('bvid'):
|
||||
platform = 'bilibili'
|
||||
else:
|
||||
platform = 'bilibili'
|
||||
|
||||
fm = ["---",
|
||||
f"title: \"{metadata.get('title', '未命名')}\"",
|
||||
f"date: {date_str}",
|
||||
f"author: {author}",
|
||||
f"platform: {platform}"]
|
||||
|
||||
# 视频类型添加 duration 和 bvid
|
||||
if content_type == "video":
|
||||
fm.insert(3, f"duration: {metadata.get('duration_str', metadata.get('duration', 0))}")
|
||||
bvid = metadata.get('bvid', '')
|
||||
if bvid:
|
||||
fm.insert(5, f"bvid: {bvid}")
|
||||
|
||||
# 添加 URL
|
||||
url = metadata.get('url', '')
|
||||
if url:
|
||||
fm.append(f"url: {url}")
|
||||
|
||||
if tags:
|
||||
fm.append("tags: [" + ", ".join(f'"{t}"' for t in tags) + "]")
|
||||
|
||||
fm.append("---")
|
||||
return '\n'.join(fm)
|
||||
|
||||
|
||||
def _clean_string(text: str) -> str:
|
||||
"""清理字符串:移除零宽度空格和前后空白"""
|
||||
if not text:
|
||||
return ''
|
||||
# 移除零宽度空格 (U+200B) 和其他不可见字符
|
||||
invisible_chars = ['\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad']
|
||||
cleaned = text
|
||||
for char in invisible_chars:
|
||||
cleaned = cleaned.replace(char, '')
|
||||
# 移除前后空白和换行
|
||||
cleaned = cleaned.strip()
|
||||
# 如果包含换行符,只取第一行
|
||||
if '\n' in cleaned:
|
||||
cleaned = cleaned.split('\n')[0].strip()
|
||||
return cleaned
|
||||
|
||||
|
||||
# ============== 主程序 ==============
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Deep Analyzer - AI 深度内容分析工具")
|
||||
parser.add_argument("--metadata", "-m", required=True, help="元数据 JSON 文件路径")
|
||||
parser.add_argument("--content", "-c", required=True, help="内容 JSON 文件路径")
|
||||
parser.add_argument("--output", "-o", required=True, help="输出 Markdown 文件路径")
|
||||
parser.add_argument("--type", "-t", default="video", choices=["video", "article"], help="内容类型")
|
||||
parser.add_argument("--api-key", "-k", default=None, help="AI API Key")
|
||||
parser.add_argument("--provider", "-p", default=None, choices=["openai", "zhipu", "minimax"], help="AI 提供商")
|
||||
parser.add_argument("--model", default=None, help="AI 模型")
|
||||
parser.add_argument("--base-url", "-u", default=None, help="API Base URL")
|
||||
parser.add_argument("--no-frontmatter", action="store_true", help="不生成 frontmatter")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 加载数据
|
||||
print(f"[Main] 加载元数据: {args.metadata}")
|
||||
raw_metadata = load_json(args.metadata)
|
||||
|
||||
print(f"[Main] 加载内容: {args.content}")
|
||||
content = load_json(args.content)
|
||||
|
||||
# 标准化元数据
|
||||
metadata = {}
|
||||
if 'title' in raw_metadata:
|
||||
metadata = raw_metadata.copy()
|
||||
# 处理 duration
|
||||
duration = metadata.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
metadata['duration_str'] = format_duration(metadata.get('duration', 0))
|
||||
elif 'metadata' in raw_metadata:
|
||||
meta = raw_metadata['metadata']
|
||||
metadata = meta.copy()
|
||||
duration = metadata.get('duration', 0)
|
||||
if isinstance(duration, str) and ':' in duration:
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
|
||||
except:
|
||||
pass
|
||||
metadata['duration_str'] = format_duration(metadata.get('duration', 0))
|
||||
|
||||
# 初始化
|
||||
ai_client = SimpleAIClient(
|
||||
api_key=args.api_key or os.environ.get("AI_API_KEY", ""),
|
||||
provider=args.provider or os.environ.get("AI_PROVIDER", ""),
|
||||
model=args.model or os.environ.get("AI_MODEL", "glm-5"),
|
||||
base_url=args.base_url or os.environ.get("AI_BASE_URL", "")
|
||||
)
|
||||
analyzer = DeepAnalyzer(ai_client)
|
||||
|
||||
# 执行分析
|
||||
print(f"[Main] 开始分析...")
|
||||
result = analyzer.analyze(metadata, content, args.type)
|
||||
|
||||
# 使用 AI 生成的原始内容(保留 Markdown 格式)
|
||||
full_analysis = result.full_analysis
|
||||
|
||||
# 删除内容中的"标签" section(只保留 frontmatter 中的 tags)
|
||||
# 匹配 ## 标签 或 标签(无##)到下一个 ## 或文件结尾之间的内容
|
||||
full_analysis = re.sub(r'(## ?)?标签\n[\s\S]*?(?=\n## ?|\n时间轴|---|\Z)', '', full_analysis)
|
||||
# 清理多余的空行
|
||||
full_analysis = re.sub(r'\n{3,}', '\n\n', full_analysis)
|
||||
|
||||
# 文章类型:追加原文
|
||||
if args.type == "article" and result.original_text:
|
||||
full_analysis = full_analysis.rstrip() + "\n\n## 原文\n\n" + result.original_text
|
||||
|
||||
# 写入输出
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
||||
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
# 添加 frontmatter
|
||||
if not args.no_frontmatter:
|
||||
fm = generate_frontmatter(metadata, result, args.type)
|
||||
f.write(fm + "\n\n")
|
||||
|
||||
# 写入分析内容
|
||||
f.write(full_analysis)
|
||||
|
||||
# 输出结果
|
||||
output = {
|
||||
"success": True,
|
||||
"filename": Path(args.output).name,
|
||||
"filepath": str(Path(args.output).resolve()),
|
||||
"message": "深度分析完成",
|
||||
"stats": {
|
||||
"summary_length": len(result.summary) if result.summary else 0,
|
||||
"topics_count": len(result.main_topics),
|
||||
"insights_count": len(result.key_insights),
|
||||
"tags_count": len(result.recommended_tags)
|
||||
}
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
"""格式化时长"""
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
s = seconds % 60
|
||||
if h > 0:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, indent=2))
|
||||
sys.exit(1)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user