diff --git a/CLAUDE.md b/CLAUDE.md index 72c1bf0..cfa96b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ Create `.env` file with: - `AiAgent`: Core agent model with comprehensive fields (includes `discipline`, `rating`) - `UsageTimelineEntry`: Daily usage data including `message_count` and `token_count` - `UserCreate/UserResponse`: User management models -- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `discipline`, `rating`) +- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `discipline`, `rating`, `rating_count`) - `AgentCollectorCreate`: Collector API input model (includes `total_tokens`, `discipline`) - `AgentUsageStatsResponse`: Usage statistics response (includes `total_tokens`) @@ -71,7 +71,8 @@ Create `.env` file with: - All operations use ObjectId for MongoDB document IDs **database.py**: MongoDB connection setup with Motor async client -- Collections: `users`, `agents`, `agent_usage`, `token_notifications` +- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings` +- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)` **notifications.py**: Optional Mailgun email notification system: - `is_mailgun_configured()`: Returns False if env vars not set (gracefully disabled) @@ -122,11 +123,19 @@ Located in `templates/` directory: - Admin can view/manage all agents ### Discipline & Star Rating -- `discipline` field classifies agents into business categories: Strategy, Creative, Oversight including delivery, Optimization, Back Office including operations +- `discipline` field classifies agents into business categories: Strategy, Creative, Oversight including delivery, Optimization, Back Office including operations, Pencil Agents - Required on registration form, optional on edit (to support legacy agents) -- `rating` field is a 1-5 star rating for quality assessment by discipline leads -- Interactive star rating widget in detail modal saves immediately via `PUT /api/agents/{id}/rating` -- Star rating also editable in the edit modal (both agent management page and admin dashboard) +- Pencil Agents discipline is auto-assigned to agents with "pencil" in the name when no discipline is set (collector API auto-tag + startup migration) +- `rating` field stores the **average** star rating (1-5) computed from all per-user ratings +- `rating_count` field stores the number of individual ratings +- **Per-user rating system**: Any authenticated user can rate any agent via `PUT /api/agents/{id}/rating` + - Individual ratings stored in `agent_ratings` collection with compound unique index on `(agent_id, user_id)` + - After each rating, the agent's average rating and count are recalculated and stored on the agent document + - `GET /api/agents/{id}/my-rating` returns the current user's rating plus the average and count +- Interactive star rating widget in detail modal shows the **user's own rating** as filled stars +- Average rating and count displayed below the stars and on agent card badges +- Rating removed from edit modals (rating is per-user, not admin-set) +- Rating framework info modal accessible via info icon next to "Rating:" label - Dashboard supports filtering by discipline and sorting by rating - Discipline badge (purple) and star rating badge displayed on agent cards - Both fields included in CSV export/import @@ -159,7 +168,7 @@ Located in `templates/` directory: - Async operations using Motor driver - Indexed queries for performance - Data aggregation for statistics -- Collections: `users`, `agents`, `agent_usage`, `token_notifications` +- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings` ## Development Guidelines diff --git a/crud.py b/crud.py index 898b1d8..2ac7f1b 100644 --- a/crud.py +++ b/crud.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from bson import ObjectId import database -from database import users_collection, agents_collection, agent_usage_collection +from database import users_collection, agents_collection, agent_usage_collection, agent_ratings_collection import auth from auth import hash_password, verify_password import re @@ -539,3 +539,73 @@ async def create_agent_from_collector(agent_data: dict): return agent_doc except Exception as e: raise Exception(f"Failed to store agent data: {str(e)}") + + +# Per-user rating functions + +async def upsert_agent_rating(agent_id: str, user_id: str, rating: float): + """Insert or update a user's individual rating for an agent""" + now = datetime.utcnow() + result = await agent_ratings_collection.update_one( + {"agent_id": agent_id, "user_id": user_id}, + { + "$set": {"rating": rating, "updated_at": now}, + "$setOnInsert": {"created_at": now} + }, + upsert=True + ) + return result + +async def get_agent_rating_stats(agent_id: str): + """Get average rating and count for an agent via aggregation""" + pipeline = [ + {"$match": {"agent_id": agent_id}}, + {"$group": { + "_id": None, + "avg_rating": {"$avg": "$rating"}, + "count": {"$sum": 1} + }} + ] + results = await agent_ratings_collection.aggregate(pipeline).to_list(length=1) + if results: + return { + "avg_rating": round(results[0]["avg_rating"], 1), + "count": results[0]["count"] + } + return {"avg_rating": None, "count": 0} + +async def get_user_rating_for_agent(agent_id: str, user_id: str): + """Fetch current user's own rating for an agent""" + doc = await agent_ratings_collection.find_one( + {"agent_id": agent_id, "user_id": user_id} + ) + return doc["rating"] if doc else None + +async def update_agent_average_rating(agent_id: str): + """Recalculate and store average rating on the agent document""" + stats = await get_agent_rating_stats(agent_id) + update_data = { + "rating": stats["avg_rating"], + "rating_count": stats["count"], + "updated_at": datetime.utcnow() + } + await agents_collection.update_one( + {"_id": ObjectId(agent_id)}, + {"$set": update_data} + ) + return stats + +async def migrate_pencil_agents_discipline(): + """Update agents with 'pencil' in name that have no discipline set to 'Pencil Agents'""" + result = await agents_collection.update_many( + { + "agent_name": {"$regex": "pencil", "$options": "i"}, + "$or": [ + {"discipline": None}, + {"discipline": ""}, + {"discipline": {"$exists": False}} + ] + }, + {"$set": {"discipline": "Pencil Agents"}} + ) + return result.modified_count diff --git a/database.py b/database.py index fc34d2b..c802dfb 100644 --- a/database.py +++ b/database.py @@ -13,6 +13,18 @@ users_collection = db.get_collection("users") agents_collection = db.get_collection("agents") agent_usage_collection = db.get_collection("agent_usage") notifications_collection = db.get_collection("token_notifications") +agent_ratings_collection = db.get_collection("agent_ratings") + +async def ensure_indexes(): + """Create database indexes for performance""" + try: + await agent_ratings_collection.create_index( + [("agent_id", 1), ("user_id", 1)], + unique=True + ) + print("Database indexes ensured successfully") + except Exception as e: + print(f"Warning: Failed to create indexes: {e}") async def check_database_health(): """Check MongoDB connection health""" diff --git a/main.py b/main.py index f9e4ad6..948049d 100644 --- a/main.py +++ b/main.py @@ -227,6 +227,7 @@ def create_agent_response(agent: dict) -> models.AiAgentResponse: last_edited_by=agent.get("last_edited_by"), discipline=agent.get("discipline"), rating=agent.get("rating"), + rating_count=agent.get("rating_count"), created_by=agent["created_by"], # Usage tracking fields usage_timeline=agent.get("usage_timeline"), @@ -266,6 +267,11 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate) usage_timeline = [{"date": entry.date, "message_count": entry.message_count, "token_count": entry.token_count} for entry in collector_data.usage_timeline] + # Auto-tag Pencil Agents discipline + discipline = collector_data.discipline + if not discipline and "pencil" in collector_data.name.lower(): + discipline = "Pencil Agents" + return { "agent_name": collector_data.name, "agent_tool": collector_data.tool, @@ -281,7 +287,7 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate) "agent_tags": collector_data.tags, "agent_metadata": collector_data.metadata, "url": collector_data.url, - "discipline": collector_data.discipline, + "discipline": discipline, # Usage tracking fields "usage_timeline": usage_timeline, "conversation_count": collector_data.conversation_count, @@ -303,6 +309,17 @@ async def me(current_user: dict = Depends(get_current_user)): "is_admin": current_user["is_admin"] } +@app.on_event("startup") +async def startup_event(): + """Run database migrations and ensure indexes on startup""" + from database import ensure_indexes + await ensure_indexes() + + # Migrate pencil agents discipline + count = await crud.migrate_pencil_agents_discipline() + if count > 0: + print(f"Pencil Agents migration: updated {count} agent(s)") + # HTML Routes @app.get("/") async def home(request: Request): @@ -893,25 +910,44 @@ async def update_agent(agent_id: str, agent: models.AiAgentCreate, current_user: @app.put("/api/agents/{agent_id}/rating") async def update_agent_rating(agent_id: str, request: Request, current_user: dict = Depends(get_current_user_from_cookie)): - """Update only the star rating of an agent (lightweight endpoint for immediate saves)""" + """Submit a per-user star rating for an agent (any authenticated user can rate)""" existing_agent = await crud.get_agent_by_id(agent_id) if not existing_agent: raise HTTPException(status_code=404, detail="Agent not found") - if not can_user_edit_agent(existing_agent, current_user): - raise HTTPException(status_code=403, detail="Not authorized to rate this agent") - body = await request.json() rating_value = body.get("rating") if rating_value is None or not (1 <= float(rating_value) <= 5): raise HTTPException(status_code=422, detail="Rating must be between 1 and 5") - updated = await crud.update_agent(agent_id, {"rating": float(rating_value)}, last_edited_by=current_user.get("email")) - if not updated: - raise HTTPException(status_code=500, detail="Failed to update rating") + user_id = str(current_user["_id"]) + await crud.upsert_agent_rating(agent_id, user_id, float(rating_value)) + stats = await crud.update_agent_average_rating(agent_id) - return {"message": "Rating updated", "rating": float(rating_value)} + return { + "message": "Rating updated", + "rating": stats["avg_rating"], + "rating_count": stats["count"], + "user_rating": float(rating_value) + } + +@app.get("/api/agents/{agent_id}/my-rating") +async def get_my_agent_rating(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)): + """Get the current user's rating for an agent plus the average""" + existing_agent = await crud.get_agent_by_id(agent_id) + if not existing_agent: + raise HTTPException(status_code=404, detail="Agent not found") + + user_id = str(current_user["_id"]) + user_rating = await crud.get_user_rating_for_agent(agent_id, user_id) + stats = await crud.get_agent_rating_stats(agent_id) + + return { + "user_rating": user_rating, + "rating": stats["avg_rating"], + "rating_count": stats["count"] + } @app.delete("/api/agents/{agent_id}") async def delete_agent(agent_id: str, current_user: dict = Depends(get_current_user_from_cookie)): @@ -1115,7 +1151,8 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)): "created_by", "total_tokens", "discipline", - "rating" + "rating", + "rating_count" ] writer = csv.DictWriter(output, fieldnames=fieldnames) @@ -1152,7 +1189,8 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)): "created_by": agent.get("created_by", ""), "total_tokens": str(agent.get("total_tokens", "")) if agent.get("total_tokens") is not None else "", "discipline": agent.get("discipline", ""), - "rating": str(agent.get("rating", "")) if agent.get("rating") is not None else "" + "rating": str(agent.get("rating", "")) if agent.get("rating") is not None else "", + "rating_count": str(agent.get("rating_count", "")) if agent.get("rating_count") is not None else "" } writer.writerow(row) diff --git a/models.py b/models.py index 9064bf0..212f5d2 100644 --- a/models.py +++ b/models.py @@ -129,6 +129,7 @@ class AiAgentResponse(BaseModel): last_edited_by: Optional[str] = None discipline: Optional[str] = None rating: Optional[float] = None + rating_count: Optional[int] = None created_by: str # Usage tracking fields (new) diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 9524cd1..247f93d 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -429,21 +429,9 @@ + -