Skip to content

Commit 70fa8d4

Browse files
authored
Merge pull request #222 from Two-Weeks-Team/feat/vertex-routing-by-role
feat: Vertex AI routing by user role
2 parents ec35efe + 3eb59c6 commit 70fa8d4

24 files changed

Lines changed: 1379 additions & 188 deletions

.env.example

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,13 @@
1-
# Somm.dev Environment Variables
2-
# Copy this file to .env and fill in your values
1+
# Somm.dev Frontend Environment Variables (Next.js / Vercel)
2+
# Copy this file to .env.local and fill in your values
3+
#
4+
# Backend environment variables → see backend/.env.example
35

46
# ==========================================
5-
# Server Configuration
6-
# ==========================================
7-
ENVIRONMENT=development
8-
DEBUG=false
9-
PORT=8000
10-
11-
# URLs
12-
FRONTEND_URL=http://localhost:3000
13-
BACKEND_URL=http://localhost:8000
14-
15-
# ==========================================
16-
# Database
17-
# ==========================================
18-
MONGO_DB=somm_db
19-
MONGODB_URI=mongodb://localhost:27017/somm_db
20-
21-
# ==========================================
22-
# Authentication
23-
# ==========================================
24-
# JWT Secret (generate with: python -c "import secrets; print(secrets.token_urlsafe(32))")
25-
JWT_SECRET_KEY=generate-a-secure-random-key
26-
27-
# GitHub OAuth (create at: https://github.com/settings/developers)
28-
GITHUB_CLIENT_ID=your-github-client-id
29-
GITHUB_CLIENT_SECRET=your-github-client-secret
30-
31-
# ==========================================
32-
# LLM API Keys
33-
# ==========================================
34-
GEMINI_API_KEY=
35-
OPENAI_API_KEY=
36-
ANTHROPIC_API_KEY=
37-
38-
# ==========================================
39-
# Optional
40-
# ==========================================
41-
GITHUB_TOKEN=
42-
SENTRY_DSN=
43-
RATE_LIMIT_PER_MINUTE=60
44-
45-
# ==========================================
46-
# Frontend (for Next.js)
7+
# Frontend (Next.js)
478
# ==========================================
9+
# Development:
4810
NEXT_PUBLIC_API_URL=http://localhost:8000
11+
12+
# Production (deployed backend):
13+
# NEXT_PUBLIC_API_URL=https://api.somm.dev

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,8 @@ backend/ENV/
105105

106106
backend/.env.production
107107

108+
# Backup files
109+
*.backup.*
110+
108111
# Local demo assets (not for deployment)
109112
_local/

backend/.env.example

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ MONGODB_URI=mongodb://localhost:27017/somm_db
1818
# Get Gemini API key from: https://makersuite.google.com/app/apikey
1919
GEMINI_API_KEY=your_gemini_api_key_here
2020

21-
# Optional: OpenAI as fallback
22-
# Get OpenAI API key from: https://platform.openai.com/api-keys
23-
OPENAI_API_KEY=your_openai_api_key_here
21+
# Vertex AI Express (API key auth for premium/admin routing)
22+
VERTEX_API_KEY=your_vertex_express_api_key_here
23+
GOOGLE_CLOUD_PROJECT=your_gcp_project_id
24+
GOOGLE_CLOUD_LOCATION=asia-northeast3
2425

25-
# Optional: Anthropic as fallback
26-
# Get Anthropic API key from: https://console.anthropic.com/
27-
ANTHROPIC_API_KEY=your_anthropic_api_key_here
26+
# Vertex AI role-based routing allowlists (comma-separated)
27+
# VERTEX_PREMIUM_USER_IDS=user_id_1,user_id_2
28+
# VERTEX_ADMIN_USER_IDS=admin_id_1,admin_id_2
29+
# VERTEX_PREMIUM_EMAILS=premium@example.com
30+
# VERTEX_ADMIN_EMAILS=admin@example.com
2831

2932
# GitHub Personal Access Token
3033
# Required permissions: repo (read access)
@@ -44,6 +47,4 @@ LANGSMITH_ENDPOINT=https://api.smith.langchain.com
4447
LANGSMITH_TRACING=true
4548
LANGCHAIN_PROJECT=somm-dev-local
4649

