Initial commit: Salary Benchmark Tool

FastAPI + React + PostgreSQL salary benchmarking tool with AI research pipeline.
- Seed data for 25+ New York roles (junior/mid/senior levels)
- Single + bulk lookup with location alias mapping (NYC -> New York, etc.)
- Research pipeline: Serper -> Firecrawl -> Cohere Rerank -> Claude analysis
- Editable validation UI for AI-proposed benchmarks
- CSV export, Montserrat font, black/white/#FFC407 design
- Fully Dockerized (app + db + frontend)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-02 22:47:32 -04:00
commit da3f5faa91
58 changed files with 5342 additions and 0 deletions

10
.env.example Normal file
View file

@ -0,0 +1,10 @@
DB_USER=salary_user
DB_PASSWORD=salary_pass
DB_HOST=db
DB_PORT=5432
DB_NAME=salary_benchmark
SERPER_API_KEY=your_serper_key_here
FIRECRAWL_API_KEY=your_firecrawl_key_here
COHERE_API_KEY=your_cohere_key_here
ANTHROPIC_API_KEY=your_anthropic_key_here

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.env
__pycache__/
*.pyc
.venv/
pgdata/
node_modules/
frontend/dist/
.DS_Store

7
Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /code
ENV PYTHONPATH=/code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

36
alembic.ini Normal file
View file

@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://salary_user:salary_pass@db:5432/salary_benchmark
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

40
alembic/env.py Normal file
View file

