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 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, OpenAIAPISettings
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 AIdolSingleItemResponse(BaseModel):
34
- """Single item response for AIdol (public)."""
26
+ class AIdolCreateResponse(BaseModel):
27
+ """Response for AIdol creation (only id)."""
35
28
 
36
- data: AIdolPublic
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
- openai_settings: OpenAIAPISettings,
44
+ google_api_key: str | None,
52
45
  image_storage: ImageStorageProtocol,
53
46
  **kwargs,
54
47
  ):
55
- super().__init__(**kwargs)
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
- self._register_image_generation_route()
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=AIdolSingleItemResponse,
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
- # Convert to Public schema (exclude claim_token)
82
- public_aidol = AIdolPublic(**created.model_dump())
83
- return AIdolSingleItemResponse(data=public_aidol)
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=AIdolSingleItemResponse,
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
- # Convert to Public schema (exclude claim_token)
105
- public_aidol = AIdolPublic(**aidol.model_dump())
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
- openai_settings: OpenAIAPISettings,
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
- openai_settings: OpenAI API settings for image generation
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
- openai_settings=openai_settings,
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 Companion, CompanionCreate, CompanionPublic, CompanionUpdate
21
-
22
-
23
- class CompanionSingleItemResponse(BaseModel):
24
- """Single item response for Companion (public)."""
25
-
26
- data: CompanionPublic
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 (public endpoint)",
86
+ description="List all Companions with optional filtering by gender and cast status",
63
87
  )
64
88
  async def list_companions(
65
- current: int = Query(1, ge=1, description="Current page number"),
66
- page_size: int = Query(10, ge=1, le=100, description="Items per page"),
67
- sort_param: str | None = Query(
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 pagination, sorting, and filtering."""
80
- sort_list, filter_list = self._parse_query_params(sort_param, filters_param)
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
- current=current,
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 = [CompanionPublic(**c.model_dump()) for c in 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=CompanionSingleItemResponse,
135
+ response_model=CompanionPublic,
97
136
  status_code=status.HTTP_201_CREATED,
98
137
  summary="Create Companion",
99
- description="Create a new Companion (public endpoint)",
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
- created = repository.create(request)
107
- # Convert to Public schema (exclude system_prompt)
108
- public_companion = CompanionPublic(**created.model_dump())
109
- return CompanionSingleItemResponse(data=public_companion)
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=CompanionSingleItemResponse,
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
- # Convert to Public schema (exclude system_prompt)
131
- public_companion = CompanionPublic(**companion.model_dump())
132
- return CompanionSingleItemResponse(data=public_companion)
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
+ ...
@@ -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, _AIdolLeadUpdate]
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 aioia_core.settings import OpenAIAPISettings
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 from the Image Generation service"""
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 OpenAI DALL-E 3"""
30
+ """Service for generating images using Google Gemini 3 (Imagen)."""
31
+
32
+ client: "genai.Client | None" = None
30
33
 
31
- def __init__(self, openai_settings: OpenAIAPISettings):
34
+ def __init__(self, api_key: str | None = None, settings=None):
32
35
  """
33
- Initialize the Image Generation service with OpenAI settings.
36
+ Initialize the Image Generation service.
34
37
 
35
38
  Args:
36
- openai_settings: OpenAI settings containing required API key
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
- def generate_image(
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
- "1024x1024",
46
- "1792x1024",
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 from a text prompt using OpenAI DALL-E 3.
59
+ Generate an image using Gemini 3 and return as PIL Image.
53
60
 
54
61
  Args:
55
- prompt: Text description of the image to generate
56
- size: Image size (default: "1024x1024")
57
- quality: Image quality "standard" or "hd" (default: "standard")
62
+ prompt: Text description.
63
+ size: Ignored (Gemini specific).
64
+ quality: Ignored (Gemini specific).
58
65
 
59
66
  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.
67
+ PIL Image object, or None if generation fails.
65
68
  """
66
- try:
67
- logger.info("Generating image with OpenAI DALL-E 3...")
68
- response = self.client.images.generate(
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
- revised_prompt = image_data.revised_prompt
73
+ try:
74
+ logger.info("Generating image with Gemini 3 (prompt: %s)...", prompt[:50])
88
75
 
89
- logger.info("Successfully generated image: %s", url[:100])
90
- return ImageGenerationResponse(
91
- url=url,
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
- except openai.OpenAIError as e:
96
- logger.error("OpenAI API error: %s", e)
97
- raise
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
- 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))
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
- Raises:
137
- openai.OpenAIError: If OpenAI API call fails.
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
- logger.info("Downloading image from DALL-E temporary URL...")
145
- return self._download_image(result.url)
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.2.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=DQzwF41bnfw8JpcrZlV3XDkvuH5AszyYCJwOmcRxTKg,6684
4
- aidol/api/companion.py,sha256=QBsPgSjBp1USunB6Jth6fdWLwBzsI6KIq6XQZu9OifQ,6287
5
- aidol/factories.py,sha256=HYkJN9qu1fZOgLW6hk3t0Ixh41mVB4tzzmOQQcso9tA,698
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=Zc9LWUcdHJlerI649v3maXbgXARiXrBiRd2cxNLnSDE,2667
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=g15VJwOI-w_a1fe0OzSUUC0zdfqKSR2JHqywdcOvTfU,1516
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/image_generation_service.py,sha256=naqOxe5jsSTs9__Nj3gwBtOQPkfWvgqVV4z6XkA6DGM,4359
22
- py_aidol-0.2.0.dist-info/METADATA,sha256=oVVlTXIUXp2BPT61cQnQLav5lozJwul0H0SOOWJdrpc,2880
23
- py_aidol-0.2.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
24
- py_aidol-0.2.0.dist-info/RECORD,,
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,,