py-aidol 0.1.0__py3-none-any.whl → 0.3.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/lead.py ADDED
@@ -0,0 +1,123 @@
1
+ """
2
+ Lead API router
3
+
4
+ Public endpoints for collecting leads (emails).
5
+ """
6
+
7
+ from typing import Annotated
8
+
9
+ from aioia_core.fastapi import BaseCrudRouter
10
+ from aioia_core.settings import JWTSettings
11
+ from fastapi import APIRouter, Depends, Header, status
12
+ from pydantic import BaseModel
13
+ from sqlalchemy.orm import Session, sessionmaker
14
+
15
+ from aidol.protocols import (
16
+ AIdolLeadRepositoryFactoryProtocol,
17
+ AIdolLeadRepositoryProtocol,
18
+ AIdolRepositoryFactoryProtocol,
19
+ NoUpdate,
20
+ )
21
+ from aidol.schemas import AIdolLead, AIdolLeadCreate, AIdolUpdate
22
+
23
+
24
+ class LeadResponse(BaseModel):
25
+ """Response for lead creation."""
26
+
27
+ email: str
28
+
29
+
30
+ class LeadRouter(
31
+ BaseCrudRouter[AIdolLead, AIdolLeadCreate, NoUpdate, AIdolLeadRepositoryProtocol]
32
+ ):
33
+ """
34
+ Lead router.
35
+
36
+ Handles email collection.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ aidol_repository_factory: AIdolRepositoryFactoryProtocol,
42
+ **kwargs,
43
+ ):
44
+ super().__init__(**kwargs)
45
+ self.aidol_repository_factory = aidol_repository_factory
46
+
47
+ def _register_routes(self) -> None:
48
+ """Register routes."""
49
+ self._register_create_lead_route()
50
+
51
+ def _register_create_lead_route(self) -> None:
52
+ """POST /leads - Collect email"""
53
+
54
+ @self.router.post(
55
+ f"/{self.resource_name}",
56
+ response_model=LeadResponse,
57
+ status_code=status.HTTP_201_CREATED,
58
+ summary="Collect Lead",
59
+ description="Collect email. Associates with AIdol if ClaimToken is valid.",
60
+ )
61
+ async def create_lead(
62
+ request: AIdolLeadCreate,
63
+ claim_token: Annotated[str | None, Header(alias="ClaimToken")] = None,
64
+ db_session: Session = Depends(self.get_db_dep),
65
+ lead_repository: AIdolLeadRepositoryProtocol = Depends(
66
+ self.get_repository_dep
67
+ ),
68
+ ):
69
+ """Collect email."""
70
+ email_saved = False
71
+
72
+ # 1. Try to associate with AIdol if token is present
73
+ if claim_token:
74
+ # Reuse session from dependency
75
+ aidol_repo = self.aidol_repository_factory.create_repository(db_session)
76
+
77
+ # Find AIdol by claim_token
78
+ # Assuming get_all supports filters
79
+ items, _ = aidol_repo.get_all(
80
+ filters=[
81
+ {
82
+ "field": "claim_token",
83
+ "operator": "eq",
84
+ "value": claim_token,
85
+ }
86
+ ]
87
+ )
88
+
89
+ if items:
90
+ aidol = items[0]
91
+ # Update AIdol email
92
+ aidol_repo.update(aidol.id, AIdolUpdate(email=request.email))
93
+ email_saved = True
94
+
95
+ # 2. If not saved as AIdol email, create Lead
96
+ if not email_saved:
97
+ lead_repository.create(request)
98
+
99
+ return LeadResponse(email=request.email)
100
+
101
+
102
+ def create_lead_router(
103
+ db_session_factory: sessionmaker,
104
+ aidol_repository_factory: AIdolRepositoryFactoryProtocol,
105
+ lead_repository_factory: AIdolLeadRepositoryFactoryProtocol,
106
+ jwt_settings: JWTSettings | None = None,
107
+ resource_name: str = "leads",
108
+ tags: list[str] | None = None,
109
+ ) -> APIRouter:
110
+ """Create Lead router."""
111
+ router = LeadRouter(
112
+ model_class=AIdolLead,
113
+ create_schema=AIdolLeadCreate,
114
+ update_schema=NoUpdate, # Update not supported
115
+ db_session_factory=db_session_factory,
116
+ repository_factory=lead_repository_factory,
117
+ aidol_repository_factory=aidol_repository_factory,
118
+ user_info_provider=None, # No auth required for lead collection
119
+ jwt_secret_key=jwt_settings.secret_key if jwt_settings else None,
120
+ resource_name=resource_name,
121
+ tags=tags or ["Lead"],
122
+ )
123
+ return router.get_router()
aidol/factories.py CHANGED
@@ -7,6 +7,7 @@ Uses BaseRepositoryFactory for BaseCrudRouter compatibility.
7
7
  from aioia_core.factories import BaseRepositoryFactory
8
8
 
9
9
  from aidol.repositories.aidol import AIdolRepository
10
+ from aidol.repositories.aidol_lead import AIdolLeadRepository
10
11
  from aidol.repositories.companion import CompanionRepository
11
12
 
12
13
 
@@ -22,3 +23,10 @@ class CompanionRepositoryFactory(BaseRepositoryFactory[CompanionRepository]):
22
23
 
23
24
  def __init__(self):
24
25
  super().__init__(repository_class=CompanionRepository)
26
+
27
+
28
+ class AIdolLeadRepositoryFactory(BaseRepositoryFactory[AIdolLeadRepository]):
29
+ """Factory for creating AIdolLead repositories."""
30
+
31
+ def __init__(self):
32
+ super().__init__(repository_class=AIdolLeadRepository)
aidol/models/__init__.py CHANGED
@@ -3,6 +3,7 @@ AIdol database models
3
3
  """
