ponshu-room-lite/tools/synology/ai-proxy/server.py

358 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Ponshu Room AI Proxy Server
============================
役割: アプリからのリクエストを受け取りGemini APIに中継
機能:
- APIキーの保護アプリにAPIキーを埋め込まない
- デバイスID認証許可されたデバイスのみアクセス可能
- レート制限1日10回まで/デバイス
- 使用状況のログ記録
"""
import os
import json
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx
# ==========================================
# 設定
# ==========================================
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-1.5-flash")
RATE_LIMIT_PER_DAY = int(os.getenv("RATE_LIMIT_PER_DAY", "10"))
DATA_DIR = Path("/app/data")
USAGE_FILE = DATA_DIR / "usage.json"
# アプリ起動時にデータディレクトリを作成
DATA_DIR.mkdir(exist_ok=True)
# ==========================================
# FastAPI アプリ初期化
# ==========================================
app = FastAPI(
title="Ponshu Room AI Proxy",
description="Gemini API Proxy with Rate Limiting",
version="1.0.0"
)
# CORS設定モバイルアプリからのリクエストを許可
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 本番環境では特定のオリジンに制限すべき
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==========================================
# データモデル
# ==========================================
class AnalyzeRequest(BaseModel):
"""画像解析リクエスト"""
device_id: str # デバイスIDSHA256ハッシュ化されたもの
images: List[str] # Base64エンコードされた画像データ
prompt: Optional[str] = None # カスタムプロンプト(オプション)
class AnalyzeResponse(BaseModel):
"""画像解析レスポンス"""
success: bool
data: Optional[Dict] = None
error: Optional[str] = None
usage: Dict[str, int] = {} # {"today": 3, "limit": 10}
# ==========================================
# 使用状況管理
# ==========================================
def load_usage_data() -> Dict:
"""使用状況データを読み込む"""
if not USAGE_FILE.exists():
return {}
try:
with open(USAGE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def save_usage_data(data: Dict) -> None:
"""使用状況データを保存"""
with open(USAGE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def get_today_key() -> str:
"""今日の日付キーYYYY-MM-DDを取得"""
return datetime.now().strftime("%Y-%m-%d")
def check_rate_limit(device_id: str) -> tuple[bool, int]:
"""
レート制限をチェック
Returns:
(制限内か, 今日の使用回数)
"""
usage_data = load_usage_data()
today = get_today_key()
# デバイスIDのエントリがない場合は作成
if device_id not in usage_data:
usage_data[device_id] = {}
# 今日の使用回数を取得
today_count = usage_data[device_id].get(today, 0)
# 制限チェック
is_within_limit = today_count < RATE_LIMIT_PER_DAY
return is_within_limit, today_count
def increment_usage(device_id: str) -> int:
"""
使用回数をインクリメント
Returns:
更新後の今日の使用回数
"""
usage_data = load_usage_data()
today = get_today_key()
if device_id not in usage_data:
usage_data[device_id] = {}
usage_data[device_id][today] = usage_data[device_id].get(today, 0) + 1
save_usage_data(usage_data)
return usage_data[device_id][today]
def cleanup_old_usage_data() -> None:
"""30日以上前のデータを削除データサイズ管理"""
usage_data = load_usage_data()
cutoff_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
for device_id in usage_data:
dates_to_remove = [
date for date in usage_data[device_id]
if date < cutoff_date
]
for date in dates_to_remove:
del usage_data[device_id][date]
save_usage_data(usage_data)
# ==========================================
# APIエンドポイント
# ==========================================
@app.get("/")
async def root():
"""ヘルスチェック"""
return {
"service": "Ponshu Room AI Proxy",
"status": "running",
"version": "1.0.0"
}
@app.get("/health")
async def health_check():
"""詳細なヘルスチェック"""
return {
"status": "healthy",
"gemini_api_key_configured": bool(GEMINI_API_KEY),
"rate_limit": RATE_LIMIT_PER_DAY,
"model": GEMINI_MODEL
}
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze_sake_label(request: AnalyzeRequest):
"""
日本酒ラベル画像を解析
レート制限: 1デバイスあたり1日10回まで
"""
# 1. APIキーのチェック
if not GEMINI_API_KEY:
raise HTTPException(
status_code=500,
detail="Gemini APIキーが設定されていません"
)
# 2. デバイスIDの検証SHA256形式かチェック
if len(request.device_id) != 64: # SHA256は64文字
raise HTTPException(
status_code=400,
detail="無効なデバイスIDです"
)
# 3. レート制限チェック
is_within_limit, today_count = check_rate_limit(request.device_id)
if not is_within_limit:
return AnalyzeResponse(
success=False,
error=f"本日の解析上限({RATE_LIMIT_PER_DAY}回)に達しました。明日またお試しください。",
usage={
"today": today_count,
"limit": RATE_LIMIT_PER_DAY
}
)
# 4. Gemini APIにリクエストを送信
try:
# プロンプトのデフォルト値
prompt = request.prompt or """
この日本酒のラベル画像複数枚ある場合は表裏などを分析してください
全ての画像から情報を統合し以下の情報をJSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
2026-01-15 15:53:44 +00:00
"type": "特定名称(例:純米大吟醸, 本醸造, 普通酒 など)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0から100の整数,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
2026-01-15 15:53:44 +00:00
},
"alcoholContent": アルコール度数数値のみ: 15.5,
"polishingRatio": 精米歩合整数のみ: 50,
"sakeMeterValue": 日本酒度数値: +3.0, -1.5,
"riceVariety": "酒米(例:山田錦、五百万石)",
"yeast": "酵母きょうかい9号、M310",
"manufacturingYearMonth": "製造年月2023.10, 2023年10月"
}
**tasteStatsの説明 (1-5の整数)**:
- aroma: 香りの強さ
- sweetness: 甘み
- acidity: 酸味
- bitterness: ビター感/キレ
- body: コクボディ
読み取れない情報は null を返してください
JSONのみを返し他の文章は含めないでください
"""
# Gemini APIエンドポイント
gemini_url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent"
# リクエストボディの構築
parts = [{"text": prompt}]
for image_data in request.images:
parts.append({
"inline_data": {
"mime_type": "image/jpeg",
"data": image_data
}
})
gemini_request = {
"contents": [{
"parts": parts
}],
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}
]
}
# HTTPクライアントでリクエスト送信
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
gemini_url,
json=gemini_request,
params={"key": GEMINI_API_KEY}
)
response.raise_for_status()
gemini_response = response.json()
# レスポンスから結果を抽出
if "candidates" not in gemini_response or not gemini_response["candidates"]:
raise HTTPException(
status_code=500,
detail="Gemini APIからの応答が不正です"
)
text_result = gemini_response["candidates"][0]["content"]["parts"][0]["text"]
# JSONをパースマークダウンのコードブロックを削除
text_result = text_result.strip().replace("```json", "").replace("```", "").strip()
result_json = json.loads(text_result)
# 5. 使用回数をインクリメント
new_count = increment_usage(request.device_id)
# 6. 成功レスポンス
return AnalyzeResponse(
success=True,
data=result_json,
usage={
"today": new_count,
"limit": RATE_LIMIT_PER_DAY
}
)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"Gemini API error: {e.response.text}"
)
except json.JSONDecodeError:
raise HTTPException(
status_code=500,
detail="Gemini APIからのJSON解析に失敗しました"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"予期しないエラー: {str(e)}"
)
@app.get("/usage/{device_id}")
async def get_usage(device_id: str):
"""デバイスの使用状況を取得"""
if len(device_id) != 64:
raise HTTPException(status_code=400, detail="無効なデバイスIDです")
_, today_count = check_rate_limit(device_id)
return {
"device_id": device_id[:8] + "...", # プライバシー保護のため一部のみ表示
"today": today_count,
"limit": RATE_LIMIT_PER_DAY,
"remaining": max(0, RATE_LIMIT_PER_DAY - today_count),
"date": get_today_key()
}
# ==========================================
# 起動時処理
# ==========================================
@app.on_event("startup")
async def startup_event():
"""サーバー起動時に古いデータをクリーンアップ"""
cleanup_old_usage_data()
print(f"🚀 AI Proxy Server started")
print(f" Model: {GEMINI_MODEL}")
print(f" Rate Limit: {RATE_LIMIT_PER_DAY} requests/day")
# ==========================================
# メイン実行
# ==========================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)