494 lines
20 KiB
Python
494 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Dify API 客戶端服務
|
||
|
||
Author: PANJIT IT Team
|
||
Created: 2024-01-28
|
||
Modified: 2024-01-28
|
||
"""
|
||
|
||
import time
|
||
import requests
|
||
from typing import Dict, Any, Optional
|
||
from flask import current_app
|
||
from app.utils.logger import get_logger
|
||
from app.utils.exceptions import APIError
|
||
from app.models.stats import APIUsageStats
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
class DifyClient:
|
||
"""Dify API 客戶端"""
|
||
|
||
def __init__(self):
|
||
# 翻译API配置
|
||
self.translation_base_url = current_app.config.get('DIFY_TRANSLATION_BASE_URL', '')
|
||
self.translation_api_key = current_app.config.get('DIFY_TRANSLATION_API_KEY', '')
|
||
|
||
# OCR API配置
|
||
self.ocr_base_url = current_app.config.get('DIFY_OCR_BASE_URL', '')
|
||
self.ocr_api_key = current_app.config.get('DIFY_OCR_API_KEY', '')
|
||
|
||
self.timeout = (10, 60) # (連接超時, 讀取超時)
|
||
self.max_retries = 3
|
||
self.retry_delay = 1.6 # 指數退避基數
|
||
|
||
if not self.translation_base_url or not self.translation_api_key:
|
||
logger.warning("Dify Translation API configuration is incomplete")
|
||
|
||
if not self.ocr_base_url or not self.ocr_api_key:
|
||
logger.warning("Dify OCR API configuration is incomplete")
|
||
|
||
def _make_request(self, method: str, endpoint: str, data: Dict[str, Any] = None,
|
||
user_id: int = None, job_id: int = None, files_data: Dict = None,
|
||
api_type: str = 'translation') -> Dict[str, Any]:
|
||
"""發送 HTTP 請求到 Dify API"""
|
||
|
||
# 根据API类型选择配置
|
||
if api_type == 'ocr':
|
||
base_url = self.ocr_base_url
|
||
api_key = self.ocr_api_key
|
||
if not base_url or not api_key:
|
||
raise APIError("Dify OCR API 未配置完整")
|
||
else: # translation
|
||
base_url = self.translation_base_url
|
||
api_key = self.translation_api_key
|
||
if not base_url or not api_key:
|
||
raise APIError("Dify Translation API 未配置完整")
|
||
|
||
url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||
|
||
headers = {
|
||
'Authorization': f'Bearer {api_key}',
|
||
'User-Agent': 'PANJIT-Document-Translator/1.0'
|
||
}
|
||
|
||
# 只有在非文件上传时才设置JSON Content-Type
|
||
if not files_data:
|
||
headers['Content-Type'] = 'application/json'
|
||
|
||
# 重試邏輯
|
||
last_exception = None
|
||
start_time = time.time()
|
||
|
||
for attempt in range(self.max_retries):
|
||
try:
|
||
# logger.debug(f"Making Dify API request: {method} {url} (attempt {attempt + 1})")
|
||
|
||
if method.upper() == 'GET':
|
||
response = requests.get(url, headers=headers, timeout=self.timeout, params=data)
|
||
elif files_data:
|
||
# 文件上传请求,使用multipart/form-data
|
||
response = requests.post(url, headers=headers, timeout=self.timeout, files=files_data, data=data)
|
||
else:
|
||
# 普通JSON请求
|
||
response = requests.post(url, headers=headers, timeout=self.timeout, json=data)
|
||
|
||
# 計算響應時間
|
||
response_time_ms = int((time.time() - start_time) * 1000)
|
||
|
||
# 檢查響應狀態
|
||
response.raise_for_status()
|
||
|
||
# 解析響應
|
||
result = response.json()
|
||
|
||
# 記錄 API 使用統計
|
||
if user_id:
|
||
self._record_api_usage(
|
||
user_id=user_id,
|
||
job_id=job_id,
|
||
endpoint=endpoint,
|
||
response_data=result,
|
||
response_time_ms=response_time_ms,
|
||
success=True
|
||
)
|
||
|
||
# logger.debug(f"Dify API request successful: {response_time_ms}ms")
|
||
return result
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
last_exception = e
|
||
response_time_ms = int((time.time() - start_time) * 1000)
|
||
|
||
# 記錄失敗的 API 調用
|
||
if user_id:
|
||
self._record_api_usage(
|
||
user_id=user_id,
|
||
job_id=job_id,
|
||
endpoint=endpoint,
|
||
response_data={},
|
||
response_time_ms=response_time_ms,
|
||
success=False,
|
||
error_message=str(e)
|
||
)
|
||
|
||
logger.warning(f"Dify API request failed (attempt {attempt + 1}): {str(e)}")
|
||
|
||
# 如果是最後一次嘗試,拋出異常
|
||
if attempt == self.max_retries - 1:
|
||
break
|
||
|
||
# 指數退避
|
||
delay = self.retry_delay ** attempt
|
||
# logger.debug(f"Retrying in {delay} seconds...")
|
||
time.sleep(delay)
|
||
|
||
# 所有重試都失敗了
|
||
error_msg = f"Dify API request failed after {self.max_retries} attempts: {str(last_exception)}"
|
||
logger.error(error_msg)
|
||
raise APIError(error_msg)
|
||
|
||
def _record_api_usage(self, user_id: int, job_id: Optional[int], endpoint: str,
|
||
response_data: Dict, response_time_ms: int, success: bool,
|
||
error_message: str = None):
|
||
"""記錄 API 使用統計"""
|
||
try:
|
||
# 從響應中提取使用量資訊
|
||
metadata = response_data.get('metadata', {})
|
||
|
||
# 如果 job_id 無效,則設為 None 以避免外鍵約束錯誤
|
||
APIUsageStats.record_api_call(
|
||
user_id=user_id,
|
||
job_id=job_id, # 已經是 Optional,如果無效會被設為 NULL
|
||
api_endpoint=endpoint,
|
||
metadata=metadata,
|
||
response_time_ms=response_time_ms,
|
||
success=success,
|
||
error_message=error_message
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to record API usage: {str(e)}")
|
||
|
||
def translate_text(self, text: str, source_language: str, target_language: str,
|
||
user_id: int = None, job_id: int = None, conversation_id: str = None) -> Dict[str, Any]:
|
||
"""翻譯文字"""
|
||
|
||
if not text.strip():
|
||
raise APIError("翻譯文字不能為空")
|
||
|
||
# 構建標準翻譯 prompt(英文指令格式)
|
||
language_names = {
|
||
'zh-tw': 'Traditional Chinese',
|
||
'zh-cn': 'Simplified Chinese',
|
||
'en': 'English',
|
||
'ja': 'Japanese',
|
||
'ko': 'Korean',
|
||
'vi': 'Vietnamese',
|
||
'th': 'Thai',
|
||
'id': 'Indonesian',
|
||
'ms': 'Malay',
|
||
'es': 'Spanish',
|
||
'fr': 'French',
|
||
'de': 'German',
|
||
'ru': 'Russian',
|
||
'ar': 'Arabic'
|
||
}
|
||
|
||
source_lang_name = language_names.get(source_language, source_language)
|
||
target_lang_name = language_names.get(target_language, target_language)
|
||
|
||
query = f"""Task: Translate ONLY into {target_lang_name} from {source_lang_name}.
|
||
|
||
Rules:
|
||
- Output translation text ONLY (no source text, no notes, no questions, no language-detection remarks).
|
||
- Preserve original line breaks.
|
||
- Do NOT wrap in quotes or code blocks.
|
||
- Maintain original formatting and structure.
|
||
|
||
{text.strip()}"""
|
||
|
||
# 構建請求資料 - 使用成功版本的格式
|
||
request_data = {
|
||
'inputs': {},
|
||
'response_mode': 'blocking',
|
||
'user': f"user_{user_id}" if user_id else "doc-translator-user",
|
||
'query': query
|
||
}
|
||
|
||
# 如果有 conversation_id,加入請求中以維持對話連續性
|
||
if conversation_id:
|
||
request_data['conversation_id'] = conversation_id
|
||
|
||
logger.info(f"[TRANSLATION] Sending translation request...")
|
||
logger.info(f"[TRANSLATION] Request data: {request_data}")
|
||
logger.info(f"[TRANSLATION] Text length: {len(text)} characters")
|
||
|
||
try:
|
||
response = self._make_request(
|
||
method='POST',
|
||
endpoint='/chat-messages',
|
||
data=request_data,
|
||
user_id=user_id,
|
||
job_id=job_id
|
||
)
|
||
|
||
# 從響應中提取翻譯結果 - 使用成功版本的方式
|
||
answer = response.get('answer')
|
||
|
||
if not isinstance(answer, str) or not answer.strip():
|
||
raise APIError("Dify API 返回空的翻譯結果")
|
||
|
||
return {
|
||
'success': True,
|
||
'translated_text': answer,
|
||
'source_text': text,
|
||
'source_language': source_language,
|
||
'target_language': target_language,
|
||
'conversation_id': response.get('conversation_id'),
|
||
'metadata': response.get('metadata', {})
|
||
}
|
||
|
||
except APIError:
|
||
raise
|
||
except Exception as e:
|
||
error_msg = f"翻譯請求處理錯誤: {str(e)}"
|
||
logger.error(error_msg)
|
||
raise APIError(error_msg)
|
||
|
||
def test_connection(self) -> bool:
|
||
"""測試 Dify API 連接"""
|
||
try:
|
||
# 發送簡單的測試請求
|
||
test_data = {
|
||
'inputs': {'text': 'test'},
|
||
'response_mode': 'blocking',
|
||
'user': 'health_check'
|
||
}
|
||
|
||
response = self._make_request(
|
||
method='POST',
|
||
endpoint='/chat-messages',
|
||
data=test_data
|
||
)
|
||
|
||
return response is not None
|
||
|
||
except Exception as e:
|
||
logger.error(f"Dify API connection test failed: {str(e)}")
|
||
return False
|
||
|
||
def get_app_info(self) -> Dict[str, Any]:
|
||
"""取得 Dify 應用資訊"""
|
||
try:
|
||
response = self._make_request(
|
||
method='GET',
|
||
endpoint='/parameters'
|
||
)
|
||
|
||
return {
|
||
'success': True,
|
||
'app_info': response
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get Dify app info: {str(e)}")
|
||
return {
|
||
'success': False,
|
||
'error': str(e)
|
||
}
|
||
|
||
@classmethod
|
||
def load_config_from_file(cls, file_path: str = 'api.txt'):
|
||
"""從檔案載入 Dify API 配置"""
|
||
try:
|
||
import os
|
||
from pathlib import Path
|
||
|
||
config_file = Path(file_path)
|
||
|
||
if not config_file.exists():
|
||
logger.warning(f"Dify config file not found: {file_path}")
|
||
return
|
||
|
||
with open(config_file, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith('#') or not line:
|
||
continue # 跳过注释和空行
|
||
|
||
# 翻译API配置(兼容旧格式)
|
||
if line.startswith('base_url:') or line.startswith('translation_base_url:'):
|
||
base_url = line.split(':', 1)[1].strip()
|
||
current_app.config['DIFY_TRANSLATION_BASE_URL'] = base_url
|
||
# 兼容旧配置
|
||
current_app.config['DIFY_API_BASE_URL'] = base_url
|
||
elif line.startswith('api:') or line.startswith('translation_api:'):
|
||
api_key = line.split(':', 1)[1].strip()
|
||
current_app.config['DIFY_TRANSLATION_API_KEY'] = api_key
|
||
# 兼容旧配置
|
||
current_app.config['DIFY_API_KEY'] = api_key
|
||
|
||
# OCR API配置
|
||
elif line.startswith('ocr_base_url:'):
|
||
ocr_base_url = line.split(':', 1)[1].strip()
|
||
current_app.config['DIFY_OCR_BASE_URL'] = ocr_base_url
|
||
elif line.startswith('ocr_api:'):
|
||
ocr_api_key = line.split(':', 1)[1].strip()
|
||
current_app.config['DIFY_OCR_API_KEY'] = ocr_api_key
|
||
|
||
logger.info("Dify API config loaded from file")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to load Dify config from file: {str(e)}")
|
||
|
||
def upload_file(self, image_data: bytes, filename: str, user_id: int = None) -> str:
|
||
"""上传图片文件到Dify OCR API并返回file_id"""
|
||
|
||
if not image_data:
|
||
raise APIError("图片数据不能为空")
|
||
|
||
logger.info(f"[OCR-UPLOAD] Starting file upload to Dify OCR API")
|
||
logger.info(f"[OCR-UPLOAD] File: {filename}, Size: {len(image_data)} bytes, User: {user_id}")
|
||
|
||
# 构建文件上传数据
|
||
files_data = {
|
||
'file': (filename, image_data, 'image/png') # 假设为PNG格式
|
||
}
|
||
|
||
form_data = {
|
||
'user': f"user_{user_id}" if user_id else "doc-translator-user"
|
||
}
|
||
|
||
# logger.debug(f"[OCR-UPLOAD] Upload form_data: {form_data}")
|
||
# logger.debug(f"[OCR-UPLOAD] Using OCR API: {self.ocr_base_url}")
|
||
|
||
try:
|
||
response = self._make_request(
|
||
method='POST',
|
||
endpoint='/files/upload',
|
||
data=form_data,
|
||
files_data=files_data,
|
||
user_id=user_id,
|
||
api_type='ocr' # 使用OCR API
|
||
)
|
||
|
||
logger.info(f"[OCR-UPLOAD] Raw Dify upload response: {response}")
|
||
|
||
file_id = response.get('id')
|
||
if not file_id:
|
||
logger.error(f"[OCR-UPLOAD] No file ID in response: {response}")
|
||
raise APIError("Dify 文件上传失败:未返回文件ID")
|
||
|
||
logger.info(f"[OCR-UPLOAD] ✓ File uploaded successfully: {file_id}")
|
||
# logger.debug(f"[OCR-UPLOAD] File details: name={response.get('name')}, size={response.get('size')}, type={response.get('mime_type')}")
|
||
|
||
return file_id
|
||
|
||
except APIError:
|
||
raise
|
||
except Exception as e:
|
||
error_msg = f"文件上传到Dify失败: {str(e)}"
|
||
logger.error(f"[OCR-UPLOAD] ✗ Upload failed: {error_msg}")
|
||
raise APIError(error_msg)
|
||
|
||
def ocr_image_with_dify(self, image_data: bytes, filename: str = "image.png",
|
||
user_id: int = None, job_id: int = None) -> str:
|
||
"""使用Dify进行图像OCR识别"""
|
||
|
||
logger.info(f"[OCR-RECOGNITION] Starting OCR process for {filename}")
|
||
logger.info(f"[OCR-RECOGNITION] Image size: {len(image_data)} bytes, User: {user_id}, Job: {job_id}")
|
||
|
||
try:
|
||
# 1. 先上传文件获取file_id
|
||
logger.info(f"[OCR-RECOGNITION] Step 1: Uploading image to Dify...")
|
||
file_id = self.upload_file(image_data, filename, user_id)
|
||
logger.info(f"[OCR-RECOGNITION] Step 1 ✓ File uploaded with ID: {file_id}")
|
||
|
||
# 2. 构建OCR请求
|
||
# 系统提示词已在Dify Chat Flow中配置,这里只需要发送简单的用户query
|
||
query = "將圖片中的文字完整的提取出來"
|
||
logger.info(f"[OCR-RECOGNITION] Step 2: Preparing OCR request...")
|
||
# logger.debug(f"[OCR-RECOGNITION] Query: {query}")
|
||
|
||
# 3. 构建Chat Flow请求,根据最新Dify运行记录,图片应该放在files数组中
|
||
request_data = {
|
||
'inputs': {},
|
||
'response_mode': 'blocking',
|
||
'user': f"user_{user_id}" if user_id else "doc-translator-user",
|
||
'query': query,
|
||
'files': [
|
||
{
|
||
'type': 'image',
|
||
'transfer_method': 'local_file',
|
||
'upload_file_id': file_id
|
||
}
|
||
]
|
||
}
|
||
|
||
logger.info(f"[OCR-RECOGNITION] Step 3: Sending OCR request to Dify...")
|
||
logger.info(f"[OCR-RECOGNITION] Request data: {request_data}")
|
||
logger.info(f"[OCR-RECOGNITION] Using OCR API: {self.ocr_base_url}")
|
||
|
||
response = self._make_request(
|
||
method='POST',
|
||
endpoint='/chat-messages',
|
||
data=request_data,
|
||
user_id=user_id,
|
||
job_id=job_id,
|
||
api_type='ocr' # 使用OCR API
|
||
)
|
||
|
||
logger.info(f"[OCR-RECOGNITION] Step 3 ✓ Received response from Dify")
|
||
logger.info(f"[OCR-RECOGNITION] Raw Dify OCR response: {response}")
|
||
|
||
# 从响应中提取OCR结果
|
||
answer = response.get('answer', '')
|
||
metadata = response.get('metadata', {})
|
||
conversation_id = response.get('conversation_id', '')
|
||
|
||
logger.info(f"[OCR-RECOGNITION] Response details:")
|
||
logger.info(f"[OCR-RECOGNITION] - Answer length: {len(answer) if answer else 0} characters")
|
||
logger.info(f"[OCR-RECOGNITION] - Conversation ID: {conversation_id}")
|
||
logger.info(f"[OCR-RECOGNITION] - Metadata: {metadata}")
|
||
|
||
if not isinstance(answer, str) or not answer.strip():
|
||
logger.error(f"[OCR-RECOGNITION] ✗ Empty or invalid answer from Dify")
|
||
logger.error(f"[OCR-RECOGNITION] Answer type: {type(answer)}, Content: '{answer}'")
|
||
raise APIError("Dify OCR 返回空的识别结果")
|
||
|
||
# 记录OCR识别的前100个字符用于调试
|
||
preview = answer[:100] + "..." if len(answer) > 100 else answer
|
||
logger.info(f"[OCR-RECOGNITION] ✓ OCR completed successfully")
|
||
logger.info(f"[OCR-RECOGNITION] Extracted {len(answer)} characters")
|
||
# logger.debug(f"[OCR-RECOGNITION] Text preview: {preview}")
|
||
|
||
return answer.strip()
|
||
|
||
except APIError:
|
||
raise
|
||
except Exception as e:
|
||
error_msg = f"Dify OCR识别失败: {str(e)}"
|
||
logger.error(f"[OCR-RECOGNITION] ✗ OCR process failed: {error_msg}")
|
||
logger.error(f"[OCR-RECOGNITION] Exception details: {type(e).__name__}: {str(e)}")
|
||
raise APIError(error_msg)
|
||
|
||
|
||
def init_dify_config(app):
|
||
"""初始化 Dify 配置"""
|
||
with app.app_context():
|
||
# 從 api.txt 載入配置
|
||
DifyClient.load_config_from_file()
|
||
|
||
# 檢查配置完整性
|
||
translation_base_url = app.config.get('DIFY_TRANSLATION_BASE_URL')
|
||
translation_api_key = app.config.get('DIFY_TRANSLATION_API_KEY')
|
||
ocr_base_url = app.config.get('DIFY_OCR_BASE_URL')
|
||
ocr_api_key = app.config.get('DIFY_OCR_API_KEY')
|
||
|
||
logger.info("Dify API Configuration Status:")
|
||
if translation_base_url and translation_api_key:
|
||
logger.info("✓ Translation API configured successfully")
|
||
else:
|
||
logger.warning("✗ Translation API configuration is incomplete")
|
||
logger.warning(f" - Translation Base URL: {'✓' if translation_base_url else '✗'}")
|
||
logger.warning(f" - Translation API Key: {'✓' if translation_api_key else '✗'}")
|
||
|
||
if ocr_base_url and ocr_api_key:
|
||
logger.info("✓ OCR API configured successfully")
|
||
else:
|
||
logger.warning("✗ OCR API configuration is incomplete (扫描PDF功能将不可用)")
|
||
logger.warning(f" - OCR Base URL: {'✓' if ocr_base_url else '✗'}")
|
||
logger.warning(f" - OCR API Key: {'✓' if ocr_api_key else '✗'}") |