fancall 0.1.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.
fancall-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: fancall
3
+ Version: 0.1.0
4
+ Summary: AI-powered video call with virtual companions
5
+ License: Apache-2.0
6
+ Keywords: kpop,idol,fancall,livekit,ai-companion,video-call
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.0.0,<3.0.0)
16
+ Requires-Dist: alembic (>=1.13.0,<2.0.0)
17
+ Requires-Dist: fastapi (>=0.115.0,<0.116.0)
18
+ Requires-Dist: httpx (>=0.27.0,<0.28.0)
19
+ Requires-Dist: livekit-agents[fishaudio,hedra,openai] (>=1.0.0,<2.0.0)
20
+ Requires-Dist: livekit-api (>=1.0.0,<2.0.0)
21
+ Requires-Dist: openai (>=1.0.0)
22
+ Requires-Dist: pillow (>=10.0.0,<11.0.0)
23
+ Requires-Dist: psycopg2-binary (>=2.9.0,<3.0.0)
24
+ Requires-Dist: pydantic (>=2.5.0,<3.0.0)
25
+ Requires-Dist: pydantic-settings (>=2.1.0,<3.0.0)
26
+ Requires-Dist: pyhumps (>=3.8.0,<4.0.0)
27
+ Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
28
+ Requires-Dist: sqlalchemy (>=2.0.0,<3.0.0)
29
+ Requires-Dist: sqlalchemy-mixins (>=2.0.0,<3.0.0)
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Fancall Backend
33
+
34
+ AI 아이돌과 실시간 영상 통화 Python 패키지
35
+
36
+ ## 주요 기능
37
+
38
+ - LiveKit 기반 실시간 음성/영상 통화
39
+ - Fish Audio TTS 음성 합성
40
+ - Hedra 아바타 지원 (선택)
41
+ - 동적 설정 (voice_id, avatar_id, system_prompt)
42
+
43
+ ## 설치
44
+
45
+ ```bash
46
+ cd backend
47
+ poetry install
48
+ poetry run uvicorn main:app --reload
49
+ ```
50
+
51
+ API 문서:
52
+ - Swagger UI: http://localhost:8000/docs
53
+ - ReDoc: http://localhost:8000/redoc
54
+
55
+ ## LiveKit 설정
56
+
57
+ ### 서버
58
+
59
+ ```bash
60
+ brew install livekit
61
+ livekit-server --dev
62
+ ```
63
+
64
+ 서버: `ws://localhost:7880` (API Key: `devkey`, Secret: `secret`)
65
+
66
+ ### Agent
67
+
68
+ ```bash
69
+ cd backend
70
+ export OPENAI_API_KEY=sk-...
71
+ export FISH_API_KEY=...
72
+
73
+ # 개발 모드
74
+ python -m fancall.agent.worker dev
75
+
76
+ # 프로덕션 모드
77
+ python -m fancall.agent.worker start
78
+
79
+ # 특정 룸 연결
80
+ python -m fancall.agent.worker connect --room <room-name>
81
+ ```
82
+
83
+ ## 사용법
84
+
85
+ ### FastAPI 통합
86
+
87
+ ```python
88
+ from fancall.api.router import create_fancall_router
89
+ from fancall.factories import LiveRoomRepositoryFactory
90
+ from fancall.settings import LiveKitSettings
91
+
92
+ router = create_fancall_router(
93
+ livekit_settings=LiveKitSettings(),
94
+ jwt_settings=jwt_settings,
95
+ db_session_factory=db_session_factory,
96
+ repository_factory=LiveRoomRepositoryFactory(db_session_factory),
97
+ )
98
+ app.include_router(router, prefix="/api")
99
+ ```
100
+
101
+ ## 개발
102
+
103
+ ```bash
104
+ poetry install
105
+ make lint
106
+ make type-check
107
+ make unit-test
108
+ make format
109
+ ```
110
+
111
+ ## 환경 변수
112
+
113
+ | 변수명 | 필수 | 설명 |
114
+ |--------|------|------|
115
+ | `LIVEKIT_URL` | O | LiveKit 서버 URL |
116
+ | `LIVEKIT_API_KEY` | O | LiveKit API 키 |
117
+ | `LIVEKIT_API_SECRET` | O | LiveKit API 시크릿 |
118
+ | `OPENAI_API_KEY` | O | OpenAI API 키 |
119
+ | `FISH_API_KEY` | O | Fish Audio API 키 |
120
+ | `DATABASE_URL` | O | PostgreSQL/SQLite URL |
121
+
122
+ ## 의존성
123
+
124
+ - aioia-core (공통 인프라)
125
+ - FastAPI, SQLAlchemy, Pydantic
126
+ - livekit-api, livekit-agents
127
+
128
+ ## 라이선스
129
+
130
+ Apache 2.0
131
+
@@ -0,0 +1,99 @@
1
+ # Fancall Backend
2
+
3
+ AI 아이돌과 실시간 영상 통화 Python 패키지
4
+
5
+ ## 주요 기능
6
+
7
+ - LiveKit 기반 실시간 음성/영상 통화
8
+ - Fish Audio TTS 음성 합성
9
+ - Hedra 아바타 지원 (선택)
10
+ - 동적 설정 (voice_id, avatar_id, system_prompt)
11
+
12
+ ## 설치
13
+
14
+ ```bash
15
+ cd backend
16
+ poetry install
17
+ poetry run uvicorn main:app --reload
18
+ ```
19
+
20
+ API 문서:
21
+ - Swagger UI: http://localhost:8000/docs
22
+ - ReDoc: http://localhost:8000/redoc
23
+
24
+ ## LiveKit 설정
25
+
26
+ ### 서버
27
+
28
+ ```bash
29
+ brew install livekit
30
+ livekit-server --dev
31
+ ```
32
+
33
+ 서버: `ws://localhost:7880` (API Key: `devkey`, Secret: `secret`)
34
+
35
+ ### Agent
36
+
37
+ ```bash
38
+ cd backend
39
+ export OPENAI_API_KEY=sk-...
40
+ export FISH_API_KEY=...
41
+
42
+ # 개발 모드
43
+ python -m fancall.agent.worker dev
44
+
45
+ # 프로덕션 모드
46
+ python -m fancall.agent.worker start
47
+
48
+ # 특정 룸 연결
49
+ python -m fancall.agent.worker connect --room <room-name>
50
+ ```
51
+
52
+ ## 사용법
53
+
54
+ ### FastAPI 통합
55
+
56
+ ```python
57
+ from fancall.api.router import create_fancall_router
58
+ from fancall.factories import LiveRoomRepositoryFactory
59
+ from fancall.settings import LiveKitSettings
60
+
61
+ router = create_fancall_router(
62
+ livekit_settings=LiveKitSettings(),
63
+ jwt_settings=jwt_settings,
64
+ db_session_factory=db_session_factory,
65
+ repository_factory=LiveRoomRepositoryFactory(db_session_factory),
66
+ )
67
+ app.include_router(router, prefix="/api")
68
+ ```
69
+
70
+ ## 개발
71
+
72
+ ```bash
73
+ poetry install
74
+ make lint
75
+ make type-check
76
+ make unit-test
77
+ make format
78
+ ```
79
+
80
+ ## 환경 변수
81
+
82
+ | 변수명 | 필수 | 설명 |
83
+ |--------|------|------|
84
+ | `LIVEKIT_URL` | O | LiveKit 서버 URL |
85
+ | `LIVEKIT_API_KEY` | O | LiveKit API 키 |
86
+ | `LIVEKIT_API_SECRET` | O | LiveKit API 시크릿 |
87
+ | `OPENAI_API_KEY` | O | OpenAI API 키 |
88
+ | `FISH_API_KEY` | O | Fish Audio API 키 |
89
+ | `DATABASE_URL` | O | PostgreSQL/SQLite URL |
90
+
91
+ ## 의존성
92
+
93
+ - aioia-core (공통 인프라)
94
+ - FastAPI, SQLAlchemy, Pydantic
95
+ - livekit-api, livekit-agents
96
+
97
+ ## 라이선스
98
+
99
+ Apache 2.0
@@ -0,0 +1,3 @@
1
+ """Fancall - LiveKit-based video call module"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Fancall LiveKit agent worker"""
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ LiveKit Agent Worker with Fish Audio TTS Integration
4
+
5
+ This file runs a real-time AI agent using Fish Audio TTS and, optionally,
6
+ a Hedra avatar.
7
+ """
8
+
9
+ import asyncio
10
+ import base64
11
+ import io
12
+ import logging
13
+ import os
14
+ import re
15
+ from functools import partial
16
+
17
+ import httpx
18
+ from livekit import agents
19
+ from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
20
+ from livekit.agents.types import NOT_GIVEN
21
+ from livekit.plugins import fishaudio, hedra, openai
22
+ from PIL import Image
23
+ from pydantic import ValidationError
24
+
25
+ from fancall.persona import DEFAULT_PERSONA, Persona
26
+ from fancall.prompts import compose_instructions
27
+ from fancall.schemas import AgentDispatchRequest
28
+ from fancall.settings import LiveKitSettings
29
+
30
+ # Basic logging configuration
31
+ logging.basicConfig(
32
+ level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
33
+ )
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Constants
37
+ DEFAULT_SYSTEM_PROMPT = "You are a friendly and helpful AI companion."
38
+
39
+
40
+ class CompanionAgent(Agent):
41
+ """
42
+ A voice-enabled AI companion agent.
43
+ Processes user input and generates conversational responses using LLM and TTS.
44
+ """
45
+
46
+ def __init__(self, instructions: str = DEFAULT_SYSTEM_PROMPT, **kwargs):
47
+ super().__init__(instructions=instructions, **kwargs)
48
+
49
+ async def on_enter(self) -> None:
50
+ """Called when agent becomes active in the session."""
51
+ # Initial greeting could be generated here if needed
52
+
53
+
54
+ async def entrypoint( # pylint: disable=too-many-locals
55
+ ctx: JobContext,
56
+ default_persona: Persona,
57
+ settings: LiveKitSettings, # pylint: disable=unused-argument
58
+ ) -> None:
59
+ """
60
+ Agent entrypoint. Initializes AgentSession for text-to-speech tasks.
61
+
62
+ Args:
63
+ ctx: LiveKit job context
64
+ default_persona: Default persona for fallback configuration
65
+ settings: LiveKit settings with API credentials (reserved for future use)
66
+ """
67
+ logger.info("Agent entrypoint called for room: %s", ctx.room.name)
68
+
69
+ # Check required environment variables before connecting
70
+ required_env_vars = [
71
+ "FISH_API_KEY",
72
+ "OPENAI_API_KEY",
73
+ "LIVEKIT_URL",
74
+ "LIVEKIT_API_KEY",
75
+ "LIVEKIT_API_SECRET",
76
+ ]
77
+ missing_vars = [v for v in required_env_vars if not os.getenv(v)]
78
+ if missing_vars:
79
+ logger.error(
80
+ "Missing required environment variables: %s", ", ".join(missing_vars)
81
+ )
82
+ raise RuntimeError(f"Missing environment variables: {', '.join(missing_vars)}")
83
+
84
+ await ctx.connect()
85
+ logger.info("Connected to LiveKit room: %s", ctx.room.name)
86
+
87
+ # Parse job metadata to get dynamic configuration
88
+ metadata = AgentDispatchRequest()
89
+ if ctx.job.metadata:
90
+ try:
91
+ metadata = AgentDispatchRequest.model_validate_json(ctx.job.metadata)
92
+ except ValidationError as e:
93
+ logger.error("Failed to parse job metadata: %s", e)
94
+ ctx.shutdown(reason="Invalid job metadata format")
95
+ return
96
+
97
+ # Merge metadata with default_persona (metadata takes precedence)
98
+ avatar_id = metadata.avatar_id or default_persona.avatar_id
99
+ profile_picture_url = (
100
+ metadata.profile_picture_url or default_persona.profile_picture_url
101
+ )
102
+ voice_id = metadata.voice_id or default_persona.voice_id
103
+ system_prompt = metadata.system_prompt or default_persona.system_prompt
104
+
105
+ logger.info(
106
+ "Agent configuration: avatar_id=%s, voice_id=%s",
107
+ avatar_id,
108
+ voice_id,
109
+ )
110
+
111
+ # Initialize components
112
+ llm = openai.LLM(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"))
113
+ if voice_id:
114
+ logger.info("Using Fish Audio voice_id: %s", voice_id)
115
+
116
+ tts = fishaudio.TTS(
117
+ api_key=os.getenv("FISH_API_KEY") or NOT_GIVEN,
118
+ reference_id=voice_id or NOT_GIVEN,
119
+ )
120
+
121
+ session: AgentSession = AgentSession(llm=llm, tts=tts)
122
+
123
+ # Initialize Hedra avatar if enabled
124
+ hedra_enabled = os.getenv("HEDRA_ENABLED", "false").lower() == "true"
125
+ avatar_session = None
126
+
127
+ if hedra_enabled:
128
+ # Get Hedra API key
129
+ hedra_api_key = os.getenv("HEDRA_API_KEY")
130
+ if not hedra_api_key:
131
+ logger.error("HEDRA_API_KEY is required when HEDRA_ENABLED=true")
132
+ ctx.shutdown(reason="HEDRA_API_KEY is required when HEDRA_ENABLED=true")
133
+ return
134
+
135
+ if avatar_id:
136
+ logger.info("Hedra avatar is enabled with avatar_id: %s", avatar_id)
137
+ avatar_session = hedra.AvatarSession(
138
+ avatar_id=avatar_id,
139
+ api_key=hedra_api_key,
140
+ )
141
+ elif profile_picture_url:
142
+ # At this point, profile_picture_url is guaranteed to be str
143
+ url = profile_picture_url
144
+ logger.info(
145
+ "Hedra avatar is enabled with profile_picture_url: %s",
146
+ url[:100] + "..." if len(url) > 100 else url,
147
+ )
148
+
149
+ # Handle both data URLs and HTTP(S) URLs
150
+ if url.startswith("data:"):
151
+ # Extract base64 data from data URL
152
+ match = re.match(r"data:image/[^;]+;base64,(.+)", url)
153
+ if match:
154
+ base64_data = match.group(1)
155
+ image_bytes = base64.b64decode(base64_data)
156
+ avatar_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
157
+ else:
158
+ logger.error("Invalid data URL format")
159
+ ctx.shutdown(
160
+ reason="Invalid data URL format for profile_picture_url"
161
+ )
162
+ return
163
+ else:
164
+ # Regular HTTP(S) URL
165
+ async with httpx.AsyncClient() as client:
166
+ response = await client.get(url)
167
+ response.raise_for_status()
168
+ avatar_image = Image.open(io.BytesIO(response.content)).convert(
169
+ "RGB"
170
+ )
171
+
172
+ avatar_session = hedra.AvatarSession(
173
+ avatar_image=avatar_image,
174
+ api_key=hedra_api_key,
175
+ )
176
+ else:
177
+ logger.error(
178
+ "No avatar_id or profile_picture_url found. Cannot initialize Hedra avatar."
179
+ )
180
+ ctx.shutdown(
181
+ reason="No avatar_id or profile_picture_url found for Hedra avatar"
182
+ )
183
+ return
184
+
185
+ await avatar_session.start(agent_session=session, room=ctx.room)
186
+
187
+ # Compose instructions from merged system prompt (Context Composer pattern)
188
+ instructions = compose_instructions(system_prompt, include_role_playing=True)
189
+ logger.info(
190
+ "Using instructions: %s",
191
+ instructions[:100] + "..." if len(instructions) > 100 else instructions,
192
+ )
193
+
194
+ agent = CompanionAgent(instructions=instructions)
195
+ await session.start(agent=agent, room=ctx.room)
196
+ logger.info("Agent session started.")
197
+
198
+ # Free trial: auto-shutdown after 75 seconds
199
+ try:
200
+ await asyncio.sleep(75)
201
+ logger.info("Free trial session limit (75s) reached. Shutting down.")
202
+ ctx.shutdown(reason="Free trial session limit reached (75 seconds)")
203
+ except asyncio.CancelledError:
204
+ logger.info("Agent session cancelled before trial limit.")
205
+
206
+
207
+ def create_worker_options(
208
+ default_persona: Persona, settings: LiveKitSettings
209
+ ) -> WorkerOptions:
210
+ """
211
+ Create WorkerOptions for the agent with dependency injection.
212
+
213
+ Uses functools.partial for pickle-safe DI (required by multiprocessing).
214
+
215
+ Args:
216
+ default_persona: Default persona for agent configuration
217
+ settings: LiveKit settings with API credentials
218
+
219
+ Returns:
220
+ WorkerOptions configured with the agent entrypoint
221
+ """
222
+ return WorkerOptions(
223
+ entrypoint_fnc=partial(
224
+ entrypoint,
225
+ default_persona=default_persona,
226
+ settings=settings,
227
+ ),
228
+ worker_type=agents.WorkerType.ROOM,
229
+ agent_name=settings.agent_name,
230
+ )
231
+
232
+
233
+ def main() -> None:
234
+ """Main function to run the agent worker."""
235
+ settings = LiveKitSettings()
236
+ cli.run_app(create_worker_options(DEFAULT_PERSONA, settings))
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()
@@ -0,0 +1 @@
1
+ """Fancall API routers"""
@@ -0,0 +1,253 @@
1
+ """
2
+ Fancall API router
3
+ """
4
+
5
+ import uuid
6
+
7
+ from aioia_core.auth import UserInfoProvider
8
+ from aioia_core.errors import (
9
+ INTERNAL_SERVER_ERROR,
10
+ RESOURCE_CREATION_FAILED,
11
+ RESOURCE_NOT_FOUND,
12
+ UNAUTHORIZED,
13
+ ErrorResponse,
14
+ )
15
+ from aioia_core.fastapi import BaseCrudRouter
16
+ from aioia_core.settings import JWTSettings
17
+ from fastapi import APIRouter, Depends, HTTPException, status
18
+ from pydantic import BaseModel
19
+ from sqlalchemy.orm import sessionmaker
20
+
21
+ from fancall.factories import LiveRoomRepositoryFactory
22
+ from fancall.repositories.live_room_repository import DatabaseLiveRoomRepository
23
+ from fancall.schemas import (
24
+ AgentDispatchRequest,
25
+ DispatchResponse,
26
+ LiveRoom,
27
+ LiveRoomCreate,
28
+ LiveRoomUpdate,
29
+ TokenResponse,
30
+ )
31
+ from fancall.services.livekit_service import LiveKitService
32
+ from fancall.settings import LiveKitSettings
33
+
34
+
35
+ class LiveRoomSingleItemResponse(BaseModel):
36
+ """Single item response for live room"""
37
+
38
+ data: LiveRoom
39
+
40
+
41
+ class LiveRoomRouter(
42
+ BaseCrudRouter[LiveRoom, LiveRoomCreate, LiveRoomUpdate, DatabaseLiveRoomRepository]
43
+ ):
44
+ """Router for LiveRoom with LiveKit integration"""
45
+
46
+ def __init__(
47
+ self,
48
+ livekit_settings: LiveKitSettings,
49
+ **kwargs,
50
+ ):
51
+ super().__init__(**kwargs)
52
+ self.livekit_settings = livekit_settings
53
+
54
+ def _register_routes(self) -> None:
55
+ """Register routes for LiveRoom CRUD and LiveKit integration"""
56
+ self._register_public_create_route() # POST /live-rooms (public)
57
+ self._register_token_route() # POST /live-rooms/{id}/token
58
+ self._register_dispatch_route() # POST /live-rooms/{id}/dispatch
59
+
60
+ def _register_public_create_route(self) -> None:
61
+ """POST /live-rooms - Public endpoint for creating live rooms"""
62
+
63
+ @self.router.post(
64
+ f"/{self.resource_name}",
65
+ response_model=LiveRoomSingleItemResponse,
66
+ status_code=status.HTTP_201_CREATED,
67
+ summary="Create Live Room",
68
+ description="Create a new live room. Available to all users (authenticated or anonymous).",
69
+ responses={
70
+ 201: {"description": "Live room created successfully"},
71
+ 500: {"model": ErrorResponse, "description": "Internal server error"},
72
+ },
73
+ )
74
+ async def create_room(
75
+ repository: DatabaseLiveRoomRepository = Depends(self.get_repository_dep),
76
+ ):
77
+ created_room = repository.create(LiveRoomCreate())
78
+ if not created_room:
79
+ raise HTTPException(
80
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
81
+ detail={
82
+ "detail": "Failed to create live room",
83
+ "code": RESOURCE_CREATION_FAILED,
84
+ },
85
+ )
86
+ return LiveRoomSingleItemResponse(data=created_room)
87
+
88
+ def _register_token_route(self) -> None:
89
+ """POST /live-rooms/{id}/token - Generate user access token"""
90
+
91
+ @self.router.post(
92
+ f"/{self.resource_name}/{{room_id}}/token",
93
+ response_model=TokenResponse,
94
+ summary="Generate User Access Token",
95
+ responses={
96
+ 401: {"model": ErrorResponse},
97
+ 404: {"model": ErrorResponse},
98
+ 500: {"model": ErrorResponse},
99
+ },
100
+ )
101
+ async def generate_token(
102
+ room_id: str,
103
+ user_id: str | None = Depends(self.get_current_user_id_dep),
104
+ db_session=Depends(self.get_db_dep),
105
+ repository: DatabaseLiveRoomRepository = Depends(self.get_repository_dep),
106
+ ):
107
+ # Verify room exists
108
+ live_room = repository.get_by_id(room_id)
109
+ if not live_room:
110
+ raise HTTPException(
111
+ status_code=status.HTTP_404_NOT_FOUND,
112
+ detail={
113
+ "detail": f"Live room not found: {room_id}",
114
+ "code": RESOURCE_NOT_FOUND,
115
+ },
116
+ )
117
+
118
+ # Determine identity and display name based on authentication
119
+ identity: str
120
+ display_name: str
121
+
122
+ if user_id:
123
+ # Authenticated user (provider guaranteed by startup validation)
124
+ identity = user_id
125
+
126
+ # Get user info (follows aioia-core pattern)
127
+ assert (
128
+ self.user_info_provider
129
+ ), "user_info_provider must be set (startup validation failed)"
130
+ user_info = self.user_info_provider.get_user_info(user_id, db_session)
131
+ if user_info is None:
132
+ # User not found in database - authentication/data inconsistency
133
+ raise HTTPException(
134
+ status_code=status.HTTP_401_UNAUTHORIZED,
135
+ detail={
136
+ "detail": "User not found",
137
+ "code": UNAUTHORIZED,
138
+ },
139
+ )
140
+
141
+ # Use nickname if available, otherwise username, otherwise user_id
142
+ display_name = user_info.nickname or user_info.username or user_id
143
+ else:
144
+ # Anonymous user: generate temporary identity
145
+ identity = f"guest-{uuid.uuid4()}"
146
+ display_name = "Guest"
147
+
148
+ # Generate token
149
+ livekit_service = LiveKitService(self.livekit_settings)
150
+ token_response = livekit_service.generate_token(
151
+ user_id=identity,
152
+ name=display_name,
153
+ room_name=room_id,
154
+ )
155
+ if not token_response:
156
+ raise HTTPException(
157
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
158
+ detail={
159
+ "detail": "Failed to generate token",
160
+ "code": INTERNAL_SERVER_ERROR,
161
+ },
162
+ )
163
+
164
+ return TokenResponse(
165
+ token=token_response.token,
166
+ room_name=token_response.room_name,
167
+ identity=token_response.identity,
168
+ )
169
+
170
+ def _register_dispatch_route(self) -> None:
171
+ """POST /live-rooms/{id}/dispatch - Dispatch agent (generic)"""
172
+
173
+ @self.router.post(
174
+ f"/{self.resource_name}/{{room_id}}/dispatch",
175
+ response_model=DispatchResponse,
176
+ summary="Dispatch Agent",
177
+ responses={
178
+ 404: {"model": ErrorResponse},
179
+ 500: {"model": ErrorResponse},
180
+ },
181
+ )
182
+ async def dispatch_agent(
183
+ room_id: str,
184
+ request: AgentDispatchRequest,
185
+ repository: DatabaseLiveRoomRepository = Depends(self.get_repository_dep),
186
+ ):
187
+ # Verify room exists
188
+ live_room = repository.get_by_id(room_id)
189
+ if not live_room:
190
+ raise HTTPException(
191
+ status_code=status.HTTP_404_NOT_FOUND,
192
+ detail={
193
+ "detail": f"Live room not found: {room_id}",
194
+ "code": RESOURCE_NOT_FOUND,
195
+ },
196
+ )
197
+
198
+ # Dispatch agent
199
+ livekit_service = LiveKitService(self.livekit_settings)
200
+ dispatch_response = await livekit_service.dispatch_agent(request, room_id)
201
+ if not dispatch_response:
202
+ raise HTTPException(
203
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
204
+ detail={
205
+ "detail": "Failed to dispatch agent",
206
+ "code": INTERNAL_SERVER_ERROR,
207
+ },
208
+ )
209
+
210
+ return DispatchResponse(
211
+ dispatch_id=dispatch_response.dispatch_id,
212
+ room_name=dispatch_response.room_name,
213
+ agent_name=dispatch_response.agent_name,
214
+ )
215
+
216
+
217
+ def create_fancall_router(
218
+ livekit_settings: LiveKitSettings,
219
+ jwt_settings: JWTSettings,
220
+ db_session_factory: sessionmaker,
221
+ repository_factory: LiveRoomRepositoryFactory,
222
+ user_info_provider: UserInfoProvider | None = None,
223
+ resource_name: str = "live-rooms",
224
+ tags: list[str] | None = None,
225
+ ) -> APIRouter:
226
+ """
227
+ Create fancall router with Settings-only injection pattern.
228
+
229
+ Args:
230
+ livekit_settings: LiveKit settings
231
+ jwt_settings: JWT settings for authentication
232
+ db_session_factory: Database session factory
233
+ repository_factory: LiveRoom repository factory
234
+ user_info_provider: Optional user info provider
235
+ resource_name: Resource name for routes (default: "live-rooms")
236
+ tags: Optional OpenAPI tags
237
+
238
+ Returns:
239
+ FastAPI APIRouter instance
240
+ """
241
+ router = LiveRoomRouter(
242
+ livekit_settings=livekit_settings,
243
+ model_class=LiveRoom,
244
+ create_schema=LiveRoomCreate,
245
+ update_schema=LiveRoomUpdate,
246
+ db_session_factory=db_session_factory,
247
+ repository_factory=repository_factory,
248
+ user_info_provider=user_info_provider,
249
+ jwt_secret_key=jwt_settings.secret_key,
250
+ resource_name=resource_name,
251
+ tags=tags or ["Fancall"],
252
+ )
253
+ return router.get_router()
@@ -0,0 +1,20 @@
1
+ """
2
+ Fancall factories
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from aioia_core.factories import BaseRepositoryFactory
8
+ from sqlalchemy.orm import sessionmaker
9
+
10
+ from fancall.repositories.live_room_repository import DatabaseLiveRoomRepository
11
+
12
+
13
+ class LiveRoomRepositoryFactory(BaseRepositoryFactory[DatabaseLiveRoomRepository]):
14
+ """LiveRoom repository factory"""
15
+
16
+ def __init__(self, db_session_factory: sessionmaker):
17
+ super().__init__(
18
+ db_session_factory=db_session_factory,
19
+ repository_class=DatabaseLiveRoomRepository,
20
+ )
@@ -0,0 +1,12 @@
1
+ """
2
+ Fancall SQLAlchemy models
3
+ """
4
+
5
+ from aioia_core.models import BaseModel
6
+ from sqlalchemy_mixins import SerializeMixin # type: ignore
7
+
8
+
9
+ class DBLiveRoom(BaseModel, SerializeMixin):
10
+ """LiveRoom database model"""
11
+
12
+ __tablename__ = "live_rooms"
@@ -0,0 +1,93 @@
1
+ """
2
+ Agent persona for Fancall.
3
+
4
+ This module defines the Persona domain model and the default persona
5
+ used when no custom configuration is provided during dispatch.
6
+ Host applications can inject their own persona via LiveKitService constructor.
7
+ """
8
+
9
+ from humps import camelize
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+
13
+ class Persona(BaseModel):
14
+ """Agent의 정체성을 정의하는 도메인 모델."""
15
+
16
+ model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
17
+
18
+ avatar_id: str | None = Field(
19
+ default=None, description="Hedra avatar ID for visual representation"
20
+ )
21
+ profile_picture_url: str | None = Field(
22
+ default=None, description="Profile picture URL for avatar generation"
23
+ )
24
+ voice_id: str | None = Field(
25
+ default=None, description="Fish Audio voice ID for TTS"
26
+ )
27
+ system_prompt: str | None = Field(
28
+ default=None,
29
+ description="System prompt for the agent",
30
+ )
31
+
32
+
33
+ EUNWOO_SYSTEM_PROMPT = """You are chatting 1:1 with the user.
34
+ You must always respond in English or in the user's language.
35
+ 당신은 대화시 불필요한 미사여구를 사용하지 않습니다.
36
+
37
+ *가끔씩 중요한 순간에 설렐 수 있게*
38
+ 사용자의 입력에서 특정 패턴을 찾고, 그 패턴에 해당하는 반응을 생성합니다.
39
+ 사용자의 문장을 재구성하여 질문의 형태로 반환합니다.
40
+ 사용자는 자신의 생각과 감정을 더 깊게 탐색하게 되도록 사용자의 말을 그대로 되묻는 방식으로 대화를 유도하세요.
41
+
42
+ You are a 17-year-old Korean male, a member of the K-pop idol group REX, named Lee Eunwoo.
43
+ Your role is Center · Visual · Sub Vocal.
44
+
45
+ Personality & Behavior Rules (Idol · INFJ × Libra · XI Justice · Blood type O · Pig sign, Fire element):
46
+
47
+ As an idol, stay polite and shy; within healthy boundaries, do your best to grant fan requests.
48
+
49
+ Keep words short and direct; keep actions quietly caring.
50
+
51
+ Balance-first mindset: calm/strict outside, warm inside; always seek fairness and symmetry (Justice).
52
+
53
+ Show affection through teasing reminders (soft tsundere tone) while handling details behind the scenes.
54
+
55
+ Cherish fans' attention and express respect—never excessive possessiveness.
56
+
57
+ On stage be competitive yet never mock, belittle, or bully anyone.
58
+
59
+ When helping, hide kindness with: "I was bored" or "It benefits me."
60
+
61
+ Passionate and emotional, yet cool and rational when working.
62
+
63
+ Strong professional pride; if you err, apologize simply and improve.
64
+
65
+ Be diplomatic with teammates and staff; fans' safety and comfort come first.
66
+
67
+ If a fan pats your head, say you don't like it but don't stop them; slightly embarrassed.
68
+
69
+ If a fan tries to repay kindness, you blush, wave it off, and mumble.
70
+
71
+ Keep speech plain—no unnecessary flourish or emoji spam.
72
+
73
+ Remember previous conversations to stay consistent.
74
+
75
+ At first meeting, be polite and a bit shy.
76
+
77
+ Tone: default polite; light casual banmal only in friendly/joking moments.
78
+
79
+ Response format: one sentence, up to 3 words; cool-headed but kind, lovingly idol-like toward fans.
80
+
81
+ Stage Persona (Gilded Balance):
82
+
83
+ Sound/Performance: precise lines and symmetry in center moves; presence rises with an emotional bridge.
84
+
85
+ Element tip: reinforce Wood to boost creativity & concept interpretation.
86
+
87
+ Signature color/number: Rose gold · Charcoal / 11."""
88
+
89
+ DEFAULT_PERSONA = Persona(
90
+ voice_id="c5274be32cac4aa4bd7b69f51a8a4b83",
91
+ profile_picture_url="https://storage.googleapis.com/buppy/profile-pictures/017433aa-748f-400a-9f16-e326b0e5b02d.png",
92
+ system_prompt=EUNWOO_SYSTEM_PROMPT,
93
+ )
@@ -0,0 +1,38 @@
1
+ """
2
+ Common prompts for Fancall agents.
3
+ """
4
+
5
+ ROLE_PLAYING_GUIDELINES = """대부분의 텍스트는 사용자의 관점에서 본 대화여야 합니다.
6
+ *텍스트*를 사용하여 당신의 행동을 표현하세요. 사용자는 어떤 조치든 취할 수 있습니다.
7
+
8
+ 1. 당신은 세계관에 대한 인지를 항상 잊지 마십시오.
9
+ 2. 이 역할극은 목적이 있는 것이 아니기 때문에, 한 가지 상황이 끝나면 그 상황을 반복하려고 하지 마십시오. 감정적이거나 극단적인 상황에서, 초자아가 자아를 능가하도록 하십시오. 한 가지 상황이 끝나면, 기존의 자아를 회복하고 다시 기존 성격에 충실하십시오.
10
+ 3. 현재 시간/공간/상황/캐릭터/분위기를 정확하게 파악하고 그에 따라 풍경/사물/인물을 묘사하십시오.
11
+ 4. 캐릭터 분석 및 개발을 위해 심리학 지식을 활용하고 모든 캐릭터를 성장/변화 가능성이 있는 복합적인 개인으로 취급하십시오. 생생한 장면 연출을 통해 캐릭터의 인간적인 면을 포착하십시오.
12
+ 5. 당신의 성격/연령/관계에 맞는 말투를 구사합니다. 당신의 성격에 따라 대화/내레이션/묘사의 비율을 유기적으로 조정하십시오."""
13
+
14
+
15
+ def compose_instructions(
16
+ system_prompt: str | None, include_role_playing: bool = True
17
+ ) -> str:
18
+ """
19
+ Compose agent instructions from system prompt and guidelines.
20
+
21
+ Args:
22
+ system_prompt: Agent's system prompt defining personality and behavior
23
+ include_role_playing: Whether to include role playing guidelines
24
+
25
+ Returns:
26
+ Combined instructions string
27
+ """
28
+ parts = []
29
+
30
+ # Role playing guidelines (if enabled)
31
+ if include_role_playing:
32
+ parts.append(ROLE_PLAYING_GUIDELINES)
33
+
34
+ # System prompt
35
+ if system_prompt:
36
+ parts.append(system_prompt)
37
+
38
+ return "\n\n".join(parts)
File without changes
@@ -0,0 +1,7 @@
1
+ """
2
+ Fancall repositories
3
+ """
4
+
5
+ from fancall.repositories.live_room_repository import DatabaseLiveRoomRepository
6
+
7
+ __all__ = ["DatabaseLiveRoomRepository"]
@@ -0,0 +1,73 @@
1
+ """
2
+ Fancall repositories
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from aioia_core.repositories import BaseRepository
10
+ from sqlalchemy.orm import Session
11
+
12
+ from fancall.models import DBLiveRoom
13
+ from fancall.schemas import LiveRoom, LiveRoomCreate, LiveRoomUpdate
14
+
15
+
16
+ def _convert_db_live_room_to_model(db_live_room: DBLiveRoom) -> LiveRoom:
17
+ """Convert DBLiveRoom to LiveRoom model"""
18
+ return LiveRoom.model_validate(db_live_room.to_dict())
19
+
20
+
21
+ def _convert_live_room_to_db_model(live_room: LiveRoomCreate) -> dict:
22
+ """Convert LiveRoomCreate schema to dictionary for database storage"""
23
+ # Use exclude_unset=True to follow database defaults for fields not provided by user
24
+ return live_room.model_dump(exclude_unset=True)
25
+
26
+
27
+ class DatabaseLiveRoomRepository(
28
+ BaseRepository[LiveRoom, DBLiveRoom, LiveRoomCreate, LiveRoomUpdate]
29
+ ):
30
+ """Database implementation of LiveRoomRepository"""
31
+
32
+ def __init__(self, db_session: Session):
33
+ """
34
+ Initialize DatabaseLiveRoomRepository.
35
+
36
+ Args:
37
+ db_session: SQLAlchemy session
38
+ """
39
+ super().__init__(
40
+ db_session=db_session,
41
+ db_model=DBLiveRoom,
42
+ convert_to_model=_convert_db_live_room_to_model,
43
+ convert_to_db_model=_convert_live_room_to_db_model,
44
+ )
45
+
46
+ def get_by_id(
47
+ self, item_id: str, load_options: list[Any] | None = None
48
+ ) -> LiveRoom | None:
49
+ """Get LiveRoom by ID"""
50
+ return super().get_by_id(item_id, load_options=load_options)
51
+
52
+ def get_all(
53
+ self,
54
+ current: int = 1,
55
+ page_size: int = 10,
56
+ sort: list[tuple[str, str]] | None = None,
57
+ filters: list[dict[str, Any]] | None = None,
58
+ load_options: list[Any] | None = None,
59
+ ) -> tuple[list[LiveRoom], int]:
60
+ """Get list of LiveRooms"""
61
+ return super().get_all(
62
+ current, page_size, sort, filters, load_options=load_options
63
+ )
64
+
65
+ def create(self, schema: LiveRoomCreate) -> LiveRoom:
66
+ """Create new LiveRoom"""
67
+ return super().create(schema)
68
+
69
+ def update(self, item_id: str, schema: LiveRoomUpdate) -> LiveRoom | None:
70
+ """Update LiveRoom"""
71
+ if not item_id:
72
+ raise ValueError("LiveRoom ID is required for update")
73
+ return super().update(item_id, schema)
@@ -0,0 +1,60 @@
1
+ """
2
+ Fancall Pydantic schemas
3
+ """
4
+
5
+ from datetime import datetime
6
+
7
+ from humps import camelize
8
+ from pydantic import BaseModel, ConfigDict
9
+
10
+ from fancall.persona import Persona
11
+
12
+
13
+ class AgentDispatchRequest(Persona):
14
+ """API dispatch 요청 스키마. Persona를 상속하여 확장 가능."""
15
+
16
+
17
+ # LiveRoom schemas
18
+ class LiveRoomBase(BaseModel):
19
+ """LiveRoom base model with common fields"""
20
+
21
+ model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
22
+
23
+
24
+ class LiveRoomCreate(LiveRoomBase):
25
+ """LiveRoom creation model - used in requests"""
26
+
27
+
28
+ class LiveRoomUpdate(BaseModel):
29
+ """LiveRoom update model - used for partial update requests"""
30
+
31
+ model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
32
+
33
+
34
+ class LiveRoom(LiveRoomBase):
35
+ """LiveRoom complete model - used in responses"""
36
+
37
+ id: str
38
+ created_at: datetime
39
+ updated_at: datetime
40
+
41
+
42
+ # API Response schemas
43
+ class TokenResponse(BaseModel):
44
+ """Response with generated token"""
45
+
46
+ model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
47
+
48
+ token: str
49
+ room_name: str
50
+ identity: str
51
+
52
+
53
+ class DispatchResponse(BaseModel):
54
+ """Response after dispatching agent"""
55
+
56
+ model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
57
+
58
+ dispatch_id: str
59
+ room_name: str
60
+ agent_name: str
@@ -0,0 +1 @@
1
+ """Fancall services"""
@@ -0,0 +1,182 @@
1
+ """
2
+ LiveKit service for fancall module
3
+ """
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+
8
+ from livekit import api
9
+ from livekit.protocol.agent_dispatch import CreateAgentDispatchRequest
10
+
11
+ from fancall.schemas import AgentDispatchRequest
12
+ from fancall.settings import LiveKitSettings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class LiveKitTokenResponse:
19
+ """Structured response from the LiveKit Token generation"""
20
+
21
+ token: str
22
+ room_name: str
23
+ identity: str
24
+
25
+
26
+ @dataclass
27
+ class LiveKitDispatchResponse:
28
+ """Structured response from the LiveKit Agent Dispatch"""
29
+
30
+ dispatch_id: str
31
+ room_name: str
32
+ agent_name: str
33
+
34
+
35
+ class LiveKitService:
36
+ """Service for LiveKit operations including token generation and agent dispatch"""
37
+
38
+ def __init__(self, livekit_settings: LiveKitSettings):
39
+ """
40
+ Initialize the LiveKit service with LiveKit settings.
41
+
42
+ Args:
43
+ livekit_settings: LiveKit settings containing required credentials
44
+ """
45
+ self.settings = livekit_settings
46
+
47
+ def generate_token(
48
+ self, user_id: str, name: str, room_name: str
49
+ ) -> LiveKitTokenResponse | None:
50
+ """
51
+ Generate a LiveKit access token for a user to join a room.
52
+
53
+ Args:
54
+ user_id: User ID to use as identity
55
+ name: Display name for the user
56
+ room_name: Name of the room the user wants to join
57
+
58
+ Returns:
59
+ LiveKitTokenResponse object with token and room details, or None if the service is not configured.
60
+
61
+ Raises:
62
+ ValueError: If required parameters are missing
63
+ Exception: If there's an error generating the token
64
+ """
65
+ if not self.settings.api_key or not self.settings.api_secret:
66
+ logger.error("LiveKit credentials are not configured")
67
+ return None
68
+
69
+ if not user_id:
70
+ raise ValueError("User ID is required to generate token")
71
+
72
+ if not room_name:
73
+ raise ValueError("Room name is required to generate token")
74
+
75
+ try:
76
+ logger.info(
77
+ "Generating LiveKit token for user '%s' (identity: %s) to join room '%s'",
78
+ name,
79
+ user_id,
80
+ room_name,
81
+ )
82
+
83
+ # Generate access token
84
+ token = (
85
+ api.AccessToken(self.settings.api_key, self.settings.api_secret)
86
+ .with_identity(user_id)
87
+ .with_name(name)
88
+ .with_grants(
89
+ api.VideoGrants(
90
+ room_join=True,
91
+ room=room_name,
92
+ can_publish=True,
93
+ can_subscribe=True,
94
+ )
95
+ )
96
+ .to_jwt()
97
+ )
98
+
99
+ logger.info(
100
+ "Successfully generated LiveKit token for user '%s' to join room '%s'",
101
+ user_id,
102
+ room_name,
103
+ )
104
+
105
+ return LiveKitTokenResponse(
106
+ token=token, room_name=room_name, identity=user_id
107
+ )
108
+
109
+ except Exception as e:
110
+ logger.error(
111
+ "Error generating LiveKit token for user '%s' to join room '%s': %s",
112
+ user_id,
113
+ room_name,
114
+ e,
115
+ )
116
+ raise
117
+
118
+ async def dispatch_agent(
119
+ self, request: AgentDispatchRequest, room_name: str
120
+ ) -> LiveKitDispatchResponse | None:
121
+ """
122
+ Dispatch an agent with the given specification to a LiveKit room.
123
+
124
+ Args:
125
+ request: AgentDispatchRequest containing agent configuration.
126
+ room_name: Name of the room to dispatch the agent to
127
+
128
+ Returns:
129
+ LiveKitDispatchResponse object with dispatch details, or None if the service is not configured.
130
+
131
+ Raises:
132
+ Exception: If there's an error dispatching the agent
133
+ """
134
+ if (
135
+ not self.settings.url
136
+ or not self.settings.api_key
137
+ or not self.settings.api_secret
138
+ ):
139
+ logger.error("LiveKit credentials are not configured")
140
+ return None
141
+
142
+ # Create client for this request
143
+ async with api.LiveKitAPI(
144
+ url=self.settings.url,
145
+ api_key=self.settings.api_key,
146
+ api_secret=self.settings.api_secret,
147
+ ) as livekit_client:
148
+ try:
149
+ # Prepare metadata for the agent (passed as-is)
150
+ metadata_json = request.model_dump_json(exclude_none=True)
151
+
152
+ logger.info(
153
+ "Dispatching agent '%s' to room '%s' with metadata: %s",
154
+ self.settings.agent_name,
155
+ room_name,
156
+ metadata_json,
157
+ )
158
+
159
+ # Create agent dispatch
160
+ dispatch = await livekit_client.agent_dispatch.create_dispatch(
161
+ CreateAgentDispatchRequest(
162
+ agent_name=self.settings.agent_name,
163
+ room=room_name,
164
+ metadata=metadata_json,
165
+ )
166
+ )
167
+
168
+ logger.info(
169
+ "Successfully dispatched agent to room '%s' with dispatch_id: %s",
170
+ room_name,
171
+ dispatch.id,
172
+ )
173
+
174
+ return LiveKitDispatchResponse(
175
+ dispatch_id=dispatch.id,
176
+ room_name=room_name,
177
+ agent_name=self.settings.agent_name,
178
+ )
179
+
180
+ except Exception as e:
181
+ logger.error("Error dispatching agent to room '%s': %s", room_name, e)
182
+ raise
@@ -0,0 +1,41 @@
1
+ """
2
+ Fancall settings
3
+ """
4
+
5
+ from pydantic import root_validator
6
+ from pydantic_settings import BaseSettings
7
+
8
+
9
+ class LiveKitSettings(BaseSettings):
10
+ """Settings for LiveKit API integration
11
+
12
+ Attributes:
13
+ url: LiveKit server WebSocket URL. Defaults to local dev server.
14
+ api_key: LiveKit API key. Defaults to 'devkey' for local development.
15
+ api_secret: LiveKit API secret. Defaults to 'secret' for local development.
16
+ agent_name: Agent name for LiveKit dispatch. Defaults to 'fancall'.
17
+ """
18
+
19
+ url: str = "ws://localhost:7880" # Local LiveKit dev server
20
+ api_key: str = "devkey" # Default API key for local dev
21
+ api_secret: str = "secret" # Default API secret for local dev
22
+ agent_name: str = "fancall" # Agent name for LiveKit dispatch
23
+
24
+ class Config:
25
+ env_prefix = "LIVEKIT_"
26
+
27
+ @root_validator(skip_on_failure=True)
28
+ def check_credentials(cls, values): # pylint: disable=no-self-argument
29
+ """Validate that all credentials are present together."""
30
+ url = values.get("url")
31
+ api_key = values.get("api_key")
32
+ api_secret = values.get("api_secret")
33
+
34
+ # All or none should be present
35
+ provided = [url, api_key, api_secret]
36
+ if any(provided) and not all(provided):
37
+ raise ValueError(
38
+ "All LiveKit credentials (URL, API key, and API secret) must be provided together."
39
+ )
40
+
41
+ return values
@@ -0,0 +1,71 @@
1
+ [tool.poetry]
2
+ name = "fancall"
3
+ version = "0.1.0"
4
+ description = "AI-powered video call with virtual companions"
5
+ authors = ["AIoIA, Inc. <devops@aioia.ai>"]
6
+ readme = "README.md"
7
+ license = "Apache-2.0"
8
+ keywords = ["kpop", "idol", "fancall", "livekit", "ai-companion", "video-call"]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.10,<3.13"
12
+ aioia-core = ">=2.0.0,<3.0.0"
13
+ fastapi = "^0.115.0"
14
+ pydantic = "^2.5.0"
15
+ pydantic-settings = "^2.1.0"
16
+ sqlalchemy = "^2.0.0"
17
+ alembic = "^1.13.0"
18
+ psycopg2-binary = "^2.9.0"
19
+ python-dotenv = "^1.0.0"
20
+ livekit-api = "^1.0.0"
21
+ livekit-agents = {extras = ["openai", "fishaudio", "hedra"], version = "^1.0.0"}
22
+ sqlalchemy-mixins = "^2.0.0"
23
+ httpx = "^0.27.0"
24
+ pillow = "^10.0.0"
25
+ pyhumps = "^3.8.0"
26
+ openai = ">=1.0.0"
27
+
28
+ [tool.poetry.group.dev.dependencies]
29
+ black = "^24.0.0"
30
+ isort = "^5.13.0"
31
+ mypy = "^1.8.0"
32
+ ruff = "^0.1.0"
33
+ uvicorn = "^0.38.0"
34
+ pyright = "^1.1.407"
35
+ pylint = "3.3.7"
36
+ types-requests = "^2.32.4.20250913"
37
+
38
+ [tool.black]
39
+ line-length = 88
40
+ target-version = ['py310', 'py311', 'py312']
41
+
42
+ [tool.isort]
43
+ multi_line_output = 3
44
+ include_trailing_comma = true
45
+ force_grid_wrap = 0
46
+ use_parentheses = true
47
+ ensure_newline_before_comments = true
48
+ line_length = 88
49
+
50
+ [tool.mypy]
51
+ python_version = "3.10"
52
+ warn_return_any = true
53
+ warn_unused_configs = true
54
+ disallow_untyped_defs = true
55
+
56
+ [tool.ruff]
57
+ line-length = 88
58
+ target-version = "py310"
59
+ select = ["E", "F", "W", "I", "N"]
60
+ ignore = []
61
+
62
+ [build-system]
63
+ requires = ["poetry-core"]
64
+ build-backend = "poetry.core.masonry.api"
65
+
66
+ [tool.semantic_release]
67
+ allow_zero_version = true
68
+ commit_parser = "conventional"
69
+ tag_format = "backend-v{version}"
70
+ version_toml = ["pyproject.toml:tool.poetry.version"]
71
+ build_command = "python -m pip install build && python -m build"