Shubham Sattigeri commited on
Commit
c5f36f3
·
1 Parent(s): 36131f5

fix: SSO dotenv path and auth routes

Browse files
.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Backend SSO configuration
2
+ SSO_DISCOVERY_URL=https://YOUR_SSO_PROVIDER/.well-known/openid-configuration
3
+ SSO_CLIENT_ID=your-client-id
4
+ SSO_CLIENT_SECRET=your-client-secret
5
+ SESSION_SECRET=replace-with-a-secure-secret
6
+ FRONTEND_URLS=http://localhost:5173,http://127.0.0.1:5173
.gitignore CHANGED
@@ -1,2 +1,3 @@
1
  node_modules/
2
  *.db
 
 
1
  node_modules/
2
  *.db
3
+ .env
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official lightweight Python image
2
+ FROM python:3.9-slim
3
+
4
+ # Set the working directory
5
+ WORKDIR /app
6
+
7
+ # Copy your requirements and install them
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ # Copy all your project files (including the massive database) into the container
12
+ COPY . .
13
+
14
+ # Expose port 7860 (Hugging Face's default port)
15
+ EXPOSE 7860
16
+
17
+ # Command to run the FastAPI server
18
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
api/__pycache__/main.cpython-311.pyc CHANGED
Binary files a/api/__pycache__/main.cpython-311.pyc and b/api/__pycache__/main.cpython-311.pyc differ
 
api/__pycache__/routes.cpython-311.pyc CHANGED
Binary files a/api/__pycache__/routes.cpython-311.pyc and b/api/__pycache__/routes.cpython-311.pyc differ
 
api/auth.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException, status
2
+ from starlette.requests import Request
3
+
4
+
5
+ def require_authenticated_user(request: Request):
6
+ """Require a logged-in SSO user when SSO is enabled."""
7
+ if getattr(request.app.state, 'sso_enabled', False) and not request.session.get('user'):
8
+ raise HTTPException(
9
+ status_code=status.HTTP_401_UNAUTHORIZED,
10
+ detail='Authentication required. Please sign in using SSO.',
11
+ )
12
+ return request.session.get('user')
api/main.py CHANGED
@@ -4,8 +4,13 @@ FastAPI application entry point.
4
  import os
5
  import json
6
  import time
 
7
  from fastapi import FastAPI
 
 
8
  from fastapi.middleware.cors import CORSMiddleware
 
 
9
  from .routes import router
10
  from artwork_bandit.db import database
11
  from artwork_bandit.features.nlp_encoder import NLPEncoder
@@ -16,7 +21,43 @@ from artwork_bandit.bandit.thompson import ThompsonBandit
16
 
17
  APP = FastAPI()
18
 
19
- APP.add_middleware(CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  APP.include_router(router)
21
 
22
  DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
 
4
  import os
5
  import json
6
  import time
7
+ from dotenv import load_dotenv
8
  from fastapi import FastAPI
9
+
10
+ load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
11
  from fastapi.middleware.cors import CORSMiddleware
12
+ from starlette.middleware.sessions import SessionMiddleware
13
+ from authlib.integrations.starlette_client import OAuth
14
  from .routes import router
15
  from artwork_bandit.db import database
16
  from artwork_bandit.features.nlp_encoder import NLPEncoder
 
21
 
22
  APP = FastAPI()
23
 
24
+ FRONTEND_URLS = os.getenv('FRONTEND_URLS', 'http://localhost:5173,http://127.0.0.1:5173').split(',')
25
+ FRONTEND_URL = FRONTEND_URLS[0].strip()
26
+
27
+ APP.add_middleware(
28
+ SessionMiddleware,
29
+ secret_key=os.getenv('SESSION_SECRET', 'replace-with-a-secure-secret'),
30
+ same_site='lax',
31
+ )
32
+ APP.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=[url.strip() for url in FRONTEND_URLS if url.strip()],
35
+ allow_methods=['*'],
36
+ allow_headers=['*'],
37
+ allow_credentials=True,
38
+ )
39
+
40
+ SSO_DISCOVERY_URL = os.getenv('SSO_DISCOVERY_URL')
41
+ SSO_CLIENT_ID = os.getenv('SSO_CLIENT_ID')
42
+ SSO_CLIENT_SECRET = os.getenv('SSO_CLIENT_SECRET')
43
+ SSO_SCOPES = os.getenv('SSO_SCOPES', 'openid profile email')
44
+
45
+ APP.state.frontend_url = FRONTEND_URL
46
+ APP.state.sso_enabled = bool(SSO_DISCOVERY_URL and SSO_CLIENT_ID and SSO_CLIENT_SECRET)
47
+ print(f"[STARTUP] SSO enabled: {APP.state.sso_enabled}")
48
+ APP.state.oauth = None
49
+
50
+ if APP.state.sso_enabled:
51
+ oauth = OAuth()
52
+ oauth.register(
53
+ name='sso',
54
+ client_id=SSO_CLIENT_ID,
55
+ client_secret=SSO_CLIENT_SECRET,
56
+ server_metadata_url=SSO_DISCOVERY_URL,
57
+ client_kwargs={'scope': SSO_SCOPES},
58
+ )
59
+ APP.state.oauth = oauth
60
+
61
  APP.include_router(router)
