py-aidol 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aidol/api/aidol.py +68 -73
- aidol/api/common.py +72 -0
- aidol/api/companion.py +157 -39
- aidol/api/lead.py +123 -0
- aidol/factories.py +8 -0
- aidol/protocols.py +26 -0
- aidol/repositories/aidol_lead.py +3 -6
- aidol/services/companion_service.py +96 -0
- aidol/services/image_generation_service.py +70 -103
- aidol/settings.py +37 -0
- {py_aidol-0.2.0.dist-info → py_aidol-0.4.0.dist-info}/METADATA +24 -6
- {py_aidol-0.2.0.dist-info → py_aidol-0.4.0.dist-info}/RECORD +13 -9
- {py_aidol-0.2.0.dist-info → py_aidol-0.4.0.dist-info}/WHEEL +1 -1
aidol/api/aidol.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# pylint: disable=duplicate-code
|
|
1
2
|
"""
|
|
2
3
|
AIdol API router
|
|
3
4
|
|
|
@@ -8,32 +9,25 @@ Public access pattern: no authentication required.
|
|
|
8
9
|
from aioia_core.auth import UserInfoProvider
|
|
9
10
|
from aioia_core.errors import ErrorResponse
|
|
10
11
|
from aioia_core.fastapi import BaseCrudRouter
|
|
11
|
-
from aioia_core.settings import JWTSettings
|
|
12
|
-
from fastapi import APIRouter, Depends, HTTPException, status
|
|
12
|
+
from aioia_core.settings import JWTSettings
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
13
14
|
from pydantic import BaseModel
|
|
14
15
|
from sqlalchemy.orm import sessionmaker
|
|
15
16
|
|
|
17
|
+
from aidol.api.common import register_image_generation_route
|
|
16
18
|
from aidol.protocols import (
|
|
17
19
|
AIdolRepositoryFactoryProtocol,
|
|
18
20
|
AIdolRepositoryProtocol,
|
|
19
21
|
ImageStorageProtocol,
|
|
20
22
|
)
|
|
21
|
-
from aidol.schemas import
|
|
22
|
-
|
|
23
|
-
AIdolCreate,
|
|
24
|
-
AIdolPublic,
|
|
25
|
-
AIdolUpdate,
|
|
26
|
-
ImageGenerationData,
|
|
27
|
-
ImageGenerationRequest,
|
|
28
|
-
ImageGenerationResponse,
|
|
29
|
-
)
|
|
30
|
-
from aidol.services import ImageGenerationService
|
|
23
|
+
from aidol.schemas import AIdol, AIdolCreate, AIdolPublic, AIdolUpdate
|
|
24
|
+
from aidol.settings import GoogleGenAISettings
|
|
31
25
|
|
|
32
26
|
|
|
33
|
-
class
|
|
34
|
-
"""
|
|
27
|
+
class AIdolCreateResponse(BaseModel):
|
|
28
|
+
"""Response for AIdol creation (only id)."""
|
|
35
29
|
|
|
36
|
-
|
|
30
|
+
id: str
|
|
37
31
|
|
|
38
32
|
|
|
39
33
|
class AIdolRouter(
|
|
@@ -48,49 +42,92 @@ class AIdolRouter(
|
|
|
48
42
|
|
|
49
43
|
def __init__(
|
|
50
44
|
self,
|
|
51
|
-
|
|
45
|
+
google_settings: GoogleGenAISettings | None,
|
|
52
46
|
image_storage: ImageStorageProtocol,
|
|
53
47
|
**kwargs,
|
|
54
48
|
):
|
|
55
|
-
|
|
56
|
-
self.openai_settings = openai_settings
|
|
49
|
+
self.google_settings = google_settings
|
|
57
50
|
self.image_storage = image_storage
|
|
51
|
+
super().__init__(**kwargs)
|
|
58
52
|
|
|
59
53
|
def _register_routes(self) -> None:
|
|
60
54
|
"""Register routes (public CRUD + image generation)"""
|
|
61
|
-
|
|
55
|
+
# Register shared image generation route
|
|
56
|
+
register_image_generation_route(
|
|
57
|
+
router=self.router,
|
|
58
|
+
resource_name=self.resource_name,
|
|
59
|
+
google_settings=self.google_settings,
|
|
60
|
+
image_storage=self.image_storage,
|
|
61
|
+
)
|
|
62
|
+
|
|
62
63
|
self._register_public_create_route()
|
|
63
64
|
self._register_public_get_route()
|
|
65
|
+
self._register_public_update_route()
|
|
66
|
+
|
|
67
|
+
def _register_public_update_route(self) -> None:
|
|
68
|
+
"""PATCH /{resource_name}/{id} - Update AIdol group (public)"""
|
|
69
|
+
|
|
70
|
+
@self.router.patch(
|
|
71
|
+
f"/{self.resource_name}/{{item_id}}",
|
|
72
|
+
response_model=AIdolPublic,
|
|
73
|
+
status_code=status.HTTP_200_OK,
|
|
74
|
+
summary="Update AIdol group",
|
|
75
|
+
description="Update AIdol group by ID (public endpoint). Returns updated AIdol data directly.",
|
|
76
|
+
responses={
|
|
77
|
+
404: {"model": ErrorResponse, "description": "AIdol group not found"},
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
async def update_aidol(
|
|
81
|
+
item_id: str,
|
|
82
|
+
data: AIdolUpdate,
|
|
83
|
+
repository: AIdolRepositoryProtocol = Depends(self.get_repository_dep),
|
|
84
|
+
):
|
|
85
|
+
"""Update AIdol group."""
|
|
86
|
+
# TODO: Verify ClaimToken if strict ownership is required (Sprint 1)
|
|
87
|
+
updated = repository.update(item_id, data)
|
|
88
|
+
if not updated:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
91
|
+
detail="AIdol group not found",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Return updated AIdol as public schema
|
|
95
|
+
return AIdolPublic(**updated.model_dump())
|
|
64
96
|
|
|
65
97
|
def _register_public_create_route(self) -> None:
|
|
66
98
|
"""POST /{resource_name} - Create an AIdol group (public)"""
|
|
67
99
|
|
|
68
100
|
@self.router.post(
|
|
69
101
|
f"/{self.resource_name}",
|
|
70
|
-
response_model=
|
|
102
|
+
response_model=AIdolCreateResponse,
|
|
71
103
|
status_code=status.HTTP_201_CREATED,
|
|
72
104
|
summary="Create AIdol group",
|
|
73
|
-
description="Create a new AIdol group (public endpoint)",
|
|
105
|
+
description="Create a new AIdol group (public endpoint). Returns only the created id.",
|
|
74
106
|
)
|
|
75
107
|
async def create_aidol(
|
|
76
108
|
request: AIdolCreate,
|
|
109
|
+
response: Response,
|
|
77
110
|
repository: AIdolRepositoryProtocol = Depends(self.get_repository_dep),
|
|
78
111
|
):
|
|
79
112
|
"""Create a new AIdol group."""
|
|
80
113
|
created = repository.create(request)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
114
|
+
|
|
115
|
+
# Set ClaimToken header
|
|
116
|
+
if created.claim_token:
|
|
117
|
+
response.headers["ClaimToken"] = created.claim_token
|
|
118
|
+
|
|
119
|
+
# Return only id
|
|
120
|
+
return AIdolCreateResponse(id=created.id)
|
|
84
121
|
|
|
85
122
|
def _register_public_get_route(self) -> None:
|
|
86
123
|
"""GET /{resource_name}/{id} - Get an AIdol group (public)"""
|
|
87
124
|
|
|
88
125
|
@self.router.get(
|
|
89
126
|
f"/{self.resource_name}/{{item_id}}",
|
|
90
|
-
response_model=
|
|
127
|
+
response_model=AIdolPublic,
|
|
91
128
|
status_code=status.HTTP_200_OK,
|
|
92
129
|
summary="Get AIdol group",
|
|
93
|
-
description="Get AIdol group by ID (public endpoint)",
|
|
130
|
+
description="Get AIdol group by ID (public endpoint). Returns AIdol data directly.",
|
|
94
131
|
responses={
|
|
95
132
|
404: {"model": ErrorResponse, "description": "AIdol group not found"},
|
|
96
133
|
},
|
|
@@ -101,54 +138,12 @@ class AIdolRouter(
|
|
|
101
138
|
):
|
|
102
139
|
"""Get AIdol group by ID."""
|
|
103
140
|
aidol = self._get_item_or_404(repository, item_id)
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
return AIdolSingleItemResponse(data=public_aidol)
|
|
107
|
-
|
|
108
|
-
def _register_image_generation_route(self) -> None:
|
|
109
|
-
"""POST /{resource_name}/images - Generate image for AIdol or Companion"""
|
|
110
|
-
|
|
111
|
-
@self.router.post(
|
|
112
|
-
f"/{self.resource_name}/images",
|
|
113
|
-
response_model=ImageGenerationResponse,
|
|
114
|
-
status_code=status.HTTP_201_CREATED,
|
|
115
|
-
summary="Generate image",
|
|
116
|
-
description="Generate image for AIdol emblem or Companion profile",
|
|
117
|
-
responses={
|
|
118
|
-
500: {"model": ErrorResponse, "description": "Image generation failed"},
|
|
119
|
-
},
|
|
120
|
-
)
|
|
121
|
-
async def generate_image(request: ImageGenerationRequest):
|
|
122
|
-
"""Generate image from prompt."""
|
|
123
|
-
# Generate and download image (TTS pattern: service returns data)
|
|
124
|
-
service = ImageGenerationService(self.openai_settings)
|
|
125
|
-
image = service.generate_and_download_image(
|
|
126
|
-
prompt=request.prompt,
|
|
127
|
-
size="1024x1024",
|
|
128
|
-
quality="standard",
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
if image is None:
|
|
132
|
-
raise HTTPException(
|
|
133
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
134
|
-
detail="Image generation failed",
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
# Upload to permanent storage (TTS pattern: API layer orchestrates)
|
|
138
|
-
image_url = self.image_storage.upload_image(image)
|
|
139
|
-
|
|
140
|
-
return ImageGenerationResponse(
|
|
141
|
-
data=ImageGenerationData(
|
|
142
|
-
image_url=image_url,
|
|
143
|
-
width=1024,
|
|
144
|
-
height=1024,
|
|
145
|
-
format="png",
|
|
146
|
-
)
|
|
147
|
-
)
|
|
141
|
+
# Return AIdol as public schema
|
|
142
|
+
return AIdolPublic(**aidol.model_dump())
|
|
148
143
|
|
|
149
144
|
|
|
150
145
|
def create_aidol_router(
|
|
151
|
-
|
|
146
|
+
google_settings: GoogleGenAISettings | None,
|
|
152
147
|
db_session_factory: sessionmaker,
|
|
153
148
|
repository_factory: AIdolRepositoryFactoryProtocol,
|
|
154
149
|
image_storage: ImageStorageProtocol,
|
|
@@ -161,7 +156,7 @@ def create_aidol_router(
|
|
|
161
156
|
Create AIdol router with dependency injection.
|
|
162
157
|
|
|
163
158
|
Args:
|
|
164
|
-
|
|
159
|
+
google_settings: Google API settings (uses ADC if api_key is None)
|
|
165
160
|
db_session_factory: Database session factory
|
|
166
161
|
repository_factory: Factory implementing AIdolRepositoryFactoryProtocol
|
|
167
162
|
image_storage: Image storage for permanent URLs
|
|
@@ -174,7 +169,7 @@ def create_aidol_router(
|
|
|
174
169
|
FastAPI APIRouter instance
|
|
175
170
|
"""
|
|
176
171
|
router = AIdolRouter(
|
|
177
|
-
|
|
172
|
+
google_settings=google_settings,
|
|
178
173
|
image_storage=image_storage,
|
|
179
174
|
model_class=AIdol,
|
|
180
175
|
create_schema=AIdolCreate,
|
aidol/api/common.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common API utilities.
|
|
3
|
+
|
|
4
|
+
Shared functions for registering common routes across different routers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from aioia_core.errors import ErrorResponse
|
|
8
|
+
from fastapi import APIRouter, HTTPException, status
|
|
9
|
+
|
|
10
|
+
from aidol.protocols import ImageStorageProtocol
|
|
11
|
+
from aidol.schemas import (
|
|
12
|
+
ImageGenerationData,
|
|
13
|
+
ImageGenerationRequest,
|
|
14
|
+
ImageGenerationResponse,
|
|
15
|
+
)
|
|
16
|
+
from aidol.services import ImageGenerationService
|
|
17
|
+
from aidol.settings import GoogleGenAISettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register_image_generation_route(
|
|
21
|
+
router: APIRouter,
|
|
22
|
+
resource_name: str,
|
|
23
|
+
google_settings: GoogleGenAISettings | None,
|
|
24
|
+
image_storage: ImageStorageProtocol,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Register image generation route to the given router.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
router: FastAPI APIRouter instance
|
|
31
|
+
resource_name: Resource name for the route path
|
|
32
|
+
google_settings: Google API settings (API Key or Vertex AI with ADC)
|
|
33
|
+
image_storage: Image Storage instance
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@router.post(
|
|
37
|
+
f"/{resource_name}/images",
|
|
38
|
+
response_model=ImageGenerationResponse,
|
|
39
|
+
status_code=status.HTTP_201_CREATED,
|
|
40
|
+
summary="Generate image",
|
|
41
|
+
description=f"Generate image for {resource_name}",
|
|
42
|
+
responses={
|
|
43
|
+
500: {"model": ErrorResponse, "description": "Image generation failed"},
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
async def generate_image(request: ImageGenerationRequest):
|
|
47
|
+
"""Generate image from prompt."""
|
|
48
|
+
# Generate and download image
|
|
49
|
+
service = ImageGenerationService(settings=google_settings)
|
|
50
|
+
image = service.generate_and_download_image(
|
|
51
|
+
prompt=request.prompt,
|
|
52
|
+
size="1024x1024",
|
|
53
|
+
quality="standard",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if image is None:
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
59
|
+
detail="Image generation failed",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Upload to permanent storage
|
|
63
|
+
image_url = image_storage.upload_image(image)
|
|
64
|
+
|
|
65
|
+
return ImageGenerationResponse(
|
|
66
|
+
data=ImageGenerationData(
|
|
67
|
+
image_url=image_url,
|
|
68
|
+
width=1024,
|
|
69
|
+
height=1024,
|
|
70
|
+
format="png",
|
|
71
|
+
)
|
|
72
|
+
)
|
aidol/api/companion.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# pylint: disable=duplicate-code
|
|
1
2
|
"""
|
|
2
3
|
Companion API router
|
|
3
4
|
|
|
@@ -9,21 +10,25 @@ from aioia_core.auth import UserInfoProvider
|
|
|
9
10
|
from aioia_core.errors import ErrorResponse
|
|
10
11
|
from aioia_core.fastapi import BaseCrudRouter
|
|
11
12
|
from aioia_core.settings import JWTSettings
|
|
12
|
-
from fastapi import APIRouter, Depends, Query, status
|
|
13
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
13
14
|
from pydantic import BaseModel
|
|
14
15
|
from sqlalchemy.orm import sessionmaker
|
|
15
16
|
|
|
17
|
+
from aidol.api.common import register_image_generation_route
|
|
16
18
|
from aidol.protocols import (
|
|
17
19
|
CompanionRepositoryFactoryProtocol,
|
|
18
20
|
CompanionRepositoryProtocol,
|
|
21
|
+
ImageStorageProtocol,
|
|
19
22
|
)
|
|
20
|
-
from aidol.schemas import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
from aidol.schemas import (
|
|
24
|
+
Companion,
|
|
25
|
+
CompanionCreate,
|
|
26
|
+
CompanionPublic,
|
|
27
|
+
CompanionUpdate,
|
|
28
|
+
Gender,
|
|
29
|
+
)
|
|
30
|
+
from aidol.services.companion_service import to_companion_public
|
|
31
|
+
from aidol.settings import GoogleGenAISettings
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
class CompanionPaginatedResponse(BaseModel):
|
|
@@ -45,11 +50,31 @@ class CompanionRouter(
|
|
|
45
50
|
Returns CompanionPublic (excludes system_prompt) for all responses.
|
|
46
51
|
"""
|
|
47
52
|
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
google_settings: GoogleGenAISettings | None,
|
|
56
|
+
image_storage: ImageStorageProtocol,
|
|
57
|
+
**kwargs,
|
|
58
|
+
):
|
|
59
|
+
self.google_settings = google_settings
|
|
60
|
+
self.image_storage = image_storage
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
|
|
48
63
|
def _register_routes(self) -> None:
|
|
49
|
-
"""Register routes (public CRUD)"""
|
|
64
|
+
"""Register routes (public CRUD + image generation)"""
|
|
65
|
+
# Register shared image generation route
|
|
66
|
+
register_image_generation_route(
|
|
67
|
+
router=self.router,
|
|
68
|
+
resource_name=self.resource_name,
|
|
69
|
+
google_settings=self.google_settings,
|
|
70
|
+
image_storage=self.image_storage,
|
|
71
|
+
)
|
|
72
|
+
|
|
50
73
|
self._register_public_list_route()
|
|
51
74
|
self._register_public_create_route()
|
|
52
75
|
self._register_public_get_route()
|
|
76
|
+
self._register_public_update_route()
|
|
77
|
+
self._register_public_delete_route()
|
|
53
78
|
|
|
54
79
|
def _register_public_list_route(self) -> None:
|
|
55
80
|
"""GET /{resource_name} - List Companions (public)"""
|
|
@@ -59,33 +84,48 @@ class CompanionRouter(
|
|
|
59
84
|
response_model=CompanionPaginatedResponse,
|
|
60
85
|
status_code=status.HTTP_200_OK,
|
|
61
86
|
summary="List Companions",
|
|
62
|
-
description="List all Companions with optional filtering
|
|
87
|
+
description="List all Companions with optional filtering by gender and cast status",
|
|
63
88
|
)
|
|
64
89
|
async def list_companions(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
None,
|
|
69
|
-
alias="sort",
|
|
70
|
-
description='Sorting criteria in JSON format. Example: [["createdAt","desc"]]',
|
|
71
|
-
),
|
|
72
|
-
filters_param: str | None = Query(
|
|
73
|
-
None,
|
|
74
|
-
alias="filters",
|
|
75
|
-
description="Filter conditions (JSON format)",
|
|
90
|
+
gender: Gender | None = Query(None, description="Filter by gender"),
|
|
91
|
+
is_cast: bool | None = Query(
|
|
92
|
+
None, alias="isCast", description="Filter by cast status"
|
|
76
93
|
),
|
|
94
|
+
aidol_id: str | None = Query(None, description="Filter by AIdol Group ID"),
|
|
77
95
|
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
78
96
|
):
|
|
79
|
-
"""List Companions with
|
|
80
|
-
|
|
97
|
+
"""List Companions with optional gender and isCast filters."""
|
|
98
|
+
filter_list: list[dict] = []
|
|
99
|
+
|
|
100
|
+
# Add filters only if provided
|
|
101
|
+
if gender is not None:
|
|
102
|
+
filter_list.append(
|
|
103
|
+
{"field": "gender", "operator": "eq", "value": gender.value}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Filter by aidol_id if provided
|
|
107
|
+
if aidol_id is not None:
|
|
108
|
+
filter_list.append(
|
|
109
|
+
{"field": "aidol_id", "operator": "eq", "value": aidol_id}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# isCast is derived from aidol_id presence
|
|
113
|
+
# isCast=true → aidol_id is not null (belongs to a group)
|
|
114
|
+
# isCast=false → aidol_id is null (not in a group)
|
|
115
|
+
if is_cast is True:
|
|
116
|
+
filter_list.append(
|
|
117
|
+
{"field": "aidol_id", "operator": "ne", "value": None}
|
|
118
|
+
)
|
|
119
|
+
elif is_cast is False:
|
|
120
|
+
filter_list.append(
|
|
121
|
+
{"field": "aidol_id", "operator": "eq", "value": None}
|
|
122
|
+
)
|
|
123
|
+
|
|
81
124
|
items, total = repository.get_all(
|
|
82
|
-
|
|
83
|
-
page_size=page_size,
|
|
84
|
-
sort=sort_list,
|
|
85
|
-
filters=filter_list,
|
|
125
|
+
filters=filter_list if filter_list else None,
|
|
86
126
|
)
|
|
87
127
|
# Convert to Public schema (exclude system_prompt)
|
|
88
|
-
public_items = [
|
|
128
|
+
public_items = [to_companion_public(c) for c in items]
|
|
89
129
|
return CompanionPaginatedResponse(data=public_items, total=total)
|
|
90
130
|
|
|
91
131
|
def _register_public_create_route(self) -> None:
|
|
@@ -93,30 +133,33 @@ class CompanionRouter(
|
|
|
93
133
|
|
|
94
134
|
@self.router.post(
|
|
95
135
|
f"/{self.resource_name}",
|
|
96
|
-
response_model=
|
|
136
|
+
response_model=CompanionPublic,
|
|
97
137
|
status_code=status.HTTP_201_CREATED,
|
|
98
138
|
summary="Create Companion",
|
|
99
|
-
description="Create a new Companion
|
|
139
|
+
description="Create a new Companion. Returns the created companion data.",
|
|
100
140
|
)
|
|
101
141
|
async def create_companion(
|
|
102
142
|
request: CompanionCreate,
|
|
103
143
|
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
104
144
|
):
|
|
105
145
|
"""Create a new Companion."""
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
146
|
+
# Exclude system_prompt from request - should not be set via API
|
|
147
|
+
sanitized_data = request.model_dump(exclude={"system_prompt"})
|
|
148
|
+
sanitized_request = CompanionCreate(**sanitized_data)
|
|
149
|
+
|
|
150
|
+
created = repository.create(sanitized_request)
|
|
151
|
+
# Return created companion as public schema
|
|
152
|
+
return to_companion_public(created)
|
|
110
153
|
|
|
111
154
|
def _register_public_get_route(self) -> None:
|
|
112
155
|
"""GET /{resource_name}/{id} - Get a Companion (public)"""
|
|
113
156
|
|
|
114
157
|
@self.router.get(
|
|
115
158
|
f"/{self.resource_name}/{{item_id}}",
|
|
116
|
-
response_model=
|
|
159
|
+
response_model=CompanionPublic,
|
|
117
160
|
status_code=status.HTTP_200_OK,
|
|
118
161
|
summary="Get Companion",
|
|
119
|
-
description="Get Companion by ID (public endpoint)",
|
|
162
|
+
description="Get Companion by ID (public endpoint). Returns companion data directly.",
|
|
120
163
|
responses={
|
|
121
164
|
404: {"model": ErrorResponse, "description": "Companion not found"},
|
|
122
165
|
},
|
|
@@ -127,14 +170,85 @@ class CompanionRouter(
|
|
|
127
170
|
):
|
|
128
171
|
"""Get Companion by ID."""
|
|
129
172
|
companion = self._get_item_or_404(repository, item_id)
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
173
|
+
# Return companion as public schema
|
|
174
|
+
return to_companion_public(companion)
|
|
175
|
+
|
|
176
|
+
def _register_public_update_route(self) -> None:
|
|
177
|
+
"""PATCH /{resource_name}/{id} - Update Companion (public)"""
|
|
178
|
+
|
|
179
|
+
@self.router.patch(
|
|
180
|
+
f"/{self.resource_name}/{{item_id}}",
|
|
181
|
+
response_model=CompanionPublic,
|
|
182
|
+
status_code=status.HTTP_200_OK,
|
|
183
|
+
summary="Update Companion",
|
|
184
|
+
description="Update Companion by ID (public endpoint). Returns updated companion data directly.",
|
|
185
|
+
responses={
|
|
186
|
+
404: {"model": ErrorResponse, "description": "Companion not found"},
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
async def update_companion(
|
|
190
|
+
item_id: str,
|
|
191
|
+
data: CompanionUpdate,
|
|
192
|
+
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
193
|
+
):
|
|
194
|
+
"""Update Companion."""
|
|
195
|
+
# Exclude system_prompt from request - should not be set via API
|
|
196
|
+
sanitized_data = data.model_dump(
|
|
197
|
+
exclude={"system_prompt"}, exclude_unset=True
|
|
198
|
+
)
|
|
199
|
+
sanitized_request = CompanionUpdate(**sanitized_data)
|
|
200
|
+
|
|
201
|
+
updated = repository.update(item_id, sanitized_request)
|
|
202
|
+
if not updated:
|
|
203
|
+
raise HTTPException(
|
|
204
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
205
|
+
detail="Companion not found",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Return updated companion as public schema
|
|
209
|
+
return to_companion_public(updated)
|
|
210
|
+
|
|
211
|
+
def _register_public_delete_route(self) -> None:
|
|
212
|
+
"""DELETE /{resource_name}/{id} - Remove Companion from Group (public)"""
|
|
213
|
+
|
|
214
|
+
@self.router.delete(
|
|
215
|
+
f"/{self.resource_name}/{{item_id}}",
|
|
216
|
+
response_model=CompanionPublic,
|
|
217
|
+
status_code=status.HTTP_200_OK,
|
|
218
|
+
summary="Remove Companion from Group",
|
|
219
|
+
description="Remove Companion from AIdol group (unassign aidol_id). Does not delete the record.",
|
|
220
|
+
responses={
|
|
221
|
+
404: {"model": ErrorResponse, "description": "Companion not found"},
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
async def delete_companion(
|
|
225
|
+
item_id: str,
|
|
226
|
+
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
227
|
+
):
|
|
228
|
+
"""Remove Companion from Group (Unassign)."""
|
|
229
|
+
# Get item first
|
|
230
|
+
self._get_item_or_404(repository, item_id)
|
|
231
|
+
|
|
232
|
+
# Update aidol_id to None (remove from group)
|
|
233
|
+
update_data = CompanionUpdate(aidol_id=None)
|
|
234
|
+
|
|
235
|
+
updated = repository.update(item_id, update_data)
|
|
236
|
+
|
|
237
|
+
if not updated:
|
|
238
|
+
raise HTTPException(
|
|
239
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
240
|
+
detail="Companion not found",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Return updated companion as public schema
|
|
244
|
+
return to_companion_public(updated)
|
|
133
245
|
|
|
134
246
|
|
|
135
247
|
def create_companion_router(
|
|
248
|
+
google_settings: GoogleGenAISettings | None,
|
|
136
249
|
db_session_factory: sessionmaker,
|
|
137
250
|
repository_factory: CompanionRepositoryFactoryProtocol,
|
|
251
|
+
image_storage: ImageStorageProtocol,
|
|
138
252
|
jwt_settings: JWTSettings | None = None,
|
|
139
253
|
user_info_provider: UserInfoProvider | None = None,
|
|
140
254
|
resource_name: str = "companions",
|
|
@@ -144,8 +258,10 @@ def create_companion_router(
|
|
|
144
258
|
Create Companion router with dependency injection.
|
|
145
259
|
|
|
146
260
|
Args:
|
|
261
|
+
google_settings: Google API settings (uses ADC if api_key is None)
|
|
147
262
|
db_session_factory: Database session factory
|
|
148
263
|
repository_factory: Factory implementing CompanionRepositoryFactoryProtocol
|
|
264
|
+
image_storage: Image storage for permanent URLs
|
|
149
265
|
jwt_settings: Optional JWT settings for authentication
|
|
150
266
|
user_info_provider: Optional user info provider
|
|
151
267
|
resource_name: Resource name for routes (default: "companions")
|
|
@@ -155,6 +271,8 @@ def create_companion_router(
|
|
|
155
271
|
FastAPI APIRouter instance
|
|
156
272
|
"""
|
|
157
273
|
router = CompanionRouter(
|
|
274
|
+
google_settings=google_settings,
|
|
275
|
+
image_storage=image_storage,
|
|
158
276
|
model_class=Companion,
|
|
159
277
|
create_schema=CompanionCreate,
|
|
160
278
|
update_schema=CompanionUpdate,
|
aidol/api/lead.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lead API router
|
|
3
|
+
|
|
4
|
+
Public endpoints for collecting leads (emails).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from aioia_core.fastapi import BaseCrudRouter
|
|
10
|
+
from aioia_core.settings import JWTSettings
|
|
11
|
+
from fastapi import APIRouter, Depends, Header, status
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
14
|
+
|
|
15
|
+
from aidol.protocols import (
|
|
16
|
+
AIdolLeadRepositoryFactoryProtocol,
|
|
17
|
+
AIdolLeadRepositoryProtocol,
|
|
18
|
+
AIdolRepositoryFactoryProtocol,
|
|
19
|
+
NoUpdate,
|
|
20
|
+
)
|
|
21
|
+
from aidol.schemas import AIdolLead, AIdolLeadCreate, AIdolUpdate
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LeadResponse(BaseModel):
|
|
25
|
+
"""Response for lead creation."""
|
|
26
|
+
|
|
27
|
+
email: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LeadRouter(
|
|
31
|
+
BaseCrudRouter[AIdolLead, AIdolLeadCreate, NoUpdate, AIdolLeadRepositoryProtocol]
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Lead router.
|
|
35
|
+
|
|
36
|
+
Handles email collection.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
aidol_repository_factory: AIdolRepositoryFactoryProtocol,
|
|
42
|
+
**kwargs,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(**kwargs)
|
|
45
|
+
self.aidol_repository_factory = aidol_repository_factory
|
|
46
|
+
|
|
47
|
+
def _register_routes(self) -> None:
|
|
48
|
+
"""Register routes."""
|
|
49
|
+
self._register_create_lead_route()
|
|
50
|
+
|
|
51
|
+
def _register_create_lead_route(self) -> None:
|
|
52
|
+
"""POST /leads - Collect email"""
|
|
53
|
+
|
|
54
|
+
@self.router.post(
|
|
55
|
+
f"/{self.resource_name}",
|
|
56
|
+
response_model=LeadResponse,
|
|
57
|
+
status_code=status.HTTP_201_CREATED,
|
|
58
|
+
summary="Collect Lead",
|
|
59
|
+
description="Collect email. Associates with AIdol if ClaimToken is valid.",
|
|
60
|
+
)
|
|
61
|
+
async def create_lead(
|
|
62
|
+
request: AIdolLeadCreate,
|
|
63
|
+
claim_token: Annotated[str | None, Header(alias="ClaimToken")] = None,
|
|
64
|
+
db_session: Session = Depends(self.get_db_dep),
|
|
65
|
+
lead_repository: AIdolLeadRepositoryProtocol = Depends(
|
|
66
|
+
self.get_repository_dep
|
|
67
|
+
),
|
|
68
|
+
):
|
|
69
|
+
"""Collect email."""
|
|
70
|
+
email_saved = False
|
|
71
|
+
|
|
72
|
+
# 1. Try to associate with AIdol if token is present
|
|
73
|
+
if claim_token:
|
|
74
|
+
# Reuse session from dependency
|
|
75
|
+
aidol_repo = self.aidol_repository_factory.create_repository(db_session)
|
|
76
|
+
|
|
77
|
+
# Find AIdol by claim_token
|
|
78
|
+
# Assuming get_all supports filters
|
|
79
|
+
items, _ = aidol_repo.get_all(
|
|
80
|
+
filters=[
|
|
81
|
+
{
|
|
82
|
+
"field": "claim_token",
|
|
83
|
+
"operator": "eq",
|
|
84
|
+
"value": claim_token,
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if items:
|
|
90
|
+
aidol = items[0]
|
|
91
|
+
# Update AIdol email
|
|
92
|
+
aidol_repo.update(aidol.id, AIdolUpdate(email=request.email))
|
|
93
|
+
email_saved = True
|
|
94
|
+
|
|
95
|
+
# 2. If not saved as AIdol email, create Lead
|
|
96
|
+
if not email_saved:
|
|
97
|
+
lead_repository.create(request)
|
|
98
|
+
|
|
99
|
+
return LeadResponse(email=request.email)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_lead_router(
|
|
103
|
+
db_session_factory: sessionmaker,
|
|
104
|
+
aidol_repository_factory: AIdolRepositoryFactoryProtocol,
|
|
105
|
+
lead_repository_factory: AIdolLeadRepositoryFactoryProtocol,
|
|
106
|
+
jwt_settings: JWTSettings | None = None,
|
|
107
|
+
resource_name: str = "leads",
|
|
108
|
+
tags: list[str] | None = None,
|
|
109
|
+
) -> APIRouter:
|
|
110
|
+
"""Create Lead router."""
|
|
111
|
+
router = LeadRouter(
|
|
112
|
+
model_class=AIdolLead,
|
|
113
|
+
create_schema=AIdolLeadCreate,
|
|
114
|
+
update_schema=NoUpdate, # Update not supported
|
|
115
|
+
db_session_factory=db_session_factory,
|
|
116
|
+
repository_factory=lead_repository_factory,
|
|
117
|
+
aidol_repository_factory=aidol_repository_factory,
|
|
118
|
+
user_info_provider=None, # No auth required for lead collection
|
|
119
|
+
jwt_secret_key=jwt_settings.secret_key if jwt_settings else None,
|
|
120
|
+
resource_name=resource_name,
|
|
121
|
+
tags=tags or ["Lead"],
|
|
122
|
+
)
|
|
123
|
+
return router.get_router()
|
aidol/factories.py
CHANGED
|
@@ -7,6 +7,7 @@ Uses BaseRepositoryFactory for BaseCrudRouter compatibility.
|
|
|
7
7
|
from aioia_core.factories import BaseRepositoryFactory
|
|
8
8
|
|
|
9
9
|
from aidol.repositories.aidol import AIdolRepository
|
|
10
|
+
from aidol.repositories.aidol_lead import AIdolLeadRepository
|
|
10
11
|
from aidol.repositories.companion import CompanionRepository
|
|
11
12
|
|
|
12
13
|
|
|
@@ -22,3 +23,10 @@ class CompanionRepositoryFactory(BaseRepositoryFactory[CompanionRepository]):
|
|
|
22
23
|
|
|
23
24
|
def __init__(self):
|
|
24
25
|
super().__init__(repository_class=CompanionRepository)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AIdolLeadRepositoryFactory(BaseRepositoryFactory[AIdolLeadRepository]):
|
|
29
|
+
"""Factory for creating AIdolLead repositories."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
super().__init__(repository_class=AIdolLeadRepository)
|
aidol/protocols.py
CHANGED
|
@@ -11,11 +11,14 @@ from typing import Protocol
|
|
|
11
11
|
|
|
12
12
|
import PIL.Image
|
|
13
13
|
from aioia_core import CrudRepositoryProtocol
|
|
14
|
+
from pydantic import BaseModel
|
|
14
15
|
from sqlalchemy.orm import Session
|
|
15
16
|
|
|
16
17
|
from aidol.schemas import (
|
|
17
18
|
AIdol,
|
|
18
19
|
AIdolCreate,
|
|
20
|
+
AIdolLead,
|
|
21
|
+
AIdolLeadCreate,
|
|
19
22
|
AIdolUpdate,
|
|
20
23
|
Companion,
|
|
21
24
|
CompanionCreate,
|
|
@@ -23,6 +26,10 @@ from aidol.schemas import (
|
|
|
23
26
|
)
|
|
24
27
|
|
|
25
28
|
|
|
29
|
+
class NoUpdate(BaseModel):
|
|
30
|
+
"""Placeholder for repositories without update support."""
|
|
31
|
+
|
|
32
|
+
|
|
26
33
|
class AIdolRepositoryProtocol(
|
|
27
34
|
CrudRepositoryProtocol[AIdol, AIdolCreate, AIdolUpdate], Protocol
|
|
28
35
|
):
|
|
@@ -98,3 +105,22 @@ class ImageStorageProtocol(Protocol):
|
|
|
98
105
|
image: PIL Image object to upload.
|
|
99
106
|
"""
|
|
100
107
|
...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AIdolLeadRepositoryProtocol(
|
|
111
|
+
CrudRepositoryProtocol[AIdolLead, AIdolLeadCreate, NoUpdate], Protocol
|
|
112
|
+
):
|
|
113
|
+
"""Protocol defining AIdolLead repository expectations.
|
|
114
|
+
|
|
115
|
+
Inherits CRUD operations from CrudRepositoryProtocol.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class AIdolLeadRepositoryFactoryProtocol(Protocol):
|
|
120
|
+
"""Protocol for factory that creates AIdolLeadRepositoryProtocol instances."""
|
|
121
|
+
|
|
122
|
+
def create_repository(
|
|
123
|
+
self, db_session: Session | None = None
|
|
124
|
+
) -> AIdolLeadRepositoryProtocol:
|
|
125
|
+
"""Create a repository instance."""
|
|
126
|
+
...
|
aidol/repositories/aidol_lead.py
CHANGED
|
@@ -7,17 +7,13 @@ Implements BaseRepository pattern for BaseCrudRouter compatibility.
|
|
|
7
7
|
from datetime import timezone
|
|
8
8
|
|
|
9
9
|
from aioia_core.repositories import BaseRepository
|
|
10
|
-
from pydantic import BaseModel
|
|
11
10
|
from sqlalchemy.orm import Session
|
|
12
11
|
|
|
13
12
|
from aidol.models import DBAIdolLead
|
|
13
|
+
from aidol.protocols import AIdolLeadRepositoryProtocol, NoUpdate
|
|
14
14
|
from aidol.schemas import AIdolLead, AIdolLeadCreate
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class _AIdolLeadUpdate(BaseModel):
|
|
18
|
-
"""Placeholder for update schema (not used)."""
|
|
19
|
-
|
|
20
|
-
|
|
21
17
|
def _convert_db_aidol_lead_to_model(db_lead: DBAIdolLead) -> AIdolLead:
|
|
22
18
|
"""Convert DB AIdolLead to Pydantic model."""
|
|
23
19
|
return AIdolLead(
|
|
@@ -35,7 +31,8 @@ def _convert_aidol_lead_create_to_db(schema: AIdolLeadCreate) -> dict:
|
|
|
35
31
|
|
|
36
32
|
|
|
37
33
|
class AIdolLeadRepository(
|
|
38
|
-
BaseRepository[AIdolLead, DBAIdolLead, AIdolLeadCreate,
|
|
34
|
+
BaseRepository[AIdolLead, DBAIdolLead, AIdolLeadCreate, NoUpdate],
|
|
35
|
+
AIdolLeadRepositoryProtocol,
|
|
39
36
|
):
|
|
40
37
|
"""
|
|
41
38
|
Database-backed AIdolLead repository.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Companion service
|
|
3
|
+
|
|
4
|
+
Business logic for Companion operations including grade and MBTI calculation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from aidol.schemas.companion import Companion, CompanionPublic, CompanionStats, Grade
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calculate_grade(stats: CompanionStats) -> Grade:
|
|
11
|
+
"""Calculate grade based on stats average.
|
|
12
|
+
|
|
13
|
+
- A: 80-100
|
|
14
|
+
- B: 60-79
|
|
15
|
+
- C: 40-59
|
|
16
|
+
- F: 0-39
|
|
17
|
+
"""
|
|
18
|
+
avg = (
|
|
19
|
+
(stats.vocal or 0)
|
|
20
|
+
+ (stats.dance or 0)
|
|
21
|
+
+ (stats.rap or 0)
|
|
22
|
+
+ (stats.visual or 0)
|
|
23
|
+
+ (stats.stamina or 0)
|
|
24
|
+
+ (stats.charm or 0)
|
|
25
|
+
) / 6
|
|
26
|
+
if avg >= 80:
|
|
27
|
+
return Grade.A
|
|
28
|
+
if avg >= 60:
|
|
29
|
+
return Grade.B
|
|
30
|
+
if avg >= 40:
|
|
31
|
+
return Grade.C
|
|
32
|
+
return Grade.F
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def calculate_mbti(
|
|
36
|
+
energy: int | None,
|
|
37
|
+
perception: int | None,
|
|
38
|
+
judgment: int | None,
|
|
39
|
+
lifestyle: int | None,
|
|
40
|
+
) -> str | None:
|
|
41
|
+
"""Calculate MBTI string from 4 dimension scores.
|
|
42
|
+
|
|
43
|
+
Each score is 1-10:
|
|
44
|
+
- energy: 1-5 = E, 6-10 = I
|
|
45
|
+
- perception: 1-5 = N, 6-10 = S
|
|
46
|
+
- judgment: 1-5 = T, 6-10 = F
|
|
47
|
+
- lifestyle: 1-5 = P, 6-10 = J
|
|
48
|
+
|
|
49
|
+
Returns None if any dimension is missing.
|
|
50
|
+
"""
|
|
51
|
+
if any(v is None for v in (energy, perception, judgment, lifestyle)):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
assert energy is not None
|
|
55
|
+
assert perception is not None
|
|
56
|
+
assert judgment is not None
|
|
57
|
+
assert lifestyle is not None
|
|
58
|
+
|
|
59
|
+
e_i = "E" if energy <= 5 else "I"
|
|
60
|
+
n_s = "N" if perception <= 5 else "S"
|
|
61
|
+
t_f = "T" if judgment <= 5 else "F"
|
|
62
|
+
p_j = "P" if lifestyle <= 5 else "J"
|
|
63
|
+
|
|
64
|
+
return f"{e_i}{n_s}{t_f}{p_j}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def to_companion_public(companion: Companion) -> CompanionPublic:
|
|
68
|
+
"""Convert Companion to CompanionPublic with calculated grade and mbti."""
|
|
69
|
+
# Build stats object
|
|
70
|
+
stats = companion.stats if companion.stats else CompanionStats()
|
|
71
|
+
|
|
72
|
+
# Calculate grade from stats
|
|
73
|
+
grade = calculate_grade(stats)
|
|
74
|
+
|
|
75
|
+
# Calculate MBTI from 4 dimensions
|
|
76
|
+
mbti = calculate_mbti(
|
|
77
|
+
companion.mbti_energy,
|
|
78
|
+
companion.mbti_perception,
|
|
79
|
+
companion.mbti_judgment,
|
|
80
|
+
companion.mbti_lifestyle,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return CompanionPublic(
|
|
84
|
+
id=companion.id,
|
|
85
|
+
aidol_id=companion.aidol_id,
|
|
86
|
+
name=companion.name,
|
|
87
|
+
gender=companion.gender,
|
|
88
|
+
grade=grade,
|
|
89
|
+
biography=companion.biography,
|
|
90
|
+
profile_picture_url=companion.profile_picture_url,
|
|
91
|
+
position=companion.position,
|
|
92
|
+
mbti=mbti,
|
|
93
|
+
stats=stats,
|
|
94
|
+
created_at=companion.created_at,
|
|
95
|
+
updated_at=companion.updated_at,
|
|
96
|
+
)
|
|
@@ -1,145 +1,112 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Image generation service for AIdol
|
|
2
|
+
Image generation service for AIdol.
|
|
3
3
|
|
|
4
|
-
Generates images using
|
|
4
|
+
Generates images using Google Gemini for AIdol emblems and Companion profiles.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
7
9
|
import logging
|
|
8
10
|
from dataclasses import dataclass
|
|
9
11
|
from io import BytesIO
|
|
10
12
|
from typing import Literal
|
|
11
13
|
|
|
12
|
-
import httpx
|
|
13
|
-
import openai
|
|
14
14
|
import PIL.Image
|
|
15
|
-
from
|
|
15
|
+
from google import genai
|
|
16
|
+
from google.genai import errors as genai_errors
|
|
17
|
+
|
|
18
|
+
from aidol.settings import GoogleGenAISettings
|
|
16
19
|
|
|
17
20
|
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
@dataclass
|
|
21
24
|
class ImageGenerationResponse:
|
|
22
|
-
"""Structured response
|
|
25
|
+
"""Structured response for compatibility (legacy)"""
|
|
23
26
|
|
|
24
|
-
url: str
|
|
25
|
-
revised_prompt: str | None
|
|
27
|
+
url: str | None = None
|
|
28
|
+
revised_prompt: str | None = None
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
class ImageGenerationService:
|
|
29
|
-
"""Service for generating images using
|
|
32
|
+
"""Service for generating images using Google Gemini 3 (Imagen)."""
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
"""
|
|
33
|
-
Initialize the Image Generation service with OpenAI settings.
|
|
34
|
+
client: "genai.Client | None" = None
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
openai_settings: OpenAI settings containing required API key
|
|
36
|
+
def __init__(self, settings: GoogleGenAISettings | None = None):
|
|
37
37
|
"""
|
|
38
|
-
|
|
39
|
-
self.client = openai.OpenAI(api_key=self.settings.api_key)
|
|
38
|
+
Initialize the Image Generation service.
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
size: Literal[
|
|
45
|
-
"1024x1024",
|
|
46
|
-
"1792x1024",
|
|
47
|
-
"1024x1792",
|
|
48
|
-
] = "1024x1024",
|
|
49
|
-
quality: Literal["standard", "hd"] = "standard",
|
|
50
|
-
) -> ImageGenerationResponse | None:
|
|
51
|
-
"""
|
|
52
|
-
Generate an image from a text prompt using OpenAI DALL-E 3.
|
|
40
|
+
Supports two authentication methods:
|
|
41
|
+
1. Google AI API: settings.api_key
|
|
42
|
+
2. Vertex AI API (ADC): settings.cloud_project (location=global hardcoded)
|
|
53
43
|
|
|
54
44
|
Args:
|
|
55
|
-
|
|
56
|
-
size: Image size (default: "1024x1024")
|
|
57
|
-
quality: Image quality "standard" or "hd" (default: "standard")
|
|
58
|
-
|
|
59
|
-
Returns:
|
|
60
|
-
An ImageGenerationResponse object containing the image URL and revised prompt,
|
|
61
|
-
or None if generation fails.
|
|
62
|
-
|
|
63
|
-
Raises:
|
|
64
|
-
openai.OpenAIError: If OpenAI API call fails.
|
|
45
|
+
settings: GoogleGenAISettings for configuration.
|
|
65
46
|
"""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
47
|
+
# Priority 1: Settings with api_key (Google AI API)
|
|
48
|
+
if settings and settings.api_key:
|
|
49
|
+
self.client = genai.Client(api_key=settings.api_key)
|
|
50
|
+
# Priority 2: Settings with cloud_project (Vertex AI, location=global)
|
|
51
|
+
elif settings and settings.cloud_project:
|
|
52
|
+
self.client = genai.Client(
|
|
53
|
+
vertexai=True,
|
|
54
|
+
project=settings.cloud_project,
|
|
55
|
+
location="global",
|
|
74
56
|
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
image_data = response.data[0]
|
|
81
|
-
url = image_data.url
|
|
82
|
-
|
|
83
|
-
if not url:
|
|
84
|
-
logger.error("No URL found in image response")
|
|
85
|
-
return None
|
|
86
|
-
|
|
87
|
-
revised_prompt = image_data.revised_prompt
|
|
88
|
-
|
|
89
|
-
logger.info("Successfully generated image: %s", url[:100])
|
|
90
|
-
return ImageGenerationResponse(
|
|
91
|
-
url=url,
|
|
92
|
-
revised_prompt=revised_prompt,
|
|
57
|
+
else:
|
|
58
|
+
logger.error(
|
|
59
|
+
"No authentication configured. "
|
|
60
|
+
"Set GOOGLE_API_KEY or GOOGLE_CLOUD_PROJECT"
|
|
93
61
|
)
|
|
94
|
-
|
|
95
|
-
except openai.OpenAIError as e:
|
|
96
|
-
logger.error("OpenAI API error: %s", e)
|
|
97
|
-
raise
|
|
98
|
-
|
|
99
|
-
def _download_image(self, url: str) -> PIL.Image.Image:
|
|
100
|
-
"""Download image from URL and return as PIL Image.
|
|
101
|
-
|
|
102
|
-
Args:
|
|
103
|
-
url: URL of the image to download.
|
|
104
|
-
|
|
105
|
-
Raises:
|
|
106
|
-
httpx.HTTPError: If download fails.
|
|
107
|
-
"""
|
|
108
|
-
with httpx.Client(timeout=30.0) as client:
|
|
109
|
-
response = client.get(url)
|
|
110
|
-
response.raise_for_status()
|
|
111
|
-
return PIL.Image.open(BytesIO(response.content))
|
|
62
|
+
self.client = None
|
|
112
63
|
|
|
113
64
|
def generate_and_download_image(
|
|
114
65
|
self,
|
|
115
66
|
prompt: str,
|
|
116
|
-
size: Literal[
|
|
117
|
-
|
|
118
|
-
"1792x1024",
|
|
119
|
-
"1024x1792",
|
|
120
|
-
] = "1024x1024",
|
|
121
|
-
quality: Literal["standard", "hd"] = "standard",
|
|
67
|
+
size: Literal["1024x1024"] = "1024x1024", # pylint: disable=unused-argument
|
|
68
|
+
quality: Literal["standard"] = "standard", # pylint: disable=unused-argument
|
|
122
69
|
) -> PIL.Image.Image | None:
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
|
-
DALL-E returns temporary URLs that expire in 1-2 hours.
|
|
126
|
-
Use this method to download the image immediately after generation.
|
|
70
|
+
"""
|
|
71
|
+
Generate an image using Gemini 3 and return as PIL Image.
|
|
127
72
|
|
|
128
73
|
Args:
|
|
129
|
-
prompt: Text description
|
|
130
|
-
size:
|
|
131
|
-
quality:
|
|
74
|
+
prompt: Text description.
|
|
75
|
+
size: Ignored (Gemini specific).
|
|
76
|
+
quality: Ignored (Gemini specific).
|
|
132
77
|
|
|
133
78
|
Returns:
|
|
134
79
|
PIL Image object, or None if generation fails.
|
|
135
|
-
|
|
136
|
-
Raises:
|
|
137
|
-
openai.OpenAIError: If OpenAI API call fails.
|
|
138
|
-
httpx.HTTPError: If image download fails.
|
|
139
80
|
"""
|
|
140
|
-
|
|
141
|
-
|
|
81
|
+
if not self.client:
|
|
82
|
+
logger.error("Gemini client not initialized")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
logger.info("Generating image with Gemini 3 (prompt: %s)...", prompt[:50])
|
|
87
|
+
|
|
88
|
+
response = self.client.models.generate_content(
|
|
89
|
+
model="gemini-3-pro-image-preview",
|
|
90
|
+
contents=[prompt], # type: ignore[arg-type]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Iterate parts to find the image
|
|
94
|
+
if response.parts:
|
|
95
|
+
for part in response.parts:
|
|
96
|
+
if part.inline_data and part.inline_data.data:
|
|
97
|
+
logger.info("Successfully generated image via Gemini.")
|
|
98
|
+
# Manually convert bytes to PIL Image to ensure it's a standard PIL object
|
|
99
|
+
# compatible with main.py's save(format="PNG") call.
|
|
100
|
+
return PIL.Image.open(BytesIO(part.inline_data.data))
|
|
101
|
+
|
|
102
|
+
logger.warning("No image data found in Gemini response.")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
except genai_errors.APIError as e:
|
|
106
|
+
logger.error("Gemini API error: code=%s, message=%s", e.code, e.message)
|
|
142
107
|
return None
|
|
143
108
|
|
|
144
|
-
|
|
145
|
-
|
|
109
|
+
# Legacy methods for compatibility if needed (can be removed or shimmed)
|
|
110
|
+
def generate_image(self, *args, **kwargs): # pylint: disable=unused-argument
|
|
111
|
+
"""Deprecated: Use generate_and_download_image instead."""
|
|
112
|
+
logger.warning("generate_image is deprecated for Gemini service.")
|
aidol/settings.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Environment settings for aidol module.
|
|
3
|
+
|
|
4
|
+
Provides GoogleGenAISettings for image generation with Google Gemini.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GoogleGenAISettings(BaseSettings):
|
|
11
|
+
"""
|
|
12
|
+
Google Gen AI SDK settings for Gemini image generation.
|
|
13
|
+
|
|
14
|
+
Supports two authentication methods:
|
|
15
|
+
1. Google AI API: GOOGLE_API_KEY
|
|
16
|
+
2. Vertex AI API (ADC): GOOGLE_CLOUD_PROJECT
|
|
17
|
+
|
|
18
|
+
For Google AI API:
|
|
19
|
+
export GOOGLE_API_KEY=your-api-key
|
|
20
|
+
|
|
21
|
+
For Vertex AI with ADC:
|
|
22
|
+
export GOOGLE_CLOUD_PROJECT=your-project-id
|
|
23
|
+
gcloud auth application-default login
|
|
24
|
+
|
|
25
|
+
Note: Vertex AI uses location="global" (hardcoded) because
|
|
26
|
+
Gemini image generation models only support the global endpoint.
|
|
27
|
+
|
|
28
|
+
Environment variables:
|
|
29
|
+
GOOGLE_API_KEY: Google API key (optional)
|
|
30
|
+
GOOGLE_CLOUD_PROJECT: GCP project ID for Vertex AI (optional)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
api_key: str | None = None
|
|
34
|
+
cloud_project: str | None = None
|
|
35
|
+
|
|
36
|
+
class Config:
|
|
37
|
+
env_prefix = "GOOGLE_"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-aidol
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Create and chat with your own AI idol group
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Keywords: kpop,idol,aidol,ai-companion,chatbot,image-generation
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Requires-Dist: aioia-core (>=2.2.0,<3.0.0)
|
|
16
16
|
Requires-Dist: fastapi (>=0.115.12,<0.116.0)
|
|
17
|
+
Requires-Dist: google-genai (>=1.60.0,<2.0.0)
|
|
17
18
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
18
19
|
Requires-Dist: openai (>=1.0.0)
|
|
19
20
|
Requires-Dist: pillow (>=10.0.0,<11.0.0)
|
|
@@ -33,7 +34,7 @@ AI 아이돌 그룹 생성 및 채팅 Python 패키지
|
|
|
33
34
|
## 주요 기능
|
|
34
35
|
|
|
35
36
|
- AI 아이돌 그룹/멤버 CRUD
|
|
36
|
-
-
|
|
37
|
+
- Google Gemini 이미지 생성 (엠블럼, 프로필)
|
|
37
38
|
- 텍스트 채팅 (페르소나 기반 응답)
|
|
38
39
|
- Buppy 통합 Adapter 패턴
|
|
39
40
|
|
|
@@ -61,7 +62,7 @@ from aidol.factories import AIdolRepositoryFactory, CompanionRepositoryFactory
|
|
|
61
62
|
# AIdol 라우터
|
|
62
63
|
aidol_router = AIdolRouter(
|
|
63
64
|
repository_factory=AIdolRepositoryFactory(),
|
|
64
|
-
|
|
65
|
+
google_settings=google_settings,
|
|
65
66
|
image_storage=image_storage,
|
|
66
67
|
)
|
|
67
68
|
|
|
@@ -86,11 +87,27 @@ make format
|
|
|
86
87
|
|
|
87
88
|
## 환경 변수
|
|
88
89
|
|
|
89
|
-
###
|
|
90
|
+
### 이미지 생성 인증 (선택, ADC 지원)
|
|
90
91
|
|
|
91
92
|
| 변수 | 설명 |
|
|
92
93
|
|------|------|
|
|
93
|
-
| `
|
|
94
|
+
| `GOOGLE_API_KEY` | Google API 키 (Google AI API) |
|
|
95
|
+
| `GOOGLE_CLOUD_PROJECT` | GCP 프로젝트 ID (Vertex AI) |
|
|
96
|
+
|
|
97
|
+
**인증 방법:**
|
|
98
|
+
|
|
99
|
+
**Option 1: Google AI API (API Key)**
|
|
100
|
+
```bash
|
|
101
|
+
export GOOGLE_API_KEY=your-api-key
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Option 2: Vertex AI (ADC)**
|
|
105
|
+
```bash
|
|
106
|
+
export GOOGLE_CLOUD_PROJECT=your-project-id
|
|
107
|
+
gcloud auth application-default login # 로컬 개발
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
> **참고**: Vertex AI 사용 시 `location=global`이 하드코딩되어 있습니다 (Gemini 이미지 생성 모델 요구사항).
|
|
94
111
|
|
|
95
112
|
### 선택
|
|
96
113
|
|
|
@@ -105,7 +122,8 @@ make format
|
|
|
105
122
|
|
|
106
123
|
- aioia-core (공통 인프라)
|
|
107
124
|
- FastAPI, SQLAlchemy, Pydantic
|
|
108
|
-
-
|
|
125
|
+
- Google Generative AI (이미지 생성)
|
|
126
|
+
- OpenAI (채팅)
|
|
109
127
|
- Pillow (이미지 처리)
|
|
110
128
|
|
|
111
129
|
## 라이선스
|
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
aidol/__init__.py,sha256=iMN-aij1k6vElJt0sZQT4QYJjvoD27Q9vtZkQA0TY9c,141
|
|
2
2
|
aidol/api/__init__.py,sha256=skD_w82nT0v1hdKK9BBOycNERexIr8F1BmSmSono4Jk,276
|
|
3
|
-
aidol/api/aidol.py,sha256=
|
|
4
|
-
aidol/api/
|
|
5
|
-
aidol/
|
|
3
|
+
aidol/api/aidol.py,sha256=AIpDMTTOxht1eEVk4YITvmJzvklpYlDTpQVrIuSjeUw,6588
|
|
4
|
+
aidol/api/common.py,sha256=w8ERo96t9tat4HPs1qw9JhyoJ8cj5crHCe92Hp3Usug,2198
|
|
5
|
+
aidol/api/companion.py,sha256=Nlx4QHqoi0y_CMczEh75n8C4xJTWp8eV1BqHjEzkOFw,10840
|
|
6
|
+
aidol/api/lead.py,sha256=RSf3GcIUVJu752rU9HG7Wy22UmnrRZnN_NGWkpTRDfE,3921
|
|
7
|
+
aidol/factories.py,sha256=5VhEbUVQEo-WruOdDauOi9xGgMxrgT339glocC1ua4o,983
|
|
6
8
|
aidol/models/__init__.py,sha256=AljQMgSE9vHx203NFQZMknKpzHIfyFLLcOMnFpMOLAs,218
|
|
7
9
|
aidol/models/aidol.py,sha256=By82BqiAasLNy8ZCNON2m46BnSCfL2J_ZFLO6_MMFO0,903
|
|
8
10
|
aidol/models/aidol_lead.py,sha256=xCa1AqJdBBeW2Edcj1pK-cXbX5oatxzCOkPyqEGBXVw,619
|
|
9
11
|
aidol/models/companion.py,sha256=fom58GXjGvAxxndS4X4MrT1HWNw8Ps99BNEyPd5JhW0,1974
|
|
10
|
-
aidol/protocols.py,sha256=
|
|
12
|
+
aidol/protocols.py,sha256=8-7iwbtMv5vQUAYGRbbKvm6YfYZZ1VZz6dufse_DYp4,3375
|
|
11
13
|
aidol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
14
|
aidol/repositories/__init__.py,sha256=4kE68a-eRhXt9z5a2XCn1_2s-p3VMpQIdL3oRDb1Ns0,296
|
|
13
15
|
aidol/repositories/aidol.py,sha256=-hf7WW_sDx4vE9bHzKQnzfkgu-on7sqOem9gFO8x0EE,1810
|
|
14
|
-
aidol/repositories/aidol_lead.py,sha256=
|
|
16
|
+
aidol/repositories/aidol_lead.py,sha256=vmCHXdTtxDSiRpd_n7DuyecZDPLls56r0N5vyT_v9zI,1488
|
|
15
17
|
aidol/repositories/companion.py,sha256=dUkJA0me2kmxqk3B4L0w4ENcHeAQFw5ki6RvZ5eLHDg,2877
|
|
16
18
|
aidol/schemas/__init__.py,sha256=sNurP-s24PgS4ZJ7xusZ7Z7wXtl1rdsnAxxdeubRXHE,923
|
|
17
19
|
aidol/schemas/aidol.py,sha256=wOfHaLu4I56elLLj6A3CGriPZE4Pz4fFAyC3emtvaCE,4135
|
|
18
20
|
aidol/schemas/aidol_lead.py,sha256=JS8U-ep0Ga6x0PdwXhJfTrcOCKgG0wfFW8pN5X35GUM,1070
|
|
19
21
|
aidol/schemas/companion.py,sha256=I4hi4LT-S9AC7lqt1jyYfd0vSqYmxYNm2k9EsZdyNyM,7584
|
|
20
22
|
aidol/services/__init__.py,sha256=3vdT_CtUfeDWbsPn7Xnp41sajovcl2nCvpZ8KNFPHYM,144
|
|
21
|
-
aidol/services/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
py_aidol-0.
|
|
23
|
+
aidol/services/companion_service.py,sha256=tNNWiIFmJQ-I3UBW06baOANXhBx5oTKoT6nkqfnDisA,2490
|
|
24
|
+
aidol/services/image_generation_service.py,sha256=zDs9HP_JckHFJJZUIo2ADSsHGppat-8V-ttu8DlN-BU,3862
|
|
25
|
+
aidol/settings.py,sha256=7oI3Vn1iGXvLRRahJ1ygD6qIu-BvZmlVvvMQxnsq1kc,1003
|
|
26
|
+
py_aidol-0.4.0.dist-info/METADATA,sha256=FI4-Xdaxi36hwykQQGgmttCOlsFOlSh_NzCluamZIvA,3431
|
|
27
|
+
py_aidol-0.4.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
28
|
+
py_aidol-0.4.0.dist-info/RECORD,,
|