diff --git a/README.md b/README.md index 3e67a8f..788cbbd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ See [Pipeline Setup Guide](./.github/PIPELINE_SETUP.md) for configuration detail - **Candidate Management**: Create, read, update, and delete candidate profiles - **Job Position Management**: Manage job postings with detailed requirements - **Application Tracking**: Track job applications and their statuses +- **🚀 AI-Powered Matching**: Semantic search-based candidate-job matching using OpenAI embeddings +- **Vector Search**: ChromaDB for efficient similarity search and ranking +- **Smart Filtering**: Metadata-based filtering with location, experience, salary, etc. +- **Auto-Sync**: Automatic synchronization of candidates and jobs to vector database - **Schema Validation**: Robust input validation using Zod - **Type Safety**: Full TypeScript implementation with Prisma-generated types - **Database Relations**: Properly structured database with foreign key relationships @@ -34,6 +38,8 @@ See [Pipeline Setup Guide](./.github/PIPELINE_SETUP.md) for configuration detail - **Language**: TypeScript - **Database**: PostgreSQL - **ORM**: Prisma +- **Vector Database**: ChromaDB +- **AI/ML**: OpenAI Embeddings API - **Validation**: Zod - **Development**: Nodemon, ts-node @@ -180,7 +186,15 @@ DATABASE_URL="postgresql://username:password@localhost:5432/database_name?schema # Server PORT=3000 -# Optional: Add other environment-specific variables +NODE_ENV=development + +# OpenAI API (required for AI matching) +OPENAI_API_KEY=your_openai_api_key_here + +# ChromaDB (for vector search) +CHROMA_HOST=localhost +CHROMA_PORT=8000 +CHROMA_URL=http://localhost:8000 ``` ### Environment Variable Descriptions @@ -188,6 +202,10 @@ PORT=3000 - `DATABASE_URL`: PostgreSQL connection string - `PORT`: Port number for the server (default: 3000) - `NODE_ENV`: Environment mode (development, production, test) +- `OPENAI_API_KEY`: **Required** - OpenAI API key for generating embeddings +- `CHROMA_HOST`: ChromaDB server host (default: localhost) +- `CHROMA_PORT`: ChromaDB server port (default: 8000) +- `CHROMA_URL`: Full ChromaDB server URL ## Database Setup @@ -211,6 +229,87 @@ npx prisma generate npx prisma studio ``` +## ChromaDB Setup + +### Option 1: Docker (Recommended) +```bash +# Pull and run ChromaDB +docker pull chromadb/chroma +docker run -p 8000:8000 chromadb/chroma +``` + +### Option 2: Local Installation +```bash +# Install ChromaDB +pip install chromadb + +# Run ChromaDB server +chroma run --host localhost --port 8000 +``` + +### Verify ChromaDB is Running +```bash +curl http://localhost:8000/api/v1/heartbeat +``` + +## AI Matching System + +### Initial Setup +1. **Get OpenAI API Key**: Visit [OpenAI Platform](https://platform.openai.com/) and create an API key +2. **Add to Environment**: Set `OPENAI_API_KEY` in your `.env` file +3. **Start ChromaDB**: Ensure ChromaDB is running on port 8000 +4. **Sync Data**: Use the sync endpoints to populate the vector database + +### Matching API Endpoints + +#### Health Check +```http +GET /api/matching/health +``` + +#### Find Matching Jobs for Candidate +```http +GET /api/matching/candidates/{candidateId}/jobs?limit=10&location=Istanbul&remote_ok=true +``` + +#### Find Matching Candidates for Job +```http +GET /api/matching/jobs/{jobId}/candidates?limit=10&availability=immediate&min_experience=2 +``` + +#### Sync Individual Items +```http +POST /api/matching/sync/candidates/{candidateId} +POST /api/matching/sync/jobs/{jobId} +``` + +#### Bulk Sync +```http +POST /api/matching/sync +Content-Type: application/json + +{ + "type": "all" // or "candidates" or "jobs" +} +``` + +### Query Parameters for Job Matching +- `limit`: Number of results (default: 10) +- `location`: Filter by location +- `remote_ok`: Filter by remote work availability (true/false) +- `experience_level`: Filter by experience level (entry, mid, senior, lead) +- `employment_type`: Filter by employment type (full_time, part_time, contract, internship) +- `salary_min`: Minimum salary filter +- `salary_max`: Maximum salary filter + +### Query Parameters for Candidate Matching +- `limit`: Number of results (default: 10) +- `location`: Filter by location +- `availability`: Filter by availability (immediate, within_week, within_month, not_available) +- `min_experience`: Minimum years of experience +- `max_experience`: Maximum years of experience +- `max_salary_expectation`: Maximum salary expectation + ## Running the Application ### Development Mode diff --git a/package-lock.json b/package-lock.json index ecf5c79..1dae23b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.11.1", + "chromadb": "^1.8.1", "cors": "^2.8.5", "dotenv": "^17.1.0", "express": "^5.1.0", + "openai": "^4.68.4", "prisma": "^6.11.1", "zod": "^3.25.76" }, @@ -244,12 +246,21 @@ "version": "24.0.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -287,6 +298,18 @@ "@types/send": "*" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -326,6 +349,42 @@ "node": ">=0.4.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -347,6 +406,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -474,6 +539,87 @@ "fsevents": "~2.3.2" } }, + "node_modules/chromadb": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/chromadb/-/chromadb-1.10.5.tgz", + "integrity": "sha512-+IeTjjf44pKUY3vp1BacwO2tFAPcWCd64zxPZZm98dVj/kbSBeaHKB2D6eX7iRLHS1PTVASuqoR6mAJ+nrsTBg==", + "license": "Apache-2.0", + "dependencies": { + "cliui": "^8.0.1", + "isomorphic-fetch": "^3.0.0" + }, + "engines": { + "node": ">=14.17.0" + }, + "peerDependencies": { + "@google/generative-ai": "^0.1.1", + "cohere-ai": "^5.0.0 || ^6.0.0 || ^7.0.0", + "ollama": "^0.5.0", + "openai": "^3.0.0 || ^4.0.0", + "voyageai": "^0.0.3-1" + }, + "peerDependenciesMeta": { + "@google/generative-ai": { + "optional": true + }, + "cohere-ai": { + "optional": true + }, + "ollama": { + "optional": true + }, + "openai": { + "optional": true + }, + "voyageai": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -557,6 +703,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -608,6 +763,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -647,6 +808,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -662,6 +838,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -734,6 +919,62 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -860,6 +1101,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -897,6 +1153,15 @@ "node": ">= 0.8" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -954,6 +1219,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -983,6 +1257,16 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -1078,6 +1362,46 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -1159,6 +1483,51 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.119", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.119.tgz", + "integrity": "sha512-d0F6m9itIPaKnrvEMlzE48UjwZaAnFW7Jwibacw9MNdqadjKNpUm9tfJYDwmShJmgqcoqYUX3EMKO1+RWiuuNg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1479,6 +1848,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1524,6 +1919,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -1607,7 +2008,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -1635,6 +2035,54 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index f1ae3ff..fc82c23 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "type": "commonjs", "dependencies": { "@prisma/client": "^6.11.1", + "chromadb": "^1.8.1", "cors": "^2.8.5", "dotenv": "^17.1.0", "express": "^5.1.0", + "openai": "^4.68.4", "prisma": "^6.11.1", "zod": "^3.25.76" }, diff --git a/src/config/matching.config.ts b/src/config/matching.config.ts new file mode 100644 index 0000000..0aa5716 --- /dev/null +++ b/src/config/matching.config.ts @@ -0,0 +1,47 @@ +import { ChromaClient } from 'chromadb'; +import OpenAI from 'openai'; + +export interface MatchingConfig { + chromaHost: string; + chromaPort: number; + openaiApiKey: string; +} + +export const getMatchingConfig = (): MatchingConfig => { + const chromaHost = process.env.CHROMA_HOST || 'localhost'; + const chromaPort = parseInt(process.env.CHROMA_PORT || '8000'); + const openaiApiKey = process.env.OPENAI_API_KEY; + + if (!openaiApiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + return { + chromaHost, + chromaPort, + openaiApiKey + }; +}; + +// OpenAI client instance +export const createOpenAIClient = (apiKey: string) => { + return new OpenAI({ + apiKey: apiKey + }); +}; + +// ChromaDB client instance +export const createChromaClient = (host: string, port: number) => { + return new ChromaClient({ + path: `http://${host}:${port}` + }); +}; + +// OpenAI Embedding Function for ChromaDB +export const createOpenAIEmbeddingFunction = (apiKey: string) => { + // ChromaDB embedding function will be created in the service layer + return { + apiKey, + model: "text-embedding-3-small" + }; +}; diff --git a/src/controllers/candidate.controller.ts b/src/controllers/candidate.controller.ts index 03753df..090e657 100644 --- a/src/controllers/candidate.controller.ts +++ b/src/controllers/candidate.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { PrismaClient, CandidateAvailability, Prisma } from "../generated/prisma"; import { CreateCandidate, UpdateCandidate } from "../schemas/candidate.schema"; import { IdParam } from "../schemas/common.schema"; +import { vectorMatchingService } from "../services/matching/vector-matching.service"; const prisma = new PrismaClient(); @@ -56,6 +57,11 @@ export const createCandidate = async (req: Request<{}, {}, CreateCandidate>, res availability: mapAvailabilityFromPrisma(newCandidate.availability) }; + // Sync to vector database (async, don't block response) + vectorMatchingService.initialize() + .then(() => vectorMatchingService.upsertCandidate(newCandidate.id)) + .catch(error => console.warn('Failed to sync candidate to vector database:', error)); + return res.status(201).json({ success: true, message: "Candidate created successfully", @@ -202,6 +208,11 @@ export const updateCandidate = async (req: Request availability: mapAvailabilityFromPrisma(updatedCandidate.availability) }; + // Sync to vector database (async, don't block response) + vectorMatchingService.initialize() + .then(() => vectorMatchingService.upsertCandidate(updatedCandidate.id)) + .catch(error => console.warn('Failed to sync candidate to vector database:', error)); + return res.status(200).json({ success: true, message: "Candidate updated successfully", @@ -260,6 +271,11 @@ export const deleteCandidate = async (req: Request, res: Response) => { where: { id } }); + // Remove from vector database (async, don't block response) + vectorMatchingService.initialize() + .then(() => vectorMatchingService.removeCandidate(id)) + .catch(error => console.warn('Failed to remove candidate from vector database:', error)); + return res.status(200).json({ success: true, message: "Candidate deleted successfully" diff --git a/src/controllers/job.controller.ts b/src/controllers/job.controller.ts index 062ad85..04fea56 100644 --- a/src/controllers/job.controller.ts +++ b/src/controllers/job.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { PrismaClient, ExperienceLevel, EmploymentType, JobStatus, Prisma } from "../generated/prisma"; import { CreateJobPosition, UpdateJobPosition } from "../schemas/job.schema"; import { IdParam } from "../schemas/common.schema"; +import { vectorMatchingService } from "../services/matching/vector-matching.service"; const prisma = new PrismaClient(); @@ -86,6 +87,11 @@ export const createJobPosition = async (req: Request<{}, {}, CreateJobPosition>, const responseJobPosition = transformJobPositionForResponse(newJobPosition); + // Sync to vector database (async, don't block response) + vectorMatchingService.initialize() + .then(() => vectorMatchingService.upsertJob(newJobPosition.id)) + .catch(error => console.warn('Failed to sync job to vector database:', error)); + return res.status(201).json({ success: true, message: "Job position created successfully", @@ -243,6 +249,11 @@ export const updateJobPosition = async (req: Request vectorMatchingService.upsertJob(updatedJobPosition.id)) + .catch(error => console.warn('Failed to sync job to vector database:', error)); + return res.status(200).json({ success: true, message: "Job position updated successfully", @@ -301,6 +312,11 @@ export const deleteJobPosition = async (req: Request, res: Response) => where: { id } }); + // Remove from vector database (async, don't block response) + vectorMatchingService.initialize() + .then(() => vectorMatchingService.removeJob(id)) + .catch(error => console.warn('Failed to remove job from vector database:', error)); + return res.status(200).json({ success: true, message: "Job position deleted successfully" diff --git a/src/controllers/matching.controller.ts b/src/controllers/matching.controller.ts new file mode 100644 index 0000000..778c532 --- /dev/null +++ b/src/controllers/matching.controller.ts @@ -0,0 +1,292 @@ +import { Request, Response } from 'express'; +import { vectorMatchingService } from '../services/matching/vector-matching.service'; +import { IdParam } from '../schemas/common.schema'; + +interface MatchingJobsQuery { + limit?: string; + location?: string; + remote_ok?: string; + experience_level?: string; + employment_type?: string; + salary_min?: string; + salary_max?: string; +} + +interface MatchingCandidatesQuery { + limit?: string; + location?: string; + availability?: string; + min_experience?: string; + max_experience?: string; + max_salary_expectation?: string; +} + +interface SyncRequest { + type: 'candidates' | 'jobs' | 'all'; +} + +/** + * Find matching jobs for a candidate + */ +export const findMatchingJobs = async ( + req: Request, + res: Response +) => { + try { + const { id: candidateId } = req.params; + const { + limit = '10', + location, + remote_ok, + experience_level, + employment_type, + salary_min, + salary_max + } = req.query; + + // Initialize vector matching service if not already done + try { + await vectorMatchingService.initialize(); + } catch (initError) { + console.warn('Vector service initialization warning:', initError); + } + + // Build filters + const filters: any = {}; + + if (location) filters.location = location; + if (remote_ok !== undefined) filters.remoteOk = remote_ok === 'true'; + if (experience_level) filters.experienceLevel = experience_level; + if (employment_type) filters.employmentType = employment_type; + if (salary_min) filters.salaryMin = parseInt(salary_min); + if (salary_max) filters.salaryMax = parseInt(salary_max); + + const matches = await vectorMatchingService.findMatchingJobs( + candidateId, + parseInt(limit), + Object.keys(filters).length > 0 ? filters : undefined + ); + + return res.status(200).json({ + success: true, + message: 'Matching jobs found successfully', + data: matches, + count: matches.length, + candidateId + }); + + } catch (error) { + console.error('Error finding matching jobs:', error); + + if ((error as Error).message.includes('not found in vector database')) { + return res.status(404).json({ + success: false, + message: 'Candidate not found in matching system. Please sync the candidate first.', + error: 'CANDIDATE_NOT_SYNCED' + }); + } + + return res.status(500).json({ + success: false, + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } +}; + +/** + * Find matching candidates for a job + */ +export const findMatchingCandidates = async ( + req: Request, + res: Response +) => { + try { + const { id: jobId } = req.params; + const { + limit = '10', + location, + availability, + min_experience, + max_experience, + max_salary_expectation + } = req.query; + + // Initialize vector matching service if not already done + try { + await vectorMatchingService.initialize(); + } catch (initError) { + console.warn('Vector service initialization warning:', initError); + } + + // Build filters + const filters: any = {}; + + if (location) filters.location = location; + if (availability) filters.availability = availability; + if (min_experience) filters.minExperience = parseInt(min_experience); + if (max_experience) filters.maxExperience = parseInt(max_experience); + if (max_salary_expectation) filters.maxSalaryExpectation = parseInt(max_salary_expectation); + + const matches = await vectorMatchingService.findMatchingCandidates( + jobId, + parseInt(limit), + Object.keys(filters).length > 0 ? filters : undefined + ); + + return res.status(200).json({ + success: true, + message: 'Matching candidates found successfully', + data: matches, + count: matches.length, + jobId + }); + + } catch (error) { + console.error('Error finding matching candidates:', error); + + if ((error as Error).message.includes('not found in vector database')) { + return res.status(404).json({ + success: false, + message: 'Job not found in matching system. Please sync the job first.', + error: 'JOB_NOT_SYNCED' + }); + } + + return res.status(500).json({ + success: false, + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } +}; + +/** + * Sync candidate to vector database + */ +export const syncCandidate = async (req: Request, res: Response) => { + try { + const { id: candidateId } = req.params; + + await vectorMatchingService.initialize(); + await vectorMatchingService.upsertCandidate(candidateId); + + return res.status(200).json({ + success: true, + message: 'Candidate synced to matching system successfully', + candidateId + }); + + } catch (error) { + console.error('Error syncing candidate:', error); + return res.status(500).json({ + success: false, + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } +}; + +/** + * Sync job to vector database + */ +export const syncJob = async (req: Request, res: Response) => { + try { + const { id: jobId } = req.params; + + await vectorMatchingService.initialize(); + await vectorMatchingService.upsertJob(jobId); + + return res.status(200).json({ + success: true, + message: 'Job synced to matching system successfully', + jobId + }); + + } catch (error) { + console.error('Error syncing job:', error); + return res.status(500).json({ + success: false, + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } +}; + +/** + * Bulk sync data to vector database + */ +export const syncData = async (req: Request<{}, {}, SyncRequest>, res: Response) => { + try { + const { type } = req.body; + + await vectorMatchingService.initialize(); + + let syncResult = ''; + + switch (type) { + case 'candidates': + await vectorMatchingService.syncAllCandidates(); + syncResult = 'All candidates synced successfully'; + break; + + case 'jobs': + await vectorMatchingService.syncAllJobs(); + syncResult = 'All jobs synced successfully'; + break; + + case 'all': + await vectorMatchingService.syncAllCandidates(); + await vectorMatchingService.syncAllJobs(); + syncResult = 'All candidates and jobs synced successfully'; + break; + + default: + return res.status(400).json({ + success: false, + message: 'Invalid sync type. Use "candidates", "jobs", or "all"' + }); + } + + return res.status(200).json({ + success: true, + message: syncResult, + type + }); + + } catch (error) { + console.error('Error syncing data:', error); + return res.status(500).json({ + success: false, + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } +}; + +/** + * Get matching system health status + */ +export const getMatchingHealth = async (req: Request, res: Response) => { + try { + // Try to initialize the service to check health + await vectorMatchingService.initialize(); + + return res.status(200).json({ + success: true, + message: 'Matching system is healthy', + status: 'online', + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('Matching system health check failed:', error); + return res.status(503).json({ + success: false, + message: 'Matching system is unhealthy', + status: 'offline', + timestamp: new Date().toISOString(), + error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + }); + } +}; diff --git a/src/index.ts b/src/index.ts index 5e3112d..2d04b03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import dotenv from 'dotenv'; import candidateRoutes from './routes/candidate.routes'; import jobRoutes from './routes/job.routes'; import applicationRoutes from './routes/application.routes'; +import matchingRoutes from './routes/matching.routes'; dotenv.config(); @@ -34,6 +35,8 @@ app.get('/health', (req, res) => { app.use('/api/candidates', candidateRoutes); app.use('/api/jobs', jobRoutes); app.use('/api/applications', applicationRoutes); +app.use('/api/matching', matchingRoutes); +app.use('/api/matching', matchingRoutes); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/src/routes/matching.routes.ts b/src/routes/matching.routes.ts new file mode 100644 index 0000000..45fecbb --- /dev/null +++ b/src/routes/matching.routes.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { + findMatchingJobs, + findMatchingCandidates, + syncCandidate, + syncJob, + syncData, + getMatchingHealth +} from '../controllers/matching.controller'; +import { validateSchema, validateParams } from '../middlewares/validation.middleware'; +import { IdParamSchema } from '../schemas/common.schema'; +import { z } from 'zod'; + +const router = Router(); + +// Sync data schema +const SyncDataSchema = z.object({ + type: z.enum(['candidates', 'jobs', 'all']) +}); + +// Health check +router.get('/health', getMatchingHealth); + +// Find matching jobs for a candidate +router.get('/candidates/:id/jobs', + validateParams(IdParamSchema), + findMatchingJobs +); + +// Find matching candidates for a job +router.get('/jobs/:id/candidates', + validateParams(IdParamSchema), + findMatchingCandidates +); + +// Sync individual candidate +router.post('/sync/candidates/:id', + validateParams(IdParamSchema), + syncCandidate +); + +// Sync individual job +router.post('/sync/jobs/:id', + validateParams(IdParamSchema), + syncJob +); + +// Bulk sync data +router.post('/sync', + validateSchema(SyncDataSchema), + syncData +); + +export default router; diff --git a/src/services/matching/vector-matching.service.ts b/src/services/matching/vector-matching.service.ts new file mode 100644 index 0000000..ba6094d --- /dev/null +++ b/src/services/matching/vector-matching.service.ts @@ -0,0 +1,574 @@ +import { ChromaClient, Collection, IncludeEnum } from 'chromadb'; +import OpenAI from 'openai'; +import { getMatchingConfig, createOpenAIClient, createChromaClient } from '../../config/matching.config'; +import { PrismaClient } from '../../generated/prisma'; + +export interface CandidateVector { + id: string; + firstName: string; + lastName: string; + email: string; + skills: string[]; + experience: number; + location: string; + availability: string; + salaryExpectation?: number; + combinedText: string; +} + +export interface JobVector { + id: string; + title: string; + company: string; + description: string; + requiredSkills: string[]; + preferredSkills: string[]; + experienceLevel: string; + location: string; + remoteOk: boolean; + salaryMin?: number; + salaryMax?: number; + employmentType: string; + combinedText: string; +} + +export interface MatchResult { + id: string; + score: number; + metadata: Record; + distance: number; +} + +export interface CandidateMatch extends MatchResult { + candidate: CandidateVector; +} + +export interface JobMatch extends MatchResult { + job: JobVector; +} + +export class VectorMatchingService { + private chromaClient: ChromaClient; + private openaiClient: OpenAI; + private prisma: PrismaClient; + private candidatesCollection: Collection | null = null; + private jobsCollection: Collection | null = null; + + constructor() { + const config = getMatchingConfig(); + this.chromaClient = createChromaClient(config.chromaHost, config.chromaPort); + this.openaiClient = createOpenAIClient(config.openaiApiKey); + this.prisma = new PrismaClient(); + } + + /** + * Initialize ChromaDB collections + */ + async initialize(): Promise { + try { + // Create or get candidates collection + this.candidatesCollection = await this.chromaClient.getOrCreateCollection({ + name: "candidates", + metadata: { description: "Candidate profiles for job matching" } + }); + + // Create or get jobs collection + this.jobsCollection = await this.chromaClient.getOrCreateCollection({ + name: "jobs", + metadata: { description: "Job positions for candidate matching" } + }); + + console.log('Vector collections initialized successfully'); + } catch (error) { + console.error('Failed to initialize vector collections:', error); + throw error; + } + } + + /** + * Generate embedding using OpenAI + */ + private async generateEmbedding(text: string): Promise { + try { + const response = await this.openaiClient.embeddings.create({ + model: "text-embedding-3-small", + input: text, + encoding_format: "float" + }); + + return response.data[0].embedding; + } catch (error) { + console.error('Failed to generate embedding:', error); + throw error; + } + } + + /** + * Create combined text for candidate + */ + private createCandidateText(candidate: CandidateVector): string { + const skillsText = candidate.skills.join(', '); + return `${candidate.firstName} ${candidate.lastName} - Skills: ${skillsText} - Experience: ${candidate.experience} years - Location: ${candidate.location} - Availability: ${candidate.availability}`; + } + + /** + * Create combined text for job + */ + private createJobText(job: JobVector): string { + const requiredSkillsText = job.requiredSkills.join(', '); + const preferredSkillsText = job.preferredSkills.join(', '); + const remoteText = job.remoteOk ? 'Remote work available' : 'On-site work'; + + return `${job.title} at ${job.company} - ${job.description} - Required skills: ${requiredSkillsText} - Preferred skills: ${preferredSkillsText} - Experience level: ${job.experienceLevel} - Location: ${job.location} - ${remoteText} - Employment type: ${job.employmentType}`; + } + + /** + * Add or update candidate in vector database + */ + async upsertCandidate(candidateId: string): Promise { + if (!this.candidatesCollection) { + throw new Error('Candidates collection not initialized'); + } + + try { + // Fetch candidate from database + const candidate = await this.prisma.candidate.findUnique({ + where: { id: candidateId } + }); + + if (!candidate) { + throw new Error(`Candidate with id ${candidateId} not found`); + } + + // Create candidate vector object + const candidateVector: CandidateVector = { + id: candidate.id, + firstName: candidate.firstName, + lastName: candidate.lastName, + email: candidate.email, + skills: candidate.skills, + experience: candidate.experience, + location: candidate.location, + availability: candidate.availability.toLowerCase(), + salaryExpectation: candidate.salary_expectation || undefined, + combinedText: '' + }; + + candidateVector.combinedText = this.createCandidateText(candidateVector); + + // Generate embedding + const embedding = await this.generateEmbedding(candidateVector.combinedText); + + // Prepare metadata (ChromaDB metadata must be flat) + const metadata = { + firstName: candidate.firstName, + lastName: candidate.lastName, + email: candidate.email, + experience: candidate.experience, + location: candidate.location, + availability: candidate.availability.toLowerCase(), + skillsCount: candidate.skills.length, + salaryExpectation: candidate.salary_expectation || 0, + skills: candidate.skills.join(',') // Convert array to string for metadata + }; + + // Upsert to ChromaDB + await this.candidatesCollection.upsert({ + ids: [candidateId], + embeddings: [embedding], + metadatas: [metadata], + documents: [candidateVector.combinedText] + }); + + console.log(`Candidate ${candidateId} upserted to vector database`); + } catch (error) { + console.error(`Failed to upsert candidate ${candidateId}:`, error); + throw error; + } + } + + /** + * Add or update job in vector database + */ + async upsertJob(jobId: string): Promise { + if (!this.jobsCollection) { + throw new Error('Jobs collection not initialized'); + } + + try { + // Fetch job from database + const job = await this.prisma.jobPosition.findUnique({ + where: { id: jobId } + }); + + if (!job) { + throw new Error(`Job with id ${jobId} not found`); + } + + // Create job vector object + const jobVector: JobVector = { + id: job.id, + title: job.title, + company: job.company, + description: job.description, + requiredSkills: job.required_skills, + preferredSkills: job.preferred_skills || [], + experienceLevel: job.experience_level.toLowerCase(), + location: job.location, + remoteOk: job.remote_ok, + salaryMin: job.salary_min || undefined, + salaryMax: job.salary_max || undefined, + employmentType: job.employment_type.toLowerCase(), + combinedText: '' + }; + + jobVector.combinedText = this.createJobText(jobVector); + + // Generate embedding + const embedding = await this.generateEmbedding(jobVector.combinedText); + + // Prepare metadata + const metadata = { + title: job.title, + company: job.company, + experienceLevel: job.experience_level.toLowerCase(), + location: job.location, + remoteOk: job.remote_ok, + employmentType: job.employment_type.toLowerCase(), + salaryMin: job.salary_min || 0, + salaryMax: job.salary_max || 0, + requiredSkillsCount: job.required_skills.length, + preferredSkillsCount: job.preferred_skills?.length || 0, + requiredSkills: job.required_skills.join(','), + preferredSkills: job.preferred_skills?.join(',') || '' + }; + + // Upsert to ChromaDB + await this.jobsCollection.upsert({ + ids: [jobId], + embeddings: [embedding], + metadatas: [metadata], + documents: [jobVector.combinedText] + }); + + console.log(`Job ${jobId} upserted to vector database`); + } catch (error) { + console.error(`Failed to upsert job ${jobId}:`, error); + throw error; + } + } + + /** + * Find matching jobs for a candidate + */ + async findMatchingJobs( + candidateId: string, + limit: number = 10, + filters?: { + location?: string; + remoteOk?: boolean; + experienceLevel?: string; + employmentType?: string; + salaryMin?: number; + salaryMax?: number; + } + ): Promise { + if (!this.candidatesCollection || !this.jobsCollection) { + throw new Error('Collections not initialized'); + } + + try { + // Get candidate data from vector database + const candidateResult = await this.candidatesCollection.get({ + ids: [candidateId], + include: [IncludeEnum.Documents, IncludeEnum.Metadatas] + }); + + if (!candidateResult.documents || candidateResult.documents.length === 0) { + throw new Error(`Candidate ${candidateId} not found in vector database`); + } + + const candidateText = candidateResult.documents[0]; + if (!candidateText) { + throw new Error(`Candidate text not found for ${candidateId}`); + } + + // Generate embedding for candidate + const candidateEmbedding = await this.generateEmbedding(candidateText); + + // Build where clause for filtering + const whereClause: Record = {}; + + if (filters) { + if (filters.location) { + whereClause.location = { $eq: filters.location }; + } + if (filters.remoteOk !== undefined) { + whereClause.remoteOk = { $eq: filters.remoteOk }; + } + if (filters.experienceLevel) { + whereClause.experienceLevel = { $eq: filters.experienceLevel.toLowerCase() }; + } + if (filters.employmentType) { + whereClause.employmentType = { $eq: filters.employmentType.toLowerCase() }; + } + if (filters.salaryMin !== undefined) { + whereClause.salaryMax = { $gte: filters.salaryMin }; + } + if (filters.salaryMax !== undefined) { + whereClause.salaryMin = { $lte: filters.salaryMax }; + } + } + + // Query similar jobs + const queryResult = await this.jobsCollection.query({ + queryEmbeddings: [candidateEmbedding], + nResults: limit, + where: Object.keys(whereClause).length > 0 ? whereClause : undefined, + include: [IncludeEnum.Documents, IncludeEnum.Metadatas, IncludeEnum.Distances] + }); + + // Process results + const matches: JobMatch[] = []; + + if (queryResult.ids && queryResult.metadatas && queryResult.distances) { + for (let i = 0; i < queryResult.ids[0].length; i++) { + const jobId = queryResult.ids[0][i]; + const metadata = queryResult.metadatas[0][i]; + const distance = queryResult.distances[0][i]; + + if (metadata) { + const jobVector: JobVector = { + id: jobId, + title: metadata.title as string, + company: metadata.company as string, + description: '', // We can fetch this separately if needed + requiredSkills: (metadata.requiredSkills as string)?.split(',') || [], + preferredSkills: (metadata.preferredSkills as string)?.split(',').filter(s => s) || [], + experienceLevel: metadata.experienceLevel as string, + location: metadata.location as string, + remoteOk: metadata.remoteOk as boolean, + salaryMin: metadata.salaryMin as number || undefined, + salaryMax: metadata.salaryMax as number || undefined, + employmentType: metadata.employmentType as string, + combinedText: '' + }; + + matches.push({ + id: jobId, + score: distance !== null ? 1 - distance : 0, // Convert distance to similarity score + metadata, + distance: distance || 0, + job: jobVector + }); + } + } + } + + return matches; + } catch (error) { + console.error(`Failed to find matching jobs for candidate ${candidateId}:`, error); + throw error; + } + } + + /** + * Find matching candidates for a job + */ + async findMatchingCandidates( + jobId: string, + limit: number = 10, + filters?: { + location?: string; + availability?: string; + minExperience?: number; + maxExperience?: number; + maxSalaryExpectation?: number; + } + ): Promise { + if (!this.candidatesCollection || !this.jobsCollection) { + throw new Error('Collections not initialized'); + } + + try { + // Get job data from vector database + const jobResult = await this.jobsCollection.get({ + ids: [jobId], + include: [IncludeEnum.Documents, IncludeEnum.Metadatas] + }); + + if (!jobResult.documents || jobResult.documents.length === 0) { + throw new Error(`Job ${jobId} not found in vector database`); + } + + const jobText = jobResult.documents[0]; + if (!jobText) { + throw new Error(`Job text not found for ${jobId}`); + } + + // Generate embedding for job + const jobEmbedding = await this.generateEmbedding(jobText); + + // Build where clause for filtering + const whereClause: Record = {}; + + if (filters) { + if (filters.location) { + whereClause.location = { $eq: filters.location }; + } + if (filters.availability) { + whereClause.availability = { $eq: filters.availability.toLowerCase() }; + } + if (filters.minExperience !== undefined) { + whereClause.experience = { $gte: filters.minExperience }; + } + if (filters.maxExperience !== undefined) { + whereClause.experience = { $lte: filters.maxExperience }; + } + if (filters.maxSalaryExpectation !== undefined) { + whereClause.salaryExpectation = { $lte: filters.maxSalaryExpectation }; + } + } + + // Query similar candidates + const queryResult = await this.candidatesCollection.query({ + queryEmbeddings: [jobEmbedding], + nResults: limit, + where: Object.keys(whereClause).length > 0 ? whereClause : undefined, + include: [IncludeEnum.Documents, IncludeEnum.Metadatas, IncludeEnum.Distances] + }); + + // Process results + const matches: CandidateMatch[] = []; + + if (queryResult.ids && queryResult.metadatas && queryResult.distances) { + for (let i = 0; i < queryResult.ids[0].length; i++) { + const candidateId = queryResult.ids[0][i]; + const metadata = queryResult.metadatas[0][i]; + const distance = queryResult.distances[0][i]; + + if (metadata) { + const candidateVector: CandidateVector = { + id: candidateId, + firstName: metadata.firstName as string, + lastName: metadata.lastName as string, + email: metadata.email as string, + skills: (metadata.skills as string)?.split(',') || [], + experience: metadata.experience as number, + location: metadata.location as string, + availability: metadata.availability as string, + salaryExpectation: metadata.salaryExpectation as number || undefined, + combinedText: '' + }; + + matches.push({ + id: candidateId, + score: distance !== null ? 1 - distance : 0, // Convert distance to similarity score + metadata, + distance: distance || 0, + candidate: candidateVector + }); + } + } + } + + return matches; + } catch (error) { + console.error(`Failed to find matching candidates for job ${jobId}:`, error); + throw error; + } + } + + /** + * Bulk sync all candidates to vector database + */ + async syncAllCandidates(): Promise { + try { + const candidates = await this.prisma.candidate.findMany({ + select: { id: true } + }); + + console.log(`Syncing ${candidates.length} candidates to vector database...`); + + for (const candidate of candidates) { + await this.upsertCandidate(candidate.id); + } + + console.log(`Successfully synced ${candidates.length} candidates`); + } catch (error) { + console.error('Failed to sync candidates:', error); + throw error; + } + } + + /** + * Bulk sync all jobs to vector database + */ + async syncAllJobs(): Promise { + try { + const jobs = await this.prisma.jobPosition.findMany({ + where: { status: 'ACTIVE' }, + select: { id: true } + }); + + console.log(`Syncing ${jobs.length} active jobs to vector database...`); + + for (const job of jobs) { + await this.upsertJob(job.id); + } + + console.log(`Successfully synced ${jobs.length} jobs`); + } catch (error) { + console.error('Failed to sync jobs:', error); + throw error; + } + } + + /** + * Remove candidate from vector database + */ + async removeCandidate(candidateId: string): Promise { + if (!this.candidatesCollection) { + throw new Error('Candidates collection not initialized'); + } + + try { + await this.candidatesCollection.delete({ + ids: [candidateId] + }); + console.log(`Candidate ${candidateId} removed from vector database`); + } catch (error) { + console.error(`Failed to remove candidate ${candidateId}:`, error); + throw error; + } + } + + /** + * Remove job from vector database + */ + async removeJob(jobId: string): Promise { + if (!this.jobsCollection) { + throw new Error('Jobs collection not initialized'); + } + + try { + await this.jobsCollection.delete({ + ids: [jobId] + }); + console.log(`Job ${jobId} removed from vector database`); + } catch (error) { + console.error(`Failed to remove job ${jobId}:`, error); + throw error; + } + } + + /** + * Close database connections + */ + async close(): Promise { + await this.prisma.$disconnect(); + } +} + +// Export singleton instance +export const vectorMatchingService = new VectorMatchingService();