py-aidol 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (29) hide show
  1. {py_aidol-0.2.0 → py_aidol-0.3.0}/PKG-INFO +2 -1
  2. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/api/aidol.py +67 -73
  3. py_aidol-0.3.0/aidol/api/common.py +71 -0
  4. py_aidol-0.3.0/aidol/api/companion.py +285 -0
  5. py_aidol-0.3.0/aidol/api/lead.py +123 -0
  6. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/factories.py +8 -0
  7. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/protocols.py +26 -0
  8. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/repositories/aidol_lead.py +3 -6
  9. py_aidol-0.3.0/aidol/services/companion_service.py +96 -0
  10. py_aidol-0.3.0/aidol/services/image_generation_service.py +100 -0
  11. {py_aidol-0.2.0 → py_aidol-0.3.0}/pyproject.toml +2 -1
  12. py_aidol-0.2.0/aidol/api/companion.py +0 -168
  13. py_aidol-0.2.0/aidol/services/image_generation_service.py +0 -145
  14. {py_aidol-0.2.0 → py_aidol-0.3.0}/README.md +0 -0
  15. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/__init__.py +0 -0
  16. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/api/__init__.py +0 -0
  17. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/models/__init__.py +0 -0
  18. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/models/aidol.py +0 -0
  19. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/models/aidol_lead.py +0 -0
  20. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/models/companion.py +0 -0
  21. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/py.typed +0 -0
  22. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/repositories/__init__.py +0 -0
  23. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/repositories/aidol.py +0 -0
  24. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/repositories/companion.py +0 -0
  25. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/schemas/__init__.py +0 -0
  26. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/schemas/aidol.py +0 -0
  27. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/schemas/aidol_lead.py +0 -0
  28. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/schemas/companion.py +0 -0
  29. {py_aidol-0.2.0 → py_aidol-0.3.0}/aidol/services/__init__.py +0 -0
