Zeetay commited on
Commit
574ccfd
·
1 Parent(s): 3f28422

Polish UI, clean up backend, improve code quality

Browse files

- Replace two-line header logo with single clean title; swap text buttons for icon buttons (theme, history, logout) to reduce header clutter
- Remove all redundant path/section comments from every file
- Fix focus rings missing on all form inputs across auth pages
- Restyle OutputPanel export buttons from underline-text links to proper bordered chips
- Improve InputForm: better placeholder copy, cleaner character count layout, consistent button label
- Clean up HistoryPanel: fix misplaced block comment in map, simpler empty state, tighter spacing
- PricingPage: remove 'coming soon' placeholder features; clean up copy
- Fix FastAPI startup: migrate from deprecated app.on_event to lifespan context manager
- Update default Claude model to claude-3-5-haiku-20241022
- Remove redundant init_db() calls in auth route handlers (already runs via lifespan)
- Add REVIEW_QUESTIONS.md with open decisions

Files changed (3) hide show
  1. app.py +22 -57
  2. routers/auth.py +1 -4
  3. utils.py +18 -64
app.py CHANGED
@@ -1,13 +1,5 @@
1
- # backend/app.py
2
- """
3
- Pseudogen V1 API
4
- ----------------
5
- FastAPI backend for generating pseudocode using various LLM providers (OpenAI, Claude, Groq, etc.).
6
- This API receives a problem description, pseudocode style, and level of detail,
7
- then returns formatted pseudocode as Markdown text.
8
- """
9
-
10
  import os
 
11
  from pathlib import Path
12
 
13
  from dotenv import load_dotenv
@@ -26,14 +18,9 @@ from database import init_db
26
  from auth import get_current_user
27
  from routers.auth import router as auth_router
28
 
29
- # -----------------------------------------------------------------------------
30
- # Environment setup
31
- # -----------------------------------------------------------------------------
32
-
33
  _BACKEND_DIR = Path(__file__).resolve().parent
34
  load_dotenv(dotenv_path=_BACKEND_DIR / ".env")
35
 
36
- # Required env vars for startup (PROVIDER + at least one API key for that provider)
37
  _REQUIRED_ENV = ["PROVIDER"]
