py-aidol 0.4.0__py3-none-any.whl → 0.5.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/__init__.py CHANGED
@@ -3,11 +3,14 @@ AIdol API routers
3
3
  """
4
4
 
5
5
  from aidol.api.aidol import AIdolRouter, create_aidol_router
6
+ from aidol.api.chatroom import ChatroomRouter, create_chatroom_router
6
7
  from aidol.api.companion import CompanionRouter, create_companion_router
7
8
 
8
9
  __all__ = [
9
10
  "AIdolRouter",
11
+ "ChatroomRouter",
10
12
  "CompanionRouter",
11
13
  "create_aidol_router",
14
+ "create_chatroom_router",
12
15
  "create_companion_router",
13
16
  ]
aidol/api/chatroom.py ADDED
@@ -0,0 +1,325 @@
1
+ """
2
+ Chatroom API router
3
+
4
+ Implements BaseCrudRouter pattern for consistency with aioia-core patterns.
5
+ """
6
+
7
+ from aioia_core.auth import UserInfoProvider
8
+ from aioia_core.errors import ErrorResponse
9
+ from aioia_core.fastapi import BaseCrudRouter
10
+ from aioia_core.settings import JWTSettings, OpenAIAPISettings
11
+ from fastapi import APIRouter, Depends, HTTPException, status
12
+ from humps import camelize
13
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+ from sqlalchemy.orm import Session, sessionmaker
16
+
17
+ from aidol.context import MessageContextBuilder
18
+ from aidol.protocols import (
19
+ ChatroomRepositoryFactoryProtocol,
20
+ ChatroomRepositoryProtocol,
21
+ CompanionRepositoryFactoryProtocol,
22
+ )
23
+ from aidol.providers.llm import OpenAILLMProvider
24
+ from aidol.schemas import (
25
+ Chatroom,
26
+ ChatroomCreate,
27
+ ChatroomUpdate,
28
+ CompanionMessageCreate,
29
+ Message,
30
+ MessageCreate,
31
+ ModelSettings,
32
+ Persona,
33
+ SenderType,
34
+ )
35
+ from aidol.services import ResponseGenerationService
36
+ from aidol.settings import Settings
37
+
38
+ # Maximum number of messages to fetch for conversation history
39
+ DEFAULT_HISTORY_LIMIT = 200
40
+
41
+
42
+ class ChatroomSingleItemResponse(BaseModel):
43
+ """Single item response for chatroom."""
44
+
45
+ data: Chatroom
46
+
47
+
48
+ class GenerateResponse(BaseModel):
49
+ """Response schema for generate_response endpoint."""
50
+
51
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
52
+
53
+ message_id: str = Field(..., description="Message ID")
54
+ content: str = Field(..., description="AI response content")
55
+
56
+
57
+ def _to_langchain_messages(messages: list[Message]) -> list[BaseMessage]:
58
+ """
59
+ Convert Message schemas to LangChain BaseMessage format.
60
+
61
+ Args:
62
+ messages: List of Message from repository.
63
+
64
+ Returns:
65
+ List of LangChain BaseMessage (HumanMessage or AIMessage).
66
+ """
67
+ result: list[BaseMessage] = []
68
+ for msg in messages:
69
+ if msg.sender_type == SenderType.USER:
70
+ result.append(HumanMessage(content=msg.content))
71
+ else:
72
+ result.append(AIMessage(content=msg.content))
73
+ return result
74
+
75
+
76
+ class ChatroomRouter(
77
+ BaseCrudRouter[Chatroom, ChatroomCreate, ChatroomUpdate, ChatroomRepositoryProtocol]
78
+ ):
79
+ """
80
+ Chatroom router with custom message endpoints.
81
+
82
+ Extends BaseCrudRouter for consistent architecture pattern.
83
+ Disables default CRUD endpoints and provides custom message endpoints.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ model_settings: Settings,
89
+ openai_settings: OpenAIAPISettings,
90
+ companion_repository_factory: CompanionRepositoryFactoryProtocol,
91
+ **kwargs,
92
+ ):
93
+ super().__init__(**kwargs)
94
+ self.model_settings = model_settings
95
+ self.openai_settings = openai_settings
96
+ self.companion_repository_factory = companion_repository_factory
97
+
98
+ def _register_routes(self) -> None:
99
+ """Register routes (fancall pattern: public CRUD + message endpoints)"""
100
+ # Chatroom CRUD (public, no auth)
101
+ self._register_public_create_route()
102
+ self._register_public_get_route()
103
+
104
+ # Message endpoints (public)
105
+ self._register_get_messages_route()
106
+ self._register_send_message_route()
107
+ self._register_generate_response_route()
108
+
109
+ def _register_public_create_route(self) -> None:
110
+ """POST /{resource_name} - Create a chatroom (public, fancall pattern)"""
111
+
112
+ @self.router.post(
113
+ f"/{self.resource_name}",
114
+ response_model=ChatroomSingleItemResponse,
115
+ status_code=status.HTTP_201_CREATED,
116
+ summary="Create chatroom",
117
+ description="Create a new chatroom (public endpoint)",
118
+ )
119
+ async def create_chatroom(
120
+ request: ChatroomCreate,
121
+ repository: ChatroomRepositoryProtocol = Depends(self.get_repository_dep),
122
+ ):
123
+ """Create a new chatroom."""
124
+ created = repository.create(request)
125
+ return ChatroomSingleItemResponse(data=created)
126
+
127
+ def _register_public_get_route(self) -> None:
128
+ """GET /{resource_name}/{id} - Get a chatroom (public, fancall pattern)"""
129
+
130
+ @self.router.get(
131
+ f"/{self.resource_name}/{{item_id}}",
132
+ response_model=ChatroomSingleItemResponse,
133
+ status_code=status.HTTP_200_OK,
134
+ summary="Get chatroom",
135
+ description="Get chatroom by ID (public endpoint)",
136
+ responses={
137
+ 404: {"model": ErrorResponse, "description": "Chatroom not found"},
138
+ },
139
+ )
140
+ async def get_chatroom(
141
+ item_id: str,
142
+ repository: ChatroomRepositoryProtocol = Depends(self.get_repository_dep),
143
+ ):
144
+ """Get chatroom by ID."""
145
+ chatroom = self._get_item_or_404(repository, item_id)
146
+ return ChatroomSingleItemResponse(data=chatroom)
147
+
148
+ def _register_get_messages_route(self) -> None:
149
+ """GET /{resource_name}/{id}/messages - Get messages from a chatroom"""
150
+
151
+ @self.router.get(
152
+ f"/{self.resource_name}/{{item_id}}/messages",
153
+ response_model=list[Message],
154
+ status_code=status.HTTP_200_OK,
155
+ summary="Get messages",
156
+ description="Get messages from a chatroom",
157
+ )
158
+ async def get_messages(
159
+ item_id: str,
160
+ limit: int = 100,
161
+ offset: int = 0,
162
+ repository: ChatroomRepositoryProtocol = Depends(self.get_repository_dep),
163
+ ):
164
+ """Get messages from a chatroom."""
165
+ return repository.get_messages_by_chatroom_id(
166
+ chatroom_id=item_id,
167
+ limit=limit,
168
+ offset=offset,
169
+ )
170
+
171
+ def _register_send_message_route(self) -> None:
172
+ """POST /{resource_name}/{id}/messages - Send a message to a chatroom"""
173
+
174
+ @self.router.post(
175
+ f"/{self.resource_name}/{{item_id}}/messages",
176
+ response_model=Message,
177
+ status_code=status.HTTP_201_CREATED,
178
+ summary="Send message",
179
+ description="Send a message to a chatroom",
180
+ )
181
+ async def send_message(
182
+ item_id: str,
183
+ request: MessageCreate,
184
+ repository: ChatroomRepositoryProtocol = Depends(self.get_repository_dep),
185
+ ):
186
+ """Send a message to a chatroom."""
187
+ # Verify chatroom exists
188
+ self._get_item_or_404(repository, item_id)
189
+
190
+ # Enforce sender_type as USER to prevent spoofing
191
+ request.sender_type = SenderType.USER
192
+
193
+ # Pass MessageCreate directly (aioia-core pattern)
194
+ return repository.add_message_to_chatroom(
195
+ chatroom_id=item_id,
196
+ message=request,
197
+ )
198
+
199
+ def _register_generate_response_route(self) -> None:
200
+ """POST /{resource_name}/{id}/companions/{companion_id}/response - Generate AI response"""
201
+
202
+ @self.router.post(
203
+ f"/{self.resource_name}/{{item_id}}/companions/{{companion_id}}/response",
204
+ response_model=GenerateResponse,
205
+ status_code=status.HTTP_201_CREATED,
206
+ summary="Generate AI response",
207
+ description="Generate AI response for a chatroom with a specific companion",
208
+ )
209
+ async def generate_response(
210
+ item_id: str,
211
+ companion_id: str,
212
+ db_session: Session = Depends(self.get_db_dep),
213
+ repository: ChatroomRepositoryProtocol = Depends(self.get_repository_dep),
214
+ ):
215
+ """Generate AI response for a chatroom."""
216
+ # Verify chatroom exists
217
+ self._get_item_or_404(repository, item_id)
218
+
219
+ # Get companion repository with same db session (Buppy pattern)
220
+ companion_repository = self.companion_repository_factory.create_repository(
221
+ db_session
222
+ )
223
+
224
+ # Get companion by ID
225
+ companion = companion_repository.get_by_id(companion_id)
226
+ if companion is None:
227
+ raise HTTPException(
228
+ status_code=status.HTTP_404_NOT_FOUND,
229
+ detail=f"Companion with id {companion_id} not found",
230
+ )
231
+
232
+ # Get conversation history
233
+ messages = repository.get_messages_by_chatroom_id(
234
+ chatroom_id=item_id,
235
+ limit=DEFAULT_HISTORY_LIMIT,
236
+ offset=0,
237
+ )
238
+
239
+ # Convert to LangChain BaseMessage format
240
+ # Reverse: DB returns newest-first, LLM needs chronological order
241
+ langchain_messages = _to_langchain_messages(list(reversed(messages)))
242
+
243
+ # Create persona from companion (KST fixed for MVP)
244
+ persona = Persona(
245
+ name=companion.name,
246
+ system_prompt=companion.system_prompt,
247
+ timezone_name="Asia/Seoul",
248
+ )
249
+ provider = OpenAILLMProvider(settings=self.openai_settings)
250
+ model_settings = ModelSettings(chat_model=self.model_settings.openai_model)
251
+
252
+ # Generate text response using ResponseGenerationService
253
+ context = (
254
+ MessageContextBuilder(provider, persona)
255
+ .with_persona()
256
+ .with_real_time_context()
257
+ .with_current_conversation(langchain_messages)
258
+ .build()
259
+ )
260
+ service = ResponseGenerationService(provider, model_settings)
261
+ response_text = service.generate_response(context)
262
+
263
+ # Save companion message (repository handles commit)
264
+ # Use CompanionMessageCreate (no id) - aioia-core pattern
265
+ companion_message = repository.add_message_to_chatroom(
266
+ chatroom_id=item_id,
267
+ message=CompanionMessageCreate(
268
+ content=response_text,
269
+ companion_id=companion_id,
270
+ ),
271
+ )
272
+
273
+ return GenerateResponse(
274
+ message_id=companion_message.id,
275
+ content=response_text,
276
+ )
277
+
278
+
279
+ def create_chatroom_router(
280
+ openai_settings: OpenAIAPISettings,
281
+ model_settings: Settings,
282
+ companion_repository_factory: CompanionRepositoryFactoryProtocol,
283
+ db_session_factory: sessionmaker,
284
+ repository_factory: ChatroomRepositoryFactoryProtocol,
285
+ jwt_settings: JWTSettings | None = None,
286
+ user_info_provider: UserInfoProvider | None = None,
287
+ resource_name: str = "chatrooms",
288
+ tags: list[str] | None = None,
289
+ ) -> APIRouter:
290
+ """
291
+ Create chatroom router with dependency injection.
292
+
293
+ Args:
294
+ openai_settings: OpenAI API settings for LLM provider
295
+ model_settings: Environment settings for aidol
296
+ companion_repository_factory: Factory for CompanionRepository.
297
+ For standalone: Use aidol.factories.CompanionRepositoryFactory.
298
+ For platform integration: Use CompanionRepositoryFactoryAdapter.
299
+ db_session_factory: Database session factory
300
+ repository_factory: Factory implementing ChatroomRepositoryFactoryProtocol.
301
+ For standalone: Use aidol.factories.ChatroomRepositoryFactory.
302
+ For platform integration: Use ChatroomRepositoryFactoryAdapter.
303
+ jwt_settings: Optional JWT settings for authentication
304
+ user_info_provider: Optional user info provider
305
+ resource_name: Resource name for routes (default: "chatrooms")
306
+ tags: Optional OpenAPI tags
307
+
308
+ Returns:
309
+ FastAPI APIRouter instance
310
+ """
311
+ router = ChatroomRouter(
312
+ model_settings=model_settings,
313
+ openai_settings=openai_settings,
314
+ companion_repository_factory=companion_repository_factory,
315
+ model_class=Chatroom,
316
+ create_schema=ChatroomCreate,
317
+ update_schema=ChatroomUpdate,
318
+ db_session_factory=db_session_factory,
319
+ repository_factory=repository_factory,
320
+ user_info_provider=user_info_provider,
321
+ jwt_secret_key=jwt_settings.secret_key if jwt_settings else None,
322
+ resource_name=resource_name,
323
+ tags=tags or ["Aidol"],
324
+ )
325
+ return router.get_router()
aidol/api/companion.py CHANGED
@@ -31,6 +31,12 @@ from aidol.services.companion_service import to_companion_public
31
31
  from aidol.settings import GoogleGenAISettings