@@ -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,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,
@@ -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
+ )
@@ -0,0 +1,285 @@
1
+ # pylint: disable=duplicate-code
2
+ """
3
+ Companion API router
4
+
5
+ Public endpoints for Companion creation and retrieval.
6
+ Public access pattern: no authentication required.
7
+ """
8
+
9
+ from aioia_core.auth import UserInfoProvider
10
+ from aioia_core.errors import ErrorResponse
11
+ from aioia_core.fastapi import BaseCrudRouter
12
+ from aioia_core.settings import JWTSettings
13
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
14
+ from pydantic import BaseModel
15
+ from sqlalchemy.orm import sessionmaker
16
+
17
+ from aidol.api.common import register_image_generation_route
18
+ from aidol.protocols import (
19
+ CompanionRepositoryFactoryProtocol,
20
+ CompanionRepositoryProtocol,
21
+ ImageStorageProtocol,
22
+ )
23
+ from aidol.schemas import (
24
+ Companion,
25
+ CompanionCreate,
26
+ CompanionPublic,
27
+ CompanionUpdate,
28
+ Gender,
29
+ )
30
+ from aidol.services.companion_service import to_companion_public
31
+
32
+
33
+ class CompanionPaginatedResponse(BaseModel):
34
+ """Paginated response for Companion (public)."""
35
+
36
+ data: list[CompanionPublic]
37
+ total: int
38
+
39
+
40
+ class CompanionRouter(
41
+ BaseCrudRouter[
42
+ Companion, CompanionCreate, CompanionUpdate, CompanionRepositoryProtocol
43
+ ]
44
+ ):
45
+ """
46
+ Companion router with public endpoints.
47
+
48
+ Public CRUD pattern: no authentication required.
49
+ Returns CompanionPublic (excludes system_prompt) for all responses.
50
+ """
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
+
62
+ def _register_routes(self) -> None:
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
+
72
+ self._register_public_list_route()
73
+ self._register_public_create_route()
74
+ self._register_public_get_route()
75
+ self._register_public_update_route()
76
+ self._register_public_delete_route()
77
+
78
+ def _register_public_list_route(self) -> None:
79
+ """GET /{resource_name} - List Companions (public)"""
80
+
81
+ @self.router.get(
82
+ f"/{self.resource_name}",
83
+ response_model=CompanionPaginatedResponse,
84
+ status_code=status.HTTP_200_OK,
85
+ summary="List Companions",
86
+ description="List all Companions with optional filtering by gender and cast status",
87
+ )
88
+ async def list_companions(
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"
92
+ ),
93
+ aidol_id: str | None = Query(None, description="Filter by AIdol Group ID"),
94
+ repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
95
+ ):
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
+
123
+ items, total = repository.get_all(
124
+ filters=filter_list if filter_list else None,
125
+ )
126
+ # Convert to Public schema (exclude system_prompt)
127
+ public_items = [to_companion_public(c) for c in items]
128
+ return CompanionPaginatedResponse(data=public_items, total=total)
129
+
130
+ def _register_public_create_route(self) -> None:
131
+ """POST /{resource_name} - Create a Companion (public)"""
132
+
133
+ @self.router.post(
134
+ f"/{self.resource_name}",
135
+ response_model=CompanionPublic,
136
+ status_code=status.HTTP_201_CREATED,
137
+ summary="Create Companion",
138
+ description="Create a new Companion. Returns the created companion data.",
139
+ )
140
+ async def create_companion(
141
+ request: CompanionCreate,
142
+ repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
143
+ ):
144
+ """Create a new 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)
152
+
153
+ def _register_public_get_route(self) -> None:
154
+ """GET /{resource_name}/{id} - Get a Companion (public)"""
155
+
156
+ @self.router.get(
157
+ f"/{self.resource_name}/{{item_id}}",
158
+ response_model=CompanionPublic,
159
+ status_code=status.HTTP_200_OK,
160
+ summary="Get Companion",
161
+ description="Get Companion by ID (public endpoint). Returns companion data directly.",
162
+ responses={
163
+ 404: {"model": ErrorResponse, "description": "Companion not found"},
164
+ },
165
+ )
166
+ async def get_companion(
167
+ item_id: str,
168
+ repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
169
+ ):
170
+ """Get Companion by ID."""
171
+ companion = self._get_item_or_404(repository, item_id)
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)
244
+
245
+
246
+ def create_companion_router(
247
+ google_api_key: str | None,
248
+ db_session_factory: sessionmaker,
249
+ repository_factory: CompanionRepositoryFactoryProtocol,
250
+ image_storage: ImageStorageProtocol,
251
+ jwt_settings: JWTSettings | None = None,
252
+ user_info_provider: UserInfoProvider | None = None,
253
+ resource_name: str = "companions",
254
+ tags: list[str] | None = None,
255
+ ) -> APIRouter:
256
+ """
257
+ Create Companion router with dependency injection.
258
+
259
+ Args:
260
+ google_api_key: Google API Key for image generation
261
+ db_session_factory: Database session factory
262
+ repository_factory: Factory implementing CompanionRepositoryFactoryProtocol
263
+ image_storage: Image storage for permanent URLs
264
+ jwt_settings: Optional JWT settings for authentication
265
+ user_info_provider: Optional user info provider
266
+ resource_name: Resource name for routes (default: "companions")
267
+ tags: Optional OpenAPI tags
268
+
269
+ Returns:
270
+ FastAPI APIRouter instance
271
+ """
272
+ router = CompanionRouter(
273
+ google_api_key=google_api_key,
274
+ image_storage=image_storage,
275
+ model_class=Companion,
276
+ create_schema=CompanionCreate,
277
+ update_schema=CompanionUpdate,
278
+ db_session_factory=db_session_factory,
279
+ repository_factory=repository_factory,
280
+ user_info_provider=user_info_provider,
281
+ jwt_secret_key=jwt_settings.secret_key if jwt_settings else None,
282
+ resource_name=resource_name,
283
+ tags=tags or ["Companion"],
284
+ )
285
+ return router.get_router()