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 +131 -0
- fancall-0.1.0/README.md +99 -0
- fancall-0.1.0/fancall/__init__.py +3 -0
- fancall-0.1.0/fancall/agent/__init__.py +1 -0
- fancall-0.1.0/fancall/agent/worker.py +240 -0
- fancall-0.1.0/fancall/api/__init__.py +1 -0
- fancall-0.1.0/fancall/api/router.py +253 -0
- fancall-0.1.0/fancall/factories.py +20 -0
- fancall-0.1.0/fancall/models.py +12 -0
- fancall-0.1.0/fancall/persona.py +93 -0
- fancall-0.1.0/fancall/prompts.py +38 -0
- fancall-0.1.0/fancall/py.typed +0 -0
- fancall-0.1.0/fancall/repositories/__init__.py +7 -0
- fancall-0.1.0/fancall/repositories/live_room_repository.py +73 -0
- fancall-0.1.0/fancall/schemas.py +60 -0
- fancall-0.1.0/fancall/services/__init__.py +1 -0
- fancall-0.1.0/fancall/services/livekit_service.py +182 -0
- fancall-0.1.0/fancall/settings.py +41 -0
- fancall-0.1.0/pyproject.toml +71 -0
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
|
+
|
fancall-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
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,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"
|