32
32
 
33
33
 
34
+ class CompanionSingleItemResponse(BaseModel):
35
+ """Single item response for Companion (public)."""
36
+
37
+ data: CompanionPublic
38
+
39
+
34
40
  class CompanionPaginatedResponse(BaseModel):
35
41
  """Paginated response for Companion (public)."""
36
42
 
@@ -133,7 +139,7 @@ class CompanionRouter(
133
139
 
134
140
  @self.router.post(
135
141
  f"/{self.resource_name}",
136
- response_model=CompanionPublic,
142
+ response_model=CompanionSingleItemResponse,
137
143
  status_code=status.HTTP_201_CREATED,
138
144
  summary="Create Companion",
139
145
  description="Create a new Companion. Returns the created companion data.",
@@ -149,17 +155,17 @@ class CompanionRouter(
149
155
 
150
156
  created = repository.create(sanitized_request)
151
157
  # Return created companion as public schema
152
- return to_companion_public(created)
158
+ return CompanionSingleItemResponse(data=to_companion_public(created))
153
159
 
154
160
  def _register_public_get_route(self) -> None:
155
161
  """GET /{resource_name}/{id} - Get a Companion (public)"""
156
162
 
157
163
  @self.router.get(
158
164
  f"/{self.resource_name}/{{item_id}}",
159
- response_model=CompanionPublic,
165
+ response_model=CompanionSingleItemResponse,
160
166
  status_code=status.HTTP_200_OK,
161
167
  summary="Get Companion",
162
- description="Get Companion by ID (public endpoint). Returns companion data directly.",
168
+ description="Get Companion by ID (public endpoint).",
163
169
  responses={
164
170
  404: {"model": ErrorResponse, "description": "Companion not found"},
165
171
  },
@@ -171,17 +177,17 @@ class CompanionRouter(
171
177
  """Get Companion by ID."""
172
178
  companion = self._get_item_or_404(repository, item_id)
173
179
  # Return companion as public schema
174
- return to_companion_public(companion)
180
+ return CompanionSingleItemResponse(data=to_companion_public(companion))
175
181
 
176
182
  def _register_public_update_route(self) -> None:
177
183
  """PATCH /{resource_name}/{id} - Update Companion (public)"""
178
184
 
179
185
  @self.router.patch(
180
186
  f"/{self.resource_name}/{{item_id}}",
181
- response_model=CompanionPublic,
187
+ response_model=CompanionSingleItemResponse,
182
188
  status_code=status.HTTP_200_OK,
183
189
  summary="Update Companion",
184
- description="Update Companion by ID (public endpoint). Returns updated companion data directly.",
190
+ description="Update Companion by ID (public endpoint).",
185
191
  responses={
186
192
  404: {"model": ErrorResponse, "description": "Companion not found"},
187
193
  },
@@ -206,14 +212,14 @@ class CompanionRouter(
206
212
  )
207
213
 
208
214
  # Return updated companion as public schema
209
- return to_companion_public(updated)
215
+ return CompanionSingleItemResponse(data=to_companion_public(updated))
210
216
 
211
217
  def _register_public_delete_route(self) -> None:
212
218
  """DELETE /{resource_name}/{id} - Remove Companion from Group (public)"""
213
219
 
214
220
  @self.router.delete(
215
221
  f"/{self.resource_name}/{{item_id}}",
216
- response_model=CompanionPublic,
222
+ response_model=CompanionSingleItemResponse,
217
223
  status_code=status.HTTP_200_OK,
218
224
  summary="Remove Companion from Group",
219
225
  description="Remove Companion from AIdol group (unassign aidol_id). Does not delete the record.",
@@ -241,7 +247,7 @@ class CompanionRouter(
241
247
  )
242
248
 
243
249
  # Return updated companion as public schema
244
- return to_companion_public(updated)
250
+ return CompanionSingleItemResponse(data=to_companion_public(updated))
245
251
 
246
252
 
247
253
  def create_companion_router(
@@ -0,0 +1,26 @@
1
+ """Context engineering for AIdol.
2
+
3
+ This module provides context composition utilities for LLM calls.
4
+ Integrators can extend these base implementations for platform-specific features.
5
+
6
+ Components:
7
+ - MessageContextBuilder: Builder pattern for assembling LLM context
8
+ """
9
+
10
+ from aidol.context.builder import (
11
+ MessageContextBuilder,
12
+ deduplicate_consecutive_same_role_messages,
13
+ ensure_first_user_message,
14
+ format_datetime_korean,
15
+ format_utc_offset,
16
+ verify_system_messages_at_front,
17
+ )
18
+
19
+ __all__ = [
20
+ "MessageContextBuilder",
21
+ "deduplicate_consecutive_same_role_messages",
22
+ "ensure_first_user_message",
23
+ "format_datetime_korean",
24
+ "format_utc_offset",
25
+ "verify_system_messages_at_front",
26
+ ]