4
4
 
5
5
  from aidol.models.aidol import DBAIdol
6
+ from aidol.models.aidol_lead import DBAIdolLead
6
7
  from aidol.models.companion import DBCompanion
7
8
 
8
- __all__ = ["DBAIdol", "DBCompanion"]
9
+ __all__ = ["DBAIdol", "DBAIdolLead", "DBCompanion"]
aidol/models/aidol.py CHANGED
@@ -18,7 +18,9 @@ class DBAIdol(BaseModel):
18
18
  __tablename__ = "aidols"
19
19
 
20
20
  # id, created_at, updated_at inherited from BaseModel
21
- name: Mapped[str] = mapped_column(String, nullable=False)
21
+ name: Mapped[str | None] = mapped_column(String, nullable=True)
22
22
  concept: Mapped[str | None] = mapped_column(String, nullable=True)
23
- profile_image_url: Mapped[str] = mapped_column(String, nullable=False)
23
+ profile_image_url: Mapped[str | None] = mapped_column(String, nullable=True)
24
24
  claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True)
25
+ email: Mapped[str | None] = mapped_column(String, nullable=True)
26
+ greeting: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -0,0 +1,22 @@
1
+ """
2
+ AIdol Leads database model
3
+
4
+ Uses aioia_core.models.BaseModel which provides:
5
+ - id: Mapped[str] (primary key, UUID default)
6
+ - created_at: Mapped[datetime]
7
+ - updated_at: Mapped[datetime]
8
+ """
9
+
10
+ from aioia_core.models import BaseModel
11
+ from sqlalchemy import String
12
+ from sqlalchemy.orm import Mapped, mapped_column
13
+
14
+
15
+ class DBAIdolLead(BaseModel):
16
+ """AIdol Lead (viewer email) database model"""
17
+
18
+ __tablename__ = "aidol_leads"
19
+
20
+ # id, created_at, updated_at inherited from BaseModel
21
+ aidol_id: Mapped[str] = mapped_column(String, nullable=False)
22
+ email: Mapped[str] = mapped_column(String, nullable=False)
aidol/models/companion.py CHANGED
@@ -8,7 +8,7 @@ Uses aioia_core.models.BaseModel which provides:
8
8
  """
9
9
 
10
10
  from aioia_core.models import BaseModel
11
- from sqlalchemy import ForeignKey, Index, String, Text
11
+ from sqlalchemy import ForeignKey, Index, Integer, String, Text
12
12
  from sqlalchemy.orm import Mapped, mapped_column
13
13
 
14
14
 
@@ -19,9 +19,27 @@ class DBCompanion(BaseModel):
19
19
 
20
20
  # id, created_at, updated_at inherited from BaseModel
21
21
  aidol_id: Mapped[str | None] = mapped_column(ForeignKey("aidols.id"), nullable=True)
22
- name: Mapped[str] = mapped_column(String, nullable=False)
22
+ name: Mapped[str | None] = mapped_column(String, nullable=True)
23
+ gender: Mapped[str | None] = mapped_column(String, nullable=True)
24
+ grade: Mapped[str | None] = mapped_column(String, nullable=True)
23
25
  biography: Mapped[str | None] = mapped_column(Text, nullable=True)
24
26
  profile_picture_url: Mapped[str | None] = mapped_column(String, nullable=True)
25
27
  system_prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
26
28
 
29
+ # MBTI scores (1-10 scale)
30
+ mbti_energy: Mapped[int | None] = mapped_column(Integer, nullable=True)
31
+ mbti_perception: Mapped[int | None] = mapped_column(Integer, nullable=True)
32
+ mbti_judgment: Mapped[int | None] = mapped_column(Integer, nullable=True)
33
+ mbti_lifestyle: Mapped[int | None] = mapped_column(Integer, nullable=True)
34
+
35
+ # Stats (0-100 scale)
36
+ vocal: Mapped[int | None] = mapped_column(Integer, nullable=True)
37
+ dance: Mapped[int | None] = mapped_column(Integer, nullable=True)
38
+ rap: Mapped[int | None] = mapped_column(Integer, nullable=True)
39
+ visual: Mapped[int | None] = mapped_column(Integer, nullable=True)
40
+ stamina: Mapped[int | None] = mapped_column(Integer, nullable=True)
41
+ charm: Mapped[int | None] = mapped_column(Integer, nullable=True)
42
+
43
+ position: Mapped[str | None] = mapped_column(String, nullable=True)
44
+
27
45
  __table_args__ = (Index("ix_companions_aidol_id", "aidol_id"),)
aidol/protocols.py CHANGED
@@ -11,11 +11,14 @@ from typing import Protocol
11
11
 
12
12
  import PIL.Image
13
13
  from aioia_core import CrudRepositoryProtocol
14
+ from pydantic import BaseModel
14
15
  from sqlalchemy.orm import Session
15
16
 
16
17
  from aidol.schemas import (
17
18
  AIdol,
18
19
  AIdolCreate,
20
+ AIdolLead,
21
+ AIdolLeadCreate,
19
22
  AIdolUpdate,
20
23
  Companion,
21
24
  CompanionCreate,
@@ -23,6 +26,10 @@ from aidol.schemas import (
23
26
  )
24
27
 
25
28
 
29
+ class NoUpdate(BaseModel):
30
+ """Placeholder for repositories without update support."""
31
+
32
+
26
33
  class AIdolRepositoryProtocol(
27
34
  CrudRepositoryProtocol[AIdol, AIdolCreate, AIdolUpdate], Protocol
28
35
  ):
@@ -98,3 +105,22 @@ class ImageStorageProtocol(Protocol):
98
105
  image: PIL Image object to upload.
99
106
  """