@ -0,0 +1,40 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = settings.database_url
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
connectable = create_async_engine(settings.database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

24
alembic/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,105 @@
"""Initial schema
Revision ID: 001
Revises:
Create Date: 2026-04-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"locations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("city", sa.String(255), nullable=False),
sa.Column("country", sa.String(100), server_default="US"),
sa.Column("normalized_name", sa.String(255), unique=True, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)
op.create_table(
"roles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("normalized_title", sa.String(255), unique=True, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)
op.create_table(
"benchmarks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=False),
sa.Column(
"location_id",
sa.Integer(),
sa.ForeignKey("locations.id"),
nullable=False,
),
sa.Column("level", sa.String(20), nullable=False),
sa.Column("salary_low", sa.Integer(), nullable=False),
sa.Column("salary_median", sa.Integer(), nullable=False),
sa.Column("salary_high", sa.Integer(), nullable=False),
sa.Column("source", sa.String(50), server_default="seed"),
sa.Column("confidence_score", sa.Float(), nullable=True),
sa.Column("validated", sa.Boolean(), server_default="false"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.UniqueConstraint("role_id", "location_id", "level", name="uq_benchmark"),
)
op.create_table(
"research_sessions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=False),
sa.Column(
"location_id",
sa.Integer(),
sa.ForeignKey("locations.id"),
nullable=False,
),
sa.Column("status", sa.String(30), server_default="searching"),
sa.Column("serper_results", JSONB, nullable=True),
sa.Column("firecrawl_results", JSONB, nullable=True),
sa.Column("cohere_ranked", JSONB, nullable=True),
sa.Column("claude_analysis", JSONB, nullable=True),
sa.Column("proposed_benchmarks", JSONB, nullable=True),
sa.Column("error_message", sa.String(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("research_sessions")
op.drop_table("benchmarks")
op.drop_table("roles")
op.drop_table("locations")

View file

@ -0,0 +1,90 @@
"""Seed NY benchmark data
Revision ID: 002
Revises: 001
Create Date: 2026-04-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SEED_DATA = [
# (title, level, salary) — all New York
("Project Manager", "mid", 110000),
("Project Manager", "senior", 140000),
("Studio Manager", "mid", 145000),
("Studio Manager", "senior", 160000),
("Account Director", "senior", 165000),
("Digital Designer", "junior", 80000),
("Digital Designer", "mid", 100000),
("Digital Designer", "senior", 120000),
("Strategy Director", "senior", 220000),
("Art Director", "junior", 95000),
("Art Director", "mid", 120000),
("Art Director", "senior", 140000),
("Integrated Designer", "junior", 80000),
("Integrated Designer", "senior", 130000),
("Motion Designer", "junior", 90000),
("Motion Designer", "mid", 115000),
("Motion Designer", "senior", 140000),
("Copywriter", "junior", 85000),
("Copywriter", "mid", 120000),
("Copywriter", "senior", 145000),
("Social Media Manager", "junior", 85000),
("Social Media Manager", "mid", 110000),
("Social Media Manager", "senior", 130000),
("Community Manager", "junior", 75000),
("Community Manager", "mid", 95000),
("Community Manager", "senior", 115000),
]
def upgrade() -> None:
# Insert location
op.execute(
"INSERT INTO locations (city, country, normalized_name) "
"VALUES ('New York', 'US', 'new york')"
)
# Collect unique titles
titles = sorted(set(title for title, _, _ in SEED_DATA))
for title in titles:
norm = title.strip().lower()
op.execute(
sa.text(
"INSERT INTO roles (title, normalized_title) VALUES (:t, :n)"
).bindparams(t=title, n=norm)
)
# Insert benchmarks — use median as the single value, derive low/high as -10%/+10%
for title, level, salary in SEED_DATA:
salary_low = int(salary * 0.90)
salary_high = int(salary * 1.10)
op.execute(
sa.text(
"INSERT INTO benchmarks (role_id, location_id, level, salary_low, salary_median, salary_high, source, validated) "
"VALUES ("
" (SELECT id FROM roles WHERE normalized_title = :norm_title),"
" (SELECT id FROM locations WHERE normalized_name = 'new york'),"
" :level, :low, :median, :high, 'seed', true"
")"
).bindparams(
norm_title=title.strip().lower(),
level=level,
low=salary_low,
median=salary,
high=salary_high,
)
)
def downgrade() -> None:
op.execute("DELETE FROM benchmarks WHERE source = 'seed'")
op.execute("DELETE FROM roles")
op.execute("DELETE FROM locations WHERE normalized_name = 'new york'")

View file

@ -0,0 +1,36 @@
"""Simplify to single salary column
Revision ID: 003
Revises: 002
Create Date: 2026-04-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new salary column, populate from salary_median, drop old columns
op.add_column("benchmarks", sa.Column("salary", sa.Integer(), nullable=True))
op.execute("UPDATE benchmarks SET salary = salary_median")
op.alter_column("benchmarks", "salary", nullable=False)
op.drop_column("benchmarks", "salary_low")
op.drop_column("benchmarks", "salary_median")
op.drop_column("benchmarks", "salary_high")
def downgrade() -> None:
op.add_column("benchmarks", sa.Column("salary_high", sa.Integer(), nullable=True))
op.add_column("benchmarks", sa.Column("salary_median", sa.Integer(), nullable=True))
op.add_column("benchmarks", sa.Column("salary_low", sa.Integer(), nullable=True))
op.execute("UPDATE benchmarks SET salary_median = salary, salary_low = salary * 0.9, salary_high = salary * 1.1")
op.alter_column("benchmarks", "salary_low", nullable=False)
op.alter_column("benchmarks", "salary_median", nullable=False)
op.alter_column("benchmarks", "salary_high", nullable=False)
op.drop_column("benchmarks", "salary")

0
app/__init__.py Normal file
View file

26
app/config.py Normal file
View file

@ -0,0 +1,26 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_user: str = "salary_user"
db_password: str = "salary_pass"
db_host: str = "db"
db_port: int = 5432
db_name: str = "salary_benchmark"
serper_api_key: str = ""
firecrawl_api_key: str = ""
cohere_api_key: str = ""
anthropic_api_key: str = ""
@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
model_config = {"env_file": ".env"}
settings = Settings()

11
app/database.py Normal file
View file

@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with async_session() as session:
yield session

23
app/main.py Normal file
View file

@ -0,0 +1,23 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.routers import benchmarks, research
app = FastAPI(title="Salary Benchmark Tool")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(benchmarks.router, prefix="/api")
app.include_router(research.router, prefix="/api")
@app.get("/api/health")
async def health():
return {"status": "ok"}

104
app/models.py Normal file
View file

@ -0,0 +1,104 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
city: Mapped[str] = mapped_column(String(255))
country: Mapped[str] = mapped_column(String(100), default="US")
normalized_name: Mapped[str] = mapped_column(String(255), unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
benchmarks: Mapped[list["Benchmark"]] = relationship(back_populates="location")
research_sessions: Mapped[list["ResearchSession"]] = relationship(
back_populates="location"
)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255))
normalized_title: Mapped[str] = mapped_column(String(255), unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
benchmarks: Mapped[list["Benchmark"]] = relationship(back_populates="role")
research_sessions: Mapped[list["ResearchSession"]] = relationship(
back_populates="role"
)
class Benchmark(Base):
__tablename__ = "benchmarks"
__table_args__ = (
UniqueConstraint("role_id", "location_id", "level", name="uq_benchmark"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
location_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
level: Mapped[str] = mapped_column(String(20))
salary: Mapped[int] = mapped_column(Integer)
source: Mapped[str] = mapped_column(String(50), default="seed")
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
validated: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
role: Mapped["Role"] = relationship(back_populates="benchmarks")
location: Mapped["Location"] = relationship(back_populates="benchmarks")
class ResearchSession(Base):
__tablename__ = "research_sessions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
location_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
status: Mapped[str] = mapped_column(String(30), default="searching")
serper_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
firecrawl_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
cohere_ranked: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
claude_analysis: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
proposed_benchmarks: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
error_message: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
role: Mapped["Role"] = relationship(back_populates="research_sessions")
location: Mapped["Location"] = relationship(back_populates="research_sessions")

0
app/routers/__init__.py Normal file
View file

31
app/routers/benchmarks.py Normal file
View file

@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import BenchmarkOut, BulkLookupRequest, BulkLookupResponse
from app.services.benchmark_service import lookup_benchmarks
router = APIRouter(tags=["benchmarks"])
@router.get("/benchmarks", response_model=list[BenchmarkOut])
async def get_benchmarks(
title: str, location: str, db: AsyncSession = Depends(get_db)
):
results = await lookup_benchmarks(db, title, location)
if not results:
raise HTTPException(status_code=404, detail="No benchmarks found")
return results
@router.post("/benchmarks/bulk", response_model=BulkLookupResponse)
async def bulk_lookup(req: BulkLookupRequest, db: AsyncSession = Depends(get_db)):
found = {}
not_found = []
for title in req.titles:
results = await lookup_benchmarks(db, title, req.location)
if results:
found[title] = results
else:
not_found.append(title)
return BulkLookupResponse(found=found, not_found=not_found)

130
app/routers/research.py Normal file
View file

@ -0,0 +1,130 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, async_session
from app.models import Benchmark, Location, ResearchSession, Role
from app.schemas import (
BulkResearchRequest,
ResearchRequest,
ResearchStatusOut,
ValidateRequest,
)
from app.services.benchmark_service import get_or_create_role, get_or_create_location
from app.services.research_pipeline import run_research_pipeline
import uuid
from datetime import datetime, timezone
router = APIRouter(tags=["research"])
@router.post("/research")
async def start_research(
req: ResearchRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
role = await get_or_create_role(db, req.title)
location = await get_or_create_location(db, req.location)
await db.commit()
session = ResearchSession(
role_id=role.id,
location_id=location.id,
status="searching",
)
db.add(session)
await db.commit()
await db.refresh(session)
background_tasks.add_task(run_research_pipeline, str(session.id))
return {"session_id": str(session.id)}
@router.get("/research/{session_id}", response_model=ResearchStatusOut)
async def get_research_status(session_id: str, db: AsyncSession = Depends(get_db)):
uid = uuid.UUID(session_id)
result = await db.execute(
select(ResearchSession).where(ResearchSession.id == uid)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Research session not found")
return ResearchStatusOut(
session_id=str(session.id),
status=session.status,
proposed_benchmarks=session.proposed_benchmarks,
claude_analysis=session.claude_analysis,
error_message=session.error_message,
)
@router.post("/research/{session_id}/validate")
async def validate_research(
session_id: str,
req: ValidateRequest,
db: AsyncSession = Depends(get_db),
):
uid = uuid.UUID(session_id)
result = await db.execute(
select(ResearchSession).where(ResearchSession.id == uid)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Research session not found")
if session.status != "pending_validation":
raise HTTPException(status_code=400, detail="Session not ready for validation")
if not req.approved:
session.status = "rejected"
await db.commit()
return {"status": "rejected"}
proposed = session.proposed_benchmarks
if not proposed or "benchmarks" not in proposed:
raise HTTPException(status_code=400, detail="No proposed benchmarks to validate")
for entry in proposed["benchmarks"]:
level = entry["level"]
adjustment = (req.adjustments or {}).get(level)
salary = adjustment.salary if adjustment and adjustment.salary else entry["salary"]
benchmark = Benchmark(
role_id=session.role_id,
location_id=session.location_id,
level=level,
salary=salary,
source="research",
confidence_score=proposed.get("confidence_score"),
validated=True,
)
db.add(benchmark)
session.status = "completed"
session.completed_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "completed"}
@router.post("/research/bulk")
async def bulk_research(
req: BulkResearchRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
sessions = []
location = await get_or_create_location(db, req.location)
for title in req.titles[:20]:
role = await get_or_create_role(db, title)
await db.commit()
session = ResearchSession(
role_id=role.id,
location_id=location.id,
status="searching",
)
db.add(session)
await db.commit()
await db.refresh(session)
background_tasks.add_task(run_research_pipeline, str(session.id))
sessions.append({"title": title, "session_id": str(session.id)})
return {"sessions": sessions}

55
app/schemas.py Normal file
View file

@ -0,0 +1,55 @@
from pydantic import BaseModel
class BenchmarkOut(BaseModel):
role: str
location: str
level: str
salary: int
source: str
validated: bool
confidence_score: float | None = None
model_config = {"from_attributes": True}
class SingleLookupParams(BaseModel):
title: str
location: str
class BulkLookupRequest(BaseModel):
location: str
titles: list[str]
class BulkLookupResponse(BaseModel):
found: dict[str, list[BenchmarkOut]]
not_found: list[str]
class ResearchRequest(BaseModel):
title: str
location: str
class BulkResearchRequest(BaseModel):
location: str
titles: list[str]
class ResearchStatusOut(BaseModel):
session_id: str
status: str
proposed_benchmarks: dict | None = None
claude_analysis: dict | None = None
error_message: str | None = None
class BenchmarkAdjustment(BaseModel):
salary: int | None = None
class ValidateRequest(BaseModel):
approved: bool
adjustments: dict[str, BenchmarkAdjustment] | None = None

0
app/services/__init__.py Normal file
View file

View file

@ -0,0 +1,96 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Benchmark, Location, Role
from app.schemas import BenchmarkOut
LOCATION_ALIASES = {
"nyc": "new york",
"new york city": "new york",
"manhattan": "new york",
"sf": "san francisco",
"san fran": "san francisco",
"la": "los angeles",
"chi": "chicago",
"dc": "washington dc",
"washington": "washington dc",
"philly": "philadelphia",
"atl": "atlanta",
"bos": "boston",
"dal": "dallas",
"hou": "houston",
"sea": "seattle",
"pdx": "portland",
"den": "denver",
"mia": "miami",
"det": "detroit",
"mpls": "minneapolis",
"nola": "new orleans",
"london": "london",
"ldn": "london",
}
def normalize(text: str) -> str:
cleaned = text.strip().lower()
return LOCATION_ALIASES.get(cleaned, cleaned)
def normalize_title(text: str) -> str:
return text.strip().lower()
async def get_or_create_role(db: AsyncSession, title: str) -> Role:
norm = normalize_title(title)
result = await db.execute(select(Role).where(Role.normalized_title == norm))
role = result.scalar_one_or_none()
if not role:
role = Role(title=title.strip(), normalized_title=norm)
db.add(role)
await db.flush()
return role
async def get_or_create_location(db: AsyncSession, location: str) -> Location:
norm = normalize(location)
result = await db.execute(
select(Location).where(Location.normalized_name == norm)
)
loc = result.scalar_one_or_none()
if not loc:
# Use the canonical name as the display city name
loc = Location(city=norm.title(), normalized_name=norm)
db.add(loc)
await db.flush()
return loc
async def lookup_benchmarks(
db: AsyncSession, title: str, location: str
) -> list[BenchmarkOut]:
norm_title = normalize_title(title)
norm_location = normalize(location)
result = await db.execute(
select(Benchmark, Role, Location)
.join(Role, Benchmark.role_id == Role.id)
.join(Location, Benchmark.location_id == Location.id)
.where(Role.normalized_title == norm_title)
.where(Location.normalized_name == norm_location)
.order_by(
Benchmark.level.desc()
)
)
rows = result.all()
return [
BenchmarkOut(
role=role.title,
location=loc.city,
level=bench.level,
salary=bench.salary,
source=bench.source,
validated=bench.validated,
confidence_score=bench.confidence_score,
)
for bench, role, loc in rows
]

View file

@ -0,0 +1,54 @@
import json
import anthropic
from app.config import settings
async def analyze_salary_data(
title: str, location: str, ranked_content: list[dict]
) -> dict:
sources_text = "\n\n---\n\n".join(
item["content"] for item in ranked_content if item.get("content")
)
prompt = f"""You are a compensation analyst. Based on the following salary data sources
for the role "{title}" in "{location}", produce a structured benchmark.
[SOURCES]
{sources_text}
[/SOURCES]
Return ONLY valid JSON in this exact format:
{{
"benchmarks": [
{{"level": "junior", "salary": <int>}},
{{"level": "mid", "salary": <int>}},
{{"level": "senior", "salary": <int>}}
],
"confidence_score": <float 0.0-1.0>,
"reasoning": "<Brief explanation of how you derived these numbers>",
"sources_used": ["<relevant source descriptions>"]
}}
Rules:
- Each salary value is a single annual USD integer representing the typical/median salary for that level
- Base your estimates on the provided data, not general knowledge
- If data is sparse, lower the confidence_score accordingly
- Return ONLY the JSON object, no markdown or explanation"""
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
message = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
# Strip markdown code fences if present
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
if response_text.endswith("```"):
response_text = response_text[:-3].strip()
return json.loads(response_text)

View file

@ -0,0 +1,27 @@
import cohere
from app.config import settings
async def rerank_results(query: str, chunks: list[str]) -> dict:
if not chunks:
return {"top_chunks": []}
client = cohere.AsyncClientV2(api_key=settings.cohere_api_key)
response = await client.rerank(
model="rerank-v3.5",
query=query,
documents=chunks,
top_n=min(5, len(chunks)),
)
top_chunks = []
for result in response.results:
top_chunks.append(
{
"content": chunks[result.index],
"relevance_score": result.relevance_score,
}
)
return {"top_chunks": top_chunks}

View file

@ -0,0 +1,31 @@
import asyncio
import httpx
from app.config import settings
SEMAPHORE = asyncio.Semaphore(3)
async def _scrape_one(client: httpx.AsyncClient, url: str) -> dict:
async with SEMAPHORE:
try:
resp = await client.post(
"https://api.firecrawl.dev/v1/scrape",
headers={"Authorization": f"Bearer {settings.firecrawl_api_key}"},
json={"url": url, "formats": ["markdown"]},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
markdown = data.get("data", {}).get("markdown", "")
# Truncate to avoid sending huge content downstream
return {"url": url, "content": markdown[:3000], "success": True}
except Exception as e:
return {"url": url, "content": "", "success": False, "error": str(e)}
async def scrape_urls(urls: list[str]) -> dict:
async with httpx.AsyncClient() as client:
tasks = [_scrape_one(client, url) for url in urls]
results = await asyncio.gather(*tasks)
return {"scraped": [r for r in results if r["success"] and r["content"]]}

View file

@ -0,0 +1,86 @@
import uuid
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from app.database import async_session
from app.models import ResearchSession
from app.services.serper_client import search_salaries
from app.services.firecrawl_client import scrape_urls
from app.services.cohere_client import rerank_results
from app.services.claude_client import analyze_salary_data
logger = logging.getLogger(__name__)
async def run_research_pipeline(session_id: str):
uid = uuid.UUID(session_id)
async with async_session() as db:
result = await db.execute(
select(ResearchSession).where(ResearchSession.id == uid)
)
session = result.scalar_one_or_none()
if not session:
logger.error(f"Research session {session_id} not found")
return
# Load role and location names
from app.models import Role, Location
role_result = await db.execute(select(Role).where(Role.id == session.role_id))
role = role_result.scalar_one()
loc_result = await db.execute(
select(Location).where(Location.id == session.location_id)
)
location = loc_result.scalar_one()
title = role.title
city = location.city
try:
# Step 1: Search
session.status = "searching"
await db.commit()
serper_results = await search_salaries(title, city)
session.serper_results = serper_results
await db.commit()
# Step 2: Scrape
session.status = "scraping"
await db.commit()
urls = [r["link"] for r in serper_results.get("results", [])[:8]]
firecrawl_results = await scrape_urls(urls)
session.firecrawl_results = firecrawl_results
await db.commit()
# Step 3: Rerank
session.status = "ranking"
await db.commit()
chunks = [
item["content"]
for item in firecrawl_results.get("scraped", [])
if item.get("content")
]
query = f"salary compensation range for {title} in {city} junior mid senior levels"
ranked = await rerank_results(query, chunks)
session.cohere_ranked = ranked
await db.commit()
# Step 4: Analyze
session.status = "analyzing"
await db.commit()
top_content = ranked.get("top_chunks", [])
analysis = await analyze_salary_data(title, city, top_content)
session.claude_analysis = analysis
session.proposed_benchmarks = analysis
session.status = "pending_validation"
session.completed_at = datetime.now(timezone.utc)
await db.commit()
except Exception as e:
logger.exception(f"Research pipeline failed for session {session_id}")
session.status = "failed"
session.error_message = str(e)
await db.commit()

View file

@ -0,0 +1,37 @@
import httpx
from app.config import settings
async def search_salaries(title: str, location: str) -> dict:
queries = [
f'"{title}" salary range {location} 2025 2026',
f'"{title}" compensation {location} glassdoor levels.fyi',
]
all_results = []
seen_urls = set()
async with httpx.AsyncClient(timeout=30) as client:
for query in queries:
resp = await client.post(
"https://google.serper.dev/search",
headers={"X-API-KEY": settings.serper_api_key},
json={"q": query, "num": 10},
)
resp.raise_for_status()
data = resp.json()
for item in data.get("organic", []):
url = item.get("link", "")
if url not in seen_urls:
seen_urls.add(url)
all_results.append(
{
"title": item.get("title", ""),
"link": url,
"snippet": item.get("snippet", ""),
}
)
return {"results": all_results[:15]}

44
docker-compose.yml Normal file
View file

@ -0,0 +1,44 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME:-salary_benchmark}
POSTGRES_USER: ${DB_USER:-salary_user}
POSTGRES_PASSWORD: ${DB_PASSWORD:-salary_pass}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5436:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-salary_user} -d ${DB_NAME:-salary_benchmark}"]
interval: 5s
retries: 5
app:
build: .
ports:
- "8002:8000"
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- ./app:/code/app
- ./alembic:/code/alembic
- ./alembic.ini:/code/alembic.ini
command: >
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
frontend:
image: node:20-alpine
working_dir: /app
ports:
- "5173:5173"
volumes:
- ./frontend:/app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
depends_on:
- app
volumes:
pgdata:

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
frontend/README.md Normal file
View file

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Salary Benchmark Tool</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2662
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
frontend/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

1
frontend/src/App.css Normal file
View file

@ -0,0 +1 @@
/* Component styles below */

17
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,17 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import LookupPage from './pages/LookupPage'
function App() {
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<LookupPage />} />
</Routes>
</Layout>
</BrowserRouter>
)
}
export default App

50
frontend/src/api.js Normal file
View file

@ -0,0 +1,50 @@
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (res.status === 404) return null;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(err.detail || 'Request failed');
}
return res.json();
}
export async function lookupBenchmarks(title, location) {
return request(`/benchmarks?title=${encodeURIComponent(title)}&location=${encodeURIComponent(location)}`);
}
export async function bulkLookup(location, titles) {
return request('/benchmarks/bulk', {
method: 'POST',
body: JSON.stringify({ location, titles }),
});
}
export async function startResearch(title, location) {
return request('/research', {
method: 'POST',
body: JSON.stringify({ title, location }),
});
}
export async function getResearchStatus(sessionId) {
return request(`/research/${sessionId}`);
}
export async function validateResearch(sessionId, approved, adjustments = null) {
return request(`/research/${sessionId}/validate`, {
method: 'POST',
body: JSON.stringify({ approved, adjustments }),
});
}
export async function bulkResearch(location, titles) {
return request('/research/bulk', {
method: 'POST',
body: JSON.stringify({ location, titles }),
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,114 @@
.benchmark-grid {
margin-top: 32px;
}
.grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.grid-title {
font-size: 18px;
font-weight: 700;
margin: 0;
}
.btn-export {
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
background: var(--white);
color: var(--black);
border: 2px solid var(--black);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-export:hover {
background: var(--accent);
border-color: var(--accent);
}
.benchmark-grid table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.benchmark-grid th {
background: var(--black);
color: var(--white);
padding: 12px 16px;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 12px;
font-weight: 600;
}
.benchmark-grid td {
padding: 10px 16px;
border-bottom: 1px solid var(--gray-200);
}
.benchmark-grid tr:nth-child(even) td {
background: var(--gray-100);
}
.role-cell {
font-weight: 600;
vertical-align: top;
}
.salary {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.level-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.level-badge.level-junior {
background: var(--gray-200);
color: var(--gray-600);
}
.level-badge.level-mid {
background: var(--accent);
color: var(--black);
}
.level-badge.level-senior {
background: var(--black);
color: var(--white);
}
.source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.source-badge.seed {
background: var(--gray-200);
color: var(--gray-600);
}
.source-badge.research {
background: var(--accent);
color: var(--black);
}

View file

@ -0,0 +1,78 @@
import './BenchmarkGrid.css'
function exportCSV(results) {
const header = 'Location,Role,Level,Salary,Source\n'
const rows = results.map((b) =>
`"${b.location}","${b.role}","${b.level}",${b.salary},"${b.source}"`
).join('\n')
const blob = new Blob([header + rows], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'salary_benchmarks.csv'
a.click()
URL.revokeObjectURL(url)
}
function BenchmarkGrid({ results, title: groupTitle }) {
if (!results || results.length === 0) return null
// Group by role
const grouped = {}
for (const r of results) {
const key = r.role
if (!grouped[key]) grouped[key] = []
grouped[key].push(r)
}
const levelOrder = { senior: 0, mid: 1, junior: 2 }
return (
<div className="benchmark-grid">
<div className="grid-header">
{groupTitle && <h3 className="grid-title">{groupTitle}</h3>}
<button className="btn-export" onClick={() => exportCSV(results)}>
Export CSV
</button>
</div>
<table>
<thead>
<tr>
<th>Role</th>
<th>Level</th>
<th>Salary</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{Object.entries(grouped).map(([role, benchmarks]) =>
benchmarks
.sort((a, b) => (levelOrder[a.level] ?? 9) - (levelOrder[b.level] ?? 9))
.map((b, i) => (
<tr key={`${role}-${b.level}`}>
{i === 0 && (
<td className="role-cell" rowSpan={benchmarks.length}>
{role}
</td>
)}
<td>
<span className={`level-badge level-${b.level}`}>
{b.level}
</span>
</td>
<td className="salary">${b.salary?.toLocaleString()}</td>
<td>
<span className={`source-badge ${b.source}`}>
{b.source}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}
export default BenchmarkGrid

View file

@ -0,0 +1,31 @@
.layout {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.layout-header {
margin-bottom: 48px;
}
.layout-header h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.layout-header .subtitle {
font-size: 16px;
color: var(--gray-600);
margin-bottom: 4px;
}
.layout-header .small-subtitle {
font-size: 12px;
color: var(--gray-400);
}
.layout-main {
width: 100%;
}

View file

@ -0,0 +1,18 @@
import './Layout.css'
function Layout({ children }) {
return (
<div className="layout">
<header className="layout-header">
<h1>Salary Benchmark Tool</h1>
<p className="subtitle">Get salary ranges for different experience levels</p>
<p className="small-subtitle">Questions? Reach out to felipeoliveira@oliver.agency</p>
</header>
<main className="layout-main">
{children}
</main>
</div>
)
}
export default Layout

View file

@ -0,0 +1,32 @@
.mode-toggle {
display: flex;
margin-bottom: 32px;
border: 2px solid var(--black);
}
.mode-toggle button {
flex: 1;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
background: var(--white);
color: var(--black);
border: none;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.mode-toggle button:first-child {
border-right: 2px solid var(--black);
}
.mode-toggle button.active {
background: var(--accent);
color: var(--black);
}
.mode-toggle button:hover:not(.active) {
background: var(--gray-100);
}

View file

@ -0,0 +1,22 @@
import './ModeToggle.css'
function ModeToggle({ mode, setMode }) {
return (
<div className="mode-toggle">
<button
className={mode === 'single' ? 'active' : ''}
onClick={() => setMode('single')}
>
Single Lookup
</button>
<button
className={mode === 'bulk' ? 'active' : ''}
onClick={() => setMode('bulk')}
>
Bulk Import
</button>
</div>
)
}
export default ModeToggle

View file

@ -0,0 +1,73 @@
.research-progress {
margin: 24px 0;
padding: 24px;
border: 2px solid var(--black);
}
.research-progress h4 {
font-size: 16px;
font-weight: 700;
margin-bottom: 16px;
}
.progress-bar {
height: 6px;
background: var(--gray-200);
margin-bottom: 20px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.5s ease;
}
.steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.step {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--gray-400);
}
.step.done {
color: var(--black);
}
.step.active {
color: var(--black);
font-weight: 600;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--gray-200);
flex-shrink: 0;
}
.step.done .step-dot {
background: var(--accent);
}
.step.active .step-dot {
background: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.3);
}
.research-error {
margin-top: 16px;
padding: 12px 16px;
background: #fff0f0;
border: 1px solid #ffcdd2;
color: #c62828;
font-size: 14px;
}

View file

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react'
import { getResearchStatus } from '../api'
import ValidationForm from './ValidationForm'
import './ResearchProgress.css'
const STEPS = ['searching', 'scraping', 'ranking', 'analyzing', 'pending_validation']
const STEP_LABELS = {
searching: 'Searching salary data...',
scraping: 'Scraping relevant pages...',
ranking: 'Ranking results...',
analyzing: 'AI analyzing data...',
pending_validation: 'Ready for review',
}
function ResearchProgress({ sessionId, title, location, onValidated }) {
const [status, setStatus] = useState('searching')
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
if (!sessionId) return
if (status === 'completed' || status === 'failed' || status === 'rejected') return
const poll = setInterval(async () => {
try {
const res = await getResearchStatus(sessionId)
if (!res) return
setStatus(res.status)
setData(res)
if (['pending_validation', 'completed', 'failed'].includes(res.status)) {
clearInterval(poll)
}
} catch (err) {
setError(err.message)
clearInterval(poll)
}
}, 3000)
return () => clearInterval(poll)
}, [sessionId, status])
if (error) {
return <div className="research-error">Error: {error}</div>
}
const currentStep = STEPS.indexOf(status)
const progress = status === 'pending_validation' ? 100 : Math.max(0, ((currentStep + 0.5) / (STEPS.length - 1)) * 100)
return (
<div className="research-progress">
<h4>Researching: {title}</h4>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="steps">
{STEPS.map((step, i) => (
<div
key={step}
className={`step ${i < currentStep ? 'done' : ''} ${i === currentStep ? 'active' : ''}`}
>
<span className="step-dot" />
<span className="step-label">{STEP_LABELS[step]}</span>
</div>
))}
</div>
{status === 'failed' && (
<div className="research-error">
Pipeline failed: {data?.error_message || 'Unknown error'}
</div>
)}
{status === 'pending_validation' && data?.proposed_benchmarks && (
<ValidationForm
sessionId={sessionId}
title={title}
location={location}
proposed={data.proposed_benchmarks}
analysis={data.claude_analysis}
onValidated={onValidated}
/>
)}
</div>
)
}
export default ResearchProgress

View file

@ -0,0 +1,111 @@
.validation-form {
margin-top: 20px;
padding: 20px;
background: var(--gray-100);
border: 1px solid var(--gray-200);
}
.validation-form h4 {
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
}
.reasoning {
font-size: 13px;
color: var(--gray-600);
margin-bottom: 12px;
line-height: 1.5;
}
.confidence {
font-size: 13px;
margin-bottom: 16px;
}
.validation-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.validation-table th {
background: var(--black);
color: var(--white);
padding: 8px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.validation-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--gray-200);
}
.validation-table input {
width: 100%;
padding: 8px 10px;
font-size: 14px;
border: 2px solid var(--gray-200);
background: var(--white);
font-family: var(--font);
font-variant-numeric: tabular-nums;
}
.validation-table input:focus {
outline: none;
border-color: var(--accent);
}
.level-cell {
width: 80px;
}
.validation-actions {
display: flex;
gap: 12px;
}
.btn-approve {
flex: 1;
padding: 12px 24px;
font-size: 14px;
font-weight: 700;
background: var(--accent);
color: var(--black);
border: 2px solid var(--accent);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-approve:hover:not(:disabled) {
background: var(--black);
color: var(--accent);
border-color: var(--black);
}
.btn-approve:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-reject {
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
background: var(--white);
color: var(--black);
border: 2px solid var(--gray-200);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-reject:hover:not(:disabled) {
border-color: var(--black);
}

View file

@ -0,0 +1,114 @@
import { useState } from 'react'
import { validateResearch } from '../api'
import './ValidationForm.css'
function ValidationForm({ sessionId, title, location, proposed, analysis, onValidated }) {
const benchmarks = proposed?.benchmarks || []
const [values, setValues] = useState(
benchmarks.reduce((acc, b) => {
acc[b.level] = { salary: b.salary }
return acc
}, {})
)
const [saving, setSaving] = useState(false)
const [done, setDone] = useState(false)
const handleChange = (level, val) => {
setValues((prev) => ({
...prev,
[level]: { salary: parseInt(val) || 0 },
}))
}
const handleApprove = async () => {
if (saving || done) return
setSaving(true)
try {
await validateResearch(sessionId, true, values)
setDone(true)
onValidated?.()
} catch (err) {
alert('Failed to save: ' + err.message)
} finally {
setSaving(false)
}
}
const handleReject = async () => {
if (saving || done) return
setSaving(true)
try {
await validateResearch(sessionId, false)
setDone(true)
onValidated?.()
} finally {
setSaving(false)
}
}
if (done) {
return (
<div className="validation-form">
<h4>Benchmark saved for {title} in {location}</h4>
</div>
)
}
const levelOrder = ['junior', 'mid', 'senior']
return (
<div className="validation-form">
<h4>AI Benchmark Proposal for {title} in {location}</h4>
{proposed?.reasoning && (
<p className="reasoning">{proposed.reasoning}</p>
)}
{proposed?.confidence_score != null && (
<div className="confidence">
Confidence: <strong>{Math.round(proposed.confidence_score * 100)}%</strong>
</div>
)}
<table className="validation-table">
<thead>
<tr>
<th>Level</th>
<th>Salary</th>
</tr>
</thead>
<tbody>
{levelOrder.map((level) => {
const v = values[level]
if (!v) return null
return (
<tr key={level}>
<td className="level-cell">
<span className={`level-badge level-${level}`}>{level}</span>
</td>
<td>
<input
type="number"
value={v.salary}
onChange={(e) => handleChange(level, e.target.value)}
/>
</td>
</tr>
)
})}
</tbody>
</table>
<div className="validation-actions">
<button className="btn-approve" onClick={handleApprove} disabled={saving}>
{saving ? 'Saving...' : 'Approve & Save'}
</button>
<button className="btn-reject" onClick={handleReject} disabled={saving}>
Reject
</button>
</div>
</div>
)
}
export default ValidationForm

1
frontend/src/index.css Normal file
View file

@ -0,0 +1 @@
/* All styles in theme.css */

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './theme.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,137 @@
.lookup-page {
max-width: 600px;
}
.form-section {
margin-top: 0;
}
.form-group {
margin-bottom: 24px;
}
.field-label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lookup-page input[type="text"],
.lookup-page textarea {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid var(--black);
background: var(--white);
color: var(--black);
transition: all 0.2s;
}
.lookup-page textarea {
min-height: 200px;
resize: vertical;
font-family: 'Monaco', 'Courier New', monospace;
}
.lookup-page input:focus,
.lookup-page textarea:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.3);
}
.helper-text {
font-size: 12px;
color: var(--gray-600);
margin-top: 8px;
}
.btn-primary {
width: 100%;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
background: var(--black);
color: var(--white);
border: 2px solid var(--black);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent);
color: var(--black);
border-color: var(--accent);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.not-found {
margin-top: 24px;
padding: 20px;
border: 2px dashed var(--gray-200);
text-align: center;
}
.not-found p {
margin-bottom: 16px;
font-size: 15px;
}
.not-found-list {
list-style: none;
padding: 0;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.not-found-list li {
padding: 4px 12px;
background: var(--gray-100);
border: 1px solid var(--gray-200);
font-size: 13px;
font-weight: 500;
}
.btn-research {
padding: 12px 24px;
font-size: 14px;
font-weight: 700;
background: var(--accent);
color: var(--black);
border: 2px solid var(--accent);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-research:hover:not(:disabled) {
background: var(--black);
color: var(--accent);
border-color: var(--black);
}
.btn-research:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-banner {
padding: 12px 16px;
background: #fff0f0;
border: 1px solid #ffcdd2;
color: #c62828;
font-size: 14px;
margin-bottom: 24px;
}

View file

@ -0,0 +1,245 @@
import { useState } from 'react'
import ModeToggle from '../components/ModeToggle'
import BenchmarkGrid from '../components/BenchmarkGrid'
import ResearchProgress from '../components/ResearchProgress'
import { lookupBenchmarks, bulkLookup, startResearch, bulkResearch } from '../api'
import './LookupPage.css'
function LookupPage() {
const [mode, setMode] = useState('single')
// Single mode state
const [title, setTitle] = useState('')
const [location, setLocation] = useState('')
const [singleResults, setSingleResults] = useState(null)
const [singleNotFound, setSingleNotFound] = useState(false)
const [singleResearchSession, setSingleResearchSession] = useState(null)
// Bulk mode state
const [bulkLocation, setBulkLocation] = useState('')
const [bulkTitles, setBulkTitles] = useState('')
const [bulkFound, setBulkFound] = useState(null)
const [bulkNotFound, setBulkNotFound] = useState([])
const [bulkResearchSessions, setBulkResearchSessions] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleSingleLookup = async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
setSingleResults(null)
setSingleNotFound(false)
setSingleResearchSession(null)
try {
const data = await lookupBenchmarks(title, location)
if (data) {
setSingleResults(data)
} else {
setSingleNotFound(true)
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleSingleResearch = async () => {
setLoading(true)
setError(null)
try {
const data = await startResearch(title, location)
setSingleResearchSession(data.session_id)
setSingleNotFound(false)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleBulkLookup = async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
setBulkFound(null)
setBulkNotFound([])
setBulkResearchSessions([])
const titles = bulkTitles
.split('\n')
.map((t) => t.trim())
.filter(Boolean)
if (titles.length === 0) {
setError('Please enter at least one job title')
setLoading(false)
return
}
try {
const data = await bulkLookup(bulkLocation, titles)
if (data) {
// Flatten found results into a single array
const allFound = Object.values(data.found).flat()
setBulkFound(allFound.length > 0 ? allFound : null)
setBulkNotFound(data.not_found || [])
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleBulkResearch = async () => {
setLoading(true)
setError(null)
try {
const data = await bulkResearch(bulkLocation, bulkNotFound)
setBulkResearchSessions(data.sessions || [])
setBulkNotFound([])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleResearchValidated = () => {
// For single mode, re-run the lookup to show saved data
if (mode === 'single') {
setSingleResearchSession(null)
handleSingleLookup({ preventDefault: () => {} })
}
// For bulk mode, the ValidationForm shows "saved" state on its own
// No need to re-trigger anything that would cause duplicate validate calls
}
return (
<div className="lookup-page">
<ModeToggle mode={mode} setMode={setMode} />
{error && <div className="error-banner">{error}</div>}
{mode === 'single' && (
<div className="form-section">
<form onSubmit={handleSingleLookup}>
<div className="form-group">
<label className="field-label" htmlFor="title">Job Title</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Creative Director"
required
/>
</div>
<div className="form-group">
<label className="field-label" htmlFor="location">Location</label>
<input
type="text"
id="location"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., New York"
required
/>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Looking up...' : 'Get Benchmark'}
</button>
</form>
{singleResults && <BenchmarkGrid results={singleResults} />}
{singleNotFound && (
<div className="not-found">
<p>No benchmark found for <strong>{title}</strong> in <strong>{location}</strong>.</p>
<button className="btn-research" onClick={handleSingleResearch} disabled={loading}>
Research This Role
</button>
</div>
)}
{singleResearchSession && (
<ResearchProgress
sessionId={singleResearchSession}
title={title}
location={location}
onValidated={handleResearchValidated}
/>
)}
</div>
)}
{mode === 'bulk' && (
<div className="form-section">
<form onSubmit={handleBulkLookup}>
<div className="form-group">
<label className="field-label" htmlFor="bulkLocation">
Location (applies to all titles)
</label>
<input
type="text"
id="bulkLocation"
value={bulkLocation}
onChange={(e) => setBulkLocation(e.target.value)}
placeholder="e.g., New York"
required
/>
</div>
<div className="form-group">
<label className="field-label" htmlFor="bulkTitles">
Job Titles (one per line)
</label>
<textarea
id="bulkTitles"
value={bulkTitles}
onChange={(e) => setBulkTitles(e.target.value)}
placeholder={"Art Director\nProject Manager\nData Analyst\nUX Designer"}
required
/>
<div className="helper-text">Paste multiple job titles, each on a new line</div>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Looking up...' : 'Run Bulk Lookup'}
</button>
</form>
{bulkFound && <BenchmarkGrid results={bulkFound} title="Found Benchmarks" />}
{bulkNotFound.length > 0 && (
<div className="not-found">
<p><strong>{bulkNotFound.length}</strong> role(s) not found:</p>
<ul className="not-found-list">
{bulkNotFound.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
<button className="btn-research" onClick={handleBulkResearch} disabled={loading}>
Research All Missing Roles
</button>
</div>
)}
{bulkResearchSessions.map((s) => (
<ResearchProgress
key={s.session_id}
sessionId={s.session_id}
title={s.title}
location={bulkLocation}
onValidated={handleResearchValidated}
/>
))}
</div>
)}
</div>
)
}
export default LookupPage

30
frontend/src/theme.css Normal file
View file

@ -0,0 +1,30 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
:root {
--black: #000000;
--white: #FFFFFF;
--accent: #FFC407;
--gray-100: #F5F5F5;
--gray-200: #E5E5E5;
--gray-400: #999999;
--gray-600: #666666;
--font: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font);
background: var(--white);
color: var(--black);
line-height: 1.6;
min-height: 100vh;
}
input, textarea, button {
font-family: var(--font);
}

15
frontend/vite.config.js Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://app:8000',
changeOrigin: true,
},
},
},
})

235
index (7).html Normal file
View file

@ -0,0 +1,235 @@
<!doctype html >
<!-- Front-end only. No JS. Ready for backend integration -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Salary Benchmark Tool</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: white;
color: black;
line-height: 1.6;
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
}
h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 16px;
color: #666;
margin-bottom: 48px;
}
.small-subtitle {
font-size: 12px;
color: #666;
margin-top: -40px;
margin-bottom: 32px;
}
.form-group {
margin-bottom: 24px;
}
.field-label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input,
textarea {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid black;
background: white;
color: black;
font-family: inherit;
transition: all 0.2s;
}
textarea {
min-height: 200px;
resize: vertical;
font-family: 'Monaco', 'Courier New', monospace;
}
input:focus,
textarea:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
button {
width: 100%;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
background: black;
color: white;
border: 2px solid black;
cursor: pointer;
font-family: inherit;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
button:hover {
background: white;
color: black;
}
.helper-text {
font-size: 12px;
color: #666;
margin-top: 8px;
}
.mode-input {
display: none;
}
.mode-toggle {
display: flex;
margin-bottom: 32px;
border: 2px solid black;
}
.mode-toggle label {
flex: 1;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
background: white;
color: black;
text-align: center;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.mode-toggle label:first-of-type {
border-right: 2px solid black;
}
.form-section {
display: none;
}
#singleMode:checked ~ .container .mode-toggle label[for="singleMode"] {
background: black;
color: white;
}
#bulkMode:checked ~ .container .mode-toggle label[for="bulkMode"] {
background: black;
color: white;
}
#singleMode:checked ~ .container #singleSection {
display: block;
}
#bulkMode:checked ~ .container #bulkSection {
display: block;
}
</style>
</head>
<body>
<input class="mode-input" type="radio" name="mode" id="singleMode" checked>
<input class="mode-input" type="radio" name="mode" id="bulkMode">
<div class="container">
<h1>Salary Benchmark Tool</h1>
<p class="subtitle">Get salary ranges for different experience levels</p>
<p class="small-subtitle">Questions? Reach out to felipeoliveira@oliver.agency</p>
<div class="mode-toggle">
<label for="singleMode">Single Lookup</label>
<label for="bulkMode">Bulk Import</label>
</div>
<div id="singleSection" class="form-section">
<form id="benchmarkForm">
<div class="form-group">
<label class="field-label" for="title">Job Title</label>
<input
type="text"
id="title"
name="title"
placeholder="e.g., Creative Director"
required
/>
</div>
<div class="form-group">
<label class="field-label" for="location">Location</label>
<input
type="text"
id="location"
name="location"
placeholder="e.g., San Francisco"
required
/>
</div>
<button type="submit">Get Benchmark</button>
</form>
</div>
<div id="bulkSection" class="form-section">
<form id="bulkImportForm">
<div class="form-group">
<label class="field-label" for="bulkLocation">Location (applies to all titles)</label>
<input
type="text"
id="bulkLocation"
name="bulkLocation"
placeholder="e.g., San Francisco"
required
/>
</div>
<div class="form-group">
<label class="field-label" for="bulkTitles">Job Titles (one per line)</label>
<textarea
id="bulkTitles"
name="bulkTitles"
placeholder="Art Director&#10;Project Manager&#10;Data Analyst&#10;UX Designer"
required
></textarea>
<div class="helper-text">Paste multiple job titles, each on a new line</div>
</div>
<button type="submit">Run Bulk Lookup</button>
</form>
</div>
</div>
</body>
</html>

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.1
pydantic-settings==2.7.1
httpx==0.28.1
anthropic==0.42.0
cohere==5.13.3