diff --git a/.context.md b/.context.md new file mode 100644 index 0000000..d33bda8 --- /dev/null +++ b/.context.md @@ -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. diff --git a/.gemini/AGENTS.md b/.gemini/AGENTS.md new file mode 100644 index 0000000..d33bda8 --- /dev/null +++ b/.gemini/AGENTS.md @@ -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. diff --git a/api/endpoints/generation_router.py b/api/endpoints/generation_router.py index dc6a98b..3634a17 100644 --- a/api/endpoints/generation_router.py +++ b/api/endpoints/generation_router.py @@ -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, diff --git a/api/endpoints/idea_router.py b/api/endpoints/idea_router.py index c78c5d4..6510b8c 100644 --- a/api/endpoints/idea_router.py +++ b/api/endpoints/idea_router.py @@ -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( diff --git a/api/models/GenerationRequest.py b/api/models/GenerationRequest.py index 4d9cdae..d05f2b1 100644 --- a/api/models/GenerationRequest.py +++ b/api/models/GenerationRequest.py @@ -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) diff --git a/api/service/generation_service.py b/api/service/generation_service.py index 818cf29..772f0c6 100644 --- a/api/service/generation_service.py +++ b/api/service/generation_service.py @@ -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) diff --git a/models/Generation.py b/models/Generation.py index 50c535f..b6331a0 100644 --- a/models/Generation.py +++ b/models/Generation.py @@ -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)) diff --git a/repos/generation_repo.py b/repos/generation_repo.py index 1109196..a4ec38a 100644 --- a/repos/generation_repo.py +++ b/repos/generation_repo.py @@ -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.