husseinelsaadi Claude Opus 4.8 commited on
Commit
41604f6
·
1 Parent(s): e0470f3

Revive Codingo: free-CPU deploy, env-driven Qdrant, SQLite fallback, CPU crash fix

Browse files

- Dockerfile: CUDA base -> python:3.10-slim CPU; install CPU torch; add spaCy model
- interview_retrieval.py: read Qdrant URL/key from env instead of dead hardcoded cluster
- interview_engine.py: guard unconditional GPU calls that crashed on CPU at import
- app.py: fall back to SQLite when DATABASE_URL is unset; normalise postgres:// scheme
- requirements.txt: drop GPU-only bitsandbytes; add nltk + python-dateutil for resume parser
- add scripts/rebuild_qdrant.py + data/ to rebuild the interview-question vector DB

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitignore CHANGED
@@ -1,3 +1,9 @@
 
 
 
 
 
 
1
  # Python bytecode
2
  __pycache__/
3
  *.py[cod]
 
1
+ # Local-only archive of old laptop files (never deploy this)
2
+ everything-old-in-my-laptop/
3
+
4
+ # Claude / editor working dirs
5
+ .claude/
6
+
7
  # Python bytecode
8
  __pycache__/
9
  *.py[cod]
Dockerfile CHANGED
@@ -1,22 +1,38 @@
1
- # Use an NVIDIA PyTorch container with cuDNN 9.1 support
2
- FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04
 
 
3
 
 
 
 
 
 
 
 
 
4
 
5
- # Basic setup
6
- ENV OMP_NUM_THREADS=1
7
- ENV DEBIAN_FRONTEND=noninteractive
8
- RUN apt-get update && apt-get install -y \
9
- python3 python3-pip ffmpeg git libsndfile1 \
10
- # Development tools required to compile native extensions such as llama-cpp-python
11
- build-essential cmake libopenblas-dev \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
- # Set up Python environment
 
 
 
 
15
  COPY requirements.txt .
16
- RUN pip install --upgrade pip && pip install -r requirements.txt
 
 
 
17
 
18
- # Copy app files
19
  COPY . /app
20
  WORKDIR /app
21
 
 
 
22
  CMD ["python3", "app.py"]
 
1
+ # CPU-only image for Hugging Face Spaces (free tier).
2
+ # Nothing in the app requires a GPU: the LLM is the Groq API, Whisper runs on
3
+ # CPU, edge-tts is a cloud service, and embeddings use the small MiniLM model.
4
+ FROM python:3.10-slim
5
 
6
+ ENV OMP_NUM_THREADS=1 \
7
+ DEBIAN_FRONTEND=noninteractive \
8
+ PIP_NO_CACHE_DIR=1 \
9
+ PYTHONUNBUFFERED=1 \
10
+ # Keep all model/cache downloads inside the writable /tmp dir on Spaces.
11
+ HF_HOME=/tmp/huggingface \
12
+ TRANSFORMERS_CACHE=/tmp/huggingface/transformers \
13
+ HUGGINGFACE_HUB_CACHE=/tmp/huggingface/hub
14
 
15
+ # System libraries: ffmpeg (audio), libsndfile1 (soundfile/librosa),
16
+ # git (some pip installs), and build tools for any source-only wheels.
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ ffmpeg git libsndfile1 build-essential \
 
 
 