62
 
63
  DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
api/routes.py CHANGED
@@ -1,4 +1,5 @@
1
  from fastapi import APIRouter, HTTPException, Request
 
2
  from .schemas import RecommendRequest, RecommendResponse, FeedbackRequest, FeedbackResponse, StatsResponse
3
  import time
4
  from artwork_bandit.db import database
@@ -7,8 +8,53 @@ import numpy as np
7
  router = APIRouter()
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  @router.get('/users')
11
  async def list_users(request: Request):
 
12
  app = request.app
13
  users = getattr(app.state, 'users', None)
14
  if users is None:
@@ -19,6 +65,7 @@ async def list_users(request: Request):
19
 
20
  @router.get('/contents')
21
  async def list_contents(request: Request):
 
22
  app = request.app
23
  contents = getattr(app.state, 'contents', None)
24
  if contents is None:
@@ -27,6 +74,7 @@ async def list_contents(request: Request):
27
 
28
  @router.post('/recommend', response_model=RecommendResponse)
29
  async def recommend(req: RecommendRequest, request: Request):
 
30
  app = request.app
31
  start = time.time()
32
  fs = app.state.feature_store
@@ -80,6 +128,7 @@ async def recommend(req: RecommendRequest, request: Request):
80
 
81
  @router.post('/feedback', response_model=FeedbackResponse)
82
  async def feedback(req: FeedbackRequest, request: Request):
 
83
  app = request.app
84
  imp = database.get_impression(req.impression_id)
85
  if imp is None:
@@ -100,6 +149,7 @@ async def feedback(req: FeedbackRequest, request: Request):
100
 
101
  @router.get('/stats', response_model=StatsResponse)
102
  async def stats(request: Request):
 
103
  rows = database.impressions_with_rewards()
104
  total = len(rows)
105
  clicks = sum(1 for r in rows if r.reward and r.reward > 0)
 
1
  from fastapi import APIRouter, HTTPException, Request
2
+ from fastapi.responses import RedirectResponse
3
  from .schemas import RecommendRequest, RecommendResponse, FeedbackRequest, FeedbackResponse, StatsResponse
4
  import time
5
  from artwork_bandit.db import database
 
8
  router = APIRouter()
9
 
10
 
11
+ def require_auth(request: Request):
12
+ if getattr(request.app.state, 'sso_enabled', False) and not request.session.get('user'):
13
+ raise HTTPException(status_code=401, detail='Authentication required')
14
+
15
+
16
+ @router.get('/auth/user')
17
+ async def auth_user(request: Request):
18
+ return {
19
+ 'is_authenticated': bool(request.session.get('user')),
20
+ 'user': request.session.get('user'),
21
+ 'sso_enabled': getattr(request.app.state, 'sso_enabled', False),
22
+ }
23
+
24
+
25
+ @router.get('/auth/login')
26
+ async def auth_login(request: Request):
27
+ if not getattr(request.app.state, 'sso_enabled', False):
28
+ raise HTTPException(status_code=400, detail='SSO is not configured')
29
+ redirect_uri = request.url_for('auth_callback')
30
+ return await request.app.state.oauth.sso.authorize_redirect(request, redirect_uri)
31
+
32
+
33
+ @router.get('/auth/callback')
34
+ async def auth_callback(request: Request):
35
+ if not getattr(request.app.state, 'sso_enabled', False):
36
+ raise HTTPException(status_code=400, detail='SSO is not configured')
37
+ token = await request.app.state.oauth.sso.authorize_access_token(request)
38
+ user = token.get('userinfo')
39
+ if not user:
40
+ user = await request.app.state.oauth.sso.parse_id_token(request, token)
41
+ request.session['user'] = {
42
+ 'name': user.get('name') or user.get('email') or user.get('preferred_username'),
43
+ 'email': user.get('email'),
44
+ 'sub': user.get('sub'),
45
+ }
46
+ return RedirectResponse(request.app.state.frontend_url)
47
+
48
+
49
+ @router.get('/auth/logout')
50
+ async def auth_logout(request: Request):
51
+ request.session.clear()
52
+ return RedirectResponse(request.app.state.frontend_url)
53
+
54
+
55
  @router.get('/users')
56
  async def list_users(request: Request):
