py-aidol 0.1.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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ AIdol - Asynchronous chat module for AI idol interaction
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ # No top-level exports - use full paths for clarity
aidol/api/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ AIdol API routers
3
+ """
4
+
5
+ from aidol.api.aidol import AIdolRouter, create_aidol_router
6
+ from aidol.api.companion import CompanionRouter, create_companion_router
7
+
8
+ __all__ = [
9
+ "AIdolRouter",
10
+ "CompanionRouter",
11
+ "create_aidol_router",
12
+ "create_companion_router",
13
+ ]
aidol/api/aidol.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ AIdol API router
3
+
4
+ Public endpoints for AIdol group creation and retrieval.
5
+ Public access pattern: no authentication required.
6
+ """
7
+
8
+ from aioia_core.auth import UserInfoProvider
9
+ from aioia_core.errors import ErrorResponse
10
+ from aioia_core.fastapi import BaseCrudRouter
11
+ from aioia_core.settings import JWTSettings, OpenAIAPISettings
12
+ from fastapi import APIRouter, Depends, HTTPException, status
13
+ from pydantic import BaseModel
14
+ from sqlalchemy.orm import sessionmaker
15
+
16
+ from aidol.protocols import (
17
+ AIdolRepositoryFactoryProtocol,
18
+ AIdolRepositoryProtocol,
19
+ ImageStorageProtocol,
20
+ )
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
31
+
32
+
33
+ class AIdolSingleItemResponse(BaseModel):
34
+ """Single item response for AIdol (public)."""
35
+
36
+ data: AIdolPublic
37
+
38
+
39
+ class AIdolRouter(
40
+ BaseCrudRouter[AIdol, AIdolCreate, AIdolUpdate, AIdolRepositoryProtocol]
41
+ ):
42
+ """
43
+ AIdol router with public endpoints.
44
+
45
+ Public CRUD pattern: no authentication required.
46
+ Returns AIdolPublic (excludes claim_token) for all responses.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ openai_settings: OpenAIAPISettings,
52
+ image_storage: ImageStorageProtocol,
53
+ **kwargs,
54
+ ):
55
+ super().__init__(**kwargs)
56
+ self.openai_settings = openai_settings
57
+ self.image_storage = image_storage
58
+
59
+ def _register_routes(self) -> None:
60
+ """Register routes (public CRUD + image generation)"""
61
+ self._register_image_generation_route()
62
+ self._register_public_create_route()
63
+ self._register_public_get_route()
64
+
65
+ def _register_public_create_route(self) -> None:
66
+ """POST /{resource_name} - Create an AIdol group (public)"""
67
+
68
+ @self.router.post(
69
+ f"/{self.resource_name}",
70
+ response_model=AIdolSingleItemResponse,
71
+ status_code=status.HTTP_201_CREATED,
72
+ summary="Create AIdol group",
73
+ description="Create a new AIdol group (public endpoint)",
74
+ )
75
+ async def create_aidol(
76
+ request: AIdolCreate,
77
+ repository: AIdolRepositoryProtocol = Depends(self.get_repository_dep),
78
+ ):
79
+ """Create a new AIdol group."""
80
+ 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)
84
+
85
+ def _register_public_get_route(self) -> None:
86
+ """GET /{resource_name}/{id} - Get an AIdol group (public)"""
87
+
88
+ @self.router.get(
89
+ f"/{self.resource_name}/{{item_id}}",
90
+ response_model=AIdolSingleItemResponse,
91
+ status_code=status.HTTP_200_OK,
92
+ summary="Get AIdol group",
93
+ description="Get AIdol group by ID (public endpoint)",
94
+ responses={
95
+ 404: {"model": ErrorResponse, "description": "AIdol group not found"},
96
+ },
97
+ )
98
+ async def get_aidol(
99
+ item_id: str,
100
+ repository: AIdolRepositoryProtocol = Depends(self.get_repository_dep),
101
+ ):
102
+ """Get AIdol group by ID."""
103
+ 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
+ )
148
+
149
+
150
+ def create_aidol_router(
151
+ openai_settings: OpenAIAPISettings,
152
+ db_session_factory: sessionmaker,
153
+ repository_factory: AIdolRepositoryFactoryProtocol,
154
+ image_storage: ImageStorageProtocol,
155
+ jwt_settings: JWTSettings | None = None,
156
+ user_info_provider: UserInfoProvider | None = None,
157
+ resource_name: str = "aidols",
158
+ tags: list[str] | None = None,
159
+ ) -> APIRouter:
160
+ """
161
+ Create AIdol router with dependency injection.
162
+
163
+ Args:
164
+ openai_settings: OpenAI API settings for image generation
165
+ db_session_factory: Database session factory
166
+ repository_factory: Factory implementing AIdolRepositoryFactoryProtocol
167
+ image_storage: Image storage for permanent URLs
168
+ jwt_settings: Optional JWT settings for authentication
169
+ user_info_provider: Optional user info provider
170
+ resource_name: Resource name for routes (default: "aidols")
171
+ tags: Optional OpenAPI tags
172
+
173
+ Returns:
174
+ FastAPI APIRouter instance
175
+ """
176
+ router = AIdolRouter(
177
+ openai_settings=openai_settings,
178
+ image_storage=image_storage,
179
+ model_class=AIdol,
180
+ create_schema=AIdolCreate,
181
+ update_schema=AIdolUpdate,
182
+ db_session_factory=db_session_factory,
183
+ repository_factory=repository_factory,
184
+ user_info_provider=user_info_provider,
185
+ jwt_secret_key=jwt_settings.secret_key if jwt_settings else None,
186
+ resource_name=resource_name,
187
+ tags=tags or ["AIdol"],
188
+ )
189
+ return router.get_router()
aidol/api/companion.py ADDED
@@ -0,0 +1,168 @@
1
+ """
2
+ Companion API router
3
+
4
+ Public endpoints for Companion creation and retrieval.
5
+ Public access pattern: no authentication required.
6
+ """
7
+
8
+ from aioia_core.auth import UserInfoProvider
9
+ from aioia_core.errors import ErrorResponse
10
+ from aioia_core.fastapi import BaseCrudRouter
11
+ from aioia_core.settings import JWTSettings
12
+ from fastapi import APIRouter, Depends, Query, status
13
+ from pydantic import BaseModel
14
+ from sqlalchemy.orm import sessionmaker
15
+
16
+ from aidol.protocols import (
17
+ CompanionRepositoryFactoryProtocol,
18
+ CompanionRepositoryProtocol,
19
+ )
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
27
+
28
+
29
+ class CompanionPaginatedResponse(BaseModel):
30
+ """Paginated response for Companion (public)."""
31
+
32
+ data: list[CompanionPublic]
33
+ total: int
34
+
35
+
36
+ class CompanionRouter(
37
+ BaseCrudRouter[
38
+ Companion, CompanionCreate, CompanionUpdate, CompanionRepositoryProtocol
39
+ ]
40
+ ):
41
+ """
42
+ Companion router with public endpoints.
43
+
44
+ Public CRUD pattern: no authentication required.
45
+ Returns CompanionPublic (excludes system_prompt) for all responses.
46
+ """
47
+
48
+ def _register_routes(self) -> None:
49
+ """Register routes (public CRUD)"""
50
+ self._register_public_list_route()
51
+ self._register_public_create_route()
52
+ self._register_public_get_route()
53
+
54
+ def _register_public_list_route(self) -> None:
55
+ """GET /{resource_name} - List Companions (public)"""
56
+
57
+ @self.router.get(
58
+ f"/{self.resource_name}",
59
+ response_model=CompanionPaginatedResponse,
60
+ status_code=status.HTTP_200_OK,
61
+ summary="List Companions",
62
+ description="List all Companions with optional filtering (public endpoint)",
63
+ )
64
+ 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)",
76
+ ),
77
+ repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
78
+ ):
79
+ """List Companions with pagination, sorting, and filtering."""
80
+ sort_list, filter_list = self._parse_query_params(sort_param, filters_param)
81
+ items, total = repository.get_all(
82
+ current=current,
83
+ page_size=page_size,
84
+ sort=sort_list,
85
+ filters=filter_list,
86
+ )
87
+ # Convert to Public schema (exclude system_prompt)
88
+ public_items = [CompanionPublic(**c.model_dump()) for c in items]
89
+ return CompanionPaginatedResponse(data=public_items, total=total)
90
+
91
+ def _register_public_create_route(self) -> None:
92
+ """POST /{resource_name} - Create a Companion (public)"""
93
+
94
+ @self.router.post(
95
+ f"/{self.resource_name}",
96
+ response_model=CompanionSingleItemResponse,
97
+ status_code=status.HTTP_201_CREATED,
98
+ summary="Create Companion",
99
+ description="Create a new Companion (public endpoint)",
100
+ )
101
+ async def create_companion(
102
+ request: CompanionCreate,
103
+ repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
104
+ ):
105
+ """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)
110
+
111
+ def _register_public_get_route(self) -> None:
112
+ """GET /{resource_name}/{id} - Get a Companion (public)"""
113
+
114
+ @self.router.get(
115
+ f"/{self.resource_name}/{{item_id}}",
116
+ response_model=CompanionSingleItemResponse,
117
+ status_code=status.HTTP_200_OK,
118
+ summary="Get Companion",
119
+ description="Get Companion by ID (public endpoint)",
120
+ responses={
121
+ 404: {"model": ErrorResponse, "description": "Companion not found"},
122
+ },
123
+ )
124
+ async def get_companion(
125
+ item_id: str,
126
+ repository: CompanionRepositoryProtocol = Depends(self.get_repository_dep),
127
+ ):
128
+ """Get Companion by ID."""
129
+ 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)
133
+
134
+
135
+ def create_companion_router(
136
+ db_session_factory: sessionmaker,
137
+ repository_factory: CompanionRepositoryFactoryProtocol,
138
+ jwt_settings: JWTSettings | None = None,
139
+ user_info_provider: UserInfoProvider | None = None,
140
+ resource_name: str = "companions",
141
+ tags: list[str] | None = None,
142
+ ) -> APIRouter:
143
+ """
144
+ Create Companion router with dependency injection.
145
+
146
+ Args:
147
+ db_session_factory: Database session factory
148
+ repository_factory: Factory implementing CompanionRepositoryFactoryProtocol
149
+ jwt_settings: Optional JWT settings for authentication
150
+ user_info_provider: Optional user info provider
151
+ resource_name: Resource name for routes (default: "companions")
152
+ tags: Optional OpenAPI tags
153
+
154
+ Returns:
155
+ FastAPI APIRouter instance
156
+ """
157
+ router = CompanionRouter(
158
+ model_class=Companion,
159
+ create_schema=CompanionCreate,
160
+ update_schema=CompanionUpdate,
161
+ db_session_factory=db_session_factory,
162
+ repository_factory=repository_factory,
163
+ user_info_provider=user_info_provider,
164
+ jwt_secret_key=jwt_settings.secret_key if jwt_settings else None,
165
+ resource_name=resource_name,
166
+ tags=tags or ["Companion"],
167
+ )
168
+ return router.get_router()
aidol/factories.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ AIdol repository factories
3
+
4
+ Uses BaseRepositoryFactory for BaseCrudRouter compatibility.
5
+ """
6
+
7
+ from aioia_core.factories import BaseRepositoryFactory
8
+
9
+ from aidol.repositories.aidol import AIdolRepository
10
+ from aidol.repositories.companion import CompanionRepository
11
+
12
+
13
+ class AIdolRepositoryFactory(BaseRepositoryFactory[AIdolRepository]):
14
+ """Factory for creating AIdol repositories."""
15
+
16
+ def __init__(self):
17
+ super().__init__(repository_class=AIdolRepository)
18
+
19
+
20
+ class CompanionRepositoryFactory(BaseRepositoryFactory[CompanionRepository]):
21
+ """Factory for creating Companion repositories."""
22
+
23
+ def __init__(self):
24
+ super().__init__(repository_class=CompanionRepository)
@@ -0,0 +1,8 @@
1
+ """
2
+ AIdol database models
3
+ """
4
+
5
+ from aidol.models.aidol import DBAIdol
6
+ from aidol.models.companion import DBCompanion
7
+
8
+ __all__ = ["DBAIdol", "DBCompanion"]
aidol/models/aidol.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ AIdol database model
3
+
4
+ Uses aioia_core.models.BaseModel which provides:
5
+ - id: Mapped[str] (primary key, UUID default)
6
+ - created_at: Mapped[datetime]
7
+ - updated_at: Mapped[datetime]
8
+ """
9
+
10
+ from aioia_core.models import BaseModel
11
+ from sqlalchemy import String
12
+ from sqlalchemy.orm import Mapped, mapped_column
13
+
14
+
15
+ class DBAIdol(BaseModel):
16
+ """AIdol (group) database model"""
17
+
18
+ __tablename__ = "aidols"
19
+
20
+ # id, created_at, updated_at inherited from BaseModel
21
+ name: Mapped[str] = mapped_column(String, nullable=False)
22
+ concept: Mapped[str | None] = mapped_column(String, nullable=True)
23
+ profile_image_url: Mapped[str] = mapped_column(String, nullable=False)
24
+ claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True)
@@ -0,0 +1,27 @@
1
+ """
2
+ Companion database model
3
+
4
+ Uses aioia_core.models.BaseModel which provides:
5
+ - id: Mapped[str] (primary key, UUID default)
6
+ - created_at: Mapped[datetime]
7
+ - updated_at: Mapped[datetime]
8
+ """
9
+
10
+ from aioia_core.models import BaseModel
11
+ from sqlalchemy import ForeignKey, Index, String, Text
12
+ from sqlalchemy.orm import Mapped, mapped_column
13
+
14
+
15
+ class DBCompanion(BaseModel):
16
+ """Companion (member) database model"""
17
+
18
+ __tablename__ = "companions"
19
+
20
+ # id, created_at, updated_at inherited from BaseModel
21
+ aidol_id: Mapped[str | None] = mapped_column(ForeignKey("aidols.id"), nullable=True)
22
+ name: Mapped[str] = mapped_column(String, nullable=False)
23
+ biography: Mapped[str | None] = mapped_column(Text, nullable=True)
24
+ profile_picture_url: Mapped[str | None] = mapped_column(String, nullable=True)
25
+ system_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
26
+
27
+ __table_args__ = (Index("ix_companions_aidol_id", "aidol_id"),)
aidol/protocols.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ AIdol repository protocols for type-safe dependency injection.
3
+
4
+ Defines the interface that AIdol Router expects from repositories.
5
+ Platform-specific integrators implement these protocols via adapters.
6
+ """
7
+
8
+ # pylint: disable=unnecessary-ellipsis
9
+
10
+ from typing import Protocol
11
+
12
+ import PIL.Image
13
+ from aioia_core import CrudRepositoryProtocol
14
+ from sqlalchemy.orm import Session
15
+
16
+ from aidol.schemas import (
17
+ AIdol,
18
+ AIdolCreate,
19
+ AIdolUpdate,
20
+ Companion,
21
+ CompanionCreate,
22
+ CompanionUpdate,
23
+ )
24
+
25
+
26
+ class AIdolRepositoryProtocol(
27
+ CrudRepositoryProtocol[AIdol, AIdolCreate, AIdolUpdate], Protocol
28
+ ):
29
+ """Protocol defining AIdol repository expectations.
30
+
31
+ This protocol enables type-safe dependency injection by defining
32
+ the exact interface that AIdolRouter uses. Inherits CRUD operations
33
+ from CrudRepositoryProtocol.
34
+ """
35
+
36
+
37
+ class AIdolRepositoryFactoryProtocol(Protocol):
38
+ """Protocol for factory that creates AIdolRepositoryProtocol instances.
39
+
40
+ Implementations:
41
+ - aidol.factories.AIdolRepositoryFactory (standalone)
42
+ """
43
+
44
+ def create_repository(
45
+ self, db_session: Session | None = None
46
+ ) -> AIdolRepositoryProtocol:
47
+ """Create a repository instance.
48
+
49
+ Args:
50
+ db_session: Optional database session.
51
+ """
52
+ ...
53
+
54
+
55
+ class CompanionRepositoryProtocol(
56
+ CrudRepositoryProtocol[Companion, CompanionCreate, CompanionUpdate], Protocol
57
+ ):
58
+ """Protocol defining Companion repository expectations.
59
+
60
+ This protocol enables type-safe dependency injection by defining
61
+ the exact interface that CompanionRouter uses. Inherits CRUD operations
62
+ from CrudRepositoryProtocol.
63
+ """
64
+
65
+
66
+ class CompanionRepositoryFactoryProtocol(Protocol):
67
+ """Protocol for factory that creates CompanionRepositoryProtocol instances.
68
+
69
+ Implementations:
70
+ - aidol.factories.CompanionRepositoryFactory (standalone)
71
+ """
72
+
73
+ def create_repository(
74
+ self, db_session: Session | None = None
75
+ ) -> CompanionRepositoryProtocol:
76
+ """Create a repository instance.
77
+
78
+ Args:
79
+ db_session: Optional database session.
80
+ """
81
+ ...
82
+
83
+
84
+ class ImageStorageProtocol(Protocol):
85
+ """Protocol for image storage operations.
86
+
87
+ Enables dependency injection of storage implementations.
88
+ Platform-specific adapters implement this protocol.
89
+
90
+ Implementations:
91
+ - ImageStorageAdapter (platform integration via StorageService)
92
+ """
93
+
94
+ def upload_image(self, image: PIL.Image.Image) -> str:
95
+ """Upload an image and return the permanent URL.
96
+
97
+ Args:
98
+ image: PIL Image object to upload.
99
+ """
100
+ ...
aidol/py.typed ADDED
File without changes
@@ -0,0 +1,11 @@
1
+ """
2
+ AIdol repositories
3
+ """
4
+
5
+ from aidol.repositories.aidol import AIdolRepository
6
+ from aidol.repositories.companion import CompanionRepository
7
+
8
+ __all__ = [
9
+ "AIdolRepository",
10
+ "CompanionRepository",
11
+ ]
@@ -0,0 +1,57 @@
1
+ # pylint: disable=duplicate-code
2
+ # TODO: Extract common AIdol converters to shared module
3
+ # (duplicated in managers/database_aidol_manager.py)
4
+ """
5
+ AIdol repository
6
+
7
+ Implements BaseRepository pattern for BaseCrudRouter compatibility.
8
+ """
9
+
10
+ from datetime import timezone
11
+
12
+ from aioia_core.repositories import BaseRepository
13
+ from sqlalchemy.orm import Session
14
+
15
+ from aidol.models import DBAIdol
16
+ from aidol.schemas import AIdol, AIdolCreate, AIdolUpdate
17
+
18
+
19
+ def _convert_db_aidol_to_model(db_aidol: DBAIdol) -> AIdol:
20
+ """Convert DB AIdol to Pydantic model.
21
+
22
+ Includes claim_token for internal use (Service layer).
23
+ Router should convert to AIdolPublic for API responses.
24
+ """
25
+ return AIdol(
26
+ id=db_aidol.id,
27
+ name=db_aidol.name,
28
+ concept=db_aidol.concept,
29
+ profile_image_url=db_aidol.profile_image_url,
30
+ claim_token=db_aidol.claim_token,
31
+ created_at=db_aidol.created_at.replace(tzinfo=timezone.utc),
32
+ updated_at=db_aidol.updated_at.replace(tzinfo=timezone.utc),
33
+ )
34
+
35
+
36
+ def _convert_aidol_create_to_db(schema: AIdolCreate) -> dict:
37
+ """Convert AIdolCreate schema to DB model data dict.
38
+
39
+ Includes claim_token for ownership verification.
40
+ """
41
+ return schema.model_dump(exclude_unset=True)
42
+
43
+
44
+ class AIdolRepository(BaseRepository[AIdol, DBAIdol, AIdolCreate, AIdolUpdate]):
45
+ """
46
+ Database-backed AIdol repository.
47
+
48
+ Extends BaseRepository for CRUD operations compatible with BaseCrudRouter.
49
+ """
50
+
51
+ def __init__(self, db_session: Session):
52
+ super().__init__(
53
+ db_session=db_session,
54
+ db_model=DBAIdol,
55
+ convert_to_model=_convert_db_aidol_to_model,
56
+ convert_to_db_model=_convert_aidol_create_to_db,
57
+ )
@@ -0,0 +1,57 @@
1
+ """
2
+ Companion repository
3
+
4
+ Implements BaseRepository pattern for BaseCrudRouter compatibility.
5
+ """
6
+
7
+ from datetime import timezone
8
+
9
+ from aioia_core.repositories import BaseRepository
10
+ from sqlalchemy.orm import Session
11
+
12
+ from aidol.models import DBCompanion
13
+ from aidol.schemas import Companion, CompanionCreate, CompanionUpdate
14
+
15
+
16
+ def _convert_db_companion_to_model(db_companion: DBCompanion) -> Companion:
17
+ """Convert DB Companion to Pydantic model.
18
+
19
+ Includes system_prompt for internal use (Service layer).
20
+ Router should convert to CompanionPublic for API responses.
21
+ """
22
+ return Companion(
23
+ id=db_companion.id,
24
+ aidol_id=db_companion.aidol_id,
25
+ name=db_companion.name,
26
+ biography=db_companion.biography,
27
+ profile_picture_url=db_companion.profile_picture_url,
28
+ system_prompt=db_companion.system_prompt,
29
+ created_at=db_companion.created_at.replace(tzinfo=timezone.utc),
30
+ updated_at=db_companion.updated_at.replace(tzinfo=timezone.utc),
31
+ )
32
+
33
+
34
+ def _convert_companion_create_to_db(schema: CompanionCreate) -> dict:
35
+ """Convert CompanionCreate schema to DB model data dict.
36
+
37
+ Includes system_prompt for AI configuration.
38
+ """
39
+ return schema.model_dump(exclude_unset=True)
40
+
41
+
42
+ class CompanionRepository(
43
+ BaseRepository[Companion, DBCompanion, CompanionCreate, CompanionUpdate]
44
+ ):
45
+ """
46
+ Database-backed Companion repository.
47
+
48
+ Extends BaseRepository for CRUD operations compatible with BaseCrudRouter.
49
+ """
50
+
51
+ def __init__(self, db_session: Session):
52
+ super().__init__(
53
+ db_session=db_session,
54
+ db_model=DBCompanion,
55
+ convert_to_model=_convert_db_companion_to_model,
56
+ convert_to_db_model=_convert_companion_create_to_db,
57
+ )
@@ -0,0 +1,37 @@
1
+ """
2
+ AIdol Pydantic schemas
3
+ """
4
+
5
+ from aidol.schemas.aidol import (
6
+ AIdol,
7
+ AIdolBase,
8
+ AIdolCreate,
9
+ AIdolPublic,
10
+ AIdolUpdate,
11
+ ImageGenerationData,
12
+ ImageGenerationRequest,
13
+ ImageGenerationResponse,
14
+ )
15
+ from aidol.schemas.companion import (
16
+ Companion,
17
+ CompanionBase,
18
+ CompanionCreate,
19
+ CompanionPublic,
20
+ CompanionUpdate,
21
+ )
22
+
23
+ __all__ = [
24
+ "AIdol",
25
+ "AIdolBase",
26
+ "AIdolCreate",
27
+ "AIdolPublic",
28
+ "AIdolUpdate",
29
+ "ImageGenerationData",
30
+ "ImageGenerationRequest",
31
+ "ImageGenerationResponse",
32
+ "Companion",
33
+ "CompanionBase",
34
+ "CompanionCreate",
35
+ "CompanionPublic",
36
+ "CompanionUpdate",
37
+ ]
aidol/schemas/aidol.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ AIdol (group) schemas
3
+
4
+ Schema hierarchy:
5
+ - AIdolBase: Mutable fields (used in Create/Update)
6
+ - AIdolCreate: Base + optional claim_token
7
+ - AIdolUpdate: Base fields only
8
+ - AIdol: Response with all fields including claim_token (internal use)
9
+ - AIdolPublic: Response without sensitive fields (API use)
10
+ """
11
+
12
+ from datetime import datetime
13
+
14
+ from humps import camelize
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class AIdolBase(BaseModel):
19
+ """Base AIdol model with mutable fields.
20
+
21
+ Contains only fields that can be modified after creation.
22
+ Excludes claim_token (immutable, set at creation only).
23
+ """
24
+
25
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
26
+
27
+ name: str = Field(..., description="AIdol group name")
28
+ concept: str | None = Field(default=None, description="Group concept or theme")
29
+ profile_image_url: str = Field(..., description="Profile image URL")
30
+
31
+
32
+ class AIdolCreate(AIdolBase):
33
+ """Schema for creating an AIdol group (no id).
34
+
35
+ claim_token is optional for anonymous ownership verification.
36
+ """
37
+
38
+ claim_token: str | None = Field(
39
+ default=None,
40
+ description="Optional client-generated UUID for ownership verification",
41
+ )
42
+
43
+
44
+ class AIdolUpdate(BaseModel):
45
+ """Schema for updating an AIdol group (all fields optional)."""
46
+
47
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
48
+
49
+ name: str | None = Field(default=None, description="AIdol group name")
50
+ concept: str | None = Field(default=None, description="Group concept or theme")
51
+ profile_image_url: str | None = Field(default=None, description="Profile image URL")
52
+
53
+
54
+ class AIdol(AIdolBase):
55
+ """AIdol response schema with id and timestamps.
56
+
57
+ Includes optional claim_token for ownership verification.
58
+ Use AIdolPublic for API responses to exclude sensitive fields.
59
+ """
60
+
61
+ model_config = ConfigDict(
62
+ populate_by_name=True, from_attributes=True, alias_generator=camelize
63
+ )
64
+
65
+ id: str = Field(..., description="AIdol group ID")
66
+ claim_token: str | None = Field(
67
+ default=None, description="Optional ownership token (sensitive, internal use)"
68
+ )
69
+ created_at: datetime = Field(..., description="Creation timestamp")
70
+ updated_at: datetime = Field(..., description="Last update timestamp")
71
+
72
+
73
+ class AIdolPublic(AIdolBase):
74
+ """Public AIdol response schema without sensitive fields.
75
+
76
+ Excludes claim_token for API responses.
77
+ """
78
+
79
+ model_config = ConfigDict(
80
+ populate_by_name=True, from_attributes=True, alias_generator=camelize
81
+ )
82
+
83
+ id: str = Field(..., description="AIdol group ID")
84
+ created_at: datetime = Field(..., description="Creation timestamp")
85
+ updated_at: datetime = Field(..., description="Last update timestamp")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Image Generation
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ class ImageGenerationRequest(BaseModel):
94
+ """Request schema for image generation."""
95
+
96
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
97
+
98
+ prompt: str = Field(
99
+ ...,
100
+ max_length=200,
101
+ description="Text description for image generation (max 200 chars)",
102
+ )
103
+
104
+
105
+ class ImageGenerationData(BaseModel):
106
+ """Image generation result data."""
107
+
108
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
109
+
110
+ image_url: str = Field(..., description="Generated image URL")
111
+ width: int = Field(..., description="Image width in pixels")
112
+ height: int = Field(..., description="Image height in pixels")
113
+ format: str = Field(..., description="Image format (e.g., png, jpg)")
114
+
115
+
116
+ class ImageGenerationResponse(BaseModel):
117
+ """Response schema for image generation."""
118
+
119
+ data: ImageGenerationData
@@ -0,0 +1,93 @@
1
+ """
2
+ Companion (member) schemas
3
+
4
+ Schema hierarchy:
5
+ - CompanionBase: Mutable fields (used in Create/Update)
6
+ - CompanionCreate: Base + system_prompt (mutable, but sensitive)
7
+ - CompanionUpdate: Base + system_prompt (mutable, but sensitive)
8
+ - Companion: Response with all fields including system_prompt (internal use)
9
+ - CompanionPublic: Response without sensitive fields (API use)
10
+ """
11
+
12
+ from datetime import datetime
13
+
14
+ from humps import camelize
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class CompanionBase(BaseModel):
19
+ """Base companion model with common mutable fields.
20
+
21
+ Contains fields that can be modified after creation.
22
+ Excludes system_prompt (sensitive, requires explicit inclusion).
23
+ """
24
+
25
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
26
+
27
+ aidol_id: str | None = Field(default=None, description="AIdol group ID")
28
+ name: str = Field(..., description="Companion name")
29
+ biography: str | None = Field(default=None, description="Companion biography")
30
+ profile_picture_url: str | None = Field(
31
+ default=None, description="Profile picture URL"
32
+ )
33
+
34
+
35
+ class CompanionCreate(CompanionBase):
36
+ """Schema for creating a companion (no id).
37
+
38
+ Includes system_prompt for creation (excluded from response for security).
39
+ """
40
+
41
+ system_prompt: str | None = Field(
42
+ default=None, description="AI system prompt (not exposed in responses)"
43
+ )
44
+
45
+
46
+ class CompanionUpdate(BaseModel):
47
+ """Schema for updating a companion (all fields optional)."""
48
+
49
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
50
+
51
+ aidol_id: str | None = Field(default=None, description="AIdol group ID")
52
+ name: str | None = Field(default=None, description="Companion name")
53
+ biography: str | None = Field(default=None, description="Companion biography")
54
+ profile_picture_url: str | None = Field(
55
+ default=None, description="Profile picture URL"
56
+ )
57
+ system_prompt: str | None = Field(
58
+ default=None, description="AI system prompt (not exposed in responses)"
59
+ )
60
+
61
+
62
+ class Companion(CompanionBase):
63
+ """Companion response schema with id and timestamps.
64
+
65
+ Includes system_prompt for internal use (Service layer).
66
+ Use CompanionPublic for API responses to exclude sensitive fields.
67
+ """
68
+
69
+ model_config = ConfigDict(
70
+ populate_by_name=True, from_attributes=True, alias_generator=camelize
71
+ )
72
+
73
+ id: str = Field(..., description="Companion ID")
74
+ system_prompt: str | None = Field(
75
+ default=None, description="AI system prompt (sensitive, internal use only)"
76
+ )
77
+ created_at: datetime = Field(..., description="Creation timestamp")
78
+ updated_at: datetime = Field(..., description="Last update timestamp")
79
+
80
+
81
+ class CompanionPublic(CompanionBase):
82
+ """Public companion response schema without sensitive fields.
83
+
84
+ Excludes system_prompt for API responses.
85
+ """
86
+
87
+ model_config = ConfigDict(
88
+ populate_by_name=True, from_attributes=True, alias_generator=camelize
89
+ )
90
+
91
+ id: str = Field(..., description="Companion ID")
92
+ created_at: datetime = Field(..., description="Creation timestamp")
93
+ updated_at: datetime = Field(..., description="Last update timestamp")
@@ -0,0 +1,9 @@
1
+ """
2
+ AIdol services
3
+ """
4
+
5
+ from aidol.services.image_generation_service import ImageGenerationService
6
+
7
+ __all__ = [
8
+ "ImageGenerationService",
9
+ ]
@@ -0,0 +1,145 @@
1
+ """
2
+ Image generation service for AIdol
3
+
4
+ Generates images using OpenAI DALL-E 3 for AIdol emblems and Companion profiles.
5
+ """
6
+
7
+ import logging
8
+ from dataclasses import dataclass
9
+ from io import BytesIO
10
+ from typing import Literal
11
+
12
+ import httpx
13
+ import openai
14
+ import PIL.Image
15
+ from aioia_core.settings import OpenAIAPISettings
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class ImageGenerationResponse:
22
+ """Structured response from the Image Generation service"""
23
+
24
+ url: str
25
+ revised_prompt: str | None
26
+
27
+
28
+ class ImageGenerationService:
29
+ """Service for generating images using OpenAI DALL-E 3"""
30
+
31
+ def __init__(self, openai_settings: OpenAIAPISettings):
32
+ """
33
+ Initialize the Image Generation service with OpenAI settings.
34
+
35
+ Args:
36
+ openai_settings: OpenAI settings containing required API key
37
+ """
38
+ self.settings = openai_settings
39
+ self.client = openai.OpenAI(api_key=self.settings.api_key)
40
+
41
+ def generate_image(
42
+ self,
43
+ prompt: str,
44
+ size: Literal[
45
+ "1024x1024",
46
+ "1792x1024",
47
+ "1024x1792",
48
+ ] = "1024x1024",
49
+ quality: Literal["standard", "hd"] = "standard",
50
+ ) -> ImageGenerationResponse | None:
51
+ """
52
+ Generate an image from a text prompt using OpenAI DALL-E 3.
53
+
54
+ 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")
58
+
59
+ Returns:
60
+ An ImageGenerationResponse object containing the image URL and revised prompt,
61
+ or None if generation fails.
62
+
63
+ Raises:
64
+ openai.OpenAIError: If OpenAI API call fails.
65
+ """
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
86
+
87
+ revised_prompt = image_data.revised_prompt
88
+
89
+ logger.info("Successfully generated image: %s", url[:100])
90
+ return ImageGenerationResponse(
91
+ url=url,
92
+ revised_prompt=revised_prompt,
93
+ )
94
+
95
+ except openai.OpenAIError as e:
96
+ logger.error("OpenAI API error: %s", e)
97
+ raise
98
+
99
+ def _download_image(self, url: str) -> PIL.Image.Image:
100
+ """Download image from URL and return as PIL Image.
101
+
102
+ Args:
103
+ url: URL of the image to download.
104
+
105
+ Raises:
106
+ httpx.HTTPError: If download fails.
107
+ """
108
+ with httpx.Client(timeout=30.0) as client:
109
+ response = client.get(url)
110
+ response.raise_for_status()
111
+ return PIL.Image.open(BytesIO(response.content))
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.
135
+
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:
142
+ return None
143
+
144
+ logger.info("Downloading image from DALL-E temporary URL...")
145
+ return self._download_image(result.url)
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-aidol
3
+ Version: 0.1.0
4
+ Summary: Create and chat with your own AI idol group
5
+ License: Apache-2.0
6
+ Keywords: kpop,idol,aidol,ai-companion,chatbot,image-generation
7
+ Author: AIoIA, Inc.
8
+ Author-email: devops@aioia.ai
9
+ Requires-Python: >=3.10,<3.13
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: aioia-core (>=2.2.0,<3.0.0)
16
+ Requires-Dist: fastapi (>=0.115.12,<0.116.0)
17
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
18
+ Requires-Dist: openai (>=1.0.0)
19
+ Requires-Dist: pillow (>=10.0.0,<11.0.0)
20
+ Requires-Dist: psycopg2-binary (>=2.9.9,<3.0.0)
21
+ Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
22
+ Requires-Dist: pydantic[email] (>=2.5.3,<3.0.0)
23
+ Requires-Dist: pyhumps (>=3.8.0,<4.0.0)
24
+ Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
25
+ Requires-Dist: sqlalchemy (>=2.0.25,<3.0.0)
26
+ Requires-Dist: sqlalchemy-mixins (>=2.0.5,<3.0.0)
27
+ Description-Content-Type: text/markdown
28
+
29
+ # AIdol Backend
30
+
31
+ AI 아이돌 그룹 생성 및 채팅 Python 패키지
32
+
33
+ ## 주요 기능
34
+
35
+ - AI 아이돌 그룹/멤버 CRUD
36
+ - DALL-E 3 이미지 생성 (엠블럼, 프로필)
37
+ - 텍스트 채팅 (페르소나 기반 응답)
38
+ - Buppy 통합 Adapter 패턴
39
+
40
+ ## 설치
41
+
42
+ ```bash
43
+ cd backend
44
+ poetry install
45
+ poetry run uvicorn main:app --reload
46
+ ```
47
+
48
+ API 문서:
49
+ - Swagger UI: http://localhost:8000/docs
50
+ - ReDoc: http://localhost:8000/redoc
51
+
52
+ ## 사용법
53
+
54
+ ### FastAPI 통합
55
+
56
+ ```python
57
+ from aidol.api.aidol import AIdolRouter
58
+ from aidol.api.companion import CompanionRouter
59
+ from aidol.factories import AIdolRepositoryFactory, CompanionRepositoryFactory
60
+
61
+ # AIdol 라우터
62
+ aidol_router = AIdolRouter(
63
+ repository_factory=AIdolRepositoryFactory(),
64
+ openai_settings=openai_settings,
65
+ image_storage=image_storage,
66
+ )
67
+
68
+ # Companion 라우터
69
+ companion_router = CompanionRouter(
70
+ repository_factory=CompanionRepositoryFactory(),
71
+ )
72
+
73
+ app.include_router(aidol_router.router, prefix="/api/aidol")
74
+ app.include_router(companion_router.router, prefix="/api/aidol")
75
+ ```
76
+
77
+ ## 개발
78
+
79
+ ```bash
80
+ poetry install
81
+ make lint
82
+ make type-check
83
+ make unit-test
84
+ make format
85
+ ```
86
+
87
+ ## 환경 변수
88
+
89
+ ### 필수 (이미지 생성 시)
90
+
91
+ | 변수 | 설명 |
92
+ |------|------|
93
+ | `OPENAI_API_KEY` | OpenAI API 키 |
94
+
95
+ ### 선택
96
+
97
+ | 변수 | 기본값 | 설명 |
98
+ |------|--------|------|
99
+ | `AIDOL_OPENAI_MODEL` | `gpt-4o-mini` | 채팅 응답 LLM 모델 |
100
+
101
+ > **참고**: 데이터베이스, 모델 등 추가 설정은 기본값으로 로컬 개발 가능합니다.
102
+ > 변경이 필요한 경우 `aidol/` 내 Settings 클래스를 참고하세요.
103
+
104
+ ## 의존성
105
+
106
+ - aioia-core (공통 인프라)
107
+ - FastAPI, SQLAlchemy, Pydantic
108
+ - OpenAI (이미지 생성, 채팅)
109
+ - Pillow (이미지 처리)
110
+
111
+ ## 라이선스
112
+
113
+ Apache 2.0
114
+
@@ -0,0 +1,21 @@
1
+ aidol/__init__.py,sha256=iMN-aij1k6vElJt0sZQT4QYJjvoD27Q9vtZkQA0TY9c,141
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
6
+ aidol/models/__init__.py,sha256=nlmLezsKx1xGdJkKANkOj3QCZQVPniXkYwQBsj8Ockk,155
7
+ aidol/models/aidol.py,sha256=0t1OJiJBnmhpRCYczmsr1aYD8pMZqE3wKaUWIQiymKM,750
8
+ aidol/models/companion.py,sha256=F64BejN3ZWP4EyNDBiV0s63dofsAgMVZJi55JV0b5gk,954
9
+ aidol/protocols.py,sha256=Zc9LWUcdHJlerI649v3maXbgXARiXrBiRd2cxNLnSDE,2667
10
+ aidol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ aidol/repositories/__init__.py,sha256=hJbn-Pw0iGMMIYkUbEqLOy-gK_0LqCJLBba21pmZ0Pw,207
12
+ aidol/repositories/aidol.py,sha256=zwgXB823EZsrYYoJn2S05VD1bcuhSlcwnlVCJoD09p0,1744
13
+ aidol/repositories/companion.py,sha256=xzAHrdCexT4cXFPTg3yCmWoqou8edeZO0A22ozMieFg,1775
14
+ aidol/schemas/__init__.py,sha256=dakC_xvl4akjGRSOJdFnB1k6_RbNplGnPzEwI_7wwKA,661
15
+ aidol/schemas/aidol.py,sha256=aTS7hx4BGUvLDMgqo3CGVY_867GO0vnAeJhRFntGG1g,3834
16
+ aidol/schemas/companion.py,sha256=SCjvNETAZII256o0myCp6q1wzp999JIO99q8cKf8Yo0,3224
17
+ aidol/services/__init__.py,sha256=3vdT_CtUfeDWbsPn7Xnp41sajovcl2nCvpZ8KNFPHYM,144
18
+ aidol/services/image_generation_service.py,sha256=naqOxe5jsSTs9__Nj3gwBtOQPkfWvgqVV4z6XkA6DGM,4359
19
+ py_aidol-0.1.0.dist-info/METADATA,sha256=_c0y1xNNGOGHmKIeSfvCqWmYLPuGfDypFHg7jn8uVoM,2880
20
+ py_aidol-0.1.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
21
+ py_aidol-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any