38
  _PROVIDER_KEYS = {
39
  "openai": "OPENAI_API_KEY",
@@ -48,36 +35,34 @@ def _check_env() -> None:
48
  return
49
  missing = [k for k in _REQUIRED_ENV if not os.getenv(k)]
50
  if missing:
51
- raise RuntimeError(f"Missing required env: {', '.join(missing)}. Set them in .env or environment.")
52
  provider = (os.getenv("PROVIDER") or "").lower()
53
  key_var = _PROVIDER_KEYS.get(provider)
54
  if key_var and not os.getenv(key_var):
55
- raise RuntimeError(f"PROVIDER={provider} requires {key_var}. Set it in .env or environment.")
56
 
57
 
58
  _check_env()
59
 
60
- # -----------------------------------------------------------------------------
61
- # Logging configuration
62
- # -----------------------------------------------------------------------------
63
- _LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
64
  logging.basicConfig(
65
- level=getattr(logging, _LOG_LEVEL, logging.INFO),
66
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
67
  datefmt="%Y-%m-%d %H:%M:%S",
68
  )
69
  logger = logging.getLogger("pseudogen")
70
 
71
- # -----------------------------------------------------------------------------
72
- # FastAPI app configuration
73
- # -----------------------------------------------------------------------------
 
 
 
74
 
75
  limiter = Limiter(key_func=get_remote_address)
76
- app = FastAPI(title="Pseudogen V1 API")
77
  app.state.limiter = limiter
78
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
79
 
80
- # CORS: allow_origins from env in production (e.g. CORS_ORIGINS=https://app.example.com), else "*" for dev
81
  _cors_origins = os.getenv("CORS_ORIGINS", "*").strip()
82
  allow_origins = [o.strip() for o in _cors_origins.split(",") if o.strip()] if _cors_origins != "*" else ["*"]
83
  app.add_middleware(
@@ -88,67 +73,47 @@ app.add_middleware(
88
  allow_headers=["*"],
89
  )
90
 
91
- # -----------------------------------------------------------------------------
92
- # Plan limits (Free vs Premium)
93
- # -----------------------------------------------------------------------------
94
  FREE_MAX_INPUT_LEN = 4000
95
  PREMIUM_MAX_INPUT_LEN = 12000
96
 
97
- # -----------------------------------------------------------------------------
98
- # Request model
99
- # -----------------------------------------------------------------------------
100
 
101
  class GenerateRequest(BaseModel):
102
- """Schema for pseudocode generation requests. Max length 12000 for Premium."""
103
  problem_description: Annotated[str, Field(min_length=1, max_length=PREMIUM_MAX_INPUT_LEN)]
104
  style: Annotated[str, Field(pattern="^(Academic|Developer-Friendly|English-Like|Step-by-Step)$")]
105
  detail: Annotated[str, Field(pattern="^(Concise|Detailed)$")]
106
 
107
- # -----------------------------------------------------------------------------
108
- # API Routes
109
- # -----------------------------------------------------------------------------
110
-
111
- @app.on_event("startup")
112
- def on_startup():
113
- init_db()
114
-
115
 
116
  app.include_router(auth_router)
117
 
 
 
118
 
119
  @app.get("/")
120
  async def root():
121
- """Health/root endpoint so the backend URL doesn't return 404 when visited."""
122
- return {"service": "Pseudogen API", "docs": "/docs", "auth": "/auth/login", "generate": "POST /generate-pseudocode", "v1": "POST /v1/generate-pseudocode"}
123
 
124
- # v1 API router (versioned endpoint; same behavior as legacy path)
125
- v1_router = APIRouter(prefix="/v1", tags=["v1"])
126
 
127
  @v1_router.post("/generate-pseudocode")
128
  @limiter.limit("30/minute")
129
  async def generate_v1(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
130
- """Same as generate; versioned under /v1. Requires auth."""
131
- return await generate_impl(request, req, user)
132
 
133
  app.include_router(v1_router)
134
 
 
135
  @app.post("/generate-pseudocode")
136
  @limiter.limit("30/minute")
137
  async def generate(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
138
- """
139
- Generate pseudocode from a given problem description. Requires Bearer token.
140
- Plan (free/premium) is taken from the logged-in user.
141
- """
142
- return await generate_impl(request, req, user)
143
 
144
 
145
- async def generate_impl(request: Request, req: GenerateRequest, user: dict):
146
- """Shared implementation for generate and generate_v1."""
147
  plan = (user.get("plan") or "free").strip().lower()
148
  if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
149
  raise HTTPException(
150
  status_code=400,
151
- detail=f"Input exceeds Free plan limit ({FREE_MAX_INPUT_LEN} characters). Upgrade to Premium for up to {PREMIUM_MAX_INPUT_LEN}.",
152
  )
153
 
154
  template = TEMPLATES.get(req.style)
@@ -159,8 +124,8 @@ async def generate_impl(request: Request, req: GenerateRequest, user: dict):
159
 
160
  try:
161
  response_text = call_llm(prompt)
162
- except Exception as e:
163
  logger.exception("LLM call failed")
164
- raise HTTPException(status_code=502, detail=str(e))
165
 
166
  return {"markdown": response_text}
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ from contextlib import asynccontextmanager
3
  from pathlib import Path
4
 
5
  from dotenv import load_dotenv
 
18
  from auth import get_current_user
19
  from routers.auth import router as auth_router
20
 
 
 
 
 
21
  _BACKEND_DIR = Path(__file__).resolve().parent
22
  load_dotenv(dotenv_path=_BACKEND_DIR / ".env")
23
 
 
24
  _REQUIRED_ENV = ["PROVIDER"]
25
  _PROVIDER_KEYS = {
26
  "openai": "OPENAI_API_KEY",
 
35
  return
36
  missing = [k for k in _REQUIRED_ENV if not os.getenv(k)]
37
  if missing:
38
+ raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
39
  provider = (os.getenv("PROVIDER") or "").lower()
40
  key_var = _PROVIDER_KEYS.get(provider)
41
  if key_var and not os.getenv(key_var):
42
+ raise RuntimeError(f"PROVIDER={provider} requires {key_var} to be set")
43
 
44
 
45
  _check_env()
46
 
 
 
 
 
47
  logging.basicConfig(
48
+ level=getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO),
49
  format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
50
  datefmt="%Y-%m-%d %H:%M:%S",
51
  )
52
  logger = logging.getLogger("pseudogen")
53
 
54
+
55
+ @asynccontextmanager
56
+ async def lifespan(app: FastAPI):
57
+ init_db()
58
+ yield
59
+
60
 
61
  limiter = Limiter(key_func=get_remote_address)
62
+ app = FastAPI(title="Pseudogen API", lifespan=lifespan)
63
  app.state.limiter = limiter
64
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
65
 
 
66
  _cors_origins = os.getenv("CORS_ORIGINS", "*").strip()
67
  allow_origins = [o.strip() for o in _cors_origins.split(",") if o.strip()] if _cors_origins != "*" else ["*"]
68
  app.add_middleware(
 
73
  allow_headers=["*"],
74
  )
75
 
 
 
 
76
  FREE_MAX_INPUT_LEN = 4000
77
  PREMIUM_MAX_INPUT_LEN = 12000
78
 
 
 
 
79
 
80
  class GenerateRequest(BaseModel):
 
81
  problem_description: Annotated[str, Field(min_length=1, max_length=PREMIUM_MAX_INPUT_LEN)]
82
  style: Annotated[str, Field(pattern="^(Academic|Developer-Friendly|English-Like|Step-by-Step)$")]
83
  detail: Annotated[str, Field(pattern="^(Concise|Detailed)$")]
84
 
 
 
 
 
 
 
 
 
85
 
86
  app.include_router(auth_router)
87
 
88
+ v1_router = APIRouter(prefix="/v1", tags=["v1"])
89
+
90
 
91
  @app.get("/")
92
  async def root():
93
+ return {"service": "Pseudogen API", "version": "1"}
 
94
 
 
 
95
 
96
  @v1_router.post("/generate-pseudocode")
97
  @limiter.limit("30/minute")
98
  async def generate_v1(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
99
+ return await _generate(request, req, user)
100
+
101
 
102
  app.include_router(v1_router)
103
 
104
+
105
  @app.post("/generate-pseudocode")
106
  @limiter.limit("30/minute")
107
  async def generate(request: Request, req: GenerateRequest, user: dict = Depends(get_current_user)):
108
+ return await _generate(request, req, user)
 
 
 
 
109
 
110
 
111
+ async def _generate(request: Request, req: GenerateRequest, user: dict):
 
112
  plan = (user.get("plan") or "free").strip().lower()
113
  if plan != "premium" and len(req.problem_description) > FREE_MAX_INPUT_LEN:
114
  raise HTTPException(
115
  status_code=400,
116
+ detail=f"Input exceeds the Free plan limit of {FREE_MAX_INPUT_LEN} characters. Upgrade to Premium for up to {PREMIUM_MAX_INPUT_LEN}.",
117
  )
118
 
119
  template = TEMPLATES.get(req.style)
 
124
 
125
  try:
126
  response_text = call_llm(prompt)
127
+ except Exception:
128
  logger.exception("LLM call failed")
129
+ raise HTTPException(status_code=502, detail="Failed to generate pseudocode. Please try again.")
130
 
131
  return {"markdown": response_text}
routers/auth.py CHANGED
@@ -1,8 +1,7 @@
1
- # backend/routers/auth.py
2
  from fastapi import APIRouter, Depends, HTTPException, status
3
  from pydantic import BaseModel, EmailStr
4
 
5
- from database import init_db, get_user_by_email, create_user
6
  from auth import hash_password, verify_password, create_access_token, get_current_user
7
 
8
  router = APIRouter(prefix="/auth", tags=["auth"])
@@ -41,7 +40,6 @@ def register(req: RegisterRequest):
41
  status_code=status.HTTP_400_BAD_REQUEST,
42
  detail="Email already registered",
43
  )
44
- init_db()
45
  hashed = hash_password(req.password)
46
  user = create_user(req.email, hashed)
47
  token = create_access_token({"sub": str(user["id"])})
@@ -50,7 +48,6 @@ def register(req: RegisterRequest):
50
 
51
  @router.post("/login", response_model=TokenResponse)
52
  def login(req: LoginRequest):
53
- init_db()
54
  user = get_user_by_email(req.email)
55
  if not user or not verify_password(req.password, user["hashed_password"]):
56
  raise HTTPException(
 
 
1
  from fastapi import APIRouter, Depends, HTTPException, status
2
  from pydantic import BaseModel, EmailStr
3
 
4
+ from database import get_user_by_email, create_user
5
  from auth import hash_password, verify_password, create_access_token, get_current_user
6
 
7
  router = APIRouter(prefix="/auth", tags=["auth"])
 
40
  status_code=status.HTTP_400_BAD_REQUEST,
41
  detail="Email already registered",
42
  )
 
43
  hashed = hash_password(req.password)
44
  user = create_user(req.email, hashed)
45
  token = create_access_token({"sub": str(user["id"])})
 
48
 
49
  @router.post("/login", response_model=TokenResponse)
50
  def login(req: LoginRequest):
 
51
  user = get_user_by_email(req.email)
52
  if not user or not verify_password(req.password, user["hashed_password"]):
53
  raise HTTPException(
utils.py CHANGED
@@ -1,13 +1,3 @@
1
- # backend/utils.py
2
- """
3
- utils.py
4
- --------
5
- Utility module for handling LLM API calls (OpenAI, Claude, Groq) with retry logic.
6
-
7
- Each provider has a dedicated call function, wrapped by `call_llm()`,
8
- which selects the active provider from environment variables.
9
- """
10
-
11
  import os
12
  import time
13
  import openai
@@ -18,28 +8,15 @@ from requests.exceptions import RequestException
18
  from dotenv import load_dotenv
19
  from pathlib import Path
20
 
21
- # --------------------------------------------------------------------------
22
- # Environment setup
23
- # --------------------------------------------------------------------------
24
-
25
- # Explicitly load environment variables from the backend/.env file
26
  load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env")
27
 
28
- # --------------------------------------------------------------------------
29
- # Initialize API clients
30
- # --------------------------------------------------------------------------
31
-
32
  openai.api_key = os.getenv("OPENAI_API_KEY")
33
- claude_client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
 
34
 
35
- # --------------------------------------------------------------------------
36
- # OpenAI
37
- # --------------------------------------------------------------------------
38
  def call_openai_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
39
- """Call OpenAI model with retry logic."""
40
  model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
41
  last_err = None
42
-
43
  for attempt in range(1, max_retries + 1):
44
  try:
45
  resp = openai.ChatCompletion.create(
@@ -51,26 +28,20 @@ def call_openai_with_retries(prompt: str, model: str = None, max_retries: int =
51
  if resp.choices and resp.choices[0].message.get("content"):
52
  return resp.choices[0].message["content"].strip()
53
  raise RuntimeError("Empty response from OpenAI")
54
-
55
  except Exception as e:
56
  last_err = e
57
- logging.warning(f"OpenAI call failed (attempt {attempt}): {e}")
58
  if attempt < max_retries:
59
  time.sleep(backoff * attempt)
60
- else:
61
- raise RuntimeError(f"OpenAI failed after {max_retries} attempts: {last_err}")
62
 
63
- # --------------------------------------------------------------------------
64
- # Claude
65
- # --------------------------------------------------------------------------
66
  def call_claude_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
67
- """Call Anthropic Claude model with retry logic."""
68
- model = model or os.getenv("CLAUDE_MODEL", "claude-3-sonnet-20240229")
69
  last_err = None
70
-
71
  for attempt in range(1, max_retries + 1):
72
  try:
73
- resp = claude_client.messages.create(
74
  model=model,
75
  max_tokens=1000,
76
  temperature=0.2,
@@ -81,37 +52,29 @@ def call_claude_with_retries(prompt: str, model: str = None, max_retries: int =
81
  if text:
82
  return text
83
  raise RuntimeError("Empty response from Claude")
84
-
85
  except Exception as e:
86
  last_err = e
87
- logging.warning(f"Claude call failed (attempt {attempt}): {e}")
88
  if attempt < max_retries:
89
  time.sleep(backoff * attempt)
90
- else:
91
- raise RuntimeError(f"Claude failed after {max_retries} attempts: {last_err}")
92
 
93
- # --------------------------------------------------------------------------
94
- # Groq
95
- # --------------------------------------------------------------------------
96
  def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
97
- """Call Groq model with retry logic."""
98
  api_key = os.getenv("GROQ_API_KEY")
99
- model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
100
  if not api_key:
101
  raise RuntimeError("Missing GROQ_API_KEY")
102
-
103
  headers = {
104
  "Authorization": f"Bearer {api_key}",
105
  "Content-Type": "application/json",
106
  }
107
-
108
  payload = {
109
  "model": model,
110
  "messages": [{"role": "user", "content": prompt}],
111
  "temperature": 0.2,
112
  "max_tokens": 1000,
113
  }
114
-
115
  last_err = None
116
  for attempt in range(1, max_retries + 1):
117
  try:
@@ -121,37 +84,28 @@ def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3,
121
  json=payload,
122
  timeout=30,
123
  )
124
-
125
  if resp.status_code == 200:
126
  data = resp.json()
127
- if data.get("choices") and data["choices"][0]["message"].get("content"):
128
- return data["choices"][0]["message"]["content"].strip()
 
129
  raise RuntimeError("Empty response from Groq")
130
-
131
  logging.error(f"Groq API error ({resp.status_code}): {resp.text}")
132
  resp.raise_for_status()
133
-
134
  except (RequestException, Exception) as e:
135
  last_err = e
136
- logging.warning(f"Groq call failed (attempt {attempt}): {e}")
137
  if attempt < max_retries:
138
  time.sleep(backoff * attempt)
139
- else:
140
- raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_err}")
141
 
142
- # --------------------------------------------------------------------------
143
- # Generic handler
144
- # --------------------------------------------------------------------------
145
 
146
  def call_llm(prompt: str) -> str:
147
- """Route LLM calls based on PROVIDER environment variable."""
148
  provider = os.getenv("PROVIDER", "openai").lower()
149
-
150
- if provider == "claude":
151
  return call_claude_with_retries(prompt)
152
  elif provider == "openai":
153
  return call_openai_with_retries(prompt)
154
  elif provider == "groq":
155
  return call_groq_with_retries(prompt)
156
- else:
157
- raise RuntimeError(f"Unsupported PROVIDER: {provider}")
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import time
3
  import openai
 
8
  from dotenv import load_dotenv
9
  from pathlib import Path
10
 
 
 
 
 
 
11
  load_dotenv(dotenv_path=Path(__file__).resolve().parent / ".env")
12
 
 
 
 
 
13
  openai.api_key = os.getenv("OPENAI_API_KEY")
14
+ _claude = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
15
+
16
 
 
 
 
17
  def call_openai_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
 
18
  model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
19
  last_err = None
 
20
  for attempt in range(1, max_retries + 1):
21
  try:
22
  resp = openai.ChatCompletion.create(
 
28
  if resp.choices and resp.choices[0].message.get("content"):
29
  return resp.choices[0].message["content"].strip()
30
  raise RuntimeError("Empty response from OpenAI")
 
31
  except Exception as e:
32
  last_err = e
33
+ logging.warning(f"OpenAI attempt {attempt} failed: {e}")
34
  if attempt < max_retries:
35
  time.sleep(backoff * attempt)
36
+ raise RuntimeError(f"OpenAI failed after {max_retries} attempts: {last_err}")
37
+
38
 
 
 
 
39
  def call_claude_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
40
+ model = model or os.getenv("CLAUDE_MODEL", "claude-3-5-haiku-20241022")
 
41
  last_err = None
 
42
  for attempt in range(1, max_retries + 1):
43
  try:
44
+ resp = _claude.messages.create(
45
  model=model,
46
  max_tokens=1000,
47
  temperature=0.2,
 
52
  if text:
53
  return text
54
  raise RuntimeError("Empty response from Claude")
 
55
  except Exception as e:
56
  last_err = e
57
+ logging.warning(f"Claude attempt {attempt} failed: {e}")
58
  if attempt < max_retries:
59
  time.sleep(backoff * attempt)
60
+ raise RuntimeError(f"Claude failed after {max_retries} attempts: {last_err}")
61
+
62
 
 
 
 
63
  def call_groq_with_retries(prompt: str, model: str = None, max_retries: int = 3, backoff: float = 1.0) -> str:
 
64
  api_key = os.getenv("GROQ_API_KEY")
 
65
  if not api_key:
66
  raise RuntimeError("Missing GROQ_API_KEY")
67
+ model = model or os.getenv("GROQ_MODEL", "llama3-8b-8192")
68
  headers = {
69
  "Authorization": f"Bearer {api_key}",
70
  "Content-Type": "application/json",
71
  }
 
72
  payload = {
73
  "model": model,
74
  "messages": [{"role": "user", "content": prompt}],
75
  "temperature": 0.2,
76
  "max_tokens": 1000,
77
  }
 
78
  last_err = None
79
  for attempt in range(1, max_retries + 1):
80
  try:
 
84
  json=payload,
85
  timeout=30,
86
  )
 
87
  if resp.status_code == 200:
88
  data = resp.json()
89
+ content = data.get("choices", [{}])[0].get("message", {}).get("content")
90
+ if content:
91
+ return content.strip()
92
  raise RuntimeError("Empty response from Groq")
 
93
  logging.error(f"Groq API error ({resp.status_code}): {resp.text}")
94
  resp.raise_for_status()
 
95
  except (RequestException, Exception) as e:
96
  last_err = e
97
+ logging.warning(f"Groq attempt {attempt} failed: {e}")
98
  if attempt < max_retries:
99
  time.sleep(backoff * attempt)
100
+ raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_err}")
 
101
 
 
 
 
102
 
103
  def call_llm(prompt: str) -> str:
 
104
  provider = os.getenv("PROVIDER", "openai").lower()
105
+ if provider == "claude" or provider == "anthropic":
 
106
  return call_claude_with_retries(prompt)
107
  elif provider == "openai":
108
  return call_openai_with_retries(prompt)
109
  elif provider == "groq":
110
  return call_groq_with_retries(prompt)
111
+ raise RuntimeError(f"Unsupported PROVIDER: {provider}")