100
107
  ...
108
+
109
+
110
+ class AIdolLeadRepositoryProtocol(
111
+ CrudRepositoryProtocol[AIdolLead, AIdolLeadCreate, NoUpdate], Protocol
112
+ ):
113
+ """Protocol defining AIdolLead repository expectations.
114
+
115
+ Inherits CRUD operations from CrudRepositoryProtocol.
116
+ """
117
+
118
+
119
+ class AIdolLeadRepositoryFactoryProtocol(Protocol):
120
+ """Protocol for factory that creates AIdolLeadRepositoryProtocol instances."""
121
+
122
+ def create_repository(
123
+ self, db_session: Session | None = None
124
+ ) -> AIdolLeadRepositoryProtocol:
125
+ """Create a repository instance."""
126
+ ...
@@ -3,9 +3,11 @@ AIdol repositories
3
3
  """
4
4
 
5
5
  from aidol.repositories.aidol import AIdolRepository
6
+ from aidol.repositories.aidol_lead import AIdolLeadRepository
6
7
  from aidol.repositories.companion import CompanionRepository
7
8
 
8
9
  __all__ = [
9
10
  "AIdolRepository",
11
+ "AIdolLeadRepository",
10
12
  "CompanionRepository",
11
13
  ]
@@ -25,6 +25,8 @@ def _convert_db_aidol_to_model(db_aidol: DBAIdol) -> AIdol:
25
25
  return AIdol(
26
26
  id=db_aidol.id,
27
27
  name=db_aidol.name,
28
+ email=db_aidol.email,
29
+ greeting=db_aidol.greeting,
28
30
  concept=db_aidol.concept,
29
31
  profile_image_url=db_aidol.profile_image_url,
30
32
  claim_token=db_aidol.claim_token,
@@ -0,0 +1,49 @@
1
+ """
2
+ AIdol Lead repository
3
+
4
+ Implements BaseRepository pattern for BaseCrudRouter compatibility.
5
+ """
6
+
7
+ from datetime import timezone
8
+
9
+ from aioia_core.repositories import BaseRepository
10
+ from sqlalchemy.orm import Session
11
+
12
+ from aidol.models import DBAIdolLead
13
+ from aidol.protocols import AIdolLeadRepositoryProtocol, NoUpdate
14
+ from aidol.schemas import AIdolLead, AIdolLeadCreate
15
+
16
+
17
+ def _convert_db_aidol_lead_to_model(db_lead: DBAIdolLead) -> AIdolLead:
18
+ """Convert DB AIdolLead to Pydantic model."""
19
+ return AIdolLead(
20
+ id=db_lead.id,
21
+ aidol_id=db_lead.aidol_id,
22
+ email=db_lead.email,
23
+ created_at=db_lead.created_at.replace(tzinfo=timezone.utc),
24
+ updated_at=db_lead.updated_at.replace(tzinfo=timezone.utc),
25
+ )
26
+
27
+
28
+ def _convert_aidol_lead_create_to_db(schema: AIdolLeadCreate) -> dict:
29
+ """Convert AIdolLeadCreate schema to DB model data dict."""
30
+ return schema.model_dump(exclude_unset=True)
31
+
32
+
33
+ class AIdolLeadRepository(
34
+ BaseRepository[AIdolLead, DBAIdolLead, AIdolLeadCreate, NoUpdate],
35
+ AIdolLeadRepositoryProtocol,
36
+ ):
37
+ """
38
+ Database-backed AIdolLead repository.
39
+
40
+ Extends BaseRepository for CRUD operations compatible with BaseCrudRouter.
41
+ """
42
+
43
+ def __init__(self, db_session: Session):
44
+ super().__init__(
45
+ db_session=db_session,
46
+ db_model=DBAIdolLead,
47
+ convert_to_model=_convert_db_aidol_lead_to_model,
48
+ convert_to_db_model=_convert_aidol_lead_create_to_db,
49
+ )
@@ -10,7 +10,15 @@ from aioia_core.repositories import BaseRepository
10
10
  from sqlalchemy.orm import Session
11
11
 
12
12
  from aidol.models import DBCompanion
13
- from aidol.schemas import Companion, CompanionCreate, CompanionUpdate
13
+ from aidol.schemas import (
14
+ Companion,
15
+ CompanionCreate,
16
+ CompanionStats,
17
+ CompanionUpdate,
18
+ Gender,
19
+ Grade,
20
+ Position,
21
+ )
14
22
 
15
23
 
16
24
  def _convert_db_companion_to_model(db_companion: DBCompanion) -> Companion:
@@ -23,20 +31,45 @@ def _convert_db_companion_to_model(db_companion: DBCompanion) -> Companion:
23
31
  id=db_companion.id,
24
32
  aidol_id=db_companion.aidol_id,
25
33
  name=db_companion.name,
34
+ gender=Gender(db_companion.gender) if db_companion.gender else None,
35
+ grade=Grade(db_companion.grade) if db_companion.grade else None,
26
36
  biography=db_companion.biography,
27
37
  profile_picture_url=db_companion.profile_picture_url,
38
+ position=Position(db_companion.position) if db_companion.position else None,
28
39
  system_prompt=db_companion.system_prompt,
40
+ mbti_energy=db_companion.mbti_energy,
41
+ mbti_perception=db_companion.mbti_perception,
42
+ mbti_judgment=db_companion.mbti_judgment,
43
+ mbti_lifestyle=db_companion.mbti_lifestyle,
44
+ stats=CompanionStats(
45
+ vocal=db_companion.vocal or 0,
46
+ dance=db_companion.dance or 0,
47
+ rap=db_companion.rap or 0,
48
+ visual=db_companion.visual or 0,
49
+ stamina=db_companion.stamina or 0,
50
+ charm=db_companion.charm or 0,
51
+ ),
29
52
  created_at=db_companion.created_at.replace(tzinfo=timezone.utc),
30
53
  updated_at=db_companion.updated_at.replace(tzinfo=timezone.utc),
31
54
  )
32
55
 
33
56
 
34
- def _convert_companion_create_to_db(schema: CompanionCreate) -> dict:
35
- """Convert CompanionCreate schema to DB model data dict.
57
+ def _convert_companion_schema_to_db(
58
+ schema: CompanionCreate | CompanionUpdate,
59
+ ) -> dict:
60
+ """Convert CompanionCreate/Update schema to DB model data dict.
36
61
 
62
+ Decomposes nested stats object into individual DB columns.
37
63
  Includes system_prompt for AI configuration.
38
64
  """
