py-aidol 0.1.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,