refactor: centralize DIFY settings in config.py and cleanup env files

- Update config.py to read both .env and .env.local (with .env.local priority)
- Move DIFY API settings from hardcoded values to environment configuration
- Remove unused PADDLEOCR_MODEL_DIR setting (models stored in ~/.paddleocr/)
- Remove deprecated argostranslate translation settings
- Add DIFY settings: base_url, api_key, timeout, max_retries, batch limits
- Update dify_client.py to use settings from config.py
- Update translation_service.py to use settings instead of constants
- Fix frontend env files to use correct variable name VITE_API_BASE_URL
- Update setup_dev_env.sh with correct PaddlePaddle version (3.2.0)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-02 17:50:47 +08:00
parent d7f7166a2d
commit c006905b6f
6 changed files with 131 additions and 67 deletions

View File

@@ -31,7 +31,7 @@ TASK_RETENTION_DAYS=30
MAX_TASKS_PER_USER=1000 MAX_TASKS_PER_USER=1000
# ===== OCR Configuration ===== # ===== OCR Configuration =====
PADDLEOCR_MODEL_DIR=./models/paddleocr # Note: PaddleOCR/PaddleX models are stored in ~/.paddleocr/ and ~/.paddlex/ by default
OCR_LANGUAGES=ch,en,japan,korean OCR_LANGUAGES=ch,en,japan,korean
OCR_CONFIDENCE_THRESHOLD=0.5 OCR_CONFIDENCE_THRESHOLD=0.5
MAX_OCR_WORKERS=4 MAX_OCR_WORKERS=4
@@ -69,10 +69,17 @@ PDF_MARGIN_RIGHT=20
# ===== Translation Configuration (DIFY API) ===== # ===== Translation Configuration (DIFY API) =====
# Enable translation feature # Enable translation feature
ENABLE_TRANSLATION=true ENABLE_TRANSLATION=true
# DIFY API endpoint # DIFY API base URL
DIFY_API_URL=https://your-dify-instance.example.com DIFY_BASE_URL=https://your-dify-instance.example.com/v1
# DIFY API key (get from DIFY dashboard) # DIFY API key (get from DIFY dashboard)
DIFY_API_KEY=your-dify-api-key DIFY_API_KEY=your-dify-api-key
# API request timeout in seconds
DIFY_TIMEOUT=120.0
# Maximum retry attempts
DIFY_MAX_RETRIES=3
# Batch translation limits
DIFY_MAX_BATCH_CHARS=5000
DIFY_MAX_BATCH_ITEMS=20
# ===== Background Tasks Configuration ===== # ===== Background Tasks Configuration =====
TASK_QUEUE_TYPE=memory TASK_QUEUE_TYPE=memory

View File

@@ -5,9 +5,13 @@ Loads environment variables and provides centralized configuration
from typing import List, Optional from typing import List, Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import Field from pydantic import Field, model_validator
from pathlib import Path from pathlib import Path
# Anchor all default paths to the backend directory to avoid scattering runtime folders
BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent
PROJECT_ROOT = BACKEND_ROOT.parent
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment variables""" """Application settings loaded from environment variables"""
@@ -28,8 +32,8 @@ class Settings(BaseSettings):
) )
# ===== Application Configuration ===== # ===== Application Configuration =====
backend_port: int = Field(default=12010) backend_port: int = Field(default=8000)
frontend_port: int = Field(default=12011) frontend_port: int = Field(default=5173)
secret_key: str = Field(default="your-secret-key-change-this") secret_key: str = Field(default="your-secret-key-change-this")
algorithm: str = Field(default="HS256") algorithm: str = Field(default="HS256")
access_token_expire_minutes: int = Field(default=1440) # 24 hours access_token_expire_minutes: int = Field(default=1440) # 24 hours
@@ -52,7 +56,7 @@ class Settings(BaseSettings):
max_tasks_per_user: int = Field(default=1000) max_tasks_per_user: int = Field(default=1000)
# ===== OCR Configuration ===== # ===== OCR Configuration =====
paddleocr_model_dir: str = Field(default="./models/paddleocr") # Note: PaddleOCR models are stored in ~/.paddleocr/ and ~/.paddlex/ by default
ocr_languages: str = Field(default="ch,en,japan,korean") ocr_languages: str = Field(default="ch,en,japan,korean")
ocr_confidence_threshold: float = Field(default=0.5) ocr_confidence_threshold: float = Field(default=0.5)
max_ocr_workers: int = Field(default=4) max_ocr_workers: int = Field(default=4)
@@ -323,10 +327,10 @@ class Settings(BaseSettings):
# ===== File Upload Configuration ===== # ===== File Upload Configuration =====
max_upload_size: int = Field(default=52428800) # 50MB max_upload_size: int = Field(default=52428800) # 50MB
allowed_extensions: str = Field(default="png,jpg,jpeg,pdf,bmp,tiff,doc,docx,ppt,pptx") allowed_extensions: str = Field(default="png,jpg,jpeg,pdf,bmp,tiff,doc,docx,ppt,pptx")
upload_dir: str = Field(default="./uploads") upload_dir: str = Field(default=str(BACKEND_ROOT / "uploads"))
temp_dir: str = Field(default="./uploads/temp") temp_dir: str = Field(default=str(BACKEND_ROOT / "uploads" / "temp"))
processed_dir: str = Field(default="./uploads/processed") processed_dir: str = Field(default=str(BACKEND_ROOT / "uploads" / "processed"))
images_dir: str = Field(default="./uploads/images") images_dir: str = Field(default=str(BACKEND_ROOT / "uploads" / "images"))
@property @property
def allowed_extensions_list(self) -> List[str]: def allowed_extensions_list(self) -> List[str]:
@@ -334,11 +338,11 @@ class Settings(BaseSettings):
return [ext.strip() for ext in self.allowed_extensions.split(",")] return [ext.strip() for ext in self.allowed_extensions.split(",")]
# ===== Export Configuration ===== # ===== Export Configuration =====
storage_dir: str = Field(default="./storage") storage_dir: str = Field(default=str(BACKEND_ROOT / "storage"))
markdown_dir: str = Field(default="./storage/markdown") markdown_dir: str = Field(default=str(BACKEND_ROOT / "storage" / "markdown"))
json_dir: str = Field(default="./storage/json") json_dir: str = Field(default=str(BACKEND_ROOT / "storage" / "json"))
exports_dir: str = Field(default="./storage/exports") exports_dir: str = Field(default=str(BACKEND_ROOT / "storage" / "exports"))
result_dir: str = Field(default="./storage/results") result_dir: str = Field(default=str(BACKEND_ROOT / "storage" / "results"))
# ===== PDF Generation Configuration ===== # ===== PDF Generation Configuration =====
pandoc_path: str = Field(default="/opt/homebrew/bin/pandoc") pandoc_path: str = Field(default="/opt/homebrew/bin/pandoc")
@@ -350,21 +354,25 @@ class Settings(BaseSettings):
pdf_margin_right: int = Field(default=20) pdf_margin_right: int = Field(default=20)
# ===== Layout-Preserving PDF Configuration ===== # ===== Layout-Preserving PDF Configuration =====
chinese_font_path: str = Field(default="./backend/fonts/NotoSansSC-Regular.ttf") chinese_font_path: str = Field(default=str(BACKEND_ROOT / "fonts" / "NotoSansSC-Regular.ttf"))
pdf_font_size_base: int = Field(default=12) pdf_font_size_base: int = Field(default=12)
pdf_enable_bbox_debug: bool = Field(default=False) # Draw bounding boxes for debugging pdf_enable_bbox_debug: bool = Field(default=False) # Draw bounding boxes for debugging
# ===== Translation Configuration (Reserved) ===== # ===== Translation Configuration (DIFY API) =====
enable_translation: bool = Field(default=False) enable_translation: bool = Field(default=True)
translation_engine: str = Field(default="offline") dify_base_url: str = Field(default="https://dify.theaken.com/v1")
argostranslate_models_dir: str = Field(default="./models/argostranslate") dify_api_key: str = Field(default="") # Required: set in .env.local
dify_timeout: float = Field(default=120.0) # seconds
dify_max_retries: int = Field(default=3)
dify_max_batch_chars: int = Field(default=5000) # Max characters per batch
dify_max_batch_items: int = Field(default=20) # Max items per batch
# ===== Background Tasks Configuration ===== # ===== Background Tasks Configuration =====
task_queue_type: str = Field(default="memory") task_queue_type: str = Field(default="memory")
redis_url: str = Field(default="redis://localhost:6379/0") redis_url: str = Field(default="redis://localhost:6379/0")
# ===== CORS Configuration ===== # ===== CORS Configuration =====
cors_origins: str = Field(default="http://localhost:12011,http://127.0.0.1:12011") cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173")
@property @property
def cors_origins_list(self) -> List[str]: def cors_origins_list(self) -> List[str]:
@@ -373,14 +381,52 @@ class Settings(BaseSettings):
# ===== Logging Configuration ===== # ===== Logging Configuration =====
log_level: str = Field(default="INFO") log_level: str = Field(default="INFO")
log_file: str = Field(default="./logs/app.log") log_file: str = Field(default=str(BACKEND_ROOT / "logs" / "app.log"))
@model_validator(mode="after")
def _normalize_paths(self):
"""Resolve all runtime paths to backend-rooted absolutes"""
path_fields = [
"upload_dir",
"temp_dir",
"processed_dir",
"images_dir",
"storage_dir",
"markdown_dir",
"json_dir",
"exports_dir",
"result_dir",
"log_file",
"chinese_font_path",
]
for field in path_fields:
value = getattr(self, field)
if value:
setattr(self, field, str(self._resolve_path(str(value))))
return self
class Config: class Config:
# Look for .env in project root (one level up from backend/) # Look for .env files in project root (one level up from backend/)
env_file = str(Path(__file__).resolve().parent.parent.parent.parent / ".env") # .env.local has higher priority and overrides .env
env_file = (
str(PROJECT_ROOT / ".env"),
str(PROJECT_ROOT / ".env.local"),
)
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
case_sensitive = False case_sensitive = False
def _resolve_path(self, path_value: str) -> Path:
"""
Convert relative paths to backend-rooted absolute paths.
This keeps runtime artifacts contained under backend/ even when the app
is launched from different working directories.
"""
path = Path(path_value)
return path if path.is_absolute() else BACKEND_ROOT / path
def ensure_directories(self): def ensure_directories(self):
"""Create all necessary directories if they don't exist""" """Create all necessary directories if they don't exist"""
dirs = [ dirs = [
@@ -393,15 +439,11 @@ class Settings(BaseSettings):
self.json_dir, self.json_dir,
self.exports_dir, self.exports_dir,
self.result_dir, self.result_dir,
self.paddleocr_model_dir,
Path(self.log_file).parent, Path(self.log_file).parent,
] ]
if self.enable_translation and self.translation_engine == "offline":
dirs.append(self.argostranslate_models_dir)
for dir_path in dirs: for dir_path in dirs:
Path(dir_path).mkdir(parents=True, exist_ok=True) self._resolve_path(str(dir_path)).mkdir(parents=True, exist_ok=True)
# Global settings instance # Global settings instance

View File

@@ -3,7 +3,6 @@ Tool_OCR - DIFY AI Client
HTTP client for DIFY translation API with batch support HTTP client for DIFY translation API with batch support
""" """
import asyncio
import logging import logging
import re import re
import time import time
@@ -12,20 +11,10 @@ from typing import Dict, List, Optional
import httpx import httpx
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# DIFY API Configuration
DIFY_BASE_URL = "https://dify.theaken.com/v1"
DIFY_API_KEY = "app-YOPrF2ro5fshzMkCZviIuUJd"
DIFY_TIMEOUT = 120.0 # seconds (increased for batch)
DIFY_MAX_RETRIES = 3
# Batch translation limits
# Conservative limits to avoid gateway timeouts
# DIFY server may have processing time limits
MAX_BATCH_CHARS = 5000
MAX_BATCH_ITEMS = 20
# Language name mapping # Language name mapping
LANGUAGE_NAMES = { LANGUAGE_NAMES = {
"en": "English", "en": "English",
@@ -77,22 +66,39 @@ class DifyClient:
- Blocking mode API calls - Blocking mode API calls
- Automatic retry with exponential backoff - Automatic retry with exponential backoff
- Token and latency tracking - Token and latency tracking
Configuration is loaded from settings (config.py / .env.local):
- DIFY_BASE_URL: API base URL
- DIFY_API_KEY: API key (required)
- DIFY_TIMEOUT: Request timeout in seconds
- DIFY_MAX_RETRIES: Max retry attempts
- DIFY_MAX_BATCH_CHARS: Max characters per batch
- DIFY_MAX_BATCH_ITEMS: Max items per batch
""" """
def __init__( def __init__(
self, self,
base_url: str = DIFY_BASE_URL, base_url: Optional[str] = None,
api_key: str = DIFY_API_KEY, api_key: Optional[str] = None,
timeout: float = DIFY_TIMEOUT, timeout: Optional[float] = None,
max_retries: int = DIFY_MAX_RETRIES max_retries: Optional[int] = None
): ):
self.base_url = base_url # Use settings as defaults when not explicitly provided
self.api_key = api_key self.base_url = base_url or settings.dify_base_url
self.timeout = timeout self.api_key = api_key or settings.dify_api_key
self.max_retries = max_retries self.timeout = timeout if timeout is not None else settings.dify_timeout
self.max_retries = max_retries if max_retries is not None else settings.dify_max_retries
self.max_batch_chars = settings.dify_max_batch_chars
self.max_batch_items = settings.dify_max_batch_items
self._total_tokens = 0 self._total_tokens = 0
self._total_requests = 0 self._total_requests = 0
# Warn if API key is not configured
if not self.api_key:
logger.warning(
"DIFY_API_KEY not configured. Set DIFY_API_KEY in .env.local for translation to work."
)
def _get_language_name(self, lang_code: str) -> str: def _get_language_name(self, lang_code: str) -> str:
"""Convert language code to full name for prompt""" """Convert language code to full name for prompt"""
return LANGUAGE_NAMES.get(lang_code, lang_code) return LANGUAGE_NAMES.get(lang_code, lang_code)

View File

@@ -19,12 +19,11 @@ from app.schemas.translation import (
TranslationProgress, TranslationProgress,
TranslationStatusEnum, TranslationStatusEnum,
) )
from app.core.config import settings
from app.services.dify_client import ( from app.services.dify_client import (
DifyClient, DifyClient,
DifyTranslationError, DifyTranslationError,
get_dify_client, get_dify_client,
MAX_BATCH_CHARS,
MAX_BATCH_ITEMS,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -205,8 +204,8 @@ class TranslationBatch:
"""Check if item can be added to this batch""" """Check if item can be added to this batch"""
item_chars = len(item.content) item_chars = len(item.content)
return ( return (
len(self.items) < MAX_BATCH_ITEMS and len(self.items) < settings.dify_max_batch_items and
self.total_chars + item_chars <= MAX_BATCH_CHARS self.total_chars + item_chars <= settings.dify_max_batch_chars
) )
def add(self, item: TranslatableItem): def add(self, item: TranslatableItem):
@@ -324,7 +323,7 @@ class TranslationService:
logger.info( logger.info(
f"Created {len(batches)} batches from {len(items)} items " f"Created {len(batches)} batches from {len(items)} items "
f"(max {MAX_BATCH_CHARS} chars, max {MAX_BATCH_ITEMS} items per batch)" f"(max {settings.dify_max_batch_chars} chars, max {settings.dify_max_batch_items} items per batch)"
) )
return batches return batches

View File

@@ -4,4 +4,4 @@
# Backend API URL # Backend API URL
# For local development: http://localhost:8000 # For local development: http://localhost:8000
# For WSL2: Use the WSL2 IP address (get with: hostname -I | awk '{print $1}') # For WSL2: Use the WSL2 IP address (get with: hostname -I | awk '{print $1}')
VITE_API_URL=http://localhost:8000 VITE_API_BASE_URL=http://localhost:8000

View File

@@ -166,7 +166,8 @@ detect_gpu() {
if [ "$FORCE_CPU" = true ]; then if [ "$FORCE_CPU" = true ]; then
echo -e "${YELLOW} 已指定 --cpu-only跳過 GPU 偵測${NC}" echo -e "${YELLOW} 已指定 --cpu-only跳過 GPU 偵測${NC}"
USE_GPU=false USE_GPU=false
PADDLE_PACKAGE="paddlepaddle>=3.2.1" PADDLE_PACKAGE="paddlepaddle==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cpu/"
return return
fi fi
@@ -179,31 +180,40 @@ detect_gpu() {
print_success "CUDA 版本: $CUDA_VERSION" print_success "CUDA 版本: $CUDA_VERSION"
CUDA_MAJOR=$(echo $CUDA_VERSION | cut -d. -f1) CUDA_MAJOR=$(echo $CUDA_VERSION | cut -d. -f1)
CUDA_MINOR=$(echo $CUDA_VERSION | cut -d. -f2)
if [ "$CUDA_MAJOR" -ge 12 ]; then if [ "$CUDA_MAJOR" -ge 13 ] || ([ "$CUDA_MAJOR" -eq 12 ] && [ "$CUDA_MINOR" -ge 6 ]); then
echo "將安裝 PaddlePaddle GPU 版本 (CUDA 12.6+)"
USE_GPU=true
PADDLE_PACKAGE="paddlepaddle-gpu==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cu126/"
elif [ "$CUDA_MAJOR" -eq 12 ]; then
echo "將安裝 PaddlePaddle GPU 版本 (CUDA 12.x)" echo "將安裝 PaddlePaddle GPU 版本 (CUDA 12.x)"
USE_GPU=true USE_GPU=true
PADDLE_PACKAGE="paddlepaddle-gpu>=3.2.1" PADDLE_PACKAGE="paddlepaddle-gpu==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cu123/" PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cu126/"
elif [ "$CUDA_MAJOR" -eq 11 ]; then elif [ "$CUDA_MAJOR" -eq 11 ]; then
echo "將安裝 PaddlePaddle GPU 版本 (CUDA 11.x)" echo "將安裝 PaddlePaddle GPU 版本 (CUDA 11.8)"
USE_GPU=true USE_GPU=true
PADDLE_PACKAGE="paddlepaddle-gpu>=3.2.1" PADDLE_PACKAGE="paddlepaddle-gpu==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cu118/" PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cu118/"
else else
print_warning "CUDA 版本不支援 ($CUDA_VERSION),將使用 CPU 版本" print_warning "CUDA 版本不支援 ($CUDA_VERSION),將使用 CPU 版本"
USE_GPU=false USE_GPU=false
PADDLE_PACKAGE="paddlepaddle>=3.2.1" PADDLE_PACKAGE="paddlepaddle==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cpu/"
fi fi
else else
print_warning "無法獲取 CUDA 版本,將使用 CPU 版本" print_warning "無法獲取 CUDA 版本,將使用 CPU 版本"
USE_GPU=false USE_GPU=false
PADDLE_PACKAGE="paddlepaddle>=3.2.1" PADDLE_PACKAGE="paddlepaddle==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cpu/"
fi fi
else else
echo -e "${YELLOW} 未偵測到 NVIDIA GPU將使用 CPU 版本${NC}" echo -e "${YELLOW} 未偵測到 NVIDIA GPU將使用 CPU 版本${NC}"
USE_GPU=false USE_GPU=false
PADDLE_PACKAGE="paddlepaddle>=3.2.1" PADDLE_PACKAGE="paddlepaddle==3.2.0"
PADDLE_INDEX="https://www.paddlepaddle.org.cn/packages/stable/cpu/"
fi fi
} }
@@ -348,7 +358,7 @@ if [ "$USE_GPU" = true ]; then
fi fi
else else
echo "安裝 CPU 版本..." echo "安裝 CPU 版本..."
pip install 'paddlepaddle>=3.2.1' pip install "$PADDLE_PACKAGE" -i "$PADDLE_INDEX"
fi fi
echo "" echo ""