39
- return schema.model_dump(exclude_unset=True)
65
+ data = schema.model_dump(exclude_unset=True, exclude={"stats"})
66
+
67
+ # Decompose stats into individual columns
68
+ if schema.stats is not None:
69
+ stats_dict = schema.stats.model_dump()
70
+ data.update(stats_dict)
71
+
72
+ return data
40
73
 
41
74
 
42
75
  class CompanionRepository(
@@ -53,5 +86,5 @@ class CompanionRepository(
53
86
  db_session=db_session,
54
87
  db_model=DBCompanion,
55
88
  convert_to_model=_convert_db_companion_to_model,
56
- convert_to_db_model=_convert_companion_create_to_db,
89
+ convert_to_db_model=_convert_companion_schema_to_db,
57
90
  )
aidol/schemas/__init__.py CHANGED
@@ -12,12 +12,17 @@ from aidol.schemas.aidol import (
12
12
  ImageGenerationRequest,
13
13
  ImageGenerationResponse,
14
14
  )
15
+ from aidol.schemas.aidol_lead import AIdolLead, AIdolLeadBase, AIdolLeadCreate
15
16
  from aidol.schemas.companion import (
16
17
  Companion,
17
18
  CompanionBase,
18
19
  CompanionCreate,
19
20
  CompanionPublic,
21
+ CompanionStats,
20
22
  CompanionUpdate,
23
+ Gender,
24
+ Grade,
25
+ Position,
21
26
  )
22
27
 
23
28
  __all__ = [
@@ -29,9 +34,16 @@ __all__ = [
29
34
  "ImageGenerationData",
30
35
  "ImageGenerationRequest",
31
36
  "ImageGenerationResponse",
37
+ "AIdolLead",
38
+ "AIdolLeadBase",
39
+ "AIdolLeadCreate",
32
40
  "Companion",
33
41
  "CompanionBase",
34
42
  "CompanionCreate",
35
43
  "CompanionPublic",
44
+ "CompanionStats",
36
45
  "CompanionUpdate",
46
+ "Gender",
47
+ "Grade",
48
+ "Position",
37
49
  ]
aidol/schemas/aidol.py CHANGED
@@ -24,20 +24,22 @@ class AIdolBase(BaseModel):
24
24
 
25
25
  model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
26
26
 
27
- name: str = Field(..., description="AIdol group name")
27
+ name: str | None = Field(default=None, description="AIdol group name")
28
+ email: str | None = Field(default=None, description="Creator email")
29
+ greeting: str | None = Field(default=None, description="Greeting message")
28
30
  concept: str | None = Field(default=None, description="Group concept or theme")
29
- profile_image_url: str = Field(..., description="Profile image URL")
31
+ profile_image_url: str | None = Field(default=None, description="Profile image URL")
30
32
 
31
33
 
32
34
  class AIdolCreate(AIdolBase):
33
35
  """Schema for creating an AIdol group (no id).
34
36
 
35
- claim_token is optional for anonymous ownership verification.
37
+ claim_token is required for ownership verification.
36
38
  """
37
39
 
38
- claim_token: str | None = Field(
39
- default=None,
40
- description="Optional client-generated UUID for ownership verification",
40
+ claim_token: str = Field(
41
+ ...,
42
+ description="Client-generated UUID for ownership verification",
41
43
  )
42
44
 
43
45
 
@@ -47,6 +49,8 @@ class AIdolUpdate(BaseModel):
47
49
  model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
48
50
 
49
51
  name: str | None = Field(default=None, description="AIdol group name")
52
+ email: str | None = Field(default=None, description="Creator email")
53
+ greeting: str | None = Field(default=None, description="Greeting message")
50
54
  concept: str | None = Field(default=None, description="Group concept or theme")
51
55
  profile_image_url: str | None = Field(default=None, description="Profile image URL")
52
56
 
@@ -0,0 +1,38 @@
1
+ """
2
+ AIdol Lead (viewer email) schemas
3
+
4
+ Schema hierarchy:
5
+ - AIdolLeadBase: Common fields
6
+ - AIdolLeadCreate: For creating a lead (no id)
7
+ - AIdolLead: Response with all fields
8
+ """
9
+
10
+ from datetime import datetime
11
+
12
+ from humps import camelize
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class AIdolLeadBase(BaseModel):
17
+ """Base AIdol Lead model with common fields."""
18
+
19
+ model_config = ConfigDict(populate_by_name=True, alias_generator=camelize)
20
+
21
+ aidol_id: str = Field(..., description="AIdol group ID")
22
+ email: str = Field(..., description="Viewer email")
23
+
24
+
25
+ class AIdolLeadCreate(AIdolLeadBase):
26
+ """Schema for creating an AIdol lead (no id)."""
27
+
28
+
29
+ class AIdolLead(AIdolLeadBase):
30
+ """AIdol Lead response schema with id and timestamps."""
31
+
32
+ model_config = ConfigDict(
33
+ populate_by_name=True, from_attributes=True, alias_generator=camelize
34
+ )
35
+
36
+ id: str = Field(..., description="Lead ID")
37
+ created_at: datetime = Field(..., description="Creation timestamp")
38
+ updated_at: datetime = Field(..., description="Last update timestamp")