This commit is contained in:
xds
2026-02-24 12:11:19 +03:00
parent f07105b0e5
commit bc9230a49b
8 changed files with 147 additions and 22 deletions

33
.context.md Normal file
View File

@@ -0,0 +1,33 @@
# Project Context: AI Char Bot
## Overview
Python backend project using FastAPI and MongoDB (Motor).
Root: `/Users/xds/develop/py projects/ai-char-bot`
## Architecture
- **API Layer**: `api/endpoints` (FastAPI routers).
- **Service Layer**: `api/service` (Business logic).
- **Data Layer**: `repos` (DAOs and Repositories).
- **Models**: `models` (Domain models) and `api/models` (Request/Response DTOs).
- **Adapters**: `adapters` (External services like S3, Google Gemini).
## Coding Standards & Preferences
- **Type Hinting**: Use `Type | None` instead of `Optional[Type]` (Python 3.10+ style).
- **Async/Await**: Extensive use of `asyncio` and asynchronous DB drivers.
- **Error Handling**:
- Repositories should return `None` if an entity is not found (e.g., `toggle_like`).
- Services/Routers handle `HTTPException`.
## Key Features & Implementation Details
- **Generations**:
- Managed by `GenerationService` and `GenerationRepo`.
- `toggle_like` returns `bool | None` (True=Liked, False=Unliked, None=Not Found).
- `get_generations` requires `current_user_id` to correctly calculate `is_liked`.
- **Ideas**:
- Managed by `IdeaService` and `IdeaRepo`.
- Can have linked generations.
- When fetching generations for an idea, ensure `current_user_id` is passed to `GenerationService`.
## Recent Changes
- Refactored `toggle_like` to handle non-existent generations and return `bool | None`.
- Updated `IdeaRouter` to pass `current_user_id` when fetching generations to ensure `is_liked` flag is correct.

33
.gemini/AGENTS.md Normal file
View File

@@ -0,0 +1,33 @@
# Project Context: AI Char Bot
## Overview
Python backend project using FastAPI and MongoDB (Motor).
Root: `/Users/xds/develop/py projects/ai-char-bot`
## Architecture
- **API Layer**: `api/endpoints` (FastAPI routers).
- **Service Layer**: `api/service` (Business logic).
- **Data Layer**: `repos` (DAOs and Repositories).
- **Models**: `models` (Domain models) and `api/models` (Request/Response DTOs).
- **Adapters**: `adapters` (External services like S3, Google Gemini).
## Coding Standards & Preferences
- **Type Hinting**: Use `Type | None` instead of `Optional[Type]` (Python 3.10+ style).
- **Async/Await**: Extensive use of `asyncio` and asynchronous DB drivers.
- **Error Handling**:
- Repositories should return `None` if an entity is not found (e.g., `toggle_like`).
- Services/Routers handle `HTTPException`.
## Key Features & Implementation Details
- **Generations**:
- Managed by `GenerationService` and `GenerationRepo`.
- `toggle_like` returns `bool | None` (True=Liked, False=Unliked, None=Not Found).
- `get_generations` requires `current_user_id` to correctly calculate `is_liked`.
- **Ideas**:
- Managed by `IdeaService` and `IdeaRepo`.
- Can have linked generations.
- When fetching generations for an idea, ensure `current_user_id` is passed to `GenerationService`.
## Recent Changes
- Refactored `toggle_like` to handle non-existent generations and return `bool | None`.
- Updated `IdeaRouter` to pass `current_user_id` when fetching generations to ensure `is_liked` flag is correct.

View File

