GitHub Actions commited on
Commit
a38f710
ยท
1 Parent(s): c0b8706

๐Ÿš€ Auto-deploy from GitHub

Browse files
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 os
2
- from fastapi import FastAPI, Request
3
- from pydantic import BaseModel
4
- from generator import build_prompt, generate_with_retry
5
- from model_loader import generator
 
 
6
 
7
- SECRET_KEY = os.getenv("SECRET_KEY")
 
 
8
 
9
- app = FastAPI()
 
 
 
10
 
11
- class GenRequest(BaseModel):
12
- birthdate: str
13
- terms: list[str]
 
 
 
 
 
 
 
 
 
14
 
15
- @app.post("/generate")
16
- def generate_text(req: GenRequest):
17
- prompt = build_prompt(req.birthdate, req.terms)
18
- output = generate_with_retry(prompt, generator, req.terms)
19
- return {"output": output}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- jinja2
4
  qrcode
5
  pillow
 
 
 
6
  transformers
7
- peft
 
 
 
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