Personal portfolio site at mohammadnasim.com with an embedded AI chatbot that answers visitor questions about Mohammad's background, grounded strictly in his CV via retrieval-augmented generation (RAG).
The repo holds both the static frontend (HTML/CSS/JS deployed to Hostinger) and the Python backend (FastAPI + LangChain on Google Cloud Run).
Live URLs
- Site: https://mohammadnasim.com
- Chatbot backend: https://cv-chatbot-679776566774.us-central1.run.app
- Backend
/health: returns{"status":"ok","chunks_indexed":N}
| Layer | Stack |
|---|---|
| Frontend | Vanilla HTML5, CSS3, ES6+ JavaScript (no framework) |
| Backend API | Python 3.11, FastAPI, Uvicorn, Pydantic |
| RAG | LangChain (LCEL), OpenAI embeddings, FAISS vector store, PyPDF |
| LLM | OpenAI gpt-5.4-mini (temp 0.2) |
| Container | Docker (python:3.11-slim base, libgomp1 for FAISS) |
| Hosting | Google Cloud Run (backend), Hostinger FTP (frontend) |
| CI/CD | GitHub Actions (frontend FTP deploy on push to main) |
| Secrets | GCP Secret Manager (OpenAI key bound at deploy time) |
.
├── index.html # Single-page portfolio site
├── styles.css # Site styles (dark/light theme, animations)
├── script.js # Chat widget + site behaviors
├── image/ # Hero photo and static assets
│
├── chatbot_backend.py # FastAPI app + RAG pipeline
├── requirements.txt # Python deps
├── Dockerfile # Container build for Cloud Run
├── .dockerignore # Excludes frontend assets from image
│
├── cv/
│ └── AgenticAI Architect Mohammad Nasim V5.0_Detailed.pdf
│
├── faiss_index/ # Pre-built vector store (baked into image)
│ ├── index.faiss
│ └── index.pkl
│
├── .github/workflows/
│ └── deploy-frontend.yml # Hostinger FTP deploy
│
└── .env # Local-only: OPENAI_API_KEY (gitignored ideally)
- Load —
PyPDFLoaderreads the CV PDF and returns one Document per page. - Annotate — a custom
annotate_employer()walks the concatenated text top-to-bottom, detecting employer headers (e.g. "State of Rhode Island Department of Transportation", "Atkins UK", "PAEW") and prepending[Employer: <name>]to every line in that section. This guarantees each chunk carries explicit attribution regardless of where the splitter cuts. - Split —
RecursiveCharacterTextSplitterwithchunk_size=2000,overlap=400, separators["\n\n", "\n", ".", " ", ""]. - Embed —
OpenAIEmbeddingsproduces vectors for each chunk. - Persist —
FAISS.save_local()writesindex.faissandindex.pklto disk. These files are baked into the Docker image so cold starts skip the embedding step.
POST /chatwith{"message": "...", "session_id": "..."}.- Retriever pulls top-
k=8chunks from FAISS by cosine similarity to the question. - The chunks are joined into a
CV Context:block and passed to the LLM along with the chat history for that session. - The LLM is instructed to answer strictly from context, refuse fabrication, attribute projects only to employers the context explicitly ties them to, and keep responses under 200 words.
- Response is returned and appended to the session history (capped at 6 turn pairs).
Without it, an 800-char chunk like "Projects: 1. AI-Powered Bridge Monitoring..." had no client name attached, so when retrieved for a Rhode Island question the LLM would misattribute it to Rhode Island. Tagging every line with [Employer: State of Michigan Department of Transportation] makes the attribution travel with the text into every chunk it lands in.
- Python 3.11+ (3.13 works; 3.11 is what Docker uses)
- A working OpenAI API key with sufficient quota
- (Optional)
gcloudCLI if you plan to deploy
# Clone (already done if reading this from the repo)
git clone https://github.com/MohammadNasim/Portfolio2.git
cd Portfolio2
# Create a venv and install deps
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
# Add your OpenAI key
"OPENAI_API_KEY=sk-proj-..." | Set-Content .env
# Run
python chatbot_backend.py
# → http://localhost:8000/health
# → http://localhost:8000/chat (POST)On first run the FAISS index is loaded from faiss_index/ on disk (built from V5 PDF). Delete that directory to force a rebuild on next startup.
The frontend is a single index.html — open it directly in a browser or serve it locally:
python -m http.server 5500
# → http://localhost:5500/index.htmlTo point the chat widget at a local backend, edit script.js:
const CHATBOT_API_URL = "http://localhost:8000";(remember to revert before pushing — production points at the Cloud Run URL).
$PROJECT = "prjadkvertexairagservice"
$REGION = "us-central1"
# Enable APIs
gcloud services enable run.googleapis.com artifactregistry.googleapis.com cloudbuild.googleapis.com secretmanager.googleapis.com --project=$PROJECT
# Artifact Registry repo
gcloud artifacts repositories create chatbot --repository-format=docker --location=$REGION --project=$PROJECT
# Cloud Build runner SA (newer GCP projects lack the legacy default)
gcloud iam service-accounts create cloudbuild-runner --project=$PROJECT
gcloud projects add-iam-policy-binding $PROJECT --member="serviceAccount:cloudbuild-runner@$PROJECT.iam.gserviceaccount.com" --role="roles/cloudbuild.builds.builder"
gcloud projects add-iam-policy-binding $PROJECT --member="serviceAccount:cloudbuild-runner@$PROJECT.iam.gserviceaccount.com" --role="roles/artifactregistry.writer"
gcloud projects add-iam-policy-binding $PROJECT --member="serviceAccount:cloudbuild-runner@$PROJECT.iam.gserviceaccount.com" --role="roles/logging.logWriter"
# Cloud Run runtime SA + secret access
gcloud iam service-accounts create chatbot-runtime --project=$PROJECT
gcloud secrets create OPENAI_API_KEY --replication-policy=automatic --project=$PROJECT
"sk-proj-..." | gcloud secrets versions add OPENAI_API_KEY --data-file=- --project=$PROJECT
gcloud secrets add-iam-policy-binding OPENAI_API_KEY --member="serviceAccount:chatbot-runtime@$PROJECT.iam.gserviceaccount.com" --role="roles/secretmanager.secretAccessor" --project=$PROJECT$IMAGE = "us-central1-docker.pkg.dev/prjadkvertexairagservice/chatbot/cv-chatbot:latest"
# 1. Build image via Cloud Build
gcloud builds submit `
--tag $IMAGE `
--project=prjadkvertexairagservice `
--region=us-central1 `
--service-account=projects/prjadkvertexairagservice/serviceAccounts/cloudbuild-runner@prjadkvertexairagservice.iam.gserviceaccount.com `
--default-buckets-behavior=REGIONAL_USER_OWNED_BUCKET
# 2. Roll new Cloud Run revision (env vars + secret bindings persist from prior deploy)
gcloud run deploy cv-chatbot --image $IMAGE --region us-central1 --project=prjadkvertexairagserviceInitial deploy used these flags (only needed once; subsequent deploys inherit them):
gcloud run deploy cv-chatbot `
--image $IMAGE `
--region us-central1 `
--platform managed `
--allow-unauthenticated `
--service-account chatbot-runtime@prjadkvertexairagservice.iam.gserviceaccount.com `
--memory 1Gi --cpu 1 `
--min-instances 0 --max-instances 3 `
--timeout 300 `
"--set-env-vars=^##^ALLOWED_ORIGINS=https://mohammadnasim.com,https://www.mohammadnasim.com" `
--set-secrets "OPENAI_API_KEY=OPENAI_API_KEY:latest"The ^##^ delimiter is required because ALLOWED_ORIGINS contains commas — gcloud uses commas as the env-var separator by default.
Pushing to main triggers .github/workflows/deploy-frontend.yml, which FTPs index.html, styles.css, script.js, and image/** to Hostinger's public_html/. Backend-only changes (chatbot_backend.py, Dockerfile, faiss_index/) do not trigger the workflow — only index.html / script.js / styles.css / image/** paths do.
# Replace the PDF in cv/ first, then:
Remove-Item -Recurse -Force faiss_index
$env:OPENAI_API_KEY = "sk-proj-..."
python -c "import chatbot_backend" # rebuilds + saves to disk
# Commit, then redeploy (see "Build and deploy" above)
git add faiss_index chatbot_backend.py cv/
git commit -m "Update CV and rebuild FAISS"# Add new version to Secret Manager
printf '%s' 'sk-proj-NEW_KEY' | gcloud secrets versions add OPENAI_API_KEY --data-file=- --project=prjadkvertexairagservice
# Pin the service to the new version (this also forces a new revision)
gcloud run services update cv-chatbot --region us-central1 --project=prjadkvertexairagservice --update-secrets "OPENAI_API_KEY=OPENAI_API_KEY:<N>"# Active revision and config
gcloud run services describe cv-chatbot --region us-central1 --project=prjadkvertexairagservice
# Image history in Artifact Registry
gcloud artifacts docker images list us-central1-docker.pkg.dev/prjadkvertexairagservice/chatbot/cv-chatbot --include-tags --sort-by=~UPDATE_TIME
# Recent Cloud Build runs
gcloud builds list --project=prjadkvertexairagservice --limit=10
# Live smoke test
$body = @{ message = "Who is Mohammad?"; session_id = "test" } | ConvertTo-Json
Invoke-RestMethod -Uri https://cv-chatbot-679776566774.us-central1.run.app/chat -Method Post -ContentType "application/json" -Body $bodyEdit chatbot_backend.py:
SYSTEM_MSG(around line 90) — instructions to the LLMEMPLOYER_ANCHORSandannotate_employer()— attribution preprocessingRecursiveCharacterTextSplitter(...)args — chunk size and overlapvectorstore.as_retriever(search_kwargs={"k": 8})— number of chunks retrieved
Then rebuild FAISS (delete faiss_index/ and re-run) and redeploy.
{"status": "ok", "chunks_indexed": 23}Request
{ "message": "What is Mohammad's current role?", "session_id": "user-123" }Response (200)
{
"answer": "Mohammad is currently...",
"session_id": "user-123"
}Error responses
400— empty message500— upstream LLM/embedding error (passes through OpenAI error detail)
Sessions are in-memory only — they reset when a Cloud Run instance dies or scales to zero. Each session retains the last 6 human/AI turn pairs.
Clears history for the given session.
| Variable | Source | Purpose |
|---|---|---|
OPENAI_API_KEY |
Secret Manager | OpenAI API authentication |
ALLOWED_ORIGINS |
--set-env-vars |
Comma-separated CORS allowlist |
PORT |
Cloud Run | Injected automatically; uvicorn binds here |
OPENAI_API_KEY=sk-proj-...
ALLOWED_ORIGINS=http://localhost:5500
.env is loaded by python-dotenv only — Cloud Run ignores it and uses the Secret Manager binding instead.
| Symptom | Likely cause | Fix |
|---|---|---|
| Chat widget shows "Could not reach the AI backend" | CORS blocked or Cloud Run cold-starting | Verify ALLOWED_ORIGINS includes the calling origin; first request after idle takes 5–20s |
/chat returns insufficient_quota 500 |
OpenAI billing or rate limit | Top up OpenAI billing or check usage caps |
| Chatbot says "Architect" instead of "Developer" | Old image still deployed | Run gcloud builds submit + gcloud run deploy again |
gcloud builds submit fails with NOT_FOUND |
Cloud Build default SA missing | Use --service-account=...cloudbuild-runner... + --default-buckets-behavior=REGIONAL_USER_OWNED_BUCKET |
| Cross-employer hallucination in answers | Chunk boundaries split client from project list | Verify annotate_employer() is running before splitting; check that anchor strings match the PDF text |
| Frontend not updating after push | Workflow didn't trigger | Confirm push touched index.html/script.js/styles.css/image/** (only those paths fire the deploy) |
For day-to-day operations, here's where each piece of state lives:
| Resource | Location |
|---|---|
| GCP project | prjadkvertexairagservice (project number 679776566774) |
| Cloud Run service | cv-chatbot in us-central1 |
| Docker image | us-central1-docker.pkg.dev/prjadkvertexairagservice/chatbot/cv-chatbot:latest |
| Build service account | cloudbuild-runner@prjadkvertexairagservice.iam.gserviceaccount.com |
| Runtime service account | chatbot-runtime@prjadkvertexairagservice.iam.gserviceaccount.com |
| OpenAI key secret | OPENAI_API_KEY in Secret Manager |
| Billing account | 0102B6-A64631-55DF6A ("Nasim Portfolio") |
| Frontend host | Hostinger, deployed via GitHub Actions FTP |
| Source repo | https://github.com/MohammadNasim/Portfolio2 |
This is a personal portfolio — code is provided as-is for reference. The CV PDF and likeness are property of Mohammad Nasim.
For questions: mnasimsiddiqui@gmail.com · LinkedIn