47-
# RAG Enrichment - Synthetic API (free tier for embeddings)
48-
# Get API key from: https://synthetic.new/
49-
SYNTHETIC_API_KEY=your_synthetic_api_key_here
50+

backend/app/api/routes/admin.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Admin API routes for user role/plan management.
2+
3+
Only accessible by users with role='admin' or listed in VERTEX_ADMIN_USER_IDS/EMAILS.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
from typing import Any
10+
11+
from bson import ObjectId
12+
from bson.errors import InvalidId
13+
from fastapi import APIRouter, Depends, HTTPException
14+
from pydantic import BaseModel, Field
15+
16+
from app.api.deps import User, get_current_user
17+
from app.database.repositories.user import UserRepository
18+
from app.services.provider_routing import _is_admin
19+
20+
logger = logging.getLogger(__name__)
21+
22+
router = APIRouter(prefix="/admin", tags=["admin"])
23+
24+
VALID_ROLES = {"user", "admin"}
25+
VALID_PLANS = {"free", "premium", "pro", "enterprise"}
26+
27+
28+
async def require_admin(user: User = Depends(get_current_user)) -> User:
29+
"""Dependency that requires the current user to be an admin."""
30+
user_repo = UserRepository()
31+
user_doc = await user_repo.get_by_id(user.id)
32+
if not _is_admin(user_doc):
33+
raise HTTPException(status_code=403, detail="Admin access required")
34+
return user
35+
36+
37+
class UserRoleUpdate(BaseModel):
38+
role: str | None = Field(None, description="User role (user, admin)")
39+
plan: str | None = Field(
40+
None, description="User plan (free, premium, pro, enterprise)"
41+
)
42+
43+
44+
class AdminUserResponse(BaseModel):
45+
id: str
46+
username: str
47+
email: str | None = None
48+
github_id: str | None = None
49+
avatar_url: str | None = None
50+
role: str = "user"
51+
plan: str = "free"
52+
created_at: str | None = None
53+
54+
55+
def _to_admin_response(u: dict) -> AdminUserResponse:
56+
return AdminUserResponse(
57+
id=str(u["_id"]),
58+
username=u.get("username", ""),
59+
email=u.get("email"),
60+
github_id=str(u.get("github_id", "")),
61+
avatar_url=u.get("avatar_url"),
62+
role=u.get("role", "user"),
63+
plan=u.get("plan", "free"),
64+
created_at=str(u.get("created_at", "")),
65+
)
66+
67+
68+
@router.get("/users", response_model=list[AdminUserResponse])
69+
async def list_users(
70+
_admin: User = Depends(require_admin),
71+
) -> list[AdminUserResponse]:
72+
"""List all users with their roles and plans."""
73+
user_repo = UserRepository()
74+
users = await user_repo.list(limit=500)
75+
return [_to_admin_response(u) for u in users]
76+
77+
78+
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
79+
async def update_user_role(
80+
user_id: str,
81+
update: UserRoleUpdate,
82+
_admin: User = Depends(require_admin),
83+
) -> AdminUserResponse:
84+
"""Update a user's role and/or plan."""
85+
try:
86+
ObjectId(user_id)
87+
except (InvalidId, Exception):
88+
raise HTTPException(status_code=400, detail="Invalid user ID format")
89+
90+
update_data: dict[str, Any] = {}
91+
92+
if update.role is not None:
93+
if update.role not in VALID_ROLES:
94+
raise HTTPException(
95+
status_code=400,
96+
detail=f"Invalid role: {update.role}. Must be one of {VALID_ROLES}",
97+
)
98+
if update.role != "admin" and user_id == _admin.id:
99+
raise HTTPException(
100+
status_code=400,
101+
detail="Cannot demote yourself. Ask another admin.",
102+
)
103+
update_data["role"] = update.role
104+
105+
if update.plan is not None:
106+
if update.plan not in VALID_PLANS:
107+
raise HTTPException(
108+
status_code=400,
109+
detail=f"Invalid plan: {update.plan}. Must be one of {VALID_PLANS}",
110+
)
111+
update_data["plan"] = update.plan
112+
113+
if not update_data:
114+
raise HTTPException(status_code=400, detail="No fields to update")
115+
116+
user_repo = UserRepository()
117+
updated = await user_repo.update_user(user_id, update_data)
118+
119+
if not updated:
120+
raise HTTPException(status_code=404, detail="User not found")
121+
122+
logger.info(
123+
"[Admin] User %s updated: %s by admin %s", user_id, update_data, _admin.id
124+
)
125+
126+
return _to_admin_response(updated)