57
+ require_auth(request)
58
  app = request.app
59
  users = getattr(app.state, 'users', None)
60
  if users is None:
 
65
 
66
  @router.get('/contents')
67
  async def list_contents(request: Request):
68
+ require_auth(request)
69
  app = request.app
70
  contents = getattr(app.state, 'contents', None)
71
  if contents is None:
 
74
 
75
  @router.post('/recommend', response_model=RecommendResponse)
76
  async def recommend(req: RecommendRequest, request: Request):
77
+ require_auth(request)
78
  app = request.app
79
  start = time.time()
80
  fs = app.state.feature_store
 
128
 
129
  @router.post('/feedback', response_model=FeedbackResponse)
130
  async def feedback(req: FeedbackRequest, request: Request):
131
+ require_auth(request)
132
  app = request.app
133
  imp = database.get_impression(req.impression_id)
134
  if imp is None:
 
149
 
150
  @router.get('/stats', response_model=StatsResponse)
151
  async def stats(request: Request):
152
+ require_auth(request)
153
  rows = database.impressions_with_rewards()
154
  total = len(rows)
155
  clicks = sum(1 for r in rows if r.reward and r.reward > 0)
features/__pycache__/vision_encoder.cpython-311.pyc CHANGED
Binary files a/features/__pycache__/vision_encoder.cpython-311.pyc and b/features/__pycache__/vision_encoder.cpython-311.pyc differ
 
features/vision_encoder.py CHANGED
@@ -31,7 +31,7 @@ def _ensure_clip_loaded():
31
  print("[VisionEncoder] Creating ViT-B-32 model...", file=sys.stderr)
32
  sys.stderr.flush()
33
  # create model; choose a commonly available pretrained
34
- _clip_model, _, _clip_preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s32b_b79k')
35
  _clip_tokenizer = open_clip.get_tokenizer('ViT-B-32')
36
  _clip_model.eval()
37
  HAS_CLIP = True
 
31
  print("[VisionEncoder] Creating ViT-B-32 model...", file=sys.stderr)
32
  sys.stderr.flush()
33
  # create model; choose a commonly available pretrained
34
+ _clip_model, _, _clip_preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k')
35
  _clip_tokenizer = open_clip.get_tokenizer('ViT-B-32')
36
  _clip_model.eval()
37
  HAS_CLIP = True
frontend/.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Frontend API base URL for development
2
+ VITE_API_BASE_URL=http://localhost:8000
frontend/src/App.jsx CHANGED
@@ -1,22 +1,66 @@
1
  import React, { useState, useEffect } from 'react'
2
- import { recommend, feedback, stats, getUsers, getContents } from './api'
 
 
 
 
 
 
 
 
 
3
 
