Spaces:
Sleeping
Sleeping
GitHub Actions commited on
Commit ยท
a38f710
1
Parent(s): c0b8706
๐ Auto-deploy from GitHub
Browse files- app/{qr_utils.py โ __init__.py} +0 -0
- app/api/__init__.py +1 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/endpoints/__init__.py +4 -0
- app/api/v1/endpoints/download.py +53 -0
- app/api/v1/endpoints/generate.py +200 -0
- app/api/v1/endpoints/health.py +49 -0
- app/api/v1/schemas/__init__.py +1 -0
- app/api/v1/schemas/horoscope_schemas.py +61 -0
- app/card_renderer.py +0 -26
- app/core/__init__.py +0 -0
- app/core/card_renderer.py +107 -0
- app/core/config.py +61 -0
- app/{constraints.py โ core/constraints.py} +0 -0
- app/{generator.py โ core/generator.py} +1 -5
- app/{lora_config.py โ core/lora_config.py} +0 -0
- app/core/model_loader.py +21 -0
- app/main.py +57 -15
- app/model_loader.py +0 -2
- app/services/database.py +39 -0
- app/utils/__init__.py +0 -0
- app/utils/file_utils.py +18 -0
- app/utils/qr_utils.py +8 -0
- requirements.txt +7 -2
- {assets โ static}/fonts/hand.ttf +0 -0
app/{qr_utils.py โ __init__.py}
RENAMED
|
File without changes
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API v1 package
|
app/api/v1/endpoints/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API v1 endpoints package
|
| 2 |
+
from . import health, generate, download
|
| 3 |
+
|
| 4 |
+
__all__ = ["health", "generate", "download"]
|
app/api/v1/endpoints/download.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from ....services.database import get_supabase_client # Corrected import
|
| 5 |
+
from ....core.config import settings # Import settings
|
| 6 |
+
from supabase import Client
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
@router.get("/download/image/{card_id}") # Changed to path parameter
|
| 11 |
+
async def download_generated_image(
|
| 12 |
+
card_id: str, # card_id from path
|
| 13 |
+
supabase: Client = Depends(get_supabase_client)
|
| 14 |
+
):
|
| 15 |
+
"""
|
| 16 |
+
Download a custom generated image using the card_id to find the image path from Supabase.
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
# Query Supabase for the image record using card_id
|
| 20 |
+
# Assuming your table is named 'horoscope_cards' and has 'id' and 'image_path'
|
| 21 |
+
response = supabase.table("horoscope_cards").select("image_path").eq("id", card_id).execute()
|
| 22 |
+
|
| 23 |
+
if not response.data:
|
| 24 |
+
raise HTTPException(status_code=404, detail="Card not found in database")
|
| 25 |
+
|
| 26 |
+
card_data = response.data[0]
|
| 27 |
+
image_filename = card_data.get("image_path") # Assuming image_path stores the filename
|
| 28 |
+
|
| 29 |
+
if not image_filename:
|
| 30 |
+
raise HTTPException(status_code=404, detail="Image filename not found for this card")
|
| 31 |
+
|
| 32 |
+
# Construct the full file path using settings
|
| 33 |
+
# image_filename is expected to be just the name of the file, e.g., "some_uuid.png"
|
| 34 |
+
full_path = settings.resolved_generated_path / image_filename
|
| 35 |
+
|
| 36 |
+
# Check if file exists
|
| 37 |
+
if not full_path.exists() or not full_path.is_file():
|
| 38 |
+
# Log this issue, as it indicates a discrepancy between DB and filesystem
|
| 39 |
+
print(f"Error: File not found on server at path: {full_path}")
|
| 40 |
+
raise HTTPException(status_code=404, detail="Image file not found on server")
|
| 41 |
+
|
| 42 |
+
return FileResponse(
|
| 43 |
+
path=str(full_path), # Ensure path is a string
|
| 44 |
+
filename=image_filename, # Use the filename from the database
|
| 45 |
+
media_type="image/png" # Assuming PNG, adjust if necessary
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
except HTTPException as http_exc: # Re-raise HTTPExceptions
|
| 49 |
+
raise http_exc
|
| 50 |
+
except Exception as e:
|
| 51 |
+
# Log the exception for debugging
|
| 52 |
+
print(f"An unexpected error occurred: {str(e)}")
|
| 53 |
+
raise HTTPException(status_code=500, detail=f"Error retrieving image: {str(e)}")
|
app/api/v1/endpoints/generate.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
# Supabase Client importieren
|
| 6 |
+
from supabase import Client
|
| 7 |
+
|
| 8 |
+
# Eigene Modulimporte
|
| 9 |
+
from ..schemas.horoscope_schemas import HoroscopeGenerateRequest, HoroscopeGenerateResponse
|
| 10 |
+
from ....core.generator import build_prompt, get_zodiac
|
| 11 |
+
from ....core.card_renderer import generate_card as render_horoscope_card # Umbenannt fรผr Klarheit
|
| 12 |
+
from ....utils.qr_utils import generate_qr_code
|
| 13 |
+
from ....services.database import get_supabase_client, save_horoscope_card
|
| 14 |
+
from ....core.config import settings # Fรผr Pfade und URLs
|
| 15 |
+
|
| 16 |
+
# Model-Ladefunktionen (angenommen, diese bleiben รคhnlich, aber ggf. anpassen)
|
| 17 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
|
| 18 |
+
from peft import PeftModel
|
| 19 |
+
|
| 20 |
+
load_dotenv()
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
# --- Globale Variablen und Modell-Ladefunktion ---
|
| 24 |
+
MODEL_PATH = os.getenv("MODEL_PATH", settings.MODEL_PATH) # Aus settings holen
|
| 25 |
+
DEFAULT_MODEL_ID = os.getenv("DEFAULT_MODEL_ID", settings.DEFAULT_MODEL_ID) # Aus settings holen
|
| 26 |
+
text_generator_pipeline = None
|
| 27 |
+
|
| 28 |
+
def load_language_model():
|
| 29 |
+
global text_generator_pipeline
|
| 30 |
+
if text_generator_pipeline is not None:
|
| 31 |
+
return text_generator_pipeline
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
tokenizer = AutoTokenizer.from_pretrained(DEFAULT_MODEL_ID)
|
| 35 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 36 |
+
DEFAULT_MODEL_ID,
|
| 37 |
+
load_in_4bit=True,
|
| 38 |
+
device_map="auto" # "auto" fรผr GPU-Nutzung falls verfรผgbar
|
| 39 |
+
)
|
| 40 |
+
# Versuche, LoRA-Gewichte zu laden
|
| 41 |
+
if settings.resolved_model_path.exists(): # Prรผfen ob der Pfad existiert
|
| 42 |
+
try:
|
| 43 |
+
model = PeftModel.from_pretrained(model, str(settings.resolved_model_path))
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"Konnte LoRA-Modell nicht laden von {settings.resolved_model_path}: {e}. Fallback auf Basismodell.")
|
| 46 |
+
# Hier kรถnnte man entscheiden, ob man einen Fehler wirft oder mit dem Basismodell weitermacht
|
| 47 |
+
else:
|
| 48 |
+
print(f"LoRA-Modellpfad {settings.resolved_model_path} nicht gefunden. Verwende Basismodell.")
|
| 49 |
+
|
| 50 |
+
text_generator_pipeline = pipeline(
|
| 51 |
+
"text-generation",
|
| 52 |
+
model=model,
|
| 53 |
+
tokenizer=tokenizer,
|
| 54 |
+
# Ggf. weitere Pipeline-Parameter hier
|
| 55 |
+
)
|
| 56 |
+
return text_generator_pipeline
|
| 57 |
+
except Exception as e:
|
| 58 |
+
# Loggen des Fehlers wรคre hier gut
|
| 59 |
+
raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Sprachmodells: {str(e)}")
|
| 60 |
+
|
| 61 |
+
# Sicherstellen, dass das Modell beim Start geladen wird (optional, kann auch lazy loading sein)
|
| 62 |
+
# load_language_model()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.post("/generate-horoscope", response_model=HoroscopeGenerateResponse)
|
| 66 |
+
async def generate_horoscope_endpoint(
|
| 67 |
+
request: HoroscopeGenerateRequest,
|
| 68 |
+
supabase: Client = Depends(get_supabase_client)
|
| 69 |
+
):
|
| 70 |
+
try:
|
| 71 |
+
# 1. Prompt erstellen
|
| 72 |
+
# Annahme: 'lang' ist fest auf 'de' gesetzt oder kommt aus der Anfrage/Config
|
| 73 |
+
lang = "de"
|
| 74 |
+
birthdate_str = request.date_of_birth.isoformat()
|
| 75 |
+
|
| 76 |
+
horoscope_prompt = build_prompt(
|
| 77 |
+
lang=lang,
|
| 78 |
+
birthdate=birthdate_str,
|
| 79 |
+
terms=request.terms
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# 2. Horoskoptext generieren
|
| 83 |
+
llm_pipeline = load_language_model()
|
| 84 |
+
# Parameter fรผr die Textgenerierung (kรถnnten auch aus request oder config kommen)
|
| 85 |
+
generation_params = {
|
| 86 |
+
"max_length": 200, # Beispielwert
|
| 87 |
+
"temperature": 0.75, # Beispielwert
|
| 88 |
+
"do_sample": True,
|
| 89 |
+
"return_full_text": False
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Der Prompt selbst ist der Input fรผr die Pipeline
|
| 93 |
+
# Die Hugging Face Pipeline erwartet den Prompt als ersten Parameter
|
| 94 |
+
# und die anderen Parameter als kwargs.
|
| 95 |
+
# Der 'prompt' im request.prompt von vorher ist jetzt 'horoscope_prompt'
|
| 96 |
+
generated_outputs = llm_pipeline(horoscope_prompt, **generation_params)
|
| 97 |
+
|
| 98 |
+
if not generated_outputs or not generated_outputs[0]["generated_text"]:
|
| 99 |
+
raise HTTPException(status_code=500, detail="Textgenerierung fehlgeschlagen oder leerer Text.")
|
| 100 |
+
|
| 101 |
+
horoscope_text = generated_outputs[0]["generated_text"].strip()
|
| 102 |
+
|
| 103 |
+
# (Optional) Constraints prรผfen, falls implementiert in app.core.constraints
|
| 104 |
+
# from app.core.constraints import check_constraints, generate_with_retry
|
| 105 |
+
# if not check_constraints(horoscope_text, request.terms):
|
| 106 |
+
# # Hier kรถnnte eine Retry-Logik oder Fehlerbehandlung erfolgen
|
| 107 |
+
# pass
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# 3. Horoskopkarte rendern
|
| 111 |
+
# Annahmen fรผr tarot_id und symbol_ids - diese mรผssten dynamisch werden
|
| 112 |
+
tarot_id = 1 # Beispiel
|
| 113 |
+
symbol_ids = [1, 2, 3, 4] # Beispiel
|
| 114 |
+
|
| 115 |
+
# card_renderer.generate_card speichert die Karte und gibt eine file_id (UUID) zurรผck
|
| 116 |
+
# Der Pfad wird in config.py รผber settings.resolved_generated_path definiert
|
| 117 |
+
# und card_renderer sollte diesen verwenden oder einen relativen Pfad dazu.
|
| 118 |
+
# Fรผr generate_card:
|
| 119 |
+
# base_path = settings.resolved_base_path
|
| 120 |
+
# symbols_path = settings.resolved_symbols_path
|
| 121 |
+
# font_path = settings.resolved_default_font_path
|
| 122 |
+
# output_dir = settings.resolved_generated_path
|
| 123 |
+
# Diese Pfade mรผssen in card_renderer.py korrekt verwendet werden.
|
| 124 |
+
|
| 125 |
+
# Temporรคre Annahme: generate_card gibt den Dateinamen (inkl. Erweiterung) zurรผck
|
| 126 |
+
# und speichert im korrekten `settings.resolved_generated_path`
|
| 127 |
+
card_file_id = render_horoscope_card(
|
| 128 |
+
tarot_id=tarot_id,
|
| 129 |
+
symbol_ids=symbol_ids,
|
| 130 |
+
text=horoscope_text,
|
| 131 |
+
# รbergabe der Pfade an card_renderer, falls es diese nicht selbst aus settings holt
|
| 132 |
+
base_images_path=settings.resolved_base_path,
|
| 133 |
+
symbols_images_path=settings.resolved_symbols_path,
|
| 134 |
+
font_path=settings.resolved_default_font_path,
|
| 135 |
+
output_path=settings.resolved_generated_path
|
| 136 |
+
)
|
| 137 |
+
card_image_filename = f"{card_file_id}.png" # Annahme, dass generate_card die ID ohne Extension zurรผckgibt
|
| 138 |
+
|
| 139 |
+
# 4. QR-Code erstellen
|
| 140 |
+
# Der Link im QR-Code sollte auf den Download-Endpunkt fรผr das Bild zeigen
|
| 141 |
+
# z.B. HOST_URL/api/v1/download/image?image_id=<card_file_id>
|
| 142 |
+
# Die HOST_URL sollte aus den Settings kommen (ggf. anpassen in config.py)
|
| 143 |
+
host_url = os.getenv("HOST_URL", "http://localhost:8000") # Fallback, besser aus settings
|
| 144 |
+
|
| 145 |
+
# Der image_id Parameter fรผr den Download-Endpunkt ist die card_file_id (UUID)
|
| 146 |
+
qr_code_content_link = f"{host_url}{settings.API_PREFIX}/download/image/{card_file_id}"
|
| 147 |
+
|
| 148 |
+
qr_code_filename_base = f"qr_{card_file_id}.png"
|
| 149 |
+
qr_code_save_path = settings.resolved_qr_code_path / qr_code_filename_base
|
| 150 |
+
|
| 151 |
+
generate_qr_code(link=qr_code_content_link, output_path=qr_code_save_path)
|
| 152 |
+
|
| 153 |
+
# 5. Daten in Supabase speichern
|
| 154 |
+
# Die Tabelle sollte 'horoscope_cards' heiรen und Spalten gemรคร HoroscopeCardData haben
|
| 155 |
+
# (id, terms, date_of_birth, horoscope_text, image_filename, qr_code_filename, qr_code_link)
|
| 156 |
+
save_horoscope_card(
|
| 157 |
+
supabase_client=supabase, # รbergeben des Supabase Clients
|
| 158 |
+
card_id=card_file_id, # UUID als String
|
| 159 |
+
terms=request.terms,
|
| 160 |
+
date_of_birth=birthdate_str,
|
| 161 |
+
horoscope_text=horoscope_text,
|
| 162 |
+
image_filename=card_image_filename,
|
| 163 |
+
qr_code_filename=qr_code_filename_base,
|
| 164 |
+
qr_code_link=qr_code_content_link
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# 6. Antwort an Frontend
|
| 168 |
+
# Die URL zum QR-Code-Bild, das direkt im Frontend angezeigt werden kann
|
| 169 |
+
# z.B. HOST_URL/static/images/qr/qr_<card_file_id>.png
|
| 170 |
+
# Der /static Pfad wird in main.py gemountet
|
| 171 |
+
qr_code_image_url = f"{host_url}/static/images/qr/{qr_code_filename_base}"
|
| 172 |
+
|
| 173 |
+
return HoroscopeGenerateResponse(
|
| 174 |
+
card_id=card_file_id,
|
| 175 |
+
qr_code_image_url=qr_code_image_url,
|
| 176 |
+
message="Horoskopkarte erfolgreich generiert."
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
except FileNotFoundError as e:
|
| 180 |
+
# Speziell fรผr Template-Fehler
|
| 181 |
+
raise HTTPException(status_code=500, detail=f"Eine benรถtigte Datei wurde nicht gefunden: {str(e)}")
|
| 182 |
+
except HTTPException as e:
|
| 183 |
+
# Weiterleiten von HTTPExceptions (z.B. vom Modell-Laden)
|
| 184 |
+
raise e
|
| 185 |
+
except Exception as e:
|
| 186 |
+
# Allgemeiner Fehler, hier wรคre besseres Logging wichtig
|
| 187 |
+
print(f"Unerwarteter Fehler im generate_horoscope_endpoint: {type(e).__name__} - {str(e)}")
|
| 188 |
+
# Stacktrace loggen fรผr Debugging:
|
| 189 |
+
import traceback
|
| 190 |
+
traceback.print_exc()
|
| 191 |
+
raise HTTPException(status_code=500, detail=f"Interne Serverfehler bei der Horoskopgenerierung: {str(e)}")
|
| 192 |
+
|
| 193 |
+
# Alte Endpunkt-Definition entfernen oder anpassen, falls der Pfad gleich bleiben soll
|
| 194 |
+
# Der Pfad wurde zu "/generate-horoscope" geรคndert, um Konflikte zu vermeiden,
|
| 195 |
+
# und das Request/Response-Modell wurde angepasst.
|
| 196 |
+
|
| 197 |
+
# @router.post("/generate", response_model=GenerateResponse)
|
| 198 |
+
# async def generate_text(request: GenerateRequest):
|
| 199 |
+
# # ... alter Code ...
|
| 200 |
+
# pass
|
app/api/v1/endpoints/health.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
import httpx
|
| 4 |
+
import asyncio
|
| 5 |
+
from typing import Optional
|
| 6 |
+
import os
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
class HealthResponse(BaseModel):
|
| 14 |
+
status: str
|
| 15 |
+
server: str
|
| 16 |
+
huggingface_space: Optional[str] = None
|
| 17 |
+
huggingface_space_url: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
HF_SPACE_URL = os.getenv("HF_SPACE_URL", None)
|
| 20 |
+
|
| 21 |
+
async def check_huggingface_space():
|
| 22 |
+
"""Check if HuggingFace space is available"""
|
| 23 |
+
if not HF_SPACE_URL:
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 28 |
+
response = await client.get(HF_SPACE_URL)
|
| 29 |
+
return "healthy" if response.status_code == 200 else "unhealthy"
|
| 30 |
+
except Exception:
|
| 31 |
+
return "unreachable"
|
| 32 |
+
|
| 33 |
+
@router.get("/health", response_model=HealthResponse)
|
| 34 |
+
async def health_check():
|
| 35 |
+
"""
|
| 36 |
+
Health check endpoint that verifies server status and HuggingFace space availability
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
# Check HuggingFace space if configured
|
| 40 |
+
hf_status = await check_huggingface_space()
|
| 41 |
+
|
| 42 |
+
return HealthResponse(
|
| 43 |
+
status="healthy",
|
| 44 |
+
server="running",
|
| 45 |
+
huggingface_space=hf_status,
|
| 46 |
+
huggingface_space_url=HF_SPACE_URL
|
| 47 |
+
)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
raise HTTPException(status_code=500, detail=f"Health check failed: {str(e)}")
|
app/api/v1/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API v1 schemas package
|
app/api/v1/schemas/horoscope_schemas.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/schemas/horoscope_schemas.py
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from datetime import date
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
# --- Request Models ---
|
| 9 |
+
|
| 10 |
+
class HoroscopeGenerateRequest(BaseModel):
|
| 11 |
+
"""
|
| 12 |
+
Schema fรผr die Anfrage zur Generierung einer personalisierten Horoskopkarte.
|
| 13 |
+
"""
|
| 14 |
+
terms: List[str] = Field(
|
| 15 |
+
...,
|
| 16 |
+
min_length=5,
|
| 17 |
+
max_length=5,
|
| 18 |
+
description="Fรผnf Schlรผsselbegriffe oder Konzepte fรผr das Horoskop.",
|
| 19 |
+
example=["Liebe", "Erfolg", "Glรผck", "Herausforderung", "Wachstum"]
|
| 20 |
+
)
|
| 21 |
+
date_of_birth: date = Field(
|
| 22 |
+
...,
|
| 23 |
+
description="Das Geburtsdatum des Nutzers im Format JJJJ-MM-TT.",
|
| 24 |
+
example="1990-05-15"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# --- Response Models ---
|
| 28 |
+
|
| 29 |
+
class HoroscopeGenerateResponse(BaseModel):
|
| 30 |
+
"""
|
| 31 |
+
Schema fรผr die Antwort nach erfolgreicher Anfrage zur Horoskopgenerierung.
|
| 32 |
+
Enthรคlt den Link zum QR-Code und die ID der generierten Karte.
|
| 33 |
+
"""
|
| 34 |
+
card_id: str = Field(
|
| 35 |
+
...,
|
| 36 |
+
description="Die eindeutige ID der generierten Horoskopkarte. Wird fรผr den Download benรถtigt.",
|
| 37 |
+
example="a1b2c3d4-e5f6-7890-1234-567890abcdef"
|
| 38 |
+
)
|
| 39 |
+
qr_code_image_url: str = Field(
|
| 40 |
+
...,
|
| 41 |
+
description="Die URL, unter der das QR-Code-Bild (das den Download-Link zur Horoskopkarte enthรคlt) direkt abrufbar ist.",
|
| 42 |
+
example="http://localhost:8000/static/qr_codes/qr_code_a1b2c3d4-e5f6-7890-1234-567890abcdef.png"
|
| 43 |
+
)
|
| 44 |
+
message: str = Field(
|
| 45 |
+
"Horoskopkarten-Generierung erfolgreich initiiert. QR-Code ist verfรผgbar.",
|
| 46 |
+
description="Eine Bestรคtigungsnachricht."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
class HoroscopeCardData(BaseModel):
|
| 50 |
+
"""
|
| 51 |
+
Schema fรผr die internen Daten einer gespeicherten Horoskopkarte (z.B. aus Supabase).
|
| 52 |
+
Dies ist kein API-Response-Modell, sondern eher ein Datenmodell fรผr Services/Datenbank.
|
| 53 |
+
"""
|
| 54 |
+
id: uuid.UUID = Field(..., description="Eindeutige ID der Horoskopkarte.")
|
| 55 |
+
terms: List[str] = Field(..., description="Die ursprรผnglichen fรผnf Schlรผsselbegriffe.")
|
| 56 |
+
date_of_birth: date = Field(..., description="Das Geburtsdatum des Nutzers.")
|
| 57 |
+
horoscope_text: str = Field(..., description="Der generierte Horoskoptext.")
|
| 58 |
+
image_path: str = Field(..., description="Dateisystempfad zum generierten Horoskopbild.")
|
| 59 |
+
qr_code_path: str = Field(..., description="Dateisystempfad zum generierten QR-Code-Bild.")
|
| 60 |
+
qr_code_link: str = Field(..., description="Der URL, der im QR-Code kodiert ist.")
|
| 61 |
+
created_at: Optional[date] = Field(None, description="Zeitpunkt der Erstellung des Eintrags.")
|
app/card_renderer.py
DELETED
|
@@ -1,26 +0,0 @@
|
|
| 1 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 2 |
-
import uuid
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
def generate_card(tarot_id: int, symbol_ids: list[int], text: str) -> str:
|
| 6 |
-
base_path = Path(__file__).parent
|
| 7 |
-
tarot_img = Image.open(base_path / f"assets/images/base/{tarot_id}.png").convert("RGBA")
|
| 8 |
-
|
| 9 |
-
# Symbole hinzufรผgen
|
| 10 |
-
for sid in symbol_ids:
|
| 11 |
-
symbol = Image.open(base_path / f"assets/images/symbols/{sid}.png").convert("RGBA")
|
| 12 |
-
tarot_img.paste(symbol, (50 + sid*10, 400), symbol) # Positionierung frei wรคhlbar
|
| 13 |
-
|
| 14 |
-
# Text hinzufรผgen
|
| 15 |
-
draw = ImageDraw.Draw(tarot_img)
|
| 16 |
-
font = ImageFont.truetype(str(base_path / "assets/fonts/hand.ttf"), 20)
|
| 17 |
-
draw.text((50, tarot_img.height - 150), text, font=font, fill="black")
|
| 18 |
-
|
| 19 |
-
# Speichern
|
| 20 |
-
output_dir = base_path / "assets/images/generated"
|
| 21 |
-
output_dir.mkdir(exist_ok=True)
|
| 22 |
-
file_id = str(uuid.uuid4())
|
| 23 |
-
output_path = output_dir / f"{file_id}.png"
|
| 24 |
-
tarot_img.save(output_path)
|
| 25 |
-
|
| 26 |
-
return file_id # nur ID zurรผckgeben
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/card_renderer.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 2 |
+
import uuid
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
def generate_card(
|
| 6 |
+
tarot_id: int,
|
| 7 |
+
symbol_ids: list[int],
|
| 8 |
+
text: str,
|
| 9 |
+
base_images_path: Path,
|
| 10 |
+
symbols_images_path: Path,
|
| 11 |
+
font_path: Path,
|
| 12 |
+
output_path: Path
|
| 13 |
+
) -> str:
|
| 14 |
+
"""
|
| 15 |
+
Generiert eine Horoskopkarte und speichert sie.
|
| 16 |
+
Verwendet jetzt รผbergebene Pfade fรผr mehr Flexibilitรคt und Testbarkeit.
|
| 17 |
+
Gibt die UUID der generierten Datei (ohne Erweiterung) zurรผck.
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
# Basiskarte laden
|
| 21 |
+
base_image_file = base_images_path / f"{tarot_id}.png"
|
| 22 |
+
if not base_image_file.exists():
|
| 23 |
+
raise FileNotFoundError(f"Basiskartenbild nicht gefunden: {base_image_file}")
|
| 24 |
+
tarot_img = Image.open(base_image_file).convert("RGBA")
|
| 25 |
+
|
| 26 |
+
# Symbole hinzufรผgen
|
| 27 |
+
# Die Positionierung hier ist ein Beispiel und muss ggf. angepasst werden
|
| 28 |
+
symbol_x_start = 50
|
| 29 |
+
symbol_y_start = 400 # Beispiel Y-Position
|
| 30 |
+
symbol_spacing = 10 # Beispiel Abstand
|
| 31 |
+
current_x = symbol_x_start
|
| 32 |
+
|
| 33 |
+
for i, sid in enumerate(symbol_ids):
|
| 34 |
+
symbol_file = symbols_images_path / f"{sid}.png"
|
| 35 |
+
if not symbol_file.exists():
|
| 36 |
+
print(f"Warnung: Symbolbild nicht gefunden: {symbol_file}, wird รผbersprungen.")
|
| 37 |
+
continue
|
| 38 |
+
symbol_img = Image.open(symbol_file).convert("RGBA")
|
| 39 |
+
|
| 40 |
+
# Beispielhafte Positionierung - muss ggf. verfeinert werden
|
| 41 |
+
# Annahme: Symbole werden nebeneinander platziert
|
| 42 |
+
position = (current_x, symbol_y_start)
|
| 43 |
+
tarot_img.paste(symbol_img, position, symbol_img)
|
| 44 |
+
current_x += symbol_img.width + symbol_spacing
|
| 45 |
+
|
| 46 |
+
# Text hinzufรผgen
|
| 47 |
+
if not font_path.exists():
|
| 48 |
+
raise FileNotFoundError(f"Schriftart nicht gefunden: {font_path}")
|
| 49 |
+
|
| 50 |
+
draw = ImageDraw.Draw(tarot_img)
|
| 51 |
+
try:
|
| 52 |
+
font_size = 20 # Beispiel
|
| 53 |
+
font = ImageFont.truetype(str(font_path), font_size)
|
| 54 |
+
except IOError:
|
| 55 |
+
print(f"Warnung: Konnte Schriftart {font_path} nicht laden. Fallback auf Standard-Schriftart (falls verfรผgbar).")
|
| 56 |
+
font = ImageFont.load_default() # Fallback
|
| 57 |
+
|
| 58 |
+
# Textpositionierung und -umbruch (vereinfacht)
|
| 59 |
+
# Fรผr besseren Textumbruch wรคren komplexere Logiken nรถtig (z.B. textwrap Modul)
|
| 60 |
+
text_x = 50
|
| 61 |
+
text_y = tarot_img.height - 150 # Beispiel Y-Position von unten
|
| 62 |
+
# draw.text((text_x, text_y), text, font=font, fill=(0, 0, 0, 255)) # Schwarz, voll opak
|
| 63 |
+
|
| 64 |
+
# Einfacher Textumbruch
|
| 65 |
+
max_width = tarot_img.width - (2 * text_x) # Maximale Breite fรผr Text
|
| 66 |
+
lines = []
|
| 67 |
+
words = text.split()
|
| 68 |
+
current_line = ""
|
| 69 |
+
for word in words:
|
| 70 |
+
# Teste, ob das Wort auf die aktuelle Zeile passt
|
| 71 |
+
# Die Methode textbbox ist ab Pillow 8.0.0 verfรผgbar, textsize ist รคlter
|
| 72 |
+
if hasattr(draw, 'textbbox'):
|
| 73 |
+
bbox = draw.textbbox((0,0), current_line + word + " ", font=font)
|
| 74 |
+
line_width = bbox[2] - bbox[0]
|
| 75 |
+
else:
|
| 76 |
+
line_width, _ = draw.textsize(current_line + word + " ", font=font)
|
| 77 |
+
|
| 78 |
+
if line_width <= max_width:
|
| 79 |
+
current_line += word + " "
|
| 80 |
+
else:
|
| 81 |
+
lines.append(current_line.strip())
|
| 82 |
+
current_line = word + " "
|
| 83 |
+
lines.append(current_line.strip()) # Letzte Zeile hinzufรผgen
|
| 84 |
+
|
| 85 |
+
line_height = font.getbbox("A")[3] - font.getbbox("A")[1] if hasattr(font, 'getbbox') else font.getsize("A")[1]
|
| 86 |
+
for i, line in enumerate(lines):
|
| 87 |
+
draw.text((text_x, text_y + (i * (line_height + 5))), line, font=font, fill=(0, 0, 0, 255))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Speichern der generierten Karte
|
| 91 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 92 |
+
file_id = str(uuid.uuid4()) # Eindeutige ID fรผr die Datei
|
| 93 |
+
output_file_path = output_path / f"{file_id}.png"
|
| 94 |
+
|
| 95 |
+
tarot_img.save(output_file_path)
|
| 96 |
+
|
| 97 |
+
return file_id # Gibt die UUID (ohne .png) zurรผck
|
| 98 |
+
|
| 99 |
+
except FileNotFoundError as e:
|
| 100 |
+
# Fehler weiterleiten, damit der aufrufende Endpunkt ihn behandeln kann
|
| 101 |
+
raise e
|
| 102 |
+
except Exception as e:
|
| 103 |
+
# Allgemeiner Fehler beim Rendern
|
| 104 |
+
print(f"Fehler in generate_card: {type(e).__name__} - {str(e)}")
|
| 105 |
+
import traceback
|
| 106 |
+
traceback.print_exc()
|
| 107 |
+
raise RuntimeError(f"Fehler beim Rendern der Horoskopkarte: {str(e)}")
|
app/core/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
+
from typing import ClassVar
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
_PROJECT_ROOT_DIR_CLS: ClassVar[Path] = Path(__file__).resolve().parent.parent.parent.parent
|
| 7 |
+
_CARDSERVER_DIR_CLS: ClassVar[Path] = _PROJECT_ROOT_DIR_CLS / "cardserver"
|
| 8 |
+
_APP_DEFAULT_STATIC_DIR_CLS: ClassVar[Path] = _CARDSERVER_DIR_CLS / "static"
|
| 9 |
+
|
| 10 |
+
MODEL_PATH: str = str(_CARDSERVER_DIR_CLS / "models" / "lora-checkpoint")
|
| 11 |
+
DEFAULT_MODEL_ID: str = "teknium/OpenHermes-2.5-Mistral-7B"
|
| 12 |
+
|
| 13 |
+
GENERATED_PATH: str = str(_APP_DEFAULT_STATIC_DIR_CLS / "images" / "generated")
|
| 14 |
+
BASE_PATH: str = str(_APP_DEFAULT_STATIC_DIR_CLS / "images" / "base")
|
| 15 |
+
SYMBOLS_PATH: str = str(_APP_DEFAULT_STATIC_DIR_CLS / "images" / "symbols")
|
| 16 |
+
QR_CODE_PATH: str = str(_APP_DEFAULT_STATIC_DIR_CLS / "images" / "qr")
|
| 17 |
+
|
| 18 |
+
STATIC_FILES_MOUNT_DIR: str = str(_APP_DEFAULT_STATIC_DIR_CLS)
|
| 19 |
+
|
| 20 |
+
FONTS_SUBDIR: str = "fonts"
|
| 21 |
+
|
| 22 |
+
API_PREFIX: str = "/api/v1"
|
| 23 |
+
PROJECT_NAME: str = "Horoskopkarten-Generator"
|
| 24 |
+
APP_VERSION: str = "1.0.0"
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def resolved_model_path(self) -> Path:
|
| 28 |
+
return Path(self.MODEL_PATH)
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def resolved_generated_path(self) -> Path:
|
| 32 |
+
return Path(self.GENERATED_PATH)
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def resolved_base_path(self) -> Path:
|
| 36 |
+
return Path(self.BASE_PATH)
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def resolved_symbols_path(self) -> Path:
|
| 40 |
+
return Path(self.SYMBOLS_PATH)
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def resolved_qr_code_path(self) -> Path:
|
| 44 |
+
return Path(self.QR_CODE_PATH)
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def resolved_static_files_mount_dir(self) -> Path:
|
| 48 |
+
return Path(self.STATIC_FILES_MOUNT_DIR)
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def resolved_default_font_path(self) -> Path:
|
| 52 |
+
return self.resolved_static_files_mount_dir / self.FONTS_SUBDIR / "hand.ttf"
|
| 53 |
+
|
| 54 |
+
model_config = SettingsConfigDict(
|
| 55 |
+
env_file=".env",
|
| 56 |
+
env_file_encoding='utf-8',
|
| 57 |
+
extra='ignore',
|
| 58 |
+
case_sensitive=False
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
settings = Settings()
|
app/{constraints.py โ core/constraints.py}
RENAMED
|
File without changes
|
app/{generator.py โ core/generator.py}
RENAMED
|
@@ -1,7 +1,3 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
from jinja2 import Template
|
| 6 |
from pathlib import Path
|
| 7 |
import datetime
|
|
@@ -26,7 +22,7 @@ def get_zodiac(birthdate: str) -> str:
|
|
| 26 |
return "Steinbock"
|
| 27 |
|
| 28 |
def build_prompt(lang: str, birthdate: str, terms: list[str]) -> str:
|
| 29 |
-
template_path = Path(__file__).parent / "templates" / lang / "base.txt"
|
| 30 |
if not template_path.exists():
|
| 31 |
raise FileNotFoundError(f"Template nicht gefunden: {template_path}")
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from jinja2 import Template
|
| 2 |
from pathlib import Path
|
| 3 |
import datetime
|
|
|
|
| 22 |
return "Steinbock"
|
| 23 |
|
| 24 |
def build_prompt(lang: str, birthdate: str, terms: list[str]) -> str:
|
| 25 |
+
template_path = Path(__file__).resolve().parent.parent.parent / "templates" / lang / "base.txt"
|
| 26 |
if not template_path.exists():
|
| 27 |
raise FileNotFoundError(f"Template nicht gefunden: {template_path}")
|
| 28 |
|
app/{lora_config.py โ core/lora_config.py}
RENAMED
|
File without changes
|
app/core/model_loader.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
|
| 2 |
+
from peft import PeftModel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def load_model():
|
| 7 |
+
base_model_id = "teknium/OpenHermes-2.5-Mistral-7B"
|
| 8 |
+
lora_path = "./models/lora-checkpoint" # oder None, falls nicht getunt
|
| 9 |
+
|
| 10 |
+
tokenizer = AutoTokenizer.from_pretrained(base_model_id)
|
| 11 |
+
model = AutoModelForCausalLM.from_pretrained(base_model_id, load_in_4bit=True, device_map="auto")
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
model = PeftModel.from_pretrained(model, lora_path)
|
| 15 |
+
except:
|
| 16 |
+
pass # fallback ohne LoRA
|
| 17 |
+
|
| 18 |
+
pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)
|
| 19 |
+
return pipe
|
| 20 |
+
|
| 21 |
+
generator = load_model()
|
app/main.py
CHANGED
|
@@ -1,19 +1,61 @@
|
|
| 1 |
-
import
|
| 2 |
-
from fastapi import
|
| 3 |
-
from
|
| 4 |
-
from
|
| 5 |
-
from
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.staticfiles import StaticFiles
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from .api.v1.endpoints import generate, download, health
|
| 5 |
+
from .core.config import settings
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
import logging
|
| 8 |
|
| 9 |
+
# Configure logging
|
| 10 |
+
logging.basicConfig(level=logging.INFO)
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
+
settings.resolved_generated_path.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
settings.resolved_base_path.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
settings.resolved_symbols_path.mkdir(parents=True, exist_ok=True)
|
| 16 |
+
settings.resolved_qr_code_path.mkdir(parents=True, exist_ok=True)
|
| 17 |
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
logger.info(f"{settings.PROJECT_NAME} Anwendung startet...")
|
| 21 |
+
logger.info(f" Model Path: {settings.resolved_model_path}")
|
| 22 |
+
logger.info(f" Generated Images Path: {settings.resolved_generated_path}")
|
| 23 |
+
logger.info(f" Base Images Path: {settings.resolved_base_path}")
|
| 24 |
+
logger.info(f" Symbols Path: {settings.resolved_symbols_path}")
|
| 25 |
+
logger.info(f" QR Codes Path: {settings.resolved_qr_code_path}")
|
| 26 |
+
logger.info(f" Static Files Mount Dir: {settings.resolved_static_files_mount_dir}")
|
| 27 |
+
logger.info(f" Default Font Path: {settings.resolved_default_font_path}")
|
| 28 |
+
yield
|
| 29 |
+
logger.info(f"{settings.PROJECT_NAME} Anwendung wird heruntergefahren.")
|
| 30 |
|
| 31 |
+
app = FastAPI(
|
| 32 |
+
title=settings.PROJECT_NAME,
|
| 33 |
+
description="Ein Service zur Generierung personalisierter Horoskopkarten mit LoRa und FastAPI.",
|
| 34 |
+
version=settings.APP_VERSION,
|
| 35 |
+
lifespan=lifespan
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
app.mount("/static", StaticFiles(directory=settings.resolved_static_files_mount_dir), name="static")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
origins = [
|
| 42 |
+
"http://localhost",
|
| 43 |
+
"http://localhost:3000",
|
| 44 |
+
# TODO: Optional Fรผge hier deine Frontend-URL(s) hinzu
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
app.add_middleware(
|
| 48 |
+
CORSMiddleware,
|
| 49 |
+
allow_origins=origins,
|
| 50 |
+
allow_credentials=True,
|
| 51 |
+
allow_methods=["*"],
|
| 52 |
+
allow_headers=["*"],
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
app.include_router(health.router, prefix=settings.API_PREFIX, tags=["Health"])
|
| 56 |
+
app.include_router(generate.router, prefix=settings.API_PREFIX, tags=["Generate Horoscope"])
|
| 57 |
+
app.include_router(download.router, prefix=settings.API_PREFIX, tags=["Download Card"])
|
| 58 |
+
|
| 59 |
+
@app.get("/")
|
| 60 |
+
async def root():
|
| 61 |
+
return {"message": f"Welcome to the {settings.PROJECT_NAME} API."}
|
app/model_loader.py
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
# model_loader.py
|
| 2 |
-
# LLM laden (lokal oder remote)
|
|
|
|
|
|
|
|
|
app/services/database.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 8 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
|
| 9 |
+
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 10 |
+
|
| 11 |
+
def get_supabase_client():
|
| 12 |
+
return supabase
|
| 13 |
+
|
| 14 |
+
def save_horoscope_card(
|
| 15 |
+
card_id: str,
|
| 16 |
+
terms: list[str],
|
| 17 |
+
date_of_birth: str, # Pydantic date wird hier als str erwartet fรผr Supabase
|
| 18 |
+
horoscope_text: str,
|
| 19 |
+
image_filename: str, # Nur der Dateiname, Pfad wird serverseitig verwaltet
|
| 20 |
+
qr_code_filename: str, # Nur der Dateiname
|
| 21 |
+
qr_code_link: str
|
| 22 |
+
):
|
| 23 |
+
"""Speichert die Daten der generierten Horoskopkarte in Supabase."""
|
| 24 |
+
try:
|
| 25 |
+
response = supabase.table("horoscope_cards").insert({
|
| 26 |
+
"id": card_id,
|
| 27 |
+
"terms": terms,
|
| 28 |
+
"date_of_birth": date_of_birth,
|
| 29 |
+
"horoscope_text": horoscope_text,
|
| 30 |
+
"image_filename": image_filename,
|
| 31 |
+
"qr_code_filename": qr_code_filename,
|
| 32 |
+
"qr_code_link": qr_code_link
|
| 33 |
+
# created_at wird von Supabase automatisch gesetzt, wenn entsprechend konfiguriert
|
| 34 |
+
}).execute()
|
| 35 |
+
return response
|
| 36 |
+
except Exception as e:
|
| 37 |
+
# Hier wรคre ein besseres Logging/Fehlerhandling gut
|
| 38 |
+
print(f"Error saving to Supabase: {e}")
|
| 39 |
+
raise
|
app/utils/__init__.py
ADDED
|
File without changes
|
app/utils/file_utils.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid, os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
GENERATED_DIR = Path(os.getenv("GENERATED_PATH", "media"))
|
| 7 |
+
HOST_URL = os.getenv("HOST_URL", "http://localhost:8000")
|
| 8 |
+
|
| 9 |
+
def save_card_image(image_bytes: bytes, extension="png") -> Path:
|
| 10 |
+
filename = f"{uuid.uuid4()}.{extension}"
|
| 11 |
+
filepath = GENERATED_DIR / filename
|
| 12 |
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
with open(filepath, "wb") as f:
|
| 14 |
+
f.write(image_bytes)
|
| 15 |
+
return filepath
|
| 16 |
+
|
| 17 |
+
def get_card_url(filename: str, base_url: str = "https://yourdomain.com/media") -> str:
|
| 18 |
+
return f"{base_url}/{filename}"
|
app/utils/qr_utils.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import qrcode
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
def generate_qr_code(link: str, output_path: Path) -> Path:
|
| 5 |
+
qr = qrcode.make(link)
|
| 6 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 7 |
+
qr.save(output_path)
|
| 8 |
+
return output_path
|
requirements.txt
CHANGED
|
@@ -1,7 +1,12 @@
|
|
| 1 |
fastapi
|
| 2 |
uvicorn[standard]
|
| 3 |
-
|
| 4 |
qrcode
|
| 5 |
pillow
|
|
|
|
|
|
|
|
|
|
| 6 |
transformers
|
| 7 |
-
|
|
|
|
|
|
|
|
|
| 1 |
fastapi
|
| 2 |
uvicorn[standard]
|
| 3 |
+
pydantic
|
| 4 |
qrcode
|
| 5 |
pillow
|
| 6 |
+
peft
|
| 7 |
+
torch
|
| 8 |
+
accelerate
|
| 9 |
transformers
|
| 10 |
+
numpy
|
| 11 |
+
supabase
|
| 12 |
+
jinja2
|
{assets โ static}/fonts/hand.ttf
RENAMED
|
File without changes
|