py-aidol 0.2.0__py3-none-any.whl → 0.3.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 +67 -73
- aidol/api/common.py +71 -0
- aidol/api/companion.py +156 -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 +56 -101
- {py_aidol-0.2.0.dist-info → py_aidol-0.3.0.dist-info}/METADATA +2 -1
- {py_aidol-0.2.0.dist-info → py_aidol-0.3.0.dist-info}/RECORD +12 -9
- {py_aidol-0.2.0.dist-info → py_aidol-0.3.0.dist-info}/WHEEL +0 -0
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,24 @@ 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
|
-
AIdol,
|
|
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
|
|
31
24
|
|
|
32
25
|
|
|
33
|
-
class
|
|
34
|
-
"""
|
|
26
|
+
class AIdolCreateResponse(BaseModel):
|
|
27
|
+
"""Response for AIdol creation (only id)."""
|
|
35
28
|
|
|
36
|
-
|
|
29
|
+
id: str
|
|
37
30
|
|
|
38
31
|
|
|
39
32
|
class AIdolRouter(
|
|
@@ -48,49 +41,92 @@ class AIdolRouter(
|
|
|
48
41
|
|
|
49
42
|
def __init__(
|
|
50
43
|
self,
|
|
51
|
-
|
|
44
|
+
google_api_key: str | None,
|
|
52
45
|
image_storage: ImageStorageProtocol,
|
|
53
46
|
**kwargs,
|
|
54
47
|
):
|
|
55
|
-
|
|
56
|
-
self.openai_settings = openai_settings
|
|
48
|
+
self.google_api_key = google_api_key
|
|
57
49
|
self.image_storage = image_storage
|
|
50
|
+
super().__init__(**kwargs)
|
|
58
51
|
|
|
59
52
|
def _register_routes(self) -> None:
|
|
60
53
|
"""Register routes (public CRUD + image generation)"""
|
|
61
|
-
|
|
54
|
+
# Register shared image generation route
|
|
55
|
+
register_image_generation_route(
|
|
56
|
+
router=self.router,
|
|
57
|
+
resource_name=self.resource_name,
|
|
58
|
+
google_api_key=self.google_api_key,
|
|
59
|
+
image_storage=self.image_storage,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
62
|
self._register_public_create_route()
|
|
63
63
|
self._register_public_get_route()
|
|
64
|
+
self._register_public_update_route()
|
|
65
|
+
|
|
66
|
+
def _register_public_update_route(self) -> None:
|
|
67
|
+
"""PATCH /{resource_name}/{id} - Update AIdol group (public)"""
|
|
68
|
+
|
|
69
|
+
@self.router.patch(
|
|
70
|
+
f"/{self.resource_name}/{{item_id}}",
|
|
71
|
+
response_model=AIdolPublic,
|
|
72
|
+
status_code=status.HTTP_200_OK,
|
|
73
|
+
summary="Update AIdol group",
|
|
74
|
+
description="Update AIdol group by ID (public endpoint). Returns updated AIdol data directly.",
|
|
75
|
+
responses={
|
|
76
|
+
404: {"model": ErrorResponse, "description": "AIdol group not found"},
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
async def update_aidol(
|
|
80
|
+
item_id: str,
|
|
81
|
+
data: AIdolUpdate,
|
|
82
|
+
repository: AIdolRepositoryProtocol = Depends(self.get_repository_dep),
|
|
83
|
+
):
|
|
84
|
+
"""Update AIdol group."""
|
|
85
|
+
# TODO: Verify ClaimToken if strict ownership is required (Sprint 1)
|
|
86
|
+
updated = repository.update(item_id, data)
|
|
87
|
+
if not updated:
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
90
|
+
detail="AIdol group not found",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Return updated AIdol as public schema
|
|
94
|
+
return AIdolPublic(**updated.model_dump())
|
|
64
95
|
|
|
65
96
|
def _register_public_create_route(self) -> None:
|
|
66
97
|
"""POST /{resource_name} - Create an AIdol group (public)"""
|
|
67
98
|
|
|
68
99
|
@self.router.post(
|
|
69
100
|
f"/{self.resource_name}",
|
|
70
|
-
response_model=
|
|
101
|
+
response_model=AIdolCreateResponse,
|
|
71
102
|
status_code=status.HTTP_201_CREATED,
|
|
72
103
|
summary="Create AIdol group",
|
|
73
|
-
description="Create a new AIdol group (public endpoint)",
|
|
104
|
+
description="Create a new AIdol group (public endpoint). Returns only the created id.",
|
|
74
105
|
)
|
|
75
106
|
async def create_aidol(
|
|
76
107
|
request: AIdolCreate,
|
|
108
|
+
response: Response,
|
|
77
109
|
repository: AIdolRepositoryProtocol = Depends(self.get_repository_dep),
|
|
78
110
|
):
|
|
79
111
|
"""Create a new AIdol group."""
|
|
80
112
|
created = repository.create(request)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
113
|
+
|
|
114
|
+
# Set ClaimToken header
|
|
115
|
+
if created.claim_token:
|
|
116
|
+
response.headers["ClaimToken"] = created.claim_token
|
|
117
|
+
|
|
118
|
+
# Return only id
|
|
119
|
+
return AIdolCreateResponse(id=created.id)
|
|
84
120
|
|
|
85
121
|
def _register_public_get_route(self) -> None:
|
|
86
122
|
"""GET /{resource_name}/{id} - Get an AIdol group (public)"""
|
|
87
123
|
|
|
88
124
|
@self.router.get(
|
|
89
125
|
f"/{self.resource_name}/{{item_id}}",
|
|
90
|
-
response_model=
|
|
126
|
+
response_model=AIdolPublic,
|
|
91
127
|
status_code=status.HTTP_200_OK,
|
|
92
128
|
summary="Get AIdol group",
|
|
93
|
-
description="Get AIdol group by ID (public endpoint)",
|
|
129
|
+
description="Get AIdol group by ID (public endpoint). Returns AIdol data directly.",
|
|
94
130
|
responses={
|
|
95
131
|
404: {"model": ErrorResponse, "description": "AIdol group not found"},
|
|
96
132
|
},
|
|
@@ -101,54 +137,12 @@ class AIdolRouter(
|
|
|
101
137
|
):
|
|
102
138
|
"""Get AIdol group by ID."""
|
|
103
139
|
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
|
-
)
|
|
140
|
+
# Return AIdol as public schema
|
|
141
|
+
return AIdolPublic(**aidol.model_dump())
|
|
148
142
|
|
|
149
143
|
|
|
150
144
|
def create_aidol_router(
|
|
151
|
-
|
|
145
|
+
google_api_key: str | None,
|
|
152
146
|
db_session_factory: sessionmaker,
|
|
153
147
|
repository_factory: AIdolRepositoryFactoryProtocol,
|
|
154
148
|
image_storage: ImageStorageProtocol,
|
|
@@ -161,7 +155,7 @@ def create_aidol_router(
|
|
|
161
155
|
Create AIdol router with dependency injection.
|
|
162
156
|
|
|
163
157
|
Args:
|
|
164
|
-
|
|
158
|
+
google_api_key: Google API Key for image generation
|
|
165
159
|
db_session_factory: Database session factory
|
|
166
160
|
repository_factory: Factory implementing AIdolRepositoryFactoryProtocol
|
|
167
161
|
image_storage: Image storage for permanent URLs
|
|
@@ -174,7 +168,7 @@ def create_aidol_router(
|
|
|
174
168
|
FastAPI APIRouter instance
|
|
175
169
|
"""
|
|
176
170
|
router = AIdolRouter(
|
|
177
|
-
|
|
171
|
+
google_api_key=google_api_key,
|
|
178
172
|
image_storage=image_storage,
|
|
179
173
|
model_class=AIdol,
|
|
180
174
|
create_schema=AIdolCreate,
|
aidol/api/common.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
|
|
18
|
+
|
|
19
|
+
def register_image_generation_route(
|
|
20
|
+
router: APIRouter,
|
|
21
|
+
resource_name: str,
|
|
22
|
+
google_api_key: str | None,
|
|
23
|
+
image_storage: ImageStorageProtocol,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Register image generation route to the given router.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
router: FastAPI APIRouter instance
|
|
30
|
+
resource_name: Resource name for the route path
|
|
31
|
+
google_api_key: Google API Key
|
|
32
|
+
image_storage: Image Storage instance
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
@router.post(
|
|
36
|
+
f"/{resource_name}/images",
|
|
37
|
+
response_model=ImageGenerationResponse,
|
|
38
|
+
status_code=status.HTTP_201_CREATED,
|
|
39
|
+
summary="Generate image",
|
|
40
|
+
description=f"Generate image for {resource_name}",
|
|
41
|
+
responses={
|
|
42
|
+
500: {"model": ErrorResponse, "description": "Image generation failed"},
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
async def generate_image(request: ImageGenerationRequest):
|
|
46
|
+
"""Generate image from prompt."""
|
|
47
|
+
# Generate and download image
|
|
48
|
+
service = ImageGenerationService(api_key=google_api_key)
|
|
49
|
+
image = service.generate_and_download_image(
|
|
50
|
+
prompt=request.prompt,
|
|
51
|
+
size="1024x1024",
|
|
52
|
+
quality="standard",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if image is None:
|
|
56
|
+
raise HTTPException(
|
|
57
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
58
|
+
detail="Image generation failed",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Upload to permanent storage
|
|
62
|
+
image_url = image_storage.upload_image(image)
|
|
63
|
+
|
|
64
|
+
return ImageGenerationResponse(
|
|
65
|
+
data=ImageGenerationData(
|
|
66
|
+
image_url=image_url,
|
|
67
|
+
width=1024,
|
|
68
|
+
height=1024,
|
|
69
|
+
format="png",
|
|
70
|
+
)
|
|
71
|
+
)
|
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,24 @@ 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
|
|
27
31
|
|
|
28
32
|
|
|
29
33
|
class CompanionPaginatedResponse(BaseModel):
|
|
@@ -45,11 +49,31 @@ class CompanionRouter(
|
|
|
45
49
|
Returns CompanionPublic (excludes system_prompt) for all responses.
|
|
46
50
|
"""
|
|
47
51
|
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
google_api_key: str | None,
|
|
55
|
+
image_storage: ImageStorageProtocol,
|
|
56
|
+
**kwargs,
|
|
57
|
+
):
|
|
58
|
+
self.google_api_key = google_api_key
|
|
59
|
+
self.image_storage = image_storage
|
|
60
|
+
super().__init__(**kwargs)
|
|
61
|
+
|
|
48
62
|
def _register_routes(self) -> None:
|
|
49
|
-
"""Register routes (public CRUD)"""
|
|
63
|
+
"""Register routes (public CRUD + image generation)"""
|
|
64
|
+
# Register shared image generation route
|
|
65
|
+
register_image_generation_route(
|
|
66
|
+
router=self.router,
|
|
67
|
+
resource_name=self.resource_name,
|
|
68
|
+
google_api_key=self.google_api_key,
|
|
69
|
+
image_storage=self.image_storage,
|
|
70
|
+
)
|
|
71
|
+
|
|
50
72
|
self._register_public_list_route()
|
|
51
73
|
self._register_public_create_route()
|
|
52
74
|
self._register_public_get_route()
|
|
75
|
+
self._register_public_update_route()
|
|
76
|
+
self._register_public_delete_route()
|
|
53
77
|
|
|
54
78
|
def _register_public_list_route(self) -> None:
|
|
55
79
|
"""GET /{resource_name} - List Companions (public)"""
|
|
@@ -59,33 +83,48 @@ class CompanionRouter(
|
|
|
59
83
|
response_model=CompanionPaginatedResponse,
|
|
60
84
|
status_code=status.HTTP_200_OK,
|
|
61
85
|
summary="List Companions",
|
|
62
|
-
description="List all Companions with optional filtering
|
|
86
|
+
description="List all Companions with optional filtering by gender and cast status",
|
|
63
87
|
)
|
|
64
88
|
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)",
|
|
89
|
+
gender: Gender | None = Query(None, description="Filter by gender"),
|
|
90
|
+
is_cast: bool | None = Query(
|
|
91
|
+
None, alias="isCast", description="Filter by cast status"
|
|
76
92
|
),
|
|
93
|
+
aidol_id: str | None = Query(None, description="Filter by AIdol Group ID"),
|
|
77
94
|
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
78
95
|
):
|
|
79
|
-
"""List Companions with
|
|
80
|
-
|
|
96
|
+
"""List Companions with optional gender and isCast filters."""
|
|
97
|
+
filter_list: list[dict] = []
|
|
98
|
+
|
|
99
|
+
# Add filters only if provided
|
|
100
|
+
if gender is not None:
|
|
101
|
+
filter_list.append(
|
|
102
|
+
{"field": "gender", "operator": "eq", "value": gender.value}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Filter by aidol_id if provided
|
|
106
|
+
if aidol_id is not None:
|
|
107
|
+
filter_list.append(
|
|
108
|
+
{"field": "aidol_id", "operator": "eq", "value": aidol_id}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# isCast is derived from aidol_id presence
|
|
112
|
+
# isCast=true → aidol_id is not null (belongs to a group)
|
|
113
|
+
# isCast=false → aidol_id is null (not in a group)
|
|
114
|
+
if is_cast is True:
|
|
115
|
+
filter_list.append(
|
|
116
|
+
{"field": "aidol_id", "operator": "ne", "value": None}
|
|
117
|
+
)
|
|
118
|
+
elif is_cast is False:
|
|
119
|
+
filter_list.append(
|
|
120
|
+
{"field": "aidol_id", "operator": "eq", "value": None}
|
|
121
|
+
)
|
|
122
|
+
|
|
81
123
|
items, total = repository.get_all(
|
|
82
|
-
|
|
83
|
-
page_size=page_size,
|
|
84
|
-
sort=sort_list,
|
|
85
|
-
filters=filter_list,
|
|
124
|
+
filters=filter_list if filter_list else None,
|
|
86
125
|
)
|
|
87
126
|
# Convert to Public schema (exclude system_prompt)
|
|
88
|
-
public_items = [
|
|
127
|
+
public_items = [to_companion_public(c) for c in items]
|
|
89
128
|
return CompanionPaginatedResponse(data=public_items, total=total)
|
|
90
129
|
|
|
91
130
|
def _register_public_create_route(self) -> None:
|
|
@@ -93,30 +132,33 @@ class CompanionRouter(
|
|
|
93
132
|
|
|
94
133
|
@self.router.post(
|
|
95
134
|
f"/{self.resource_name}",
|
|
96
|
-
response_model=
|
|
135
|
+
response_model=CompanionPublic,
|
|
97
136
|
status_code=status.HTTP_201_CREATED,
|
|
98
137
|
summary="Create Companion",
|
|
99
|
-
description="Create a new Companion
|
|
138
|
+
description="Create a new Companion. Returns the created companion data.",
|
|
100
139
|
)
|
|
101
140
|
async def create_companion(
|
|
102
141
|
request: CompanionCreate,
|
|
103
142
|
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
104
143
|
):
|
|
105
144
|
"""Create a new Companion."""
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
145
|
+
# Exclude system_prompt from request - should not be set via API
|
|
146
|
+
sanitized_data = request.model_dump(exclude={"system_prompt"})
|
|
147
|
+
sanitized_request = CompanionCreate(**sanitized_data)
|
|
148
|
+
|
|
149
|
+
created = repository.create(sanitized_request)
|
|
150
|
+
# Return created companion as public schema
|
|
151
|
+
return to_companion_public(created)
|
|
110
152
|
|
|
111
153
|
def _register_public_get_route(self) -> None:
|
|
112
154
|
"""GET /{resource_name}/{id} - Get a Companion (public)"""
|
|
113
155
|
|
|
114
156
|
@self.router.get(
|
|
115
157
|
f"/{self.resource_name}/{{item_id}}",
|
|
116
|
-
response_model=
|
|
158
|
+
response_model=CompanionPublic,
|
|
117
159
|
status_code=status.HTTP_200_OK,
|
|
118
160
|
summary="Get Companion",
|
|
119
|
-
description="Get Companion by ID (public endpoint)",
|
|
161
|
+
description="Get Companion by ID (public endpoint). Returns companion data directly.",
|
|
120
162
|
responses={
|
|
121
163
|
404: {"model": ErrorResponse, "description": "Companion not found"},
|
|
122
164
|
},
|
|
@@ -127,14 +169,85 @@ class CompanionRouter(
|
|
|
127
169
|
):
|
|
128
170
|
"""Get Companion by ID."""
|
|
129
171
|
companion = self._get_item_or_404(repository, item_id)
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
172
|
+
# Return companion as public schema
|
|
173
|
+
return to_companion_public(companion)
|
|
174
|
+
|
|
175
|
+
def _register_public_update_route(self) -> None:
|
|
176
|
+
"""PATCH /{resource_name}/{id} - Update Companion (public)"""
|
|
177
|
+
|
|
178
|
+
@self.router.patch(
|
|
179
|
+
f"/{self.resource_name}/{{item_id}}",
|
|
180
|
+
response_model=CompanionPublic,
|
|
181
|
+
status_code=status.HTTP_200_OK,
|
|
182
|
+
summary="Update Companion",
|
|
183
|
+
description="Update Companion by ID (public endpoint). Returns updated companion data directly.",
|
|
184
|
+
responses={
|
|
185
|
+
404: {"model": ErrorResponse, "description": "Companion not found"},
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
async def update_companion(
|
|
189
|
+
item_id: str,
|
|
190
|
+
data: CompanionUpdate,
|
|
191
|
+
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
192
|
+
):
|
|
193
|
+
"""Update Companion."""
|
|
194
|
+
# Exclude system_prompt from request - should not be set via API
|
|
195
|
+
sanitized_data = data.model_dump(
|
|
196
|
+
exclude={"system_prompt"}, exclude_unset=True
|
|
197
|
+
)
|
|
198
|
+
sanitized_request = CompanionUpdate(**sanitized_data)
|
|
199
|
+
|
|
200
|
+
updated = repository.update(item_id, sanitized_request)
|
|
201
|
+
if not updated:
|
|
202
|
+
raise HTTPException(
|
|
203
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
204
|
+
detail="Companion not found",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Return updated companion as public schema
|
|
208
|
+
return to_companion_public(updated)
|
|
209
|
+
|
|
210
|
+
def _register_public_delete_route(self) -> None:
|
|
211
|
+
"""DELETE /{resource_name}/{id} - Remove Companion from Group (public)"""
|
|
212
|
+
|
|
213
|
+
@self.router.delete(
|
|
214
|
+
f"/{self.resource_name}/{{item_id}}",
|
|
215
|
+
response_model=CompanionPublic,
|
|
216
|
+
status_code=status.HTTP_200_OK,
|
|
217
|
+
summary="Remove Companion from Group",
|
|
218
|
+
description="Remove Companion from AIdol group (unassign aidol_id). Does not delete the record.",
|
|
219
|
+
responses={
|
|
220
|
+
404: {"model": ErrorResponse, "description": "Companion not found"},
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
async def delete_companion(
|
|
224
|
+
item_id: str,
|
|
225
|
+
repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
|
|
226
|
+
):
|
|
227
|
+
"""Remove Companion from Group (Unassign)."""
|
|
228
|
+
# Get item first
|
|
229
|
+
self._get_item_or_404(repository, item_id)
|
|
230
|
+
|
|
231
|
+
# Update aidol_id to None (remove from group)
|
|
232
|
+
update_data = CompanionUpdate(aidol_id=None)
|
|
233
|
+
|
|
234
|
+
updated = repository.update(item_id, update_data)
|
|
235
|
+
|
|
236
|
+
if not updated:
|
|
237
|
+
raise HTTPException(
|
|
238
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
239
|
+
detail="Companion not found",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Return updated companion as public schema
|
|
243
|
+
return to_companion_public(updated)
|
|
133
244
|
|
|
134
245
|
|
|
135
246
|
def create_companion_router(
|
|
247
|
+
google_api_key: str | None,
|
|
136
248
|
db_session_factory: sessionmaker,
|
|
137
249
|
repository_factory: CompanionRepositoryFactoryProtocol,
|
|
250
|
+
image_storage: ImageStorageProtocol,
|
|
138
251
|
jwt_settings: JWTSettings | None = None,
|
|
139
252
|
user_info_provider: UserInfoProvider | None = None,
|
|
140
253
|
resource_name: str = "companions",
|
|
@@ -144,8 +257,10 @@ def create_companion_router(
|
|
|
144
257
|
Create Companion router with dependency injection.
|
|
145
258
|
|
|
146
259
|
Args:
|
|
260
|
+
google_api_key: Google API Key for image generation
|
|
147
261
|
db_session_factory: Database session factory
|
|
148
262
|
repository_factory: Factory implementing CompanionRepositoryFactoryProtocol
|
|
263
|
+
image_storage: Image storage for permanent URLs
|
|
149
264
|
jwt_settings: Optional JWT settings for authentication
|
|
150
265
|
user_info_provider: Optional user info provider
|
|
151
266
|
resource_name: Resource name for routes (default: "companions")
|
|
@@ -155,6 +270,8 @@ def create_companion_router(
|
|
|
155
270
|
FastAPI APIRouter instance
|
|
156
271
|
"""
|
|
157
272
|
router = CompanionRouter(
|
|
273
|
+
google_api_key=google_api_key,
|
|
274
|
+
image_storage=image_storage,
|
|
158
275
|
model_class=Companion,
|
|
159
276
|
create_schema=CompanionCreate,
|
|
160
277
|
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
|
+
)
|
|
@@ -4,142 +4,97 @@ Image generation service for AIdol
|
|
|
4
4
|
Generates images using OpenAI DALL-E 3 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
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
@dataclass
|
|
21
22
|
class ImageGenerationResponse:
|
|
22
|
-
"""Structured response
|
|
23
|
+
"""Structured response for compatibility (legacy)"""
|
|
23
24
|
|
|
24
|
-
url: str
|
|
25
|
-
revised_prompt: str | None
|
|
25
|
+
url: str | None = None
|
|
26
|
+
revised_prompt: str | None = None
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
class ImageGenerationService:
|
|
29
|
-
"""Service for generating images using
|
|
30
|
+
"""Service for generating images using Google Gemini 3 (Imagen)."""
|
|
31
|
+
|
|
32
|
+
client: "genai.Client | None" = None
|
|
30
33
|
|
|
31
|
-
def __init__(self,
|
|
34
|
+
def __init__(self, api_key: str | None = None, settings=None):
|
|
32
35
|
"""
|
|
33
|
-
Initialize the Image Generation service
|
|
36
|
+
Initialize the Image Generation service.
|
|
34
37
|
|
|
35
38
|
Args:
|
|
36
|
-
|
|
39
|
+
api_key: Google API Key.
|
|
40
|
+
settings: Unused, kept for compatibility.
|
|
37
41
|
"""
|
|
38
|
-
self.settings = openai_settings
|
|
39
|
-
self.client = openai.OpenAI(api_key=self.settings.api_key)
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
# Use explicitly provided api_key, otherwise fallback to settings or env
|
|
44
|
+
if api_key:
|
|
45
|
+
self.client = genai.Client(api_key=api_key)
|
|
46
|
+
elif settings and hasattr(settings, "api_key") and settings.api_key:
|
|
47
|
+
self.client = genai.Client(api_key=settings.api_key)
|
|
48
|
+
else:
|
|
49
|
+
# Try loading from GOOGLE_API_KEY environment variable (Client handles this)
|
|
50
|
+
self.client = genai.Client()
|
|
51
|
+
|
|
52
|
+
def generate_and_download_image(
|
|
42
53
|
self,
|
|
43
54
|
prompt: str,
|
|
44
|
-
size: Literal[
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"1024x1792",
|
|
48
|
-
] = "1024x1024",
|
|
49
|
-
quality: Literal["standard", "hd"] = "standard",
|
|
50
|
-
) -> ImageGenerationResponse | None:
|
|
55
|
+
size: Literal["1024x1024"] = "1024x1024", # pylint: disable=unused-argument
|
|
56
|
+
quality: Literal["standard"] = "standard", # pylint: disable=unused-argument
|
|
57
|
+
) -> PIL.Image.Image | None:
|
|
51
58
|
"""
|
|
52
|
-
Generate an image
|
|
59
|
+
Generate an image using Gemini 3 and return as PIL Image.
|
|
53
60
|
|
|
54
61
|
Args:
|
|
55
|
-
prompt: Text description
|
|
56
|
-
size:
|
|
57
|
-
quality:
|
|
62
|
+
prompt: Text description.
|
|
63
|
+
size: Ignored (Gemini specific).
|
|
64
|
+
quality: Ignored (Gemini specific).
|
|
58
65
|
|
|
59
66
|
Returns:
|
|
60
|
-
|
|
61
|
-
or None if generation fails.
|
|
62
|
-
|
|
63
|
-
Raises:
|
|
64
|
-
openai.OpenAIError: If OpenAI API call fails.
|
|
67
|
+
PIL Image object, or None if generation fails.
|
|
65
68
|
"""
|
|
66
|
-
|
|
67
|
-
logger.
|
|
68
|
-
|
|
69
|
-
model="dall-e-3",
|
|
70
|
-
prompt=prompt,
|
|
71
|
-
size=size,
|
|
72
|
-
quality=quality,
|
|
73
|
-
n=1,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
if not response.data or len(response.data) == 0:
|
|
77
|
-
logger.error("No image data returned from OpenAI")
|
|
78
|
-
return None
|
|
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
|
|
69
|
+
if not self.client:
|
|
70
|
+
logger.error("Gemini client not initialized")
|
|
71
|
+
return None
|
|
86
72
|
|
|
87
|
-
|
|
73
|
+
try:
|
|
74
|
+
logger.info("Generating image with Gemini 3 (prompt: %s)...", prompt[:50])
|
|
88
75
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
revised_prompt=revised_prompt,
|
|
76
|
+
response = self.client.models.generate_content(
|
|
77
|
+
model="gemini-3-pro-image-preview",
|
|
78
|
+
contents=[prompt], # type: ignore[arg-type]
|
|
93
79
|
)
|
|
94
80
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
81
|
+
# Iterate parts to find the image
|
|
82
|
+
if response.parts:
|
|
83
|
+
for part in response.parts:
|
|
84
|
+
if part.inline_data and part.inline_data.data:
|
|
85
|
+
logger.info("Successfully generated image via Gemini.")
|
|
86
|
+
# Manually convert bytes to PIL Image to ensure it's a standard PIL object
|
|
87
|
+
# compatible with main.py's save(format="PNG") call.
|
|
88
|
+
return PIL.Image.open(BytesIO(part.inline_data.data))
|
|
98
89
|
|
|
99
|
-
|
|
100
|
-
|
|
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))
|
|
112
|
-
|
|
113
|
-
def generate_and_download_image(
|
|
114
|
-
self,
|
|
115
|
-
prompt: str,
|
|
116
|
-
size: Literal[
|
|
117
|
-
"1024x1024",
|
|
118
|
-
"1792x1024",
|
|
119
|
-
"1024x1792",
|
|
120
|
-
] = "1024x1024",
|
|
121
|
-
quality: Literal["standard", "hd"] = "standard",
|
|
122
|
-
) -> PIL.Image.Image | None:
|
|
123
|
-
"""Generate an image and download as PIL Image.
|
|
124
|
-
|
|
125
|
-
DALL-E returns temporary URLs that expire in 1-2 hours.
|
|
126
|
-
Use this method to download the image immediately after generation.
|
|
127
|
-
|
|
128
|
-
Args:
|
|
129
|
-
prompt: Text description of the image to generate.
|
|
130
|
-
size: Image size (default: "1024x1024").
|
|
131
|
-
quality: Image quality "standard" or "hd" (default: "standard").
|
|
132
|
-
|
|
133
|
-
Returns:
|
|
134
|
-
PIL Image object, or None if generation fails.
|
|
90
|
+
logger.warning("No image data found in Gemini response.")
|
|
91
|
+
return None
|
|
135
92
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
httpx.HTTPError: If image download fails.
|
|
139
|
-
"""
|
|
140
|
-
result = self.generate_image(prompt, size, quality)
|
|
141
|
-
if result is None:
|
|
93
|
+
except genai_errors.APIError as e:
|
|
94
|
+
logger.error("Gemini API error: code=%s, message=%s", e.code, e.message)
|
|
142
95
|
return None
|
|
143
96
|
|
|
144
|
-
|
|
145
|
-
|
|
97
|
+
# Legacy methods for compatibility if needed (can be removed or shimmed)
|
|
98
|
+
def generate_image(self, *args, **kwargs): # pylint: disable=unused-argument
|
|
99
|
+
"""Deprecated: Use generate_and_download_image instead."""
|
|
100
|
+
logger.warning("generate_image is deprecated for Gemini service.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-aidol
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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)
|
|
@@ -1,24 +1,27 @@
|
|
|
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=8fRkYq6-pEVKpQWbamv45IT2-b9FIgilSoPWg3itiK0,6486
|
|
4
|
+
aidol/api/common.py,sha256=R_2RjV_XjAI5TuTSXNbYEjE-wY3wC_hP2Bww0z2g5ZA,2094
|
|
5
|
+
aidol/api/companion.py,sha256=XVZum54ueskJgcaAA2Z_Z6xa3Zpy0CGrl3MIQIga-kY,10738
|
|
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
|
-
py_aidol-0.
|
|
24
|
-
py_aidol-0.
|
|
23
|
+
aidol/services/companion_service.py,sha256=tNNWiIFmJQ-I3UBW06baOANXhBx5oTKoT6nkqfnDisA,2490
|
|
24
|
+
aidol/services/image_generation_service.py,sha256=fq4ua1sO4xT4BK0b-Db2u_G0lbXElbO63MFZfstOlWY,3456
|
|
25
|
+
py_aidol-0.3.0.dist-info/METADATA,sha256=xuQO_y10IvUUZe4Z-VcQkOnQ9ICHjVtSY5iVE5A0oTE,2926
|
|
26
|
+
py_aidol-0.3.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
|
|
27
|
+
py_aidol-0.3.0.dist-info/RECORD,,
|
|
File without changes
|