4
  export default function App() {
5
- const [userId, setUserId] = useState('user_001')
6
- const [contentId, setContentId] = useState('content_001')
 
 
 
 
7
  const [users, setUsers] = useState([])
8
  const [contents, setContents] = useState([])
9
  const [result, setResult] = useState(null)
10
  const [impressionId, setImpressionId] = useState(null)
 
11
  const [statusMsg, setStatusMsg] = useState('')
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  async function handleRecommend() {
14
  setStatusMsg('Requesting recommendation...')
15
  try {
16
- const res = await recommend(userId, contentId)
17
  setResult(res)
18
  setImpressionId(res.impression_id)
19
- setStatusMsg(`Got recommendation (latency ${Math.round(res.latency_ms)} ms)`)
 
20
  } catch (e) {
21
  setStatusMsg('Error: ' + (e.message || e))
22
  }
@@ -24,97 +68,165 @@ export default function App() {
24
 
25
  async function handleFeedback(reward = 1.0) {
26
  if (!impressionId) {
27
- setStatusMsg('No impression to give feedback for')
28
  return
29
  }
30
  setStatusMsg('Sending feedback...')
31
  try {
32
- const res = await feedback(impressionId, reward)
33
- setStatusMsg('Feedback recorded')
34
  } catch (e) {
35
  setStatusMsg('Error: ' + (e.message || e))
36
  }
37
  }
38
 
39
  async function handleStats() {
40
- setStatusMsg('Fetching stats...')
41
  try {
42
  const s = await stats()
43
- setStatusMsg(`CTR: ${s.overall_ctr} (${s.total_clicks}/${s.total_impressions})`)
 
44
  } catch (e) {
45
  setStatusMsg('Error: ' + (e.message || e))
46
  }
47
  }
48
 
49
  useEffect(() => {
50
- async function loadLists() {
51
- try {
52
- const [u, c] = await Promise.all([getUsers(), getContents()])
53
- setUsers(u || [])
54
- setContents(c || [])
55
- if (u && u.length && !userId) setUserId(u[0].user_id)
56
- if (c && c.length && !contentId) setContentId(c[0].content_id)
57
- } catch (e) {
58
- setStatusMsg('Failed to load users/contents: ' + (e.message || e))
59
- }
60
- }
61
- loadLists()
62
  }, [])
63
 
 
 
64
  return (
65
  <div className="container">
66
- <header>
67
- <h1>Artwork Bandit — Demo UI</h1>
68
- <p className="subtitle">Recommend artworks, send feedback, and view stats</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  </header>
70
 
71
- <main>
72
- <div className="controls">
73
- <label>
74
- User
75
- <select value={userId} onChange={(e) => setUserId(e.target.value)}>
76
- {users.map((u) => (
77
- <option key={u.user_id} value={u.user_id}>{u.user_id} {u.mood ? `— ${u.mood}` : ''}</option>
78
- ))}
79
- </select>
80
- </label>
81
- <label>
82
- Content
83
- <select value={contentId} onChange={(e) => setContentId(e.target.value)}>
84
- {contents.map((c) => (
85
- <option key={c.content_id} value={c.content_id}>{c.content_id} {c.title ? `— ${c.title}` : ''}</option>
86
- ))}
87
- </select>
88
- </label>
89
- <div className="actions">
90
- <button onClick={handleRecommend}>Recommend</button>
91
- <button onClick={() => handleFeedback(1.0)}>Click (reward=1)</button>
92
- <button onClick={() => handleFeedback(0.0)}>No Click (reward=0)</button>
93
- <button onClick={handleStats}>Stats</button>
94
  </div>
95
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- <section className="result">
98
- {result ? (
99
- <div className="card">
100
- <img src={result.artwork_image || ''} alt={result.artwork_id} />
101
- <div className="card-body">
102
- <h3>{result.artwork_id}</h3>
103
- <p>Algorithm: {result.algorithm}</p>
104
- <p>Latency: {Math.round(result.latency_ms)} ms</p>
105
- <p>Impression: {result.impression_id}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  </div>
107
- </div>
108
- ) : (
109
- <p>No recommendation yet.</p>
110
- )}
111
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
- <div className="status">{statusMsg}</div>
114
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  <footer>
117
- <small>Runs against API at <code>http://127.0.0.1:8000</code></small>
 
118
  </footer>
119
  </div>
120
  )
 
1
  import React, { useState, useEffect } from 'react'
2
+ import {
3
+ initiateLogin,
4
+ initiateLogout,
5
+ getCurrentUser,
6
+ recommend,
7
+ feedback,
8
+ stats,
9
+ getUsers,
10
+ getContents,
11
+ } from './api'
12
 
13
  export default function App() {
14
+ const [authUser, setAuthUser] = useState(null)
15
+ const [authChecked, setAuthChecked] = useState(false)
16
+ const [ssoEnabled, setSsoEnabled] = useState(false)
17
+ const [userId, setUserId] = useState('')
18
+ const [contentId, setContentId] = useState('')
19
+ const [algorithm, setAlgorithm] = useState('linucb')
20
  const [users, setUsers] = useState([])
21
  const [contents, setContents] = useState([])
22
  const [result, setResult] = useState(null)
23
  const [impressionId, setImpressionId] = useState(null)
24
+ const [statsData, setStatsData] = useState(null)
25
  const [statusMsg, setStatusMsg] = useState('')
26
 
27
+ async function refreshSession() {
28
+ setStatusMsg('Checking authentication...')
29
+ try {
30
+ const auth = await getCurrentUser()
31
+ setAuthUser(auth.user)
32
+ setSsoEnabled(auth.sso_enabled)
33
+ if (auth.user) {
34
+ await loadLists()
35
+ }
36
+ setStatusMsg('')
37
+ } catch (e) {
38
+ setStatusMsg('Unable to verify authentication: ' + (e.message || e))
39
+ } finally {
40
+ setAuthChecked(true)
41
+ }
42
+ }
43
+
44
+ async function loadLists() {
45
+ try {
46
+ const [u, c] = await Promise.all([getUsers(), getContents()])
47
+ setUsers(u || [])
48
+ setContents(c || [])
49
+ if (u?.length && !userId) setUserId(u[0].user_id)
50
+ if (c?.length && !contentId) setContentId(c[0].content_id)
51
+ } catch (e) {
52
+ setStatusMsg('Failed to load users or contents: ' + (e.message || e))
53
+ }
54
+ }
55
+
56
  async function handleRecommend() {
57
  setStatusMsg('Requesting recommendation...')
58
  try {
59
+ const res = await recommend(userId, contentId, algorithm)
60
  setResult(res)
61
  setImpressionId(res.impression_id)
62
+ setStatsData(null)
63
+ setStatusMsg(`Recommendation delivered in ${Math.round(res.latency_ms)} ms`)
64
  } catch (e) {
65
  setStatusMsg('Error: ' + (e.message || e))
66
  }
 
68
 
69
  async function handleFeedback(reward = 1.0) {
70
  if (!impressionId) {
71
+ setStatusMsg('No recommendation available for feedback')
72
  return
73
  }
74
  setStatusMsg('Sending feedback...')
75
  try {
76
+ await feedback(impressionId, reward)
77
+ setStatusMsg('Feedback recorded successfully')
78
  } catch (e) {
79
  setStatusMsg('Error: ' + (e.message || e))
80
  }
81
  }
82
 
83
  async function handleStats() {
84
+ setStatusMsg('Loading production stats...')
85
  try {
86
  const s = await stats()
87
+ setStatsData(s)
88
+ setStatusMsg('Stats loaded')
89
  } catch (e) {
90
  setStatusMsg('Error: ' + (e.message || e))
91
  }
92
  }
93
 
94
  useEffect(() => {
95
+ refreshSession()
 
 
 
 
 
 
 
 
 
 
 
96
  }, [])
97
 
98
+ const isReady = authChecked && (!!authUser || !authUser)
99
+
100
  return (
101
  <div className="container">
102
+ <header className="topbar">
103
+ <div>
104
+ <p className="eyebrow">Artwork Bandit</p>
105
+ <h1>Recommendation Studio</h1>
106
+ <p className="subtitle">A modern SSO dashboard for artwork recommendation, feedback, and performance insights.</p>
107
+ </div>
108
+ <div className="profile-bar">
109
+ {authChecked ? (
110
+ authUser ? (
111
+ <>
112
+ <div className="profile-info">
113
+ <span>{authUser.name}</span>
114
+ <small>{authUser.email}</small>
115
+ </div>
116
+ <button className="secondary" onClick={initiateLogout}>Sign out</button>
117
+ </>
118
+ ) : ssoEnabled ? (
119
+ <button className="primary" onClick={initiateLogin}>Sign in with SSO</button>
120
+ ) : (
121
+ <div className="profile-info">
122
+ <span>SSO is not configured</span>
123
+ <small>Please set SSO environment variables to enable login.</small>
124
+ </div>
125
+ )
126
+ ) : (
127
+ <span>Checking login...</span>
128
+ )}
129
+ </div>
130
  </header>
131
 
132
+ {!authUser ? (
133
+ <main className="hero-card">
134
+ <div>
135
+ <h2>Secure access with SSO</h2>
136
+ <p>Sign in to access the artwork recommendation engine, track model performance, and share feedback securely.</p>
137
+ <button className="primary" onClick={initiateLogin} disabled={!ssoEnabled}>
138
+ {ssoEnabled ? 'Start with SSO' : 'SSO not configured'}
139
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </div>
141
+ <div className="hero-aside">
142
+ <p>Configured backend SSO keeps your recommendation experiments protected while preserving the same fast feedback loop.</p>
143
+ {!ssoEnabled && <p className="empty-state">Set <code>SSO_DISCOVERY_URL</code>, <code>SSO_CLIENT_ID</code>, and <code>SSO_CLIENT_SECRET</code> to enable login.</p>}
144
+ </div>
145
+ </main>
146
+ ) : (
147
+ <main>
148
+ <section className="dashboard-grid">
149
+ <section className="panel controls-panel">
150
+ <h2>Recommendation controls</h2>
151
+ <label>
152
+ Dataset user
153
+ <select value={userId} onChange={(e) => setUserId(e.target.value)}>
154
+ {users.map((u) => (
155
+ <option key={u.user_id} value={u.user_id}>{u.user_id}{u.mood ? ` — ${u.mood}` : ''}</option>
156
+ ))}
157
+ </select>
158
+ </label>
159
 
160
+ <label>
161
+ Content topic
162
+ <select value={contentId} onChange={(e) => setContentId(e.target.value)}>
163
+ {contents.map((c) => (
164
+ <option key={c.content_id} value={c.content_id}>{c.title || c.content_id}</option>
165
+ ))}
166
+ </select>
167
+ </label>
168
+
169
+ <label>
170
+ Algorithm
171
+ <select value={algorithm} onChange={(e) => setAlgorithm(e.target.value)}>
172
+ <option value="linucb">LinUCB</option>
173
+ <option value="thompson">Thompson Sampling</option>
174
+ </select>
175
+ </label>
176
+
177
+ <div className="actions">
178
+ <button className="primary" onClick={handleRecommend}>Recommend artwork</button>
179
+ <button onClick={() => handleFeedback(1.0)}>Submit click</button>
180
+ <button onClick={() => handleFeedback(0.0)}>Submit no click</button>
181
+ <button className="secondary" onClick={handleStats}>View stats</button>
182
  </div>
183
+ </section>
184
+
185
+ <section className="panel result-panel">
186
+ <h2>Latest recommendation</h2>
187
+ {result ? (
188
+ <div className="card result-card">
189
+ <img src={result.artwork_image || ''} alt={result.artwork_id} />
190
+ <div className="card-body">
191
+ <h3>{result.artwork_id}</h3>
192
+ <p>Algorithm: {result.algorithm}</p>
193
+ <p>Impression ID: {result.impression_id}</p>
194
+ <p>Latency: {Math.round(result.latency_ms)} ms</p>
195
+ </div>
196
+ </div>
197
+ ) : (
198
+ <p className="empty-state">No recommendation requested yet. Use the controls to generate your first prediction.</p>
199
+ )}
200
+ </section>
201
+ </section>
202
 
203
+ <section className="panel stats-panel">
204
+ <h2>Live KPI dashboard</h2>
205
+ {statsData ? (
206
+ <div className="stats-grid">
207
+ <div className="stat-card">
208
+ <span>Total impressions</span>
209
+ <strong>{statsData.total_impressions}</strong>
210
+ </div>
211
+ <div className="stat-card">
212
+ <span>Total clicks</span>
213
+ <strong>{statsData.total_clicks}</strong>
214
+ </div>
215
+ <div className="stat-card">
216
+ <span>Overall CTR</span>
217
+ <strong>{(statsData.overall_ctr * 100).toFixed(1)}%</strong>
218
+ </div>
219
+ </div>
220
+ ) : (
221
+ <p className="empty-state">Run the stats query to inspect production performance.</p>
222
+ )}
223
+ </section>
224
+ </main>
225
+ )}
226
 
227
  <footer>
228
+ <p>{statusMsg || 'Ready'}</p>
229
+ <small>Backend session auth is enabled when SSO credentials are configured.</small>
230
  </footer>
231
  </div>
232
  )
frontend/src/api.js CHANGED
@@ -1,28 +1,42 @@
1
  import axios from 'axios'
2
 
3
- const BASE = 'http://127.0.0.1:8000'
 
4
 
5
- export async function recommend(user_id, content_id) {
6
- const r = await axios.post(`${BASE}/recommend`, { user_id, content_id })
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  return r.data
8
  }
9
 
10
  export async function feedback(impression_id, reward) {
11
- const r = await axios.post(`${BASE}/feedback`, { impression_id, reward })
12
  return r.data
13
  }
14
 
15
  export async function stats() {
16
- const r = await axios.get(`${BASE}/stats`)
17
  return r.data
18
  }
19
 
20
  export async function getUsers() {
21
- const r = await axios.get(`${BASE}/users`)
22
  return r.data
23
  }
24
 
25
  export async function getContents() {
26
- const r = await axios.get(`${BASE}/contents`)
27
  return r.data
28
  }
 
1
  import axios from 'axios'
2
 
3
+ const BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
4
+ const client = axios.create({ baseURL: BASE, withCredentials: true })
5
 
6
+ export const initiateLogin = () => {
7
+ window.location.href = `${BASE}/auth/login`
8
+ }
9
+
10
+ export const initiateLogout = () => {
11
+ window.location.href = `${BASE}/auth/logout`
12
+ }
13
+
14
+ export async function getCurrentUser() {
15
+ const r = await client.get('/auth/user')
16
+ return r.data
17
+ }
18
+
19
+ export async function recommend(user_id, content_id, algorithm = 'linucb') {
20
+ const r = await client.post('/recommend', { user_id, content_id, algorithm })
21
  return r.data
22
  }
23
 
24
  export async function feedback(impression_id, reward) {
25
+ const r = await client.post('/feedback', { impression_id, reward })
26
  return r.data
27
  }
28
 
29
  export async function stats() {
30
+ const r = await client.get('/stats')
31
  return r.data
32
  }
33
 
34
  export async function getUsers() {
35
+ const r = await client.get('/users')
36
  return r.data
37
  }
38
 
39
  export async function getContents() {
40
+ const r = await client.get('/contents')
41
  return r.data
42
  }
frontend/src/styles.css CHANGED
@@ -1,18 +1,282 @@
1
- :root{--bg:#0f1724;--card:#0b1220;--muted:#94a3b8;--accent:#06b6d4}
2
- *{box-sizing:border-box;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,'Helvetica Neue',Arial}
3
- body{margin:0;background:linear-gradient(180deg,#041022 0%,#071029 100%);color:#e6eef6}
4
- .container{max-width:980px;margin:32px auto;padding:24px}
5
- header h1{margin:0;font-size:1.6rem}
6
- .subtitle{margin:4px 0 18px;color:var(--muted)}
7
- .controls{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
8
- label{display:flex;flex-direction:column;font-size:0.9rem;color:var(--muted)}
9
- input{padding:8px 10px;border-radius:8px;border:1px solid #123;background:#071226;color:inherit}
10
- .actions{grid-column:1/-1;display:flex;gap:10px;margin-top:8px}
11
- button{background:var(--accent);border:none;padding:8px 12px;border-radius:8px;color:#052;cursor:pointer}
12
- .result{margin-top:18px}
13
- .card{display:flex;gap:12px;background:linear-gradient(90deg,rgba(255,255,255,0.02),transparent);padding:12px;border-radius:10px;align-items:center}
14
- .card img{width:130px;height:130px;object-fit:cover;border-radius:8px;background:#022}
15
- .card-body h3{margin:0 0 6px}
16
- .status{margin-top:12px;color:var(--muted)}
17
- footer{margin-top:22px;color:var(--muted)}
18
- code{background:#022;padding:2px 6px;border-radius:6px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0f1724;
3
+ --surface: #111827;
4
+ --surface-soft: #131f33;
5
+ --muted: #94a3b8;
6
+ --text: #e6eef6;
7
+ --accent: #22d3ee;
8
+ --accent-dark: #0e7490;
9
+ --border: rgba(148, 163, 184, 0.14);
10
+ }
11
+
12
+ * {
13
+ box-sizing: border-box;
14
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, 'Helvetica Neue', Arial, sans-serif;
15
+ }
16
+
17
+ html,
18
+ body {
19
+ margin: 0;
20
+ min-height: 100%;
21
+ background: radial-gradient(circle at top left, rgba(56, 189, 248, 0.16), transparent 35%),
22
+ radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.15), transparent 28%),
23
+ linear-gradient(180deg, #050811 0%, #08101c 100%);
24
+ color: var(--text);
25
+ }
26
+
27
+ button,
28
+ select {
29
+ font: inherit;
30
+ }
31
+
32
+ .container {
33
+ max-width: 1180px;
34
+ margin: 0 auto;
35
+ padding: 28px 24px 42px;
36
+ }
37
+
38
+ header h1 {
39
+ margin: 0;
40
+ font-size: clamp(2rem, 2.8vw, 3.4rem);
41
+ line-height: 1.05;
42
+ }
43
+
44
+ .subtitle {
45
+ margin: 12px 0 0;
46
+ color: var(--muted);
47
+ max-width: 720px;
48
+ }
49
+
50
+ .topbar {
51
+ display: flex;
52
+ flex-wrap: wrap;
53
+ justify-content: space-between;
54
+ gap: 20px;
55
+ align-items: center;
56
+ margin-bottom: 28px;
57
+ }
58
+
59
+ .eyebrow {
60
+ text-transform: uppercase;
61
+ letter-spacing: 0.24em;
62
+ color: var(--accent);
63
+ margin: 0 0 12px;
64
+ font-size: 0.82rem;
65
+ }
66
+
67
+ .profile-bar {
68
+ display: flex;
69
+ gap: 12px;
70
+ align-items: center;
71
+ }
72
+
73
+ .profile-info {
74
+ display: grid;
75
+ gap: 2px;
76
+ text-align: right;
77
+ }
78
+
79
+ .profile-info span {
80
+ font-weight: 700;
81
+ }
82
+
83
+ .profile-info small {
84
+ color: var(--muted);
85
+ }
86
+
87
+ button {
88
+ border: none;
89
+ border-radius: 999px;
90
+ padding: 12px 18px;
91
+ cursor: pointer;
92
+ transition: transform 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
93
+ }
94
+
95
+ button.primary {
96
+ background: linear-gradient(135deg, var(--accent), #38bdf8);
97
+ color: #031827;
98
+ box-shadow: 0 18px 50px rgba(34, 211, 238, 0.18);
99
+ }
100
+
101
+ button.secondary {
102
+ background: rgba(255, 255, 255, 0.08);
103
+ color: var(--text);
104
+ border: 1px solid rgba(255, 255, 255, 0.08);
105
+ }
106
+
107
+ button:hover {
108
+ transform: translateY(-1px);
109
+ }
110
+
111
+ .hero-card,
112
+ .dashboard-grid,
113
+ .panel,
114
+ .stats-grid {
115
+ display: grid;
116
+ }
117
+
118
+ .hero-card {
119
+ gap: 20px;
120
+ padding: 36px;
121
+ border-radius: 24px;
122
+ background: rgba(255, 255, 255, 0.04);
123
+ border: 1px solid rgba(255, 255, 255, 0.06);
124
+ box-shadow: 0 30px 80px rgba(0, 0, 0, 0.22);
125
+ }
126
+
127
+ .hero-card h2,
128
+ .panel h2 {
129
+ margin: 0 0 14px;
130
+ font-size: 1.4rem;
131
+ }
132
+
133
+ .hero-card p,
134
+ .panel p,
135
+ .empty-state {
136
+ color: var(--muted);
137
+ line-height: 1.7;
138
+ }
139
+
140
+ .hero-aside {
141
+ color: var(--text);
142
+ font-size: 0.95rem;
143
+ line-height: 1.8;
144
+ }
145
+
146
+ .dashboard-grid {
147
+ gap: 24px;
148
+ grid-template-columns: minmax(300px, 360px) minmax(400px, 1fr);
149
+ }
150
+
151
+ .panel {
152
+ gap: 16px;
153
+ padding: 26px;
154
+ border-radius: 24px;
155
+ background: rgba(255, 255, 255, 0.04);
156
+ border: 1px solid rgba(255, 255, 255, 0.06);
157
+ }
158
+
159
+ .controls-panel label,
160
+ .panel label {
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: 8px;
164
+ color: var(--muted);
165
+ }
166
+
167
+ label select {
168
+ min-width: 0;
169
+ border-radius: 16px;
170
+ border: 1px solid rgba(148, 163, 184, 0.18);
171
+ background: rgba(15, 23, 42, 0.9);
172
+ color: var(--text);
173
+ padding: 14px 16px;
174
+ }
175
+
176
+ .actions {
177
+ display: grid;
178
+ gap: 12px;
179
+ margin-top: 12px;
180
+ }
181
+
182
+ .actions button {
183
+ width: 100%;
184
+ }
185
+
186
+ .result-panel {
187
+ min-height: 316px;
188
+ }
189
+
190
+ .card {
191
+ display: grid;
192
+ grid-template-columns: 180px 1fr;
193
+ gap: 20px;
194
+ align-items: center;
195
+ padding: 24px;
196
+ border-radius: 24px;
197
+ background: rgba(255, 255, 255, 0.04);
198
+ border: 1px solid rgba(255, 255, 255, 0.06);
199
+ }
200
+
201
+ .result-card img {
202
+ width: 100%;
203
+ max-width: 180px;
204
+ aspect-ratio: 1 / 1;
205
+ object-fit: cover;
206
+ border-radius: 20px;
207
+ background: #0d172a;
208
+ }
209
+
210
+ .card-body h3 {
211
+ margin: 0 0 12px;
212
+ }
213
+
214
+ .card-body p {
215
+ margin: 4px 0;
216
+ color: var(--muted);
217
+ }
218
+
219
+ .stats-panel {
220
+ grid-column: 1 / -1;
221
+ }
222
+
223
+ .stats-grid {
224
+ grid-template-columns: repeat(3, minmax(180px, 1fr));
225
+ gap: 16px;
226
+ }
227
+
228
+ .stat-card {
229
+ padding: 20px;
230
+ border-radius: 20px;
231
+ background: rgba(15, 23, 42, 0.9);
232
+ border: 1px solid rgba(148, 163, 184, 0.12);
233
+ }
234
+
235
+ .stat-card span {
236
+ display: block;
237
+ color: var(--muted);
238
+ margin-bottom: 8px;
239
+ }
240
+
241
+ .stat-card strong {
242
+ display: block;
243
+ font-size: 1.8rem;
244
+ color: var(--text);
245
+ }
246
+
247
+ footer {
248
+ margin-top: 24px;
249
+ color: var(--muted);
250
+ display: flex;
251
+ justify-content: space-between;
252
+ align-items: center;
253
+ flex-wrap: wrap;
254
+ gap: 12px;
255
+ }
256
+
257
+ .empty-state {
258
+ padding: 28px 0;
259
+ color: var(--muted);
260
+ }
261
+
262
+ @media (max-width: 900px) {
263
+ .dashboard-grid,
264
+ .stats-grid {
265
+ grid-template-columns: 1fr;
266
+ }
267
+ }
268
+
269
+ @media (max-width: 640px) {
270
+ .container {
271
+ padding: 20px 16px 28px;
272
+ }
273
+
274
+ .topbar {
275
+ align-items: flex-start;
276
+ }
277
+
278
+ .profile-bar {
279
+ width: 100%;
280
+ justify-content: flex-start;
281
+ }
282
+ }
requirements.txt CHANGED
@@ -14,3 +14,6 @@ httpx==0.27.0
14
  matplotlib==3.9.0
15
  seaborn==0.13.2
16
  tqdm==4.66.4
 
 
 
 
14
  matplotlib==3.9.0
15
  seaborn==0.13.2
16
  tqdm==4.66.4
17
+ authlib==1.2.0
18
+ python-dotenv==1.0.1
19
+ itsdangerous==2.2.0