19
  && rm -rf /var/lib/apt/lists/*
20
 
21
+ # Install the CPU build of PyTorch first so the heavy CUDA wheel is never
22
+ # pulled in by transitive dependencies.
23
+ RUN pip install --upgrade pip && \
24
+ pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cpu
25
+
26
  COPY requirements.txt .
27
+ RUN pip install -r requirements.txt
28
+
29
+ # Pre-download the small spaCy English model used by the resume parser.
30
+ RUN python -m spacy download en_core_web_sm
31
 
32
+ # Copy the application code.
33
  COPY . /app
34
  WORKDIR /app
35
 
36
+ EXPOSE 7860
37
+
38
  CMD ["python3", "app.py"]
README.md CHANGED
@@ -1,6 +1,38 @@
1
  ---
2
  title: Codingo
 
 
 
3
  sdk: docker
4
  app_file: app.py
5
- hardware: gpu
6
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Codingo
3
+ emoji: 🤖
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
  app_file: app.py
8
+ pinned: false
9
  ---
10
+
11
+ # Codingo — AI-Powered Smart Recruitment System
12
+
13
+ A Flask web app where companies post jobs and candidates apply, then an AI
14
+ interviewer ("LUNA") conducts an automated voice interview and scores the
15
+ candidate.
16
+
17
+ ## Required Space secrets
18
+
19
+ Set these under **Settings → Variables and secrets**:
20
+
21
+ | Secret | Purpose |
22
+ | --- | --- |
23
+ | `GROQ_API_KEY` | LLM that generates interview questions and chatbot replies |
24
+ | `QDRANT_API_URL` | URL of the Qdrant cluster holding the interview questions |
25
+ | `QDRANT_API_KEY` | API key for that Qdrant cluster |
26
+ | `DATABASE_URL` | *(optional)* Postgres URL; if unset, the app uses SQLite in /tmp |
27
+
28
+ ## Rebuilding the interview-question vector database
29
+
30
+ The `interview_questions` Qdrant collection is built from
31
+ `data/shuffled_questions.json` (4233 Q&A pairs, all-MiniLM-L6-v2, 384-dim,
32
+ cosine):
33
+
34
+ ```bash
35
+ export QDRANT_API_URL="https://<cluster>.qdrant.io"
36
+ export QDRANT_API_KEY="<key>"
37
+ python scripts/rebuild_qdrant.py
38
+ ```
app.py CHANGED
@@ -64,8 +64,17 @@ app.config['SESSION_COOKIE_SECURE'] = True
64
  app.config['REMEMBER_COOKIE_SAMESITE'] = 'None'
65
  app.config['REMEMBER_COOKIE_SECURE'] = True
66
 
67
- # Configure the database connection
68
- app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL")
 
 
 
 
 
 
 
 
 
69
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
70
 
71
  # Create necessary directories in writable locations
 
64
  app.config['REMEMBER_COOKIE_SAMESITE'] = 'None'
65
  app.config['REMEMBER_COOKIE_SECURE'] = True
66
 
67
+ # Configure the database connection. Use DATABASE_URL when provided (e.g. a
68
+ # hosted Postgres set as a Space secret); otherwise fall back to a local
69
+ # SQLite file in the writable /tmp directory so the app runs with zero
70
+ # database setup. Note: on Hugging Face the /tmp filesystem is ephemeral, so
71
+ # SQLite data resets when the Space restarts.
72
+ _database_url = os.getenv("DATABASE_URL") or "sqlite:////tmp/codingo.db"
73
+ # SQLAlchemy expects the 'postgresql://' scheme; some providers hand out
74
+ # 'postgres://', which newer SQLAlchemy rejects. Normalise it.
75
+ if _database_url.startswith("postgres://"):
76
+ _database_url = _database_url.replace("postgres://", "postgresql://", 1)
77
+ app.config['SQLALCHEMY_DATABASE_URI'] = _database_url
78
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
79
 
80
  # Create necessary directories in writable locations
backend/services/interview_engine.py CHANGED
@@ -30,16 +30,16 @@ try:
30
  except Exception as e:
31
  print("⚠️ Qdrant check failed:", e)
32
 
 
 
 
33
  if torch.cuda.is_available():
34
  print("🔥 CUDA Available")
35
- print(torch.cuda.get_device_name(0))
36
- print("cuDNN version:", torch.backends.cudnn.version())
 
37
  else:
38
- print("❌ CUDA Not Available")
39
- print("🔥 CUDA:", torch.cuda.is_available())
40
- print("🧠 GPU:", torch.cuda.get_device_name(0))
41
- print("💡 cuDNN version:", torch.backends.cudnn.version())
42
- print("💥 cuDNN enabled:", torch.backends.cudnn.is_available())
43
 
44
 
45
 
 
30
  except Exception as e:
31
  print("⚠️ Qdrant check failed:", e)
32
 
33
+ # Report GPU availability without assuming a GPU is present. Calling
34
+ # torch.cuda.get_device_name(0) on a CPU-only host raises and would crash
35
+ # the import (and therefore the whole app), so guard every GPU-only call.
36
  if torch.cuda.is_available():
37
  print("🔥 CUDA Available")
38
+ print("🧠 GPU:", torch.cuda.get_device_name(0))
39
+ print("💡 cuDNN version:", torch.backends.cudnn.version())
40
+ print("💥 cuDNN enabled:", torch.backends.cudnn.is_available())
41
  else:
42
+ print("❌ CUDA Not Available — running on CPU")
 
 
 
 
43
 
44
 
45
 
backend/services/interview_retrieval.py CHANGED
@@ -67,15 +67,38 @@ SentenceTransformer = None # type: ignore
67
  # ---------------------------------------------------------------------------
68
  # Qdrant configuration
69
  #
70
- # These connection details must not be altered. They point to the
71
- # existing Qdrant instance containing interview questions and answers.
72
-
73
- if QdrantClient is not None:
74
- qdrant_client: QdrantClient | None = QdrantClient(
75
- url="https://313b1ceb-057f-4b7b-89f5-7b19a213fe65.us-east-1-0.aws.cloud.qdrant.io:6333",
76
- api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.w13SPZbljbSvt9Ch_0r034QhMFlmEr4ctXqLo2zhxm4",
77
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  else:
 
 
 
 
 
79
  qdrant_client = None
80
 
81
  # Name of the Qdrant collection containing interview Q&A pairs. Do not
 
67
  # ---------------------------------------------------------------------------
68
  # Qdrant configuration
69
  #
70
+ # Connection details are read from the environment so the app can point at
71
+ # whichever Qdrant cluster currently holds the ``interview_questions``
72
+ # collection. Set QDRANT_API_URL and QDRANT_API_KEY (e.g. as Hugging Face
73
+ # Space secrets, or in a local .env file). Rebuild the collection from the
74
+ # bundled dataset with ``scripts/rebuild_qdrant.py``.
75
+ import os as _os
76
+
77
+ _qdrant_url = _os.getenv("QDRANT_API_URL")
78
+ _qdrant_key = _os.getenv("QDRANT_API_KEY")
79
+
80
+ # Qdrant's REST API listens on :6333; append it if only the bare host is given.
81
+ if _qdrant_url:
82
+ _qdrant_url = _qdrant_url.rstrip("/")
83
+ if ".qdrant.io" in _qdrant_url and not _qdrant_url.rsplit(":", 1)[-1].isdigit():
84
+ _qdrant_url = _qdrant_url + ":6333"
85
+
86
+ if QdrantClient is not None and _qdrant_url and _qdrant_key:
87
+ try:
88
+ qdrant_client: QdrantClient | None = QdrantClient(
89
+ url=_qdrant_url,
90
+ api_key=_qdrant_key,
91
+ check_compatibility=False,
92
+ )
93
+ except Exception as _exc: # pragma: no cover - network/config issues
94
+ logging.error(f"Failed to initialise Qdrant client: {_exc}")
95
+ qdrant_client = None
96
  else:
97
+ if QdrantClient is not None and not (_qdrant_url and _qdrant_key):
98
+ logging.warning(
99
+ "QDRANT_API_URL / QDRANT_API_KEY not set; interview question "
100
+ "retrieval will fall back to default questions."
101
+ )
102
  qdrant_client = None
103
 
104
  # Name of the Qdrant collection containing interview Q&A pairs. Do not
data/merged_dataset.json ADDED
The diff for this file is too large to render. See raw diff
 
data/shuffled_questions.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -22,7 +22,6 @@ inputimeout==1.0.4
22
  evaluate==0.4.5
23
  accelerate==0.29.3
24
  huggingface_hub==0.20.3
25
- bitsandbytes
26
  faster-whisper==0.10.0
27
  edge-tts==6.1.2
28
  gunicorn
@@ -33,9 +32,9 @@ pydub>=0.25.1
33
  requests>=2.31.0
34
  psycopg2-binary
35
  matplotlib
36
- bitsandbytes>=0.41.0
37
  pdfminer
38
  pdfminer.six
39
  python-docx
40
  spacy
41
-
 
 
22
  evaluate==0.4.5
23
  accelerate==0.29.3
24
  huggingface_hub==0.20.3
 
25
  faster-whisper==0.10.0
26
  edge-tts==6.1.2
27
  gunicorn
 
32
  requests>=2.31.0
33
  psycopg2-binary
34
  matplotlib
 
35
  pdfminer
36
  pdfminer.six
37
  python-docx
38
  spacy
39
+ nltk
40
+ python-dateutil
scripts/rebuild_qdrant.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rebuild the Qdrant ``interview_questions`` collection from the local dataset.
3
+
4
+ This recreates, byte-for-byte, the vector database the original app used:
5
+ * collection name : interview_questions
6
+ * vector size : 384 (all-MiniLM-L6-v2)
7
+ * distance : COSINE
8
+ * payload : {"job_role": <lower>, "question": ..., "answer": ...}
9
+
10
+ The original cluster was deleted after inactivity, but every question is
11
+ preserved in ``data/shuffled_questions.json`` (4233 Q&A pairs, 26 roles),
12
+ which is exactly what populated Qdrant in the first place.
13
+
14
+ Usage:
15
+ export QDRANT_API_URL="https://<your-cluster>.qdrant.io:6333"
16
+ export QDRANT_API_KEY="<your-key>"
17
+ python scripts/rebuild_qdrant.py
18
+
19
+ It is safe to re-run: the collection is recreated from scratch each time.
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import sys
26
+
27
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
28
+
29
+ COLLECTION_NAME = "interview_questions"
30
+ VECTOR_SIZE = 384
31
+ DATA_FILE = os.path.join(
32
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
33
+ "data",
34
+ "shuffled_questions.json",
35
+ )
36
+
37
+
38
+ def main() -> int:
39
+ url = os.getenv("QDRANT_API_URL")
40
+ key = os.getenv("QDRANT_API_KEY")
41
+ if not url or not key:
42
+ logging.error(
43
+ "Set QDRANT_API_URL and QDRANT_API_KEY environment variables first."
44
+ )
45
+ return 1
46
+
47
+ # Qdrant cloud URLs need the :6333 REST port; add it if the user pasted
48
+ # the bare hostname from the dashboard.
49
+ if url.endswith("/"):
50
+ url = url[:-1]
51
+ if ".qdrant.io" in url and not url.rsplit(":", 1)[-1].isdigit():
52
+ url = url + ":6333"
53
+
54
+ from qdrant_client import QdrantClient
55
+ from qdrant_client.http.models import Distance, PointStruct, VectorParams
56
+ from sentence_transformers import SentenceTransformer
57
+
58
+ logging.info("Loading dataset from %s", DATA_FILE)
59
+ with open(DATA_FILE, "r", encoding="utf-8") as f:
60
+ rows = json.load(f)
61
+ logging.info("Loaded %d Q&A rows", len(rows))
62
+
63
+ logging.info("Loading embedding model all-MiniLM-L6-v2 (first run downloads it)")
64
+ model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
65
+
66
+ client = QdrantClient(url=url, api_key=key, check_compatibility=False, timeout=120)
67
+
68
+ logging.info("Recreating collection '%s' (size=%d, COSINE)", COLLECTION_NAME, VECTOR_SIZE)
69
+ client.recreate_collection(
70
+ collection_name=COLLECTION_NAME,
71
+ vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE),
72
+ )
73
+
74
+ # Build the points. We embed the QUESTION text, exactly like the original
75
+ # notebook, and store role/question/answer in the payload.
76
+ questions, payloads = [], []
77
+ for item in rows:
78
+ try:
79
+ role = item["Job Role"].lower().strip()
80
+ question = item["Questions"].strip()
81
+ answer = item["Answers"].strip()
82
+ except (KeyError, AttributeError):
83
+ continue
84
+ if not question:
85
+ continue
86
+ questions.append(question)
87
+ payloads.append({"job_role": role, "question": question, "answer": answer})
88
+
89
+ logging.info("Embedding %d questions...", len(questions))
90
+ vectors = model.encode(questions, batch_size=128, show_progress_bar=True)
91
+
92
+ batch_size = 64
93
+ total = len(questions)
94
+ for start in range(0, total, batch_size):
95
+ end = min(start + batch_size, total)
96
+ points = [
97
+ PointStruct(id=i, vector=vectors[i].tolist(), payload=payloads[i])
98
+ for i in range(start, end)
99
+ ]
100
+ for attempt in range(1, 4):
101
+ try:
102
+ client.upsert(collection_name=COLLECTION_NAME, points=points, wait=True)
103
+ break
104
+ except Exception as exc:
105
+ logging.warning("Batch %d-%d attempt %d failed: %s", start, end, attempt, exc)
106
+ if attempt == 3:
107
+ raise
108
+ logging.info("Uploaded %d/%d", end, total)
109
+
110
+ info = client.get_collection(COLLECTION_NAME)
111
+ logging.info(
112
+ "Done. Collection '%s' now has %s points (distance=%s).",
113
+ COLLECTION_NAME,
114
+ info.points_count,
115
+ info.config.params.vectors.distance,
116
+ )
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())