backend/app/api/routes/evaluate.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class EvaluateRequest(BaseModel):
6363
)
6464
provider: str | None = Field(
6565
default=None,
66-
description="LLM provider (gemini, openai, anthropic)",
66+
description="LLM provider (gemini, vertex)",
6767
)
6868
model: str | None = Field(
6969
default=None,
@@ -296,12 +296,17 @@ async def get_result(
296296
"""
297297
# Check if this is a public demo evaluation
298298
is_public_demo = evaluation_id in PUBLIC_DEMO_EVALUATIONS
299-
299+
300300
# Require auth for non-public evaluations
301301
if not is_public_demo and user is None:
302302
raise CorkedError("Authentication required to view this evaluation")
303-
303+
304304
try:
305+
if not is_public_demo:
306+
progress = await get_evaluation_progress(evaluation_id)
307+
if progress.get("user_id") != user.id:
308+
raise CorkedError("Access denied: evaluation belongs to another user")
309+
305310
result = await get_evaluation_result(evaluation_id)
306311
except EmptyCellarError:
307312
raise EmptyCellarError(f"Evaluation not found: {evaluation_id}") from None

backend/app/core/config.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ class Settings(BaseSettings):
3838

3939
# LLM APIs
4040
GEMINI_API_KEY: str = ""
41-
OPENAI_API_KEY: str = ""
42-
ANTHROPIC_API_KEY: str = ""
41+
42+
# Vertex AI Express (API key auth)
43+
VERTEX_API_KEY: str = ""
44+
GOOGLE_CLOUD_PROJECT: str = ""
45+
GOOGLE_CLOUD_LOCATION: str = "asia-northeast3"
46+
VERTEX_PREMIUM_USER_IDS: str = ""
47+
VERTEX_ADMIN_USER_IDS: str = ""
48+
VERTEX_PREMIUM_EMAILS: str = ""
49+
VERTEX_ADMIN_EMAILS: str = ""
4350

4451
# LangSmith Tracing (optional - enables LangGraph monitoring)
4552
LANGSMITH_API_KEY: str = ""
@@ -56,14 +63,10 @@ class Settings(BaseSettings):
5663
# Optional: Rate Limiting
5764
RATE_LIMIT_PER_MINUTE: int = 60
5865

59-
# RAG Enrichment (adds context retrieval before evaluation)
66+
# RAG Enrichment (Gemini embeddings + Google Search grounding)
6067
RAG_ENABLED: bool = True
6168
RAG_TOP_K: int = 4
62-
RAG_EMBEDDING_MODEL: str = "hf:nomic-ai/nomic-embed-text-v1.5"
63-
64-
# Synthetic API (for embeddings - free tier)
65-
SYNTHETIC_API_KEY: str = ""
66-
SYNTHETIC_BASE_URL: str = "https://api.synthetic.new/openai/v1"
69+
RAG_EMBEDDING_MODEL: str = "gemini-embedding-001"
6770

6871
# Server settings
6972
PORT: int = 8000

backend/app/graph/grand_tasting_graph.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
CellarNotesNode,
3131
)
3232
from app.graph.nodes.rag_enrich import rag_enrich
33+
from app.graph.nodes.web_search_enrich import web_search_enrich
34+
from app.graph.nodes.code_analysis_enrich import code_analysis_enrich
3335

3436

3537
def create_grand_tasting_graph():
@@ -54,8 +56,17 @@ def create_grand_tasting_graph():
5456
"terroir",
5557
]
5658

59+
enrichment_nodes = []
60+
5761
if settings.RAG_ENABLED:
5862
builder.add_node("rag_enrich", rag_enrich)
63+
enrichment_nodes.append("rag_enrich")
64+
65+
builder.add_node("web_search_enrich", web_search_enrich)
66+
enrichment_nodes.append("web_search_enrich")
67+
68+
builder.add_node("code_analysis_enrich", code_analysis_enrich)
69+
enrichment_nodes.append("code_analysis_enrich")
5970

6071
builder.add_node("aroma", aroma.evaluate)
6172
builder.add_node("palate", palate.evaluate)
@@ -66,13 +77,10 @@ def create_grand_tasting_graph():
6677
builder.add_node("terroir", terroir.evaluate)
6778
builder.add_node("cellar", cellar.evaluate)
6879

69-
if settings.RAG_ENABLED:
70-
builder.add_edge("__start__", "rag_enrich")
71-
for node in parallel_nodes:
72-
builder.add_edge("rag_enrich", node)
73-
else:
80+
for enrich_node in enrichment_nodes:
81+
builder.add_edge("__start__", enrich_node)
7482
for node in parallel_nodes:
75-
builder.add_edge("__start__", node)
83+
builder.add_edge(enrich_node, node)
7684

7785
for node in parallel_nodes:
7886
builder.add_edge(node, "cellar")

backend/app/graph/graph.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,17 @@
1515
from app.graph.nodes.laurent import LaurentNode
1616
from app.graph.nodes.jeanpierre import JeanPierreNode
1717
from app.graph.nodes.rag_enrich import rag_enrich
18+
from app.graph.nodes.web_search_enrich import web_search_enrich
19+
from app.graph.nodes.code_analysis_enrich import code_analysis_enrich
1820

1921

2022
def create_evaluation_graph():
2123
"""Create and configure the evaluation graph.
2224
2325
The graph follows a fan-out/fan-in pattern:
24-
- Optional: RAG enrichment node runs first (if RAG_ENABLED)
25-
- Fan-out: 5 sommelier nodes run in parallel
26-
- Fan-in: All must complete before Jean-Pierre synthesis
27-
- End: Jean-Pierre connects to END after synthesis
26+
- Stage 1: RAG enrichment + Web Search run in parallel
27+
- Stage 2: 5 sommelier nodes run in parallel (after both enrichments complete)
28+
- Stage 3: Jean-Pierre synthesis (after all sommeliers complete)
2829
2930
Returns:
3031
Compiled LangGraph with MongoDB checkpointer for state persistence.
@@ -39,9 +40,17 @@ def create_evaluation_graph():
3940
builder = StateGraph(EvaluationState)
4041

4142
sommelier_nodes = ["marcel", "isabella", "heinrich", "sofia", "laurent"]
43+
enrichment_nodes = []
4244

4345
if settings.RAG_ENABLED:
4446
builder.add_node("rag_enrich", rag_enrich)
47+
enrichment_nodes.append("rag_enrich")
48+
49+
builder.add_node("web_search_enrich", web_search_enrich)
50+
enrichment_nodes.append("web_search_enrich")
51+
52+
builder.add_node("code_analysis_enrich", code_analysis_enrich)
53+
enrichment_nodes.append("code_analysis_enrich")
4554

4655
builder.add_node("marcel", marcel.evaluate)
4756
builder.add_node("isabella", isabella.evaluate)
@@ -50,13 +59,10 @@ def create_evaluation_graph():
5059
builder.add_node("laurent", laurent.evaluate)
5160
builder.add_node("jeanpierre", jeanpierre.evaluate)
5261

53-
if settings.RAG_ENABLED:
54-
builder.add_edge("__start__", "rag_enrich")
55-
for node in sommelier_nodes:
56-
builder.add_edge("rag_enrich", node)
57-
else:
58-
for node in sommelier_nodes:
59-
builder.add_edge("__start__", node)
62+
for enrich_node in enrichment_nodes:
63+
builder.add_edge("__start__", enrich_node)
64+
for sommelier in sommelier_nodes:
65+
builder.add_edge(enrich_node, sommelier)
6066

6167
for node in sommelier_nodes:
6268
builder.add_edge(node, "jeanpierre")

0 commit comments

Comments
 (0)