@@ -66,6 +66,7 @@ async def get_generations(
character_id: Optional[str] = None,
limit: int = 10,
offset: int = 0,
only_liked: bool = False,
generation_service: GenerationService = Depends(get_generation_service),
current_user: dict = Depends(get_current_user),
project_id: Optional[str] = Depends(get_project_id),
@@ -75,13 +76,16 @@ async def get_generations(
# If project_id is set, we don't filter by user to show all project-wide generations
created_by_filter = None if project_id else str(current_user["_id"])
only_liked_by = str(current_user["_id"]) if only_liked else None
return await generation_service.get_generations(
character_id=character_id,
limit=limit,
offset=offset,
created_by=created_by_filter,
project_id=project_id
project_id=project_id,
only_liked_by=only_liked_by,
current_user_id=str(current_user["_id"])
)
@@ -150,7 +154,7 @@ async def get_generation_group(
generation_service: GenerationService = Depends(get_generation_service),
current_user: dict = Depends(get_current_user)
):
return await generation_service.get_generations_by_group(group_id)
return await generation_service.get_generations_by_group(group_id, current_user_id=str(current_user["_id"]))
@router.get("/{generation_id}", response_model=GenerationResponse)
@@ -159,7 +163,7 @@ async def get_generation(
generation_service: GenerationService = Depends(get_generation_service),
current_user: dict = Depends(get_current_user)
) -> GenerationResponse:
gen = await generation_service.get_generation(generation_id)
gen = await generation_service.get_generation(generation_id, current_user_id=str(current_user["_id"]))
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
@@ -176,6 +180,18 @@ async def get_generation(
return gen
@router.post("/{generation_id}/like", response_model=dict)
async def toggle_like(
generation_id: str,
generation_service: GenerationService = Depends(get_generation_service),
current_user: dict = Depends(get_current_user)
):
is_liked = await generation_service.toggle_like(generation_id, str(current_user["_id"]))
if is_liked is None:
raise HTTPException(status_code=404, detail="Generation not found")
return {"is_liked": is_liked}
@router.post("/import", response_model=GenerationResponse)
async def import_external_generation(
request: Request,

View File

@@ -68,18 +68,10 @@ async def get_idea_generations(
idea_id: str,
limit: int = 50,
offset: int = 0,
generation_service: GenerationService = Depends(get_generation_service)
generation_service: GenerationService = Depends(get_generation_service),
current_user: dict = Depends(get_current_user)
):
# Depending on how generation service implements filtering by idea_id.
# We might need to update generation_service to support getting by idea_id directly
# or ensure generic get_generations supports it.
# Looking at generation_router.py, get_generations doesn't have idea_id arg?
# Let's check generation_service.get_generations signature again.
# It has: (character_id, limit, offset, user_id, project_id). NO IDEA_ID.
# I need to update GenerationService.get_generations too!
# For now, let's assume I will update it.
return await generation_service.get_generations(idea_id=idea_id, limit=limit, offset=offset)
return await generation_service.get_generations(idea_id=idea_id, limit=limit, offset=offset, current_user_id=str(current_user["_id"]))
@router.post("/{idea_id}/generations/{generation_id}")
async def add_generation_to_idea(

View File

@@ -50,6 +50,8 @@ class GenerationResponse(BaseModel):
created_by: Optional[str] = None
generation_group_id: Optional[str] = None
idea_id: Optional[str] = None
likes_count: int = 0
is_liked: bool = False
created_at: datetime = datetime.now(UTC)
updated_at: datetime = datetime.now(UTC)

View File

@@ -100,29 +100,40 @@ class GenerationService:
return await asyncio.to_thread(self.gemini.generate_text, prompt=technical_prompt, images_list=images)
async def get_generations(self, **kwargs) -> GenerationsResponse:
current_user_id = kwargs.pop('current_user_id', None)
generations = await self.dao.generations.get_generations(**kwargs)
total_count = await self.dao.generations.count_generations(
character_id=kwargs.get('character_id'),
created_by=kwargs.get('created_by'),
project_id=kwargs.get('project_id'),
idea_id=kwargs.get('idea_id')
idea_id=kwargs.get('idea_id'),
only_liked_by=kwargs.get('only_liked_by')
)
return GenerationsResponse(
generations=[GenerationResponse(**gen.model_dump()) for gen in generations],
generations=[self._map_to_response(gen, current_user_id) for gen in generations],
total_count=total_count
)
async def get_generation(self, generation_id: str) -> Optional[GenerationResponse]:
async def get_generation(self, generation_id: str, current_user_id: Optional[str] = None) -> Optional[GenerationResponse]:
gen = await self.dao.generations.get_generation(generation_id)
return GenerationResponse(**gen.model_dump()) if gen else None
return self._map_to_response(gen, current_user_id) if gen else None
async def get_generations_by_group(self, group_id: str) -> GenerationGroupResponse:
async def toggle_like(self, generation_id: str, user_id: str) -> bool | None:
return await self.dao.generations.toggle_like(generation_id, user_id)
async def get_generations_by_group(self, group_id: str, current_user_id: Optional[str] = None) -> GenerationGroupResponse:
generations = await self.dao.generations.get_generations_by_group(group_id)
return GenerationGroupResponse(
generation_group_id=group_id,
generations=[GenerationResponse(**gen.model_dump()) for gen in generations]
generations=[self._map_to_response(gen, current_user_id) for gen in generations]
)
def _map_to_response(self, gen: Generation, current_user_id: Optional[str] = None) -> GenerationResponse:
res = GenerationResponse(**gen.model_dump())
res.likes_count = len(gen.liked_by) if gen.liked_by else 0
res.is_liked = current_user_id in gen.liked_by if current_user_id and gen.liked_by else False
return res
async def get_running_generations(self, user_id: Optional[str] = None, project_id: Optional[str] = None) -> List[Generation]:
return await self.dao.generations.get_generations(status=GenerationStatus.RUNNING, created_by=user_id, project_id=project_id)

View File

@@ -40,6 +40,7 @@ class Generation(BaseModel):
created_by: Optional[str] = None # Stores User ID (Telegram ID or Web User ObjectId)
project_id: Optional[str] = None
idea_id: Optional[str] = None
liked_by: List[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

View File

@@ -26,7 +26,8 @@ class GenerationRepo:
return Generation(**res)
async def get_generations(self, character_id: Optional[str] = None, status: Optional[GenerationStatus] = None,
limit: int = 10, offset: int = 0, created_by: Optional[str] = None, project_id: Optional[str] = None, idea_id: Optional[str] = None) -> List[Generation]:
limit: int = 10, offset: int = 0, created_by: Optional[str] = None, project_id: Optional[str] = None,
idea_id: Optional[str] = None, only_liked_by: Optional[str] = None) -> List[Generation]:
filter: dict[str, Any] = {"is_deleted": False}
if character_id is not None:
@@ -43,6 +44,8 @@ class GenerationRepo:
filter["project_id"] = project_id
if idea_id is not None:
filter["idea_id"] = idea_id
if only_liked_by is not None:
filter["liked_by"] = only_liked_by
# If fetching for an idea, sort by created_at ascending (cronological)
# Otherwise typically descending (newest first)
@@ -57,7 +60,8 @@ class GenerationRepo:
return generations
async def count_generations(self, character_id: Optional[str] = None, status: Optional[GenerationStatus] = None,
album_id: Optional[str] = None, created_by: Optional[str] = None, project_id: Optional[str] = None, idea_id: Optional[str] = None) -> int:
album_id: Optional[str] = None, created_by: Optional[str] = None, project_id: Optional[str] = None,
idea_id: Optional[str] = None, only_liked_by: Optional[str] = None) -> int:
args = {}
if character_id is not None:
args["linked_character_id"] = character_id
@@ -73,6 +77,8 @@ class GenerationRepo:
args["idea_id"] = idea_id
if album_id is not None:
args["album_id"] = album_id
if only_liked_by is not None:
args["liked_by"] = only_liked_by
return await self.collection.count_documents(args)
async def get_generations_by_ids(self, generation_ids: List[str]) -> List[Generation]:
@@ -94,6 +100,37 @@ class GenerationRepo:
async def update_generation(self, generation: Generation, ):
res = await self.collection.update_one({"_id": ObjectId(generation.id)}, {"$set": generation.model_dump()})
async def toggle_like(self, generation_id: str, user_id: str) -> bool | None:
"""
Toggles like for a user on a generation.
Returns True if liked, False if unliked, None if generation not found.
"""
if not ObjectId.is_valid(generation_id):
return None
oid = ObjectId(generation_id)
# Check if generation exists
gen = await self.collection.find_one({"_id": oid}, {"liked_by": 1})
if not gen:
return None
if user_id in gen.get("liked_by", []):
# Unlike
await self.collection.update_one(
{"_id": oid},
{"$pull": {"liked_by": user_id}}
)
return False
else:
# Like
await self.collection.update_one(
{"_id": oid},
{"$addToSet": {"liked_by": user_id}}
)
return True
async def get_usage_stats(self, created_by: Optional[str] = None, project_id: Optional[str] = None) -> dict:
"""
Calculates usage statistics (runs, tokens, cost) using MongoDB aggregation.