Files
ewoooc/services/ai_history_service.py
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

595 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI 生成歷史記錄服務
提供 AI 生成結果的 CRUD 操作
"""
import json
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
from sqlalchemy import desc, func, or_
from database.manager import get_session
from database.ai_models import AIGenerationHistory, AIPromptTemplate
logger = logging.getLogger(__name__)
class AIHistoryService:
"""AI 生成歷史記錄服務"""
@staticmethod
def save_generation(
generation_type: str,
output_content: str,
product_name: str = None,
input_keywords: List[str] = None,
input_style: str = None,
input_trend_topic: str = None,
model_name: str = None,
generation_duration: float = None,
created_by: int = None,
ai_provider: str = 'ollama',
input_tokens: int = 0,
output_tokens: int = 0
) -> Optional[int]:
"""
儲存 AI 生成結果
Args:
generation_type: 生成類型 (copy/recommend/weather_analysis)
output_content: 生成的內容
product_name: 商品名稱
input_keywords: 輸入的關鍵字列表
input_style: 文案風格
input_trend_topic: 趨勢話題
model_name: 使用的模型名稱
generation_duration: 生成耗時(秒)
created_by: 創建者用戶 ID
ai_provider: AI 提供者 ('ollama''gemini')
input_tokens: 輸入 token 數量
output_tokens: 輸出 token 數量
Returns:
新建記錄的 ID失敗則返回 None
"""
session = get_session()
try:
history = AIGenerationHistory(
generation_type=generation_type,
product_name=product_name,
input_keywords=AIHistoryService._safe_json_dumps(input_keywords),
input_style=input_style,
input_trend_topic=input_trend_topic,
output_content=output_content,
model_name=model_name,
generation_duration=generation_duration,
created_by=created_by,
ai_provider=ai_provider,
input_tokens=input_tokens,
output_tokens=output_tokens
)
session.add(history)
session.commit()
logger.info(f"AI 生成記錄已儲存 | ID: {history.id} | Type: {generation_type} | Provider: {ai_provider}")
return history.id
except Exception as e:
session.rollback()
logger.error(f"儲存 AI 生成記錄失敗: {e}")
return None
finally:
session.close()
@staticmethod
def _safe_json_dumps(data) -> Optional[str]:
"""安全的 JSON 序列化"""
if data is None:
return None
try:
return json.dumps(data, ensure_ascii=False)
except (TypeError, ValueError) as e:
logger.warning(f"JSON 序列化失敗,使用字串轉換: {e}")
return json.dumps(str(data), ensure_ascii=False)
@staticmethod
def get_history_list(
page: int = 1,
per_page: int = 20,
generation_type: str = None,
search: str = None,
is_favorite: bool = None,
start_date: datetime = None,
end_date: datetime = None,
created_by: int = None
) -> Dict[str, Any]:
"""
獲取生成歷史列表(分頁)
Args:
page: 頁碼(從 1 開始)
per_page: 每頁數量
generation_type: 篩選生成類型
search: 搜尋關鍵字(商品名稱或內容)
is_favorite: 篩選收藏
start_date: 開始日期
end_date: 結束日期
created_by: 創建者用戶 ID
Returns:
包含 items, total, page, per_page, total_pages 的字典
"""
# 輸入驗證
page = max(1, page)
per_page = max(1, min(100, per_page)) # 限制每頁最多 100 筆
session = get_session()
try:
query = session.query(AIGenerationHistory)
# 應用篩選條件
if generation_type:
query = query.filter(AIGenerationHistory.generation_type == generation_type)
if search:
search_pattern = f"%{search}%"
query = query.filter(
or_(
AIGenerationHistory.product_name.like(search_pattern),
AIGenerationHistory.output_content.like(search_pattern),
AIGenerationHistory.input_trend_topic.like(search_pattern)
)
)
if is_favorite is not None:
query = query.filter(AIGenerationHistory.is_favorite.is_(is_favorite))
if start_date:
query = query.filter(AIGenerationHistory.created_at >= start_date)
if end_date:
query = query.filter(AIGenerationHistory.created_at <= end_date)
if created_by:
query = query.filter(AIGenerationHistory.created_by == created_by)
# 計算總數
total = query.count()
# 排序和分頁
query = query.order_by(desc(AIGenerationHistory.created_at))
offset = (page - 1) * per_page
items = query.offset(offset).limit(per_page).all()
return {
'items': [item.to_dict() for item in items],
'total': total,
'page': page,
'per_page': per_page,
'total_pages': (total + per_page - 1) // per_page
}
except Exception as e:
logger.error(f"獲取 AI 生成歷史列表失敗: {e}")
return {
'items': [],
'total': 0,
'page': page,
'per_page': per_page,
'total_pages': 0
}
finally:
session.close()
@staticmethod
def get_by_id(history_id: int) -> Optional[Dict[str, Any]]:
"""
根據 ID 獲取單筆記錄
Args:
history_id: 記錄 ID
Returns:
記錄字典,不存在則返回 None
"""
session = get_session()
try:
history = session.query(AIGenerationHistory).filter_by(id=history_id).first()
return history.to_dict() if history else None
except Exception as e:
logger.error(f"獲取 AI 生成記錄失敗: {e}")
return None
finally:
session.close()
@staticmethod
def update(
history_id: int,
output_content: str = None,
rating: int = None,
is_favorite: bool = None,
is_used: bool = None,
notes: str = None
) -> bool:
"""
更新生成記錄
Args:
history_id: 記錄 ID
output_content: 更新後的內容
rating: 評分 (1-5)
is_favorite: 是否收藏
is_used: 是否已使用
notes: 備註
Returns:
是否更新成功
"""
session = get_session()
try:
history = session.query(AIGenerationHistory).filter_by(id=history_id).first()
if not history:
return False
if output_content is not None:
history.output_content = output_content
if rating is not None:
history.rating = max(1, min(5, rating)) # 限制 1-5
if is_favorite is not None:
history.is_favorite = is_favorite
if is_used is not None:
history.is_used = is_used
if notes is not None:
history.notes = notes
history.updated_at = datetime.now()
session.commit()
logger.info(f"AI 生成記錄已更新 | ID: {history_id}")
return True
except Exception as e:
session.rollback()
logger.error(f"更新 AI 生成記錄失敗: {e}")
return False
finally:
session.close()
@staticmethod
def delete(history_id: int) -> bool:
"""
刪除生成記錄
Args:
history_id: 記錄 ID
Returns:
是否刪除成功
"""
session = get_session()
try:
history = session.query(AIGenerationHistory).filter_by(id=history_id).first()
if not history:
return False
session.delete(history)
session.commit()
logger.info(f"AI 生成記錄已刪除 | ID: {history_id}")
return True
except Exception as e:
session.rollback()
logger.error(f"刪除 AI 生成記錄失敗: {e}")
return False
finally:
session.close()
@staticmethod
def batch_delete(history_ids: List[int]) -> Dict[str, Any]:
"""
批次刪除生成記錄(優化版本:使用批次刪除)
Args:
history_ids: 記錄 ID 列表
Returns:
包含 success_count 和 failed_ids 的字典
"""
if not history_ids:
return {'success_count': 0, 'failed_ids': []}
session = get_session()
try:
# 先查詢存在的 ID
existing_ids = session.query(AIGenerationHistory.id).filter(
AIGenerationHistory.id.in_(history_ids)
).all()
existing_id_set = {row[0] for row in existing_ids}
# 找出不存在的 ID
failed_ids = [hid for hid in history_ids if hid not in existing_id_set]
# 批次刪除存在的記錄
if existing_id_set:
deleted_count = session.query(AIGenerationHistory).filter(
AIGenerationHistory.id.in_(existing_id_set)
).delete(synchronize_session=False)
session.commit()
else:
deleted_count = 0
logger.info(f"批次刪除 AI 生成記錄 | 成功: {deleted_count} | 失敗: {len(failed_ids)}")
return {
'success_count': deleted_count,
'failed_ids': failed_ids
}
except Exception as e:
session.rollback()
logger.error(f"批次刪除 AI 生成記錄失敗: {e}")
return {
'success_count': 0,
'failed_ids': list(history_ids)
}
finally:
session.close()
@staticmethod
def toggle_favorite(history_id: int) -> Optional[bool]:
"""
切換收藏狀態
Args:
history_id: 記錄 ID
Returns:
新的收藏狀態,失敗則返回 None
"""
session = get_session()
try:
history = session.query(AIGenerationHistory).filter_by(id=history_id).first()
if not history:
return None
history.is_favorite = not history.is_favorite
history.updated_at = datetime.now()
session.commit()
return history.is_favorite
except Exception as e:
session.rollback()
logger.error(f"切換收藏狀態失敗: {e}")
return None
finally:
session.close()
@staticmethod
def get_statistics(days: int = 30, created_by: int = None) -> Dict[str, Any]:
"""
獲取生成統計資料
Args:
days: 統計天數
created_by: 用戶 ID可選
Returns:
統計資料字典
"""
# 輸入驗證
days = max(1, min(365, days)) # 限制 1-365 天
session = get_session()
try:
start_date = datetime.now() - timedelta(days=days)
# 建立基礎篩選條件(避免重複使用 query 物件)
def build_base_filters():
filters = [AIGenerationHistory.created_at >= start_date]
if created_by:
filters.append(AIGenerationHistory.created_by == created_by)
return filters
base_filters = build_base_filters()
# 總數
total_count = session.query(AIGenerationHistory).filter(*base_filters).count()
# 按類型統計
type_stats = session.query(
AIGenerationHistory.generation_type,
func.count(AIGenerationHistory.id)
).filter(*base_filters).group_by(AIGenerationHistory.generation_type).all()
# 收藏數(獨立查詢,避免污染)
favorite_count = session.query(AIGenerationHistory).filter(
*base_filters,
AIGenerationHistory.is_favorite.is_(True)
).count()
# 已使用數(獨立查詢,避免污染)
used_count = session.query(AIGenerationHistory).filter(
*base_filters,
AIGenerationHistory.is_used.is_(True)
).count()
# 平均生成時間
avg_duration = session.query(
func.avg(AIGenerationHistory.generation_duration)
).filter(
*base_filters,
AIGenerationHistory.generation_duration.isnot(None)
).scalar() or 0
# 熱門商品
popular_products = session.query(
AIGenerationHistory.product_name,
func.count(AIGenerationHistory.id).label('count')
).filter(
*base_filters,
AIGenerationHistory.product_name.isnot(None)
).group_by(
AIGenerationHistory.product_name
).order_by(desc('count')).limit(10).all()
return {
'total_count': total_count,
'type_stats': {t: c for t, c in type_stats},
'favorite_count': favorite_count,
'used_count': used_count,
'avg_duration': round(avg_duration, 2),
'popular_products': [{'name': p, 'count': c} for p, c in popular_products],
'period_days': days
}
except Exception as e:
logger.error(f"獲取 AI 統計資料失敗: {e}")
return {
'total_count': 0,
'type_stats': {},
'favorite_count': 0,
'used_count': 0,
'avg_duration': 0,
'popular_products': [],
'period_days': days
}
finally:
session.close()
class AITemplateService:
"""AI 提示模板服務"""
@staticmethod
def get_all_templates(template_type: str = None, active_only: bool = True) -> List[Dict[str, Any]]:
"""
獲取所有模板
Args:
template_type: 篩選類型
active_only: 是否只返回啟用的模板
Returns:
模板列表
"""
session = get_session()
try:
query = session.query(AIPromptTemplate)
if template_type:
query = query.filter(AIPromptTemplate.template_type == template_type)
if active_only:
query = query.filter(AIPromptTemplate.is_active.is_(True))
templates = query.order_by(AIPromptTemplate.is_system.desc(), AIPromptTemplate.name).all()
return [t.to_dict() for t in templates]
except Exception as e:
logger.error(f"獲取 AI 模板列表失敗: {e}")
return []
finally:
session.close()
@staticmethod
def get_by_id(template_id: int) -> Optional[Dict[str, Any]]:
"""根據 ID 獲取模板"""
session = get_session()
try:
template = session.query(AIPromptTemplate).filter_by(id=template_id).first()
return template.to_dict() if template else None
except Exception as e:
logger.error(f"獲取 AI 模板失敗: {e}")
return None
finally:
session.close()
@staticmethod
def create_template(
name: str,
template_type: str,
user_prompt_template: str,
description: str = None,
system_prompt: str = None,
default_temperature: float = 0.7,
default_style: str = None,
created_by: int = None
) -> Optional[int]:
"""創建新模板"""
session = get_session()
try:
template = AIPromptTemplate(
name=name,
description=description,
template_type=template_type,
system_prompt=system_prompt,
user_prompt_template=user_prompt_template,
default_temperature=default_temperature,
default_style=default_style,
is_system=False,
created_by=created_by
)
session.add(template)
session.commit()
return template.id
except Exception as e:
session.rollback()
logger.error(f"創建 AI 模板失敗: {e}")
return None
finally:
session.close()
@staticmethod
def update_template(template_id: int, **kwargs) -> bool:
"""更新模板"""
session = get_session()
try:
template = session.query(AIPromptTemplate).filter_by(id=template_id).first()
if not template:
return False
# 不允許修改系統模板的核心內容
if template.is_system:
allowed_fields = ['is_active']
else:
allowed_fields = ['name', 'description', 'system_prompt', 'user_prompt_template',
'default_temperature', 'default_style', 'is_active']
for field in allowed_fields:
if field in kwargs and kwargs[field] is not None:
setattr(template, field, kwargs[field])
template.updated_at = datetime.now()
session.commit()
return True
except Exception as e:
session.rollback()
logger.error(f"更新 AI 模板失敗: {e}")
return False
finally:
session.close()
@staticmethod
def delete_template(template_id: int) -> bool:
"""刪除模板(不能刪除系統模板)"""
session = get_session()
try:
template = session.query(AIPromptTemplate).filter_by(id=template_id).first()
if not template or template.is_system:
return False
session.delete(template)
session.commit()
return True
except Exception as e:
session.rollback()
logger.error(f"刪除 AI 模板失敗: {e}")
return False
finally:
session.close()
# 建立全域服務實例
ai_history_service = AIHistoryService()
ai_template_service = AITemplateService()