diff --git a/.env b/.env index 746dea7..c8f8ce1 100644 --- a/.env +++ b/.env @@ -1,20 +1,51 @@ # ============================================ -# Development Environment Configuration +# PRODUCTION Environment Configuration # ============================================ +# IMPORTANT: Keep this file secure and never commit to git! + +# ==================== DATABASE ==================== DATABASE_URL=mongodb+srv://thesharmakeshav:TFJUWDRi46dbR5TB@cluster0.kegg0.mongodb.net/verifydev?retryWrites=true&w=majority CHAT_DATABASE_URL=mongodb+srv://thesharmakeshav:TFJUWDRi46dbR5TB@cluster0.kegg0.mongodb.net/verifydev?retryWrites=true&w=majority -GITHUB_CLIENT_ID=Ov23lir98xHDxyEDYrzs -GITHUB_CLIENT_SECRET=10377fe6414ad7419077a66e9494da6b3eb955c0 + +# ==================== GITHUB OAUTH ==================== +GITHUB_CLIENT_ID=Ov23liGWgQieB5R0LTyS +GITHUB_CLIENT_SECRET=cfe167df000cde12886d6e637853f96c50c72117 GITHUB_CALLBACK_URL=http://localhost:8000/api/v1/auth/github/callback GITHUB_TOKEN= -JWT_ACCESS_SECRET=dev_access_secret_change_in_production_32chars! -JWT_REFRESH_SECRET=dev_refresh_secret_change_in_production_32! -REDIS_HOST=redis + +# ==================== JWT SECRETS ==================== +# PRODUCTION: Generate strong secrets with: openssl rand -base64 32 +JWT_ACCESS_SECRET=prod_access_secret_CHANGE_THIS_32chars_min! +JWT_REFRESH_SECRET=prod_refresh_secret_CHANGE_THIS_32chars! + +# ==================== REDIS ==================== +REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= + +# ==================== RABBITMQ ==================== RABBITMQ_USER=verifydev -RABBITMQ_PASS=verifydev_dev_password -RABBITMQ_URL=amqp://verifydev:verifydev_dev_password@rabbitmq:5672 -CORS_ORIGIN=http://localhost:3000,http://localhost:5173,https://verifydev.me +RABBITMQ_PASS=verifydev_prod_password_CHANGE_THIS +RABBITMQ_URL=amqp://verifydev:verifydev_prod_password_CHANGE_THIS@localhost:5672 + +# ==================== CORS ==================== +CORS_ORIGIN=https://verifydev.me,https://www.verifydev.me,https://api.verifydev.me,http://localhost:3000,http://localhost:5173,http://localhost:8000 + +# ==================== ENVIRONMENT ==================== NODE_ENV=development FRONTEND_URL=http://localhost:3000 + +WORKER_COUNT=4 +# RabbitMQ prefetch count (should match WORKER_COUNT) +PREFETCH_COUNT=4 + + +# ==================== SERVICE PORTS ==================== +AUTH_SERVICE_PORT=3001 +USER_SERVICE_PORT=3002 +JOB_SERVICE_PORT=3004 +RECRUITER_SERVICE_PORT=3005 +CHAT_SERVICE_PORT=3006 +ANALYZER_SERVICE_PORT=8001 +RESUME_SERVICE_PORT=8003 +GATEWAY_PORT=8000 diff --git a/.env.example b/.env.example index 558fa70..bd9472b 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ CHAT_DATABASE_URL=mongodb+srv://YOUR_USERNAME:YOUR_PASSWORD@cluster0.xxxxx.mongo # Create OAuth App at: https://github.com/settings/developers GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret -GITHUB_CALLBACK_URL=http://localhost:8000/api/v1/auth/github/callback +GITHUB_CALLBACK_URL=http://localhost:80/api/v1/auth/github/callback # Personal Access Token for repo cloning (optional, for private repos) GITHUB_TOKEN= @@ -42,6 +42,13 @@ RABBITMQ_USER=verifydev RABBITMQ_PASS=verifydev_dev_password RABBITMQ_URL=amqp://verifydev:verifydev_dev_password@rabbitmq:5672 +# ==================== CLOUDINARY ==================== +# For avatar/image uploads +# Get from: https://cloudinary.com/console +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret + # ==================== CORS ==================== # Comma-separated list of allowed origins CORS_ORIGIN=http://localhost:3000,http://localhost:5173 @@ -59,3 +66,4 @@ CHAT_SERVICE_PORT=3006 ANALYZER_SERVICE_PORT=8001 RESUME_SERVICE_PORT=8003 GATEWAY_PORT=8000 + diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..9a03b04 --- /dev/null +++ b/.env.production @@ -0,0 +1,46 @@ +# ============================================ +# PRODUCTION Environment Configuration +# ============================================ +# IMPORTANT: Keep this file secure and never commit to git! + +# ==================== DATABASE ==================== +DATABASE_URL=mongodb+srv://thesharmakeshav:TFJUWDRi46dbR5TB@cluster0.kegg0.mongodb.net/verifydev?retryWrites=true&w=majority +CHAT_DATABASE_URL=mongodb+srv://thesharmakeshav:TFJUWDRi46dbR5TB@cluster0.kegg0.mongodb.net/verifydev?retryWrites=true&w=majority + +# ==================== GITHUB OAUTH ==================== +GITHUB_CLIENT_ID=Ov23li8KrcXPVTwLxWUE +GITHUB_CLIENT_SECRET=34d8078e6d9a86b8daadf94aa9c36c053a276ba7 +GITHUB_CALLBACK_URL=https://api.verifydev.me/api/v1/auth/github/callback +GITHUB_TOKEN= + +# ==================== JWT SECRETS ==================== +# PRODUCTION: Generate strong secrets with: openssl rand -base64 32 +JWT_ACCESS_SECRET=prod_access_secret_CHANGE_THIS_32chars_min! +JWT_REFRESH_SECRET=prod_refresh_secret_CHANGE_THIS_32chars! + +# ==================== REDIS ==================== +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# ==================== RABBITMQ ==================== +RABBITMQ_USER=verifydev +RABBITMQ_PASS=verifydev_prod_password_CHANGE_THIS +RABBITMQ_URL=amqp://verifydev:verifydev_prod_password_CHANGE_THIS@rabbitmq:5672 + +# ==================== CORS ==================== +CORS_ORIGIN=https://verifydev.me,https://www.verifydev.me,https://api.verifydev.me + +# ==================== ENVIRONMENT ==================== +NODE_ENV=production +FRONTEND_URL=https://verifydev.me + +# ==================== SERVICE PORTS ==================== +AUTH_SERVICE_PORT=3001 +USER_SERVICE_PORT=3002 +JOB_SERVICE_PORT=3004 +RECRUITER_SERVICE_PORT=3005 +CHAT_SERVICE_PORT=3006 +ANALYZER_SERVICE_PORT=8001 +RESUME_SERVICE_PORT=8003 +GATEWAY_PORT=8000 diff --git a/README.md b/README.md index c5c3a88..72975da 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# VerifyDev Backend + # VerifyDev Backend > Developer Verification & Recruitment Platform - Microservices Architecture @@ -13,7 +13,7 @@ VerifyDev automatically **verifies developer skills** by analyzing their GitHub - [Performance Analysis](./PERFORMANCE.md) - **Latency Metrics & Benchmarks** - [Services](#services) - [API Routes](#api-routes) -- [Getting Started](#getting-started) +- [Getting Started](#getting-started) --- diff --git a/ai-service/.env.example b/ai-service/.env.example new file mode 100644 index 0000000..ad1a6ce --- /dev/null +++ b/ai-service/.env.example @@ -0,0 +1,18 @@ +# AI Service Environment Variables + +# Server +PORT=3008 +NODE_ENV=development + +# Ollama Configuration +OLLAMA_HOST=http://ollama:11434 +OLLAMA_MODEL=llama3.2 + +# WhatsApp Cloud API (Get from Meta Developer Console) +WHATSAPP_TOKEN=your_whatsapp_token_here +WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id_here +WHATSAPP_VERIFY_TOKEN=your_custom_verify_token_here + +# Internal Service URLs +JOB_SERVICE_URL=http://job-service:3003 +USER_SERVICE_URL=http://user-service:3002 diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..564491d --- /dev/null +++ b/ai-service/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci --only=production + +# Copy source +COPY dist/ ./dist/ + +# Environment +ENV NODE_ENV=production +ENV PORT=3008 + +EXPOSE 3008 + +CMD ["node", "dist/server.js"] diff --git a/ai-service/Dockerfile.dev b/ai-service/Dockerfile.dev new file mode 100644 index 0000000..5a19e9b --- /dev/null +++ b/ai-service/Dockerfile.dev @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm install + +# Copy source (for dev, we mount volumes) +COPY . . + +# Environment +ENV NODE_ENV=development +ENV PORT=3008 + +EXPOSE 3008 + +CMD ["npm", "run", "dev"] diff --git a/ai-service/package-lock.json b/ai-service/package-lock.json new file mode 100644 index 0000000..2c1b3d3 --- /dev/null +++ b/ai-service/package-lock.json @@ -0,0 +1,2088 @@ +{ + "name": "ai-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-service", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "helmet": "^7.1.0", + "ollama": "^0.5.11", + "pino": "^8.17.1", + "pino-pretty": "^10.3.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.10.5", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "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": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "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/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "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/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "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", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "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/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "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/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ollama": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.18.tgz", + "integrity": "sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz", + "integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.2.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.7.0", + "thread-stream": "^2.6.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", + "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==", + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", + "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", + "integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/ai-service/package.json b/ai-service/package.json new file mode 100644 index 0000000..5e838b9 --- /dev/null +++ b/ai-service/package.json @@ -0,0 +1,29 @@ +{ + "name": "ai-service", + "version": "1.0.0", + "description": "VerifyDev AI Service - WhatsApp chatbot with Ollama LLM", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js" + }, + "dependencies": { + "axios": "^1.6.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "helmet": "^7.1.0", + "ollama": "^0.5.11", + "pino": "^8.17.1", + "pino-pretty": "^10.3.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.10.5", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} diff --git a/ai-service/src/api/v1/controllers/query.controller.ts b/ai-service/src/api/v1/controllers/query.controller.ts new file mode 100644 index 0000000..f192cb5 --- /dev/null +++ b/ai-service/src/api/v1/controllers/query.controller.ts @@ -0,0 +1,81 @@ +import { Request, Response } from 'express'; +import { processMessage } from '../../services/ollama.service.js'; +import { logger } from '../../utils/logger.js'; + +export class QueryController { + /** + * Direct query endpoint for testing/internal use + * POST /api/v1/ai/query + */ + static async processQuery(req: Request, res: Response): Promise { + try { + const { query, userId } = req.body; + + if (!query || typeof query !== 'string') { + res.status(400).json({ + success: false, + error: 'Query is required' + }); + return; + } + + logger.info({ query, userId }, 'Processing direct query'); + + const response = await processMessage(query, userId ? { + phoneNumber: 'direct', + userId, + messageCount: 1 + } : undefined); + + res.json({ + success: true, + data: { + query, + response + } + }); + } catch (error) { + logger.error({ error }, 'Error processing query'); + res.status(500).json({ + success: false, + error: 'Failed to process query' + }); + } + } + + /** + * Parse intent without executing (for debugging) + * POST /api/v1/ai/parse + */ + static async parseIntent(req: Request, res: Response): Promise { + try { + const { query } = req.body; + + if (!query || typeof query !== 'string') { + res.status(400).json({ + success: false, + error: 'Query is required' + }); + return; + } + + // For now, return processed response + // In future, could add intent-only parsing + const response = await processMessage(query); + + res.json({ + success: true, + data: { + query, + response + } + }); + } catch (error) { + logger.error({ error }, 'Error parsing intent'); + res.status(500).json({ + success: false, + error: 'Failed to parse intent' + }); + } + } +} diff --git a/ai-service/src/api/v1/controllers/whatsapp.controller.ts b/ai-service/src/api/v1/controllers/whatsapp.controller.ts new file mode 100644 index 0000000..1207264 --- /dev/null +++ b/ai-service/src/api/v1/controllers/whatsapp.controller.ts @@ -0,0 +1,115 @@ +import { Request, Response } from 'express'; +import { config } from '../../config/index.js'; +import { logger } from '../../utils/logger.js'; +import { processMessage } from '../../services/ollama.service.js'; +import { sendWhatsAppMessage, markMessageAsRead } from '../../services/whatsapp.service.js'; +import type { WhatsAppWebhookPayload, ConversationContext } from '../../types/index.js'; + +// Simple in-memory context store (use Redis in production) +const conversationContexts = new Map(); + +export class WhatsAppController { + /** + * Webhook verification (GET) + * Meta sends this to verify webhook URL ownership + */ + static verifyWebhook(req: Request, res: Response): void { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + logger.debug({ mode, token }, 'Webhook verification request'); + + if (mode === 'subscribe' && token === config.whatsapp.verifyToken) { + logger.info('Webhook verified successfully'); + res.status(200).send(challenge); + } else { + logger.warn({ mode, token }, 'Webhook verification failed'); + res.sendStatus(403); + } + } + + /** + * Handle incoming WhatsApp messages (POST) + */ + static async handleMessage(req: Request, res: Response): Promise { + // Always respond 200 immediately to acknowledge receipt + res.sendStatus(200); + + try { + const payload = req.body as WhatsAppWebhookPayload; + + // Validate payload structure + if (payload.object !== 'whatsapp_business_account') { + return; + } + + // Process each entry + for (const entry of payload.entry || []) { + for (const change of entry.changes || []) { + const value = change.value; + + // Skip non-message events + if (!value.messages || value.messages.length === 0) { + continue; + } + + for (const message of value.messages) { + // Only handle text messages for now + if (message.type !== 'text' || !message.text?.body) { + continue; + } + + const phoneNumber = message.from; + const userMessage = message.text.body; + const messageId = message.id; + + logger.info({ phoneNumber, userMessage }, 'Received WhatsApp message'); + + // Mark as read + await markMessageAsRead(messageId); + + // Get or create conversation context + let context = conversationContexts.get(phoneNumber); + if (!context) { + context = { + phoneNumber, + messageCount: 0, + lastJobs: [] + }; + conversationContexts.set(phoneNumber, context); + } + context.messageCount++; + + // Extract user ID from message if present (format: [UserID: xxx]) + const userIdMatch = userMessage.match(/\[UserID:\s*([^\]]+)\]/); + if (userIdMatch) { + context.userId = userIdMatch[1].trim(); + logger.info({ phoneNumber, userId: context.userId }, 'User ID linked'); + } + + // Process with Ollama + const aiResponse = await processMessage(userMessage, context); + + // Extract job IDs from response if present (for context tracking) + // This is a simple pattern - could be more sophisticated + const jobIdPattern = /ID:\s*([a-f0-9]{24})/gi; + const jobMatches = aiResponse.matchAll(jobIdPattern); + const newJobs: Array<{ id: string; title: string }> = []; + for (const match of jobMatches) { + newJobs.push({ id: match[1], title: 'Job' }); + } + if (newJobs.length > 0) { + context.lastJobs = newJobs; + } + + // Send response via WhatsApp + await sendWhatsAppMessage(phoneNumber, aiResponse); + } + } + } + } catch (error) { + logger.error({ error }, 'Error handling WhatsApp message'); + } + } +} diff --git a/ai-service/src/api/v1/routes/query.routes.ts b/ai-service/src/api/v1/routes/query.routes.ts new file mode 100644 index 0000000..ac63401 --- /dev/null +++ b/ai-service/src/api/v1/routes/query.routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { QueryController } from '../controllers/query.controller.js'; + +const router = Router(); + +/** + * @route POST /api/v1/ai/query + * @desc Process a natural language job query + * @access Public (for testing) / Internal + * @body { query: string, userId?: string } + */ +router.post('/query', QueryController.processQuery); + +/** + * @route POST /api/v1/ai/parse + * @desc Parse intent from query (debugging) + * @access Internal + * @body { query: string } + */ +router.post('/parse', QueryController.parseIntent); + +export default router; diff --git a/ai-service/src/api/v1/routes/whatsapp.routes.ts b/ai-service/src/api/v1/routes/whatsapp.routes.ts new file mode 100644 index 0000000..237a6fe --- /dev/null +++ b/ai-service/src/api/v1/routes/whatsapp.routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { WhatsAppController } from '../controllers/whatsapp.controller.js'; + +const router = Router(); + +/** + * @route GET /api/v1/ai/whatsapp/webhook + * @desc WhatsApp webhook verification + * @access Public (Meta sends verification requests here) + */ +router.get('/webhook', WhatsAppController.verifyWebhook); + +/** + * @route POST /api/v1/ai/whatsapp/webhook + * @desc Handle incoming WhatsApp messages + * @access Public (Meta sends messages here) + */ +router.post('/webhook', WhatsAppController.handleMessage); + +export default router; diff --git a/ai-service/src/config/index.ts b/ai-service/src/config/index.ts new file mode 100644 index 0000000..7a10293 --- /dev/null +++ b/ai-service/src/config/index.ts @@ -0,0 +1,23 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +export const config = { + port: parseInt(process.env.PORT || '3008', 10), + nodeEnv: process.env.NODE_ENV || 'development', + + ollama: { + host: process.env.OLLAMA_HOST || 'http://localhost:11434', + model: process.env.OLLAMA_MODEL || 'llama3.2', + }, + + whatsapp: { + token: process.env.WHATSAPP_TOKEN || '', + phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID || '', + verifyToken: process.env.WHATSAPP_VERIFY_TOKEN || 'verifydev_webhook_secret', + }, + + services: { + jobService: process.env.JOB_SERVICE_URL || 'http://job-service:3003', + userService: process.env.USER_SERVICE_URL || 'http://user-service:3002', + }, +}; diff --git a/ai-service/src/server.ts b/ai-service/src/server.ts new file mode 100644 index 0000000..61656e3 --- /dev/null +++ b/ai-service/src/server.ts @@ -0,0 +1,75 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import { config } from './config/index.js'; +import { logger } from './utils/logger.js'; +import { checkOllamaHealth } from './services/ollama.service.js'; +import whatsappRoutes from './api/v1/routes/whatsapp.routes.js'; +import queryRoutes from './api/v1/routes/query.routes.js'; + +const app = express(); + +// Middleware +app.use(helmet()); +app.use(cors()); +app.use(express.json()); + +// Request logging +app.use((req, res, next) => { + logger.debug({ method: req.method, path: req.path }, 'Incoming request'); + next(); +}); + +// Health check endpoint +app.get('/health', async (req, res) => { + const ollamaHealthy = await checkOllamaHealth(); + + res.json({ + status: 'ok', + service: 'ai-service', + timestamp: new Date().toISOString(), + dependencies: { + ollama: ollamaHealthy ? 'healthy' : 'unhealthy' + } + }); +}); + +// API Routes +app.use('/api/v1/ai/whatsapp', whatsappRoutes); +app.use('/api/v1/ai', queryRoutes); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + success: false, + error: 'Not found' + }); +}); + +// Error handler +app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.error({ error: err }, 'Unhandled error'); + res.status(500).json({ + success: false, + error: 'Internal server error' + }); +}); + +// Start server +const PORT = config.port; + +app.listen(PORT, () => { + logger.info({ port: PORT, env: config.nodeEnv }, '🤖 AI Service started'); + logger.info({ ollamaHost: config.ollama.host, model: config.ollama.model }, 'Ollama config'); + + // Check Ollama health on startup + checkOllamaHealth().then(healthy => { + if (healthy) { + logger.info('✅ Ollama connection healthy'); + } else { + logger.warn('⚠️ Ollama not available - AI features will be limited'); + } + }); +}); + +export default app; diff --git a/ai-service/src/services/job-query.service.ts b/ai-service/src/services/job-query.service.ts new file mode 100644 index 0000000..bc46740 --- /dev/null +++ b/ai-service/src/services/job-query.service.ts @@ -0,0 +1,215 @@ +import axios from 'axios'; +import { config } from '../config/index.js'; +import { logger } from '../utils/logger.js'; +import type { Job, UserSkill } from '../types/index.js'; + +interface SearchParams { + tech?: string; + role?: string; + type?: string; + experience?: string; + limit?: number; +} + +interface CountParams { + tech?: string; + role?: string; + type?: string; +} + +// Map role names to job categories +const ROLE_TO_CATEGORY: Record = { + 'backend': 'BACKEND', + 'frontend': 'FRONTEND', + 'fullstack': 'FULLSTACK', + 'full-stack': 'FULLSTACK', + 'mobile': 'MOBILE', + 'devops': 'DEVOPS', + 'data': 'DATA_ENGINEERING', + 'ml': 'MACHINE_LEARNING', + 'ai': 'MACHINE_LEARNING', + 'security': 'SECURITY', + 'design': 'DESIGN', + 'qa': 'QA', + 'testing': 'QA', +}; + +// Map experience levels +const EXPERIENCE_TO_LEVEL: Record = { + 'entry': 'ENTRY', + 'junior': 'JUNIOR', + 'mid': 'MID', + 'middle': 'MID', + 'senior': 'SENIOR', + 'lead': 'LEAD', + 'principal': 'PRINCIPAL', +}; + +class JobQueryService { + private jobServiceUrl: string; + private userServiceUrl: string; + + constructor() { + this.jobServiceUrl = config.services.jobService; + this.userServiceUrl = config.services.userService; + } + + /** + * Search jobs with filters + */ + async searchJobs(params: SearchParams): Promise<{ jobs: Job[]; total: number } | { error: string }> { + try { + const queryParams = new URLSearchParams(); + + // Map filters to API parameters + if (params.tech) { + queryParams.append('skills', params.tech); + } + if (params.role && ROLE_TO_CATEGORY[params.role.toLowerCase()]) { + queryParams.append('category', ROLE_TO_CATEGORY[params.role.toLowerCase()]); + } + if (params.type) { + if (params.type === 'remote') { + queryParams.append('isRemote', 'true'); + } else if (params.type === 'onsite') { + queryParams.append('isRemote', 'false'); + } + } + if (params.experience && EXPERIENCE_TO_LEVEL[params.experience.toLowerCase()]) { + queryParams.append('level', EXPERIENCE_TO_LEVEL[params.experience.toLowerCase()]); + } + queryParams.append('limit', String(params.limit || 5)); + + const url = `${this.jobServiceUrl}/api/v1/jobs/search?${queryParams.toString()}`; + logger.debug({ url }, 'Searching jobs'); + + const response = await axios.get(url, { timeout: 5000 }); + + return { + jobs: response.data.data?.jobs || response.data.jobs || [], + total: response.data.data?.total || response.data.total || 0 + }; + } catch (error) { + logger.error({ error }, 'Error searching jobs'); + return { error: 'Failed to fetch jobs. Please try again.' }; + } + } + + /** + * Count jobs matching criteria + */ + async countJobs(params: CountParams): Promise<{ count: number; filters: CountParams } | { error: string }> { + try { + const result = await this.searchJobs({ ...params, limit: 1 }); + + if ('error' in result) { + return result; + } + + return { + count: result.total, + filters: params + }; + } catch (error) { + logger.error({ error }, 'Error counting jobs'); + return { error: 'Failed to count jobs. Please try again.' }; + } + } + + /** + * Get jobs matched to user's profile + */ + async getMatchedJobs(userId: string, limit: number = 5): Promise<{ jobs: Job[]; total: number; userSkills: string[] } | { error: string }> { + try { + // Get user skills first + const skillsUrl = `${this.userServiceUrl}/api/v1/users/${userId}/skills-summary`; + logger.debug({ skillsUrl }, 'Fetching user skills'); + + let userSkills: string[] = []; + try { + const skillsResponse = await axios.get(skillsUrl, { timeout: 3000 }); + userSkills = skillsResponse.data.data?.skills?.map((s: UserSkill) => s.name) || []; + } catch (error) { + logger.warn({ error, userId }, 'Could not fetch user skills'); + } + + // Get matched jobs from job-service + const matchedUrl = `${this.jobServiceUrl}/api/v1/jobs/matched`; + const response = await axios.get(matchedUrl, { + headers: { 'x-user-id': userId }, + params: { limit }, + timeout: 5000 + }); + + return { + jobs: response.data.data?.jobs || response.data.jobs || [], + total: response.data.data?.total || response.data.total || 0, + userSkills + }; + } catch (error) { + logger.error({ error, userId }, 'Error getting matched jobs'); + + // Fallback to regular search if matched endpoint fails + return await this.searchJobs({ limit }); + } + } + + /** + * Get job details + */ + async getJobDetails(jobId: string): Promise { + try { + const url = `${this.jobServiceUrl}/api/v1/jobs/${jobId}`; + logger.debug({ url }, 'Getting job details'); + + const response = await axios.get(url, { timeout: 5000 }); + return response.data.data || response.data; + } catch (error) { + logger.error({ error, jobId }, 'Error getting job details'); + return { error: 'Job not found or failed to fetch details.' }; + } + } + + /** + * Apply to a job + */ + async applyToJob(userId: string, jobId: string): Promise<{ success: boolean; message: string }> { + try { + const url = `${this.jobServiceUrl}/api/v1/jobs/${jobId}/apply`; + logger.info({ userId, jobId }, 'Applying to job'); + + await axios.post(url, {}, { + headers: { + 'x-user-id': userId, + 'Authorization': `Bearer internal-service-token` + }, + timeout: 5000 + }); + + return { + success: true, + message: 'Successfully applied to the job!' + }; + } catch (error: any) { + logger.error({ error, userId, jobId }, 'Error applying to job'); + + // Handle specific error cases + if (error.response?.status === 409) { + return { success: false, message: 'You have already applied to this job.' }; + } + if (error.response?.status === 404) { + return { success: false, message: 'Job not found.' }; + } + if (error.response?.status === 401) { + return { success: false, message: 'Please link your VerifyDev account first.' }; + } + + return { + success: false, + message: 'Failed to apply. Please try through the app.' + }; + } + } +} + +export const jobQueryService = new JobQueryService(); diff --git a/ai-service/src/services/ollama.service.ts b/ai-service/src/services/ollama.service.ts new file mode 100644 index 0000000..a4b026a --- /dev/null +++ b/ai-service/src/services/ollama.service.ts @@ -0,0 +1,280 @@ +import { Ollama } from 'ollama'; +import { config } from '../config/index.js'; +import { logger } from '../utils/logger.js'; +import type { ParsedQuery, Job, ConversationContext } from '../types/index.js'; +import { jobQueryService } from './job-query.service.js'; + +// Initialize Ollama client +const ollama = new Ollama({ host: config.ollama.host }); + +// Tool definitions for Ollama function calling +const TOOLS = [ + { + type: 'function' as const, + function: { + name: 'search_jobs', + description: 'Search for jobs based on filters like tech stack, role, location type. Use this when user asks to find or list jobs.', + parameters: { + type: 'object', + properties: { + tech: { + type: 'string', + description: 'Technology or skill to filter by (e.g., react, golang, python, node, java)' + }, + role: { + type: 'string', + description: 'Job role category (e.g., backend, frontend, fullstack, devops, mobile)' + }, + type: { + type: 'string', + enum: ['remote', 'onsite', 'hybrid'], + description: 'Work location type' + }, + experience: { + type: 'string', + enum: ['entry', 'junior', 'mid', 'senior', 'lead'], + description: 'Experience level required' + }, + limit: { + type: 'number', + description: 'Maximum number of jobs to return (default 5)' + } + } + } + } + }, + { + type: 'function' as const, + function: { + name: 'count_jobs', + description: 'Count how many jobs match the given criteria. Use this when user asks "kitni jobs" or "how many jobs".', + parameters: { + type: 'object', + properties: { + tech: { type: 'string', description: 'Technology to filter by' }, + role: { type: 'string', description: 'Role category to filter by' }, + type: { type: 'string', enum: ['remote', 'onsite', 'hybrid'] } + } + } + } + }, + { + type: 'function' as const, + function: { + name: 'get_matched_jobs', + description: 'Get jobs that match the user\'s profile and verified skills. Use when user says "mere liye jobs" or "jobs for me" or "matching jobs".', + parameters: { + type: 'object', + properties: { + userId: { + type: 'string', + description: 'User ID to fetch their profile and find matching jobs' + }, + limit: { + type: 'number', + description: 'Maximum number of jobs to return (default 5)' + } + }, + required: ['userId'] + } + } + }, + { + type: 'function' as const, + function: { + name: 'get_job_details', + description: 'Get detailed information about a specific job. Use when user asks about a specific job or says "tell me more about job X".', + parameters: { + type: 'object', + properties: { + jobId: { + type: 'string', + description: 'The ID of the job to get details for' + } + }, + required: ['jobId'] + } + } + }, + { + type: 'function' as const, + function: { + name: 'apply_to_job', + description: 'Apply to a job on behalf of the user. Use when user confirms they want to apply.', + parameters: { + type: 'object', + properties: { + userId: { type: 'string', description: 'User ID applying' }, + jobId: { type: 'string', description: 'Job ID to apply to' } + }, + required: ['userId', 'jobId'] + } + } + } +]; + +// System prompt for the AI +const SYSTEM_PROMPT = `You are VerifyDev AI, a friendly and helpful job assistant for a developer hiring platform. + +YOUR PERSONALITY: +- You understand both English and Hinglish (Hindi + English mix) +- You are concise but friendly +- You use emojis sparingly to make responses engaging +- You always use the available tools to get real data - NEVER make up job listings + +LANGUAGE UNDERSTANDING: +- "kitni" = "how many" → use count_jobs +- "dikhao", "batao", "show" = list jobs → use search_jobs +- "mere liye", "for me", "meri profile" = matching jobs → use get_matched_jobs +- "apply kar do", "apply karna hai" = apply to job → use apply_to_job +- "remote", "ghar se", "work from home" = remote type filter + +RULES: +1. ALWAYS use tools to fetch real data - never invent jobs +2. Format job listings nicely with numbers so user can reference them +3. If user says a number (like "2nd wala" or "second one"), reference the previously shown jobs +4. Before applying, confirm the job details with user +5. Keep responses under 500 characters when possible (WhatsApp limit friendly) +6. If userId is not available for matching/applying, ask user to link their account first + +RESPONSE FORMAT FOR JOBS: +When listing jobs, format like: +1️⃣ **Job Title** - Company + 📍 Location | 💰 Salary Range + 🔧 Key Skills + +Always end with a call to action like "Reply with number for details!" or "Kisi ko apply karna hai?".`; + +// Execute tool calls and get results +async function executeToolCall(toolName: string, args: Record, context?: ConversationContext): Promise { + logger.debug({ toolName, args }, 'Executing tool call'); + + switch (toolName) { + case 'search_jobs': + return await jobQueryService.searchJobs({ + tech: args.tech as string, + role: args.role as string, + type: args.type as string, + experience: args.experience as string, + limit: (args.limit as number) || 5 + }); + + case 'count_jobs': + return await jobQueryService.countJobs({ + tech: args.tech as string, + role: args.role as string, + type: args.type as string + }); + + case 'get_matched_jobs': + const userId = (args.userId as string) || context?.userId; + if (!userId) { + return { error: 'User ID not available. User needs to link their VerifyDev account.' }; + } + return await jobQueryService.getMatchedJobs(userId, (args.limit as number) || 5); + + case 'get_job_details': + return await jobQueryService.getJobDetails(args.jobId as string); + + case 'apply_to_job': + const applyUserId = (args.userId as string) || context?.userId; + if (!applyUserId) { + return { error: 'User ID not available. User needs to link their VerifyDev account first.' }; + } + return await jobQueryService.applyToJob(applyUserId, args.jobId as string); + + default: + return { error: `Unknown tool: ${toolName}` }; + } +} + +// Main function to process user message with Ollama +export async function processMessage( + userMessage: string, + context?: ConversationContext +): Promise { + logger.info({ userMessage, context }, 'Processing message with Ollama'); + + try { + // Build conversation messages + const messages: Array<{ role: 'system' | 'user' | 'assistant' | 'tool'; content: string }> = [ + { role: 'system', content: SYSTEM_PROMPT } + ]; + + // Add context if available + if (context?.userId) { + messages[0].content += `\n\nCurrent user ID: ${context.userId}`; + } + if (context?.lastJobs && context.lastJobs.length > 0) { + messages[0].content += `\n\nPreviously shown jobs:\n${context.lastJobs.map((j, i) => `${i + 1}. ${j.title} (ID: ${j.id})`).join('\n')}`; + } + + messages.push({ role: 'user', content: userMessage }); + + // First call to Ollama with tools + const response = await ollama.chat({ + model: config.ollama.model, + messages, + tools: TOOLS, + stream: false + }); + + logger.debug({ response: response.message }, 'Ollama initial response'); + + // Handle tool calls if present + if (response.message.tool_calls && response.message.tool_calls.length > 0) { + // Add assistant message with tool calls + messages.push({ + role: 'assistant', + content: response.message.content || '' + }); + + // Execute each tool call + for (const toolCall of response.message.tool_calls) { + const toolResult = await executeToolCall( + toolCall.function.name, + toolCall.function.arguments as Record, + context + ); + + messages.push({ + role: 'tool', + content: JSON.stringify(toolResult) + }); + } + + // Get final response with tool results + const finalResponse = await ollama.chat({ + model: config.ollama.model, + messages, + stream: false + }); + + return finalResponse.message.content || 'Sorry, I could not process that request.'; + } + + // No tool calls, return direct response + return response.message.content || 'Sorry, I could not understand that. Try asking about jobs!'; + + } catch (error) { + logger.error({ error }, 'Error processing message with Ollama'); + + // Check if Ollama is not running + if ((error as Error).message?.includes('ECONNREFUSED')) { + return '⚠️ AI service is temporarily unavailable. Please try again in a few minutes.'; + } + + return 'Sorry, something went wrong. Please try again!'; + } +} + +// Health check for Ollama connection +export async function checkOllamaHealth(): Promise { + try { + await ollama.list(); + return true; + } catch (error) { + logger.error({ error }, 'Ollama health check failed'); + return false; + } +} diff --git a/ai-service/src/services/whatsapp.service.ts b/ai-service/src/services/whatsapp.service.ts new file mode 100644 index 0000000..a6633fe --- /dev/null +++ b/ai-service/src/services/whatsapp.service.ts @@ -0,0 +1,132 @@ +import axios from 'axios'; +import { config } from '../config/index.js'; +import { logger } from '../utils/logger.js'; + +const WHATSAPP_API = 'https://graph.facebook.com/v18.0'; + +/** + * Send a text message via WhatsApp + */ +export async function sendWhatsAppMessage(to: string, message: string): Promise { + if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) { + logger.warn('WhatsApp credentials not configured'); + return false; + } + + try { + const url = `${WHATSAPP_API}/${config.whatsapp.phoneNumberId}/messages`; + + await axios.post( + url, + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to, + type: 'text', + text: { + preview_url: false, + body: message + } + }, + { + headers: { + 'Authorization': `Bearer ${config.whatsapp.token}`, + 'Content-Type': 'application/json' + } + } + ); + + logger.info({ to, messageLength: message.length }, 'WhatsApp message sent'); + return true; + } catch (error: any) { + logger.error({ + error: error.response?.data || error.message, + to + }, 'Failed to send WhatsApp message'); + return false; + } +} + +/** + * Send a message with quick reply buttons + */ +export async function sendWhatsAppButtons( + to: string, + bodyText: string, + buttons: Array<{ id: string; title: string }> +): Promise { + if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) { + logger.warn('WhatsApp credentials not configured'); + return false; + } + + try { + const url = `${WHATSAPP_API}/${config.whatsapp.phoneNumberId}/messages`; + + await axios.post( + url, + { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to, + type: 'interactive', + interactive: { + type: 'button', + body: { text: bodyText }, + action: { + buttons: buttons.slice(0, 3).map(btn => ({ + type: 'reply', + reply: { id: btn.id, title: btn.title.substring(0, 20) } + })) + } + } + }, + { + headers: { + 'Authorization': `Bearer ${config.whatsapp.token}`, + 'Content-Type': 'application/json' + } + } + ); + + logger.info({ to, buttonCount: buttons.length }, 'WhatsApp buttons sent'); + return true; + } catch (error: any) { + logger.error({ + error: error.response?.data || error.message, + to + }, 'Failed to send WhatsApp buttons'); + return false; + } +} + +/** + * Mark message as read + */ +export async function markMessageAsRead(messageId: string): Promise { + if (!config.whatsapp.token || !config.whatsapp.phoneNumberId) { + return; + } + + try { + const url = `${WHATSAPP_API}/${config.whatsapp.phoneNumberId}/messages`; + + await axios.post( + url, + { + messaging_product: 'whatsapp', + status: 'read', + message_id: messageId + }, + { + headers: { + 'Authorization': `Bearer ${config.whatsapp.token}`, + 'Content-Type': 'application/json' + } + } + ); + } catch (error) { + // Silently fail for read receipts + logger.debug({ messageId }, 'Failed to mark message as read'); + } +} diff --git a/ai-service/src/types/index.ts b/ai-service/src/types/index.ts new file mode 100644 index 0000000..889a056 --- /dev/null +++ b/ai-service/src/types/index.ts @@ -0,0 +1,88 @@ +// Intent types for job queries +export type Intent = + | 'COUNT_JOBS' + | 'LIST_JOBS' + | 'MATCH_JOBS' + | 'JOB_DETAILS' + | 'APPLY_JOB' + | 'GREETING' + | 'HELP' + | 'UNKNOWN'; + +// Extracted filters from user query +export interface QueryFilters { + role: string | null; // backend, frontend, fullstack, etc. + tech: string | null; // react, golang, python, etc. + type: string | null; // remote, onsite, hybrid + experience: string | null; // junior, mid, senior + jobId: string | null; // for specific job operations +} + +// Parsed query result +export interface ParsedQuery { + intent: Intent; + filters: QueryFilters; + originalQuery: string; +} + +// WhatsApp message types +export interface WhatsAppMessage { + from: string; // Phone number + id: string; // Message ID + timestamp: string; + type: 'text' | 'image' | 'audio' | 'document'; + text?: { body: string }; +} + +export interface WhatsAppWebhookPayload { + object: string; + entry: Array<{ + id: string; + changes: Array<{ + value: { + messaging_product: string; + metadata: { + display_phone_number: string; + phone_number_id: string; + }; + contacts?: Array<{ + profile: { name: string }; + wa_id: string; + }>; + messages?: WhatsAppMessage[]; + }; + field: string; + }>; + }>; +} + +// Conversation context for multi-turn +export interface ConversationContext { + phoneNumber: string; + userId?: string; + lastJobs?: Array<{ id: string; title: string }>; + lastIntent?: Intent; + messageCount: number; +} + +// Job data from job-service +export interface Job { + id: string; + title: string; + description: string; + type: string; + level: string; + location: string; + isRemote: boolean; + salaryMin?: number; + salaryMax?: number; + requiredSkills: string[]; + recruiterId: string; +} + +// User skills from user-service +export interface UserSkill { + name: string; + score: number; + isVerified: boolean; +} diff --git a/ai-service/src/utils/logger.ts b/ai-service/src/utils/logger.ts new file mode 100644 index 0000000..d9d0cb5 --- /dev/null +++ b/ai-service/src/utils/logger.ts @@ -0,0 +1,9 @@ +import pino from 'pino'; +import { config } from '../config/index.js'; + +export const logger = pino({ + level: config.nodeEnv === 'development' ? 'debug' : 'info', + transport: config.nodeEnv === 'development' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); diff --git a/ai-service/tsconfig.json b/ai-service/tsconfig.json new file mode 100644 index 0000000..f44baa9 --- /dev/null +++ b/ai-service/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/aura-processor/src/consumers/project-analyzed.ts b/aura-processor/src/consumers/project-analyzed.ts index 6b66dbe..eef33b4 100644 --- a/aura-processor/src/consumers/project-analyzed.ts +++ b/aura-processor/src/consumers/project-analyzed.ts @@ -251,11 +251,26 @@ function mapTrustLevel(level?: string): 'UNVERIFIED' | 'LOW' | 'MEDIUM' | 'HIGH' return validLevels.includes(normalized) ? normalized as any : undefined; } -function mapEffortClass(effortClass?: string): 'MINIMAL' | 'LOW' | 'MODERATE' | 'SIGNIFICANT' | 'SUBSTANTIAL' | 'MAJOR' | undefined { - if (!effortClass) return undefined; +function mapEffortClass(effortClass?: string): 'MINIMAL' | 'LOW' | 'MODERATE' | 'SIGNIFICANT' | 'SUBSTANTIAL' | 'MAJOR' | null { + if (!effortClass) return null; const normalized = effortClass.toUpperCase(); + // Map Go's EffortClassification values to Prisma EffortClass enum + const aliasMap: Record = { + 'SUSPICIOUS': 'MINIMAL', // Go sends SUSPICIOUS — map to closest Prisma enum + }; + const mapped = aliasMap[normalized] || normalized; const validClasses = ['MINIMAL', 'LOW', 'MODERATE', 'SIGNIFICANT', 'SUBSTANTIAL', 'MAJOR']; - return validClasses.includes(normalized) ? normalized as any : undefined; + return validClasses.includes(mapped) ? mapped as any : null; +} + +function formatYearRange(yearsMin?: number, yearsMax?: number, yearsEstimate?: string | number): string | null { + if (yearsMin != null && yearsMax != null) { + return `${yearsMin}-${yearsMax} years`; + } + if (yearsEstimate != null) { + return String(yearsEstimate); + } + return null; } // ============================================ @@ -385,91 +400,180 @@ async function updateProject( }, }); - // 2. SIMPLIFIED: Store only essential analysis data to avoid MongoDB pipeline limit - // Split into: (a) Core analysis (b) Dimensional analysis (c) Relations + // 2. Build analysis data — all fields in a flat object + // NOTE: We use raw MongoDB $set via runCommandRaw to avoid + // MongoDB Atlas's 50-stage aggregation pipeline limit (P2010 error). + // Prisma's update/upsert generates one pipeline stage per field, + // and this model has 90+ fields which exceeds the 50-stage limit. - const coreAnalysisData = { - analyzerVersion: signals.analysisVersion || '3.0.0', - analyzedAt: new Date(), + const now = new Date(); + const analysisFields: Record = { + analyzer_version: signals.analysisVersion || '3.0.0', + analyzed_at: { $date: now.toISOString() }, + updated_at: { $date: now.toISOString() }, // Scores - overallScore: projectScore, - structureScore: breakdown.structure, - codeQualityScore: breakdown.codeQuality, - testingScore: breakdown.testing || 0, - documentationScore: breakdown.documentation || 0, - bestPracticesScore: breakdown.bestPractices || 0, + overall_score: projectScore, + structure_score: breakdown.structure, + code_quality_score: breakdown.codeQuality, + testing_score: breakdown.testing || 0, + documentation_score: breakdown.documentation || 0, + best_practices_score: breakdown.bestPractices || 0, // Basic info - primaryLanguage: signals.primaryLanguage, - totalFiles: signals.totalFiles || 0, - totalLines: signals.totalLines || 0, + primary_language: signals.primaryLanguage, + total_files: signals.totalFiles || 0, + total_lines: signals.totalLines || 0, // Architecture - architectureType: mapArchitectureType(signals.industryAnalysis?.architecture?.type) as any, - serviceCount: signals.industryAnalysis?.architecture?.serviceCount || 0, - engineeringLevel: mapEngineeringLevel(signals.industryAnalysis?.engineeringLevel), + service_count: signals.industryAnalysis?.architecture?.serviceCount || 0, // Code quality basics - hasReadme: signals.codeSignals.hasReadme, - hasLicense: signals.codeSignals.hasLicense, - hasDockerfile: signals.codeSignals.hasDockerfile, - hasTypeScript: signals.codeSignals.hasTypeScript, - testFilesCount: signals.codeSignals.testFilesCount || 0, + has_readme: signals.codeSignals.hasReadme || false, + has_license: signals.codeSignals.hasLicense || false, + has_dockerfile: signals.codeSignals.hasDockerfile || false, + has_typescript: signals.codeSignals.hasTypeScript || false, + test_files_count: signals.codeSignals.testFilesCount || 0, // Folder structure basics - hasSrcFolder: signals.folderStructure.hasSrcFolder, - hasTests: signals.folderStructure.hasTests, - maxDepth: signals.folderStructure.maxDepth, - topLevelFolders: signals.folderStructure.topLevelFolders || [], - - // Dimensional Analysis - ...(signals.intelligenceVerdict?.dimensions ? { - fundamentalsScore: signals.intelligenceVerdict.dimensions.fundamentals?.score ? Math.round(signals.intelligenceVerdict.dimensions.fundamentals.score) : undefined, - fundamentalsConfidence: signals.intelligenceVerdict.dimensions.fundamentals?.confidence, - engineeringDepthScore: signals.intelligenceVerdict.dimensions.engineeringDepth?.score ? Math.round(signals.intelligenceVerdict.dimensions.engineeringDepth.score) : undefined, - engineeringDepthConfidence: signals.intelligenceVerdict.dimensions.engineeringDepth?.confidence, - productionReadinessScore: signals.intelligenceVerdict.dimensions.productionReadiness?.score ? Math.round(signals.intelligenceVerdict.dimensions.productionReadiness.score) : undefined, - productionReadinessConfidence: signals.intelligenceVerdict.dimensions.productionReadiness?.confidence, - testingMaturityScore: signals.intelligenceVerdict.dimensions.testingMaturity?.score ? Math.round(signals.intelligenceVerdict.dimensions.testingMaturity.score) : undefined, - testingMaturityConfidence: signals.intelligenceVerdict.dimensions.testingMaturity?.confidence, - architectureScore: signals.intelligenceVerdict.dimensions.architecture?.score ? Math.round(signals.intelligenceVerdict.dimensions.architecture.score) : undefined, - architectureConfidence: signals.intelligenceVerdict.dimensions.architecture?.confidence, - infraDevOpsScore: signals.intelligenceVerdict.dimensions.infraDevOps?.score ? Math.round(signals.intelligenceVerdict.dimensions.infraDevOps.score) : undefined, - infraDevOpsConfidence: signals.intelligenceVerdict.dimensions.infraDevOps?.confidence, - } : {}), - - // Experience & Trust - ...(signals.intelligenceVerdict?.experienceAnalysis ? { - experienceLevel: mapExperienceLevel(signals.intelligenceVerdict.experienceAnalysis.level), - experienceConfidence: signals.intelligenceVerdict.experienceAnalysis.confidence, - experienceYearRange: signals.intelligenceVerdict.experienceAnalysis.yearsEstimate, - } : {}), - - ...(signals.intelligenceVerdict?.trustAnalysis ? { - trustScore: signals.intelligenceVerdict.trustAnalysis.score ? Math.round(signals.intelligenceVerdict.trustAnalysis.score) : undefined, - trustLevel: mapTrustLevel(signals.intelligenceVerdict.trustAnalysis.level), - effortClass: signals.intelligenceVerdict.trustAnalysis.effortClass ? mapEffortClass(signals.intelligenceVerdict.trustAnalysis.effortClass) : undefined, - authenticityScore: signals.intelligenceVerdict.trustAnalysis.authenticityScore ? Math.round(signals.intelligenceVerdict.trustAnalysis.authenticityScore) : undefined, - hasOriginalWork: signals.intelligenceVerdict.trustAnalysis.hasOriginalWork, - authenticityFlags: signals.intelligenceVerdict.trustAnalysis.flags || [], - } : {}), - - // Verdict - ...(signals.intelligenceVerdict?.verdictDetailed ? { - verdictSummary: signals.intelligenceVerdict.verdictDetailed.summary, - verdictStrengths: signals.intelligenceVerdict.verdictDetailed.strengths, - verdictGrowthAreas: signals.intelligenceVerdict.verdictDetailed.growthAreas, - verdictJustification: signals.intelligenceVerdict.verdictDetailed.cautions?.join('; '), - } : {}), + has_src_folder: signals.folderStructure.hasSrcFolder || false, + has_tests: signals.folderStructure.hasTests || false, + max_depth: signals.folderStructure.maxDepth || 0, + top_level_folders: signals.folderStructure.topLevelFolders || [], }; - // 3. Upsert ProjectAnalysis with MINIMAL fields - const analysis = await prisma.projectAnalysis.upsert({ + // Architecture type (enum stored as string) + const archType = mapArchitectureType(signals.industryAnalysis?.architecture?.type); + if (archType) analysisFields.architecture_type = archType; + + const engLevel = mapEngineeringLevel(signals.industryAnalysis?.engineeringLevel); + if (engLevel) analysisFields.engineering_level = engLevel; + + // Dimensional Analysis + if (signals.intelligenceVerdict?.dimensions) { + const dims = signals.intelligenceVerdict.dimensions; + if (dims.fundamentals?.score != null) analysisFields.fundamentals_score = Math.round(dims.fundamentals.score); + if (dims.fundamentals?.confidence != null) analysisFields.fundamentals_confidence = dims.fundamentals.confidence; + if (dims.engineeringDepth?.score != null) analysisFields.engineering_depth_score = Math.round(dims.engineeringDepth.score); + if (dims.engineeringDepth?.confidence != null) analysisFields.engineering_depth_confidence = dims.engineeringDepth.confidence; + if (dims.productionReadiness?.score != null) analysisFields.production_readiness_score = Math.round(dims.productionReadiness.score); + if (dims.productionReadiness?.confidence != null) analysisFields.production_readiness_confidence = dims.productionReadiness.confidence; + if (dims.testingMaturity?.score != null) analysisFields.testing_maturity_score = Math.round(dims.testingMaturity.score); + if (dims.testingMaturity?.confidence != null) analysisFields.testing_maturity_confidence = dims.testingMaturity.confidence; + if (dims.architecture?.score != null) analysisFields.architecture_dimension_score = Math.round(dims.architecture.score); + if (dims.architecture?.confidence != null) analysisFields.architecture_confidence = dims.architecture.confidence; + if (dims.infraDevOps?.score != null) analysisFields.infra_devops_score = Math.round(dims.infraDevOps.score); + if (dims.infraDevOps?.confidence != null) analysisFields.infra_devops_confidence = dims.infraDevOps.confidence; + } + + // Experience + if (signals.intelligenceVerdict?.experienceAnalysis) { + const exp = signals.intelligenceVerdict.experienceAnalysis; + const mappedLevel = mapExperienceLevel(exp.level); + if (mappedLevel) analysisFields.experience_level = mappedLevel; + if (exp.confidence != null) analysisFields.experience_confidence = exp.confidence; + const yearRange = formatYearRange(exp.yearsMin, exp.yearsMax, exp.yearsEstimate); + if (yearRange) analysisFields.experience_year_range = yearRange; + } + + // Trust + if (signals.intelligenceVerdict?.trustAnalysis) { + const trust = signals.intelligenceVerdict.trustAnalysis; + if (trust.score != null) analysisFields.trust_score = Math.round(trust.score); + const tl = mapTrustLevel(trust.level); + if (tl) analysisFields.trust_level = tl; + const ec = mapEffortClass(trust.effortClass); + if (ec) analysisFields.effort_class = ec; + if (trust.authenticityScore != null) analysisFields.authenticity_score = Math.round(trust.authenticityScore); + if (trust.hasOriginalWork != null) analysisFields.has_original_work = trust.hasOriginalWork; + analysisFields.authenticity_flags = trust.flags || []; + } + + // Verdict + if (signals.intelligenceVerdict?.verdictDetailed) { + const v = signals.intelligenceVerdict.verdictDetailed; + if (v.summary) analysisFields.dimensional_verdict_summary = v.summary; + if (v.strengths) analysisFields.dimensional_strengths = v.strengths; + if (v.growthAreas) analysisFields.dimensional_growth_areas = v.growthAreas; + if (v.cautions?.length) analysisFields.verdict_justification = v.cautions.join('; '); + } + + // Complexity + if (signals.complexity) { + analysisFields.complexity_total_score = signals.complexity.totalScore || 0; + analysisFields.complexity_architecture_score = signals.complexity.architectureScore || 0; + analysisFields.complexity_infrastructure_score = signals.complexity.infrastructureScore || 0; + analysisFields.complexity_code_quality_score = signals.complexity.codeQualityScore || 0; + analysisFields.complexity_scale_label = signals.complexity.scaleLabel || 'Unknown'; + } + + // Git Forensics + if (signals.gitForensics) { + analysisFields.git_commit_count = signals.gitForensics.commitCount || 0; + analysisFields.git_first_commit_date = signals.gitForensics.firstCommitDate || null; + analysisFields.git_last_commit_date = signals.gitForensics.lastCommitDate || null; + analysisFields.git_largest_commit_ratio = signals.gitForensics.largestCommitRatio || 0; + analysisFields.git_refactor_count = signals.gitForensics.refactorCount || 0; + analysisFields.git_primary_author_pct = signals.gitForensics.primaryAuthorPct || 0; + analysisFields.git_is_premium_feature = signals.gitForensics.isPremium || false; + } + + // Authorship + if (signals.authorshipVerdict) { + analysisFields.authorship_level = signals.authorshipVerdict.level || null; + analysisFields.authorship_confidence = signals.authorshipVerdict.confidence || null; + analysisFields.authorship_reasons = signals.authorshipVerdict.reasons || []; + } + + // Intelligence Verdict top-level + if (signals.intelligenceVerdict) { + analysisFields.verdict_project_intent = signals.intelligenceVerdict.projectIntentSummary || ''; + analysisFields.verdict_tech_stack = signals.intelligenceVerdict.techStackSnapshot || []; + analysisFields.verdict_arch_maturity = signals.intelligenceVerdict.architectureMaturity || 0; + analysisFields.verdict_overall_score = signals.intelligenceVerdict.overallScore || 0; + analysisFields.verdict_developer_level = signals.intelligenceVerdict.developerLevel || null; + analysisFields.verdict_key_signals = signals.intelligenceVerdict.keySignals || []; + analysisFields.verdict_strength_signals = signals.intelligenceVerdict.strengthSignals || []; + analysisFields.verdict_risk_signals = signals.intelligenceVerdict.riskSignals || []; + analysisFields.verdict_senior_verdict = signals.intelligenceVerdict.seniorEngineerVerdict || ''; + analysisFields.verdict_hire_signal = signals.intelligenceVerdict.hireSignal || null; + analysisFields.verdict_analysis_time_ms = signals.intelligenceVerdict.analysisTimeMs || 0; + analysisFields.verdict_modules_executed = signals.intelligenceVerdict.modulesExecuted || []; + analysisFields.verdict_modules_skipped = signals.intelligenceVerdict.modulesSkipped || []; + } + + // Architecture details + if (signals.industryAnalysis?.architecture) { + analysisFields.architecture_communication = signals.industryAnalysis.architecture.communication || []; + analysisFields.architecture_patterns = signals.industryAnalysis.architecture.patterns || []; + analysisFields.architecture_service_names = signals.industryAnalysis.architecture.services || []; + analysisFields.architecture_gateway = signals.industryAnalysis.architecture.gateway || null; + } + + // 3. Use raw MongoDB updateOne with $set to bypass Prisma's pipeline limit + // MongoDB native $set is a single pipeline stage regardless of field count + const rawResult: any = await prisma.$runCommandRaw({ + update: 'project_analyses', + updates: [ + { + q: { project_id: { $oid: signals.projectId } }, + u: { $set: analysisFields, $setOnInsert: { project_id: { $oid: signals.projectId }, created_at: { $date: now.toISOString() } } }, + upsert: true, + }, + ], + }); + + // Get the analysis ID for creating related records + const analysisDoc = await prisma.projectAnalysis.findUnique({ where: { projectId: signals.projectId }, - create: { projectId: signals.projectId, ...coreAnalysisData }, - update: { ...coreAnalysisData, updatedAt: new Date() }, + select: { id: true }, }); + + if (!analysisDoc) { + throw new Error('Failed to create/update ProjectAnalysis'); + } + + const analysis = analysisDoc; // 4. Store Language Stats if (signals.languages?.length > 0) { diff --git a/chat-service/src/api/controllers/room.controller.ts b/chat-service/src/api/controllers/room.controller.ts index 7745ada..5bacd6f 100644 --- a/chat-service/src/api/controllers/room.controller.ts +++ b/chat-service/src/api/controllers/room.controller.ts @@ -17,10 +17,39 @@ export const roomController = { const { userId } = req.user; const rooms = await chatRoomService.getUserRooms(userId); + // Collect IDs of users with missing names + const missingNameIds = new Set(); + rooms.forEach((room: any) => { + const other = chatRoomService.getOtherParticipant(room, userId); + if (!other.name || other.name === 'Unknown') { + missingNameIds.add(other.userId); + } + }); + + // Fetch missing user details + let userMap = new Map(); + if (missingNameIds.size > 0) { + try { + const { userClient } = await import('../../services/user-client.service.js'); + userMap = await userClient.getUsers(Array.from(missingNameIds)); + } catch (err) { + logger.warn({ err }, 'Failed to fetch missing user details'); + } + } + // Enrich with unread counts and otherParticipant details const enrichedRooms = rooms.map((room: any) => { - const otherParticipant = chatRoomService.getOtherParticipant(room, userId); + const otherParticipant = chatRoomService.getOtherParticipant(room, userId) as any; + // Use fetched name if available and current name is missing + if ((!otherParticipant.name || otherParticipant.name === 'Unknown') && userMap.has(otherParticipant.userId)) { + const user = userMap.get(otherParticipant.userId); + if (user) { + otherParticipant.name = user.name; + if (user.avatarUrl) otherParticipant.avatarUrl = user.avatarUrl; + } + } + return { ...room, unread: room.unreadCounts?.find((u: any) => u.userId === userId)?.count || 0, diff --git a/chat-service/src/services/user-client.service.ts b/chat-service/src/services/user-client.service.ts new file mode 100644 index 0000000..11982ab --- /dev/null +++ b/chat-service/src/services/user-client.service.ts @@ -0,0 +1,73 @@ +import { config } from '../config/index.js'; +import { logger } from '../utils/logger.js'; + +interface User { + id: string; + email: string; + name: string; + avatarUrl?: string; + role: 'CANDIDATE' | 'RECRUITER' | 'ADMIN'; +} + +export const userClient = { + /** + * Get user details by ID + * Uses internal service communication + */ + async getUser(userId: string): Promise { + try { + // Default to localhost/docker service name if not configured + // We try both service name (docker) and localhost (local dev) + // Ideally this comes from config + const userServiceUrl = process.env.USER_SERVICE_URL || 'http://user-service:3002'; + + const response = await fetch(`${userServiceUrl}/api/v1/users/${userId}/public`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + // Add internal secret if needed for bypass? + // For public profile, it should be open or require valid token + } + }); + + if (!response.ok) { + // Fallback for local development if running outside docker + if (userServiceUrl.includes('user-service')) { + const localUrl = 'http://localhost:3002'; + const localRes = await fetch(`${localUrl}/api/v1/users/${userId}/public`); + if (localRes.ok) { + const data = await localRes.json() as { success: boolean; data: User }; + return data.data; + } + } + return null; + } + + const data = await response.json() as { success: boolean; data: User }; + return data.data; // Assuming standard ApiResponse structure { success: true, data: User } + } catch (error) { + logger.error({ userId, error }, 'Failed to fetch user details'); + return null; + } + }, + + /** + * Batch fetch users (simulated via parallel requests for now) + */ + async getUsers(userIds: string[]): Promise> { + const uniqueIds = [...new Set(userIds)]; + const users = new Map(); + + // Limit concurrency if needed, but for now specific parallel fetch + const promises = uniqueIds.map(id => this.getUser(id)); + const results = await Promise.all(promises); + + results.forEach((user, index) => { + if (user) { + users.set(uniqueIds[index], user); + } + }); + + return users; + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 209be27..70addd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - resume-service - project-analyzer - chat-service + - ai-service restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost/health"] @@ -314,6 +315,10 @@ services: - PUBLISH_QUEUE=project.analyzed - CLONE_DIR=/tmp/repos - GITHUB_TOKEN=${GITHUB_TOKEN:-} + # Worker Pool Configuration - process multiple repos concurrently + - WORKER_COUNT=${ANALYZER_WORKER_COUNT:-4} + - PREFETCH_COUNT=${ANALYZER_PREFETCH_COUNT:-4} + - ANALYSIS_TIMEOUT_SEC=${ANALYSIS_TIMEOUT_SEC:-120} volumes: - analyzer_repos:/tmp/repos depends_on: @@ -358,11 +363,75 @@ services: max-size: "10m" max-file: "3" + # ==================== OLLAMA (Local LLM) ==================== + ollama: + image: ollama/ollama:latest + container_name: verifydev-ollama + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + environment: + - OLLAMA_HOST=0.0.0.0 + restart: unless-stopped + networks: + - verifydev-network + deploy: + resources: + limits: + memory: 4G + cpus: '2.0' + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + # ==================== AI SERVICE ==================== + ai-service: + build: + context: ./ai-service + dockerfile: Dockerfile.dev + container_name: verifydev-ai + ports: + - "${AI_SERVICE_PORT:-3008}:3008" + environment: + - NODE_ENV=${NODE_ENV:-development} + - PORT=3008 + - OLLAMA_HOST=http://ollama:11434 + - OLLAMA_MODEL=llama3.2 + - WHATSAPP_TOKEN=${WHATSAPP_TOKEN:-} + - WHATSAPP_PHONE_NUMBER_ID=${WHATSAPP_PHONE_NUMBER_ID:-} + - WHATSAPP_VERIFY_TOKEN=${WHATSAPP_VERIFY_TOKEN:-verifydev_webhook_secret} + - JOB_SERVICE_URL=http://job-service:3004 + - USER_SERVICE_URL=http://user-service:3002 + volumes: + - ./ai-service:/app + - /app/node_modules + depends_on: + - ollama + - job-service + - user-service + restart: unless-stopped + networks: + - verifydev-network + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # ==================== VOLUMES ==================== volumes: redis_data: rabbitmq_data: analyzer_repos: + ollama_data: # ==================== NETWORKS ==================== networks: diff --git a/gateway/conf.d/api.conf b/gateway/conf.d/api.conf index 5a85990..e91a52d 100644 --- a/gateway/conf.d/api.conf +++ b/gateway/conf.d/api.conf @@ -545,6 +545,41 @@ server { proxy_busy_buffers_size 256k; } + # ========================================== + # AI Service Routes (WhatsApp Bot, NLP) + # ========================================== + location /api/v1/ai { + # CORS + add_header 'Access-Control-Allow-Origin' '$http_origin' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '$http_origin' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + + proxy_pass http://ai_service; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + # Longer timeout for AI processing + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + # ========================================== # Service Health Checks (Internal) # ========================================== diff --git a/gateway/nginx.conf b/gateway/nginx.conf index 7d8b2bf..9b0eff4 100644 --- a/gateway/nginx.conf +++ b/gateway/nginx.conf @@ -129,6 +129,12 @@ http { keepalive 16; } + # AI Service (WhatsApp Bot, NLP) + upstream ai_service { + server ai-service:3008 weight=1 max_fails=3 fail_timeout=30s; + keepalive 16; + } + # Include service-specific configurations include /etc/nginx/conf.d/*.conf; } diff --git a/project-analyzer/.env.example b/project-analyzer/.env.example index 6642f1c..b1eb74f 100644 --- a/project-analyzer/.env.example +++ b/project-analyzer/.env.example @@ -15,5 +15,12 @@ CLONE_DIR=/tmp/repos MAX_REPO_SIZE_MB=100 ANALYSIS_TIMEOUT_SEC=120 +# Worker Pool Configuration (for concurrent processing) +# Number of concurrent workers (recommended: 2-8 based on CPU cores) +WORKER_COUNT=4 +# RabbitMQ prefetch count (should match WORKER_COUNT) +PREFETCH_COUNT=4 + # GitHub (for private repos - optional) GITHUB_TOKEN= + diff --git a/project-analyzer/LEARNING_ROADMAP.md b/project-analyzer/LEARNING_ROADMAP.md deleted file mode 100644 index b7553a8..0000000 --- a/project-analyzer/LEARNING_ROADMAP.md +++ /dev/null @@ -1,247 +0,0 @@ -# 📚 Project Analyzer - Learning Road Map - -> **Ye guide follow karo agar tum akele ye system samajhna chahte ho** - ---- - -## 🎯 Goal - -8-10 hours mein poora intelligence engine samajh jaoge aur khud changes kar sakoge. - ---- - -## 📅 Learning Plan (4 Days) - -### Day 1: Architecture & Data Flow (2 hours) - -**Step 1: Big Picture (30 min)** -``` -1. README.md padho - Quick overview -2. DEVELOPER.md ka "Architecture" section - System design -3. Diagram samjho: RabbitMQ → Analyzer → Parser → Intelligence → Output -``` - -**Step 2: Entry Point (30 min)** -``` -Files to read: -├── cmd/main.go # Service start, RabbitMQ connection -└── internal/analyzer/analyzer.go # Main orchestrator (lines 110-370) - -Focus on: -- analyze() function - Main flow -- Parallel execution (5 goroutines) -- How results are published to RabbitMQ -``` - -**Step 3: Data Types (1 hour)** -``` -Files to read: -├── pkg/signals/infrastructure.go # 400+ signal constants -├── pkg/signals/types.go # ProjectSignals, AnalyzeRequest -└── pkg/signals/verified_skills.go # VerifiedSkill struct - -Samjho: -- Signal kya hai? (e.g., SignalPostgres, SignalDocker) -- VerifiedSkill kya hai? (Name, Category, Confidence, Evidence) -- IndustryAnalysis kaise banta hai? -``` - ---- - -### Day 2: Signal Extraction - Parser Layer (3 hours) - -**Step 4: Main Extractor (1 hour)** -``` -File: internal/parser/infra_extractor.go (529 lines) - -Functions to trace: -1. NewInfraExtractor() - Initialization -2. Extract() - Main pipeline (line 30-109) -3. extractRootFileSignals() - Dockerfile, docker-compose check -4. extractDependencySignals() - package.json, go.mod - -Practice: Add debug log in Extract() and watch output -``` - -**Step 5: Technology Detection (1 hour)** -``` -Files: -├── infra_extractor_node.go # package.json parsing (200+ deps) -├── infra_extractor_go.go # go.mod parsing -├── infra_extractor_services.go # Microservices detection - -Key function: scanServicePackageJSON() -- Dekho kaise "prisma" → SignalPrisma ban raha hai -- Dekho kaise dependencies map ho rahi hain -``` - -**Step 6: Git Forensics (1 hour)** -``` -File: internal/parser/git_forensics.go (254 lines) - -Key concepts: -1. GitAnalyzer.Analyze() - Main function -2. generateVerdict() - SNAPSHOT vs ORGANIC detection -3. parseCommits() - Git log parsing - -Rules samjho: -- LargestCommitRatio > 0.80 → SNAPSHOT -- TimeGap < 24h && RefactorCount == 0 → SNAPSHOT -``` - ---- - -### Day 3: Skill Conversion - Inference Engine (2 hours) - -**Step 7: Skill Rules (1.5 hours)** -``` -File: internal/parser/inference_engine.go (2043 lines) - -Structure samjho: -type SkillRule struct { - SkillName string // "PostgreSQL" - RequiredSignals []InfraSignal // Must have - OptionalSignals []InfraSignal // Boost if present - BaseConfidence float64 // Starting confidence - Weight int // Aura points -} - -Key functions: -1. loadRules() - All 150+ rules defined here (line 39-1791) -2. InferSkills() - Main conversion (line 1793-1843) -3. evaluateRule() - Rule matching logic (line 1845-1951) - -Exercise: Find PostgreSQL rule and trace how it becomes VerifiedSkill -``` - -**Step 8: Confidence Calculation (30 min)** -``` -File: internal/intelligence/types.go - -Function: ComputeConfidence() -- Evidence boost: +10% for 2-3 evidences, +20% for 4+ -- Package + Config file → boost to 95% - -File: internal/analyzer/analyzer.go -Function: applyAuthorshipPenaltyToSkills() (line 581-639) -- SNAPSHOT penalty: ×0.90 (10% penalty) -- ORGANIC bonus: ×1.05 (5% boost) -``` - ---- - -### Day 4: Intelligence Layer & Verdict (2 hours) - -**Step 9: Pipeline (1 hour)** -``` -File: internal/intelligence/pipeline.go (725 lines) - -7 Stages samjho: -1. Signal Scanning (signal_scanner.go) -2. Intent Inference (intent_inferer.go) -3. Skill Extraction -4. Usage Verification (usage_verifier.go) -5. Risk Modeling (risk_modeling.go) -6. Suggestion Generation -7. Verdict Generation - -Key function: Run() (line 80-238) -``` - -**Step 10: Final Verdict (1 hour)** -``` -File: internal/intelligence/verdict_engine.go (419 lines) - -Functions: -1. GenerateVerdict() - Main function -2. calculateOverallScore() - 0-100 score -3. calculateHireSignal() - STRONG_HIRE, HIRE, MAYBE, NO_HIRE -4. generateEngineerVerdict() - Senior engineer assessment - -Output samjho: -- Summary, TechStack, Strengths, Risks -- OverallScore, HireSignal -``` - ---- - -## 🔧 Hands-On Exercises - -### Exercise 1: Add Debug Logs -```go -// Add in infra_extractor.go:Extract() -log.Debug().Msg("Starting signal extraction...") - -// Rebuild and watch logs -docker compose build project-analyzer -docker compose logs -f project-analyzer -``` - -### Exercise 2: Trace a Skill -``` -Trace PostgreSQL: -1. infra_extractor_node.go: "prisma" detected → SignalPrisma -2. inference_engine.go: PostgreSQL rule matches SignalPostgres -3. types.go: ComputeConfidence() boosts score -4. analyzer.go: applyAuthorshipPenaltyToSkills() applies penalty -5. Final confidence = 86% -``` - -### Exercise 3: Add a New Skill -``` -Add: "Supabase" skill -1. pkg/signals/infrastructure.go: Add SignalSupabase -2. infra_extractor_node.go: Detect "@supabase/supabase-js" -3. inference_engine.go: Add SkillRule for Supabase -4. Rebuild and test -``` - ---- - -## 🗂️ File Priority (Most Important First) - -``` -🔴 CRITICAL (Read first): -├── internal/analyzer/analyzer.go -├── internal/parser/infra_extractor.go -├── internal/parser/inference_engine.go -└── pkg/signals/infrastructure.go - -🟡 IMPORTANT (Read second): -├── internal/parser/git_forensics.go -├── internal/intelligence/pipeline.go -├── internal/intelligence/types.go -└── internal/intelligence/verdict_engine.go - -🟢 OPTIONAL (Read if needed): -├── internal/parser/infra_extractor_*.go (specific tech) -├── internal/intelligence/usage_verifier.go -├── internal/intelligence/confidence_calibrator.go -└── internal/intelligence/skill_taxonomy.go -``` - ---- - -## 💡 Tips - -1. **Logs dekho pehle** - `docker compose logs -f project-analyzer` -2. **One file at a time** - Sab ek saath mat padho -3. **Debug logs add karo** - Samajhne ka best tarika -4. **Exercise karo** - Reading se zyada doing se seekhoge -5. **DEVELOPER.md refer karo** - Sab detail wahan hai - ---- - -## ⏰ Time Summary - -| Day | Topic | Time | -|-----|-------|------| -| 1 | Architecture & Data Types | 2 hours | -| 2 | Parser Layer (Signal Extraction) | 3 hours | -| 3 | Inference Engine (Skill Rules) | 2 hours | -| 4 | Intelligence Layer & Verdict | 2 hours | -| **Total** | **Full Understanding** | **9 hours** | - ---- - -*Follow this plan and you'll be able to modify the engine independently! 🚀* diff --git a/project-analyzer/README.md b/project-analyzer/README.md index caf4ff8..4582ae9 100644 --- a/project-analyzer/README.md +++ b/project-analyzer/README.md @@ -1,88 +1,508 @@ -# 🧠 Project Analyzer Engine +# 🔍 Project Analyzer Engine -> **Go-based Intelligence Engine for Developer Skill Verification** +> **AI-Powered Code Analysis Engine** | Go 1.21+ | Worker Pool Enabled -Analyzes GitHub repositories to extract verified skills, calculate dimensional scores, and generate trust signals. +VerifyDev का core analysis engine जो GitHub repositories को scan करके developer skills verify करता है। + +--- + +## 📋 Table of Contents + +1. [Quick Start](#-quick-start) +2. [How It Works](#-how-it-works) +3. [Architecture Overview](#-architecture-overview) +4. [File Structure](#-file-structure) +5. [Analysis Pipeline](#-analysis-pipeline) +6. [Configuration](#-configuration) +7. [API Reference](#-api-reference) + +--- ## 🚀 Quick Start ```bash -# Build -docker compose build project-analyzer +# Run locally +go run cmd/main.go -# Run -docker compose up -d project-analyzer +# Run with Docker +docker compose up project-analyzer -# Check logs -docker compose logs -f project-analyzer +# Environment variables (optional) +WORKER_COUNT=4 # Concurrent workers (default: 4) +PREFETCH_COUNT=4 # RabbitMQ prefetch (default: 4) +ANALYSIS_TIMEOUT_SEC=120 # Per-repo timeout ``` -## 📁 Project Structure +--- + +## 🧠 How It Works + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ ANALYSIS FLOW │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. MESSAGE RECEIVED (RabbitMQ) │ +│ └─ project.analyze.request queue │ +│ │ +│ 2. GIT CLONE │ +│ └─ Clone repo to /tmp/repos/{projectId} │ +│ │ +│ 3. PARALLEL ANALYSIS (5 concurrent goroutines) │ +│ ├─ Language Stats → Count lines per language │ +│ ├─ Folder Structure → Detect src/, components/, etc │ +│ ├─ Code Signals → README, Docker, CI detection │ +│ ├─ Infra Extraction → 400+ tech pattern detection │ +│ └─ Git Forensics → Authorship verification │ +│ │ +│ 4. INTELLIGENCE PIPELINE (7 stages) │ +│ ├─ Signal Scanning → Fast tech detection │ +│ ├─ Intent Inference → Project purpose (prod/hobby/learning) │ +│ ├─ Skill Extraction → Convert signals → verified skills │ +│ ├─ Usage Verification → Check actual code usage │ +│ ├─ Risk Modeling → Security & quality assessment │ +│ ├─ Suggestion Gen → Improvement recommendations │ +│ └─ Verdict Generation → Final recruiter-grade assessment │ +│ │ +│ 5. PUBLISH RESULT (RabbitMQ) │ +│ └─ project.analyzed queue → Aura Processor │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🏗️ Architecture Overview ``` project-analyzer/ -├── cmd/main.go # Entry point +│ +├── cmd/ +│ └── main.go # Entry point, HTTP server, worker pool +│ ├── internal/ │ ├── analyzer/ # Main orchestrator -│ ├── parser/ # Signal extraction (18 files) -│ │ ├── inference_engine.go # 150+ skill rules -│ │ ├── infra_extractor*.go # Technology detection -│ │ └── git_forensics.go # Authorship verification -│ └── intelligence/ # Advanced analysis (17 files) -│ ├── pipeline.go # 7-stage analysis -│ ├── verdict_engine.go # Final assessment -│ └── usage_verifier.go # Verify actual usage -├── pkg/signals/ # Data types & constants -├── DEVELOPER.md # 📖 FULL DOCUMENTATION -└── README.md # This file +│ │ ├── analyzer.go # Analysis flow, message handling +│ │ └── security.go # Security pattern detection +│ │ +│ ├── config/ +│ │ └── config.go # Environment configuration +│ │ +│ ├── git/ +│ │ └── client.go # Git clone/delete operations +│ │ +│ ├── intelligence/ # 🧠 AI Analysis Layer (17 modules) +│ │ ├── pipeline.go # 7-stage analysis orchestration +│ │ ├── types.go # Core types & confidence vectors +│ │ ├── signal_scanner.go # Fast signal detection +│ │ ├── usage_verifier.go # Actual usage verification +│ │ ├── verdict_engine.go # Final verdict generation +│ │ ├── confidence_calibrator.go +│ │ ├── skill_taxonomy.go +│ │ ├── risk_modeling.go +│ │ ├── security_scanner.go +│ │ └── ... (8 more modules) +│ │ +│ ├── parser/ # Code parsing layer +│ │ ├── parser.go # Language stats, folder analysis +│ │ ├── infra_extractor.go # Main signal extraction +│ │ ├── infra_extractor_*.go # Language-specific extractors +│ │ ├── git_forensics.go # Authorship detection +│ │ └── inference_engine.go # Signal → Skill conversion +│ │ +│ ├── rabbitmq/ +│ │ └── client.go # Message queue integration +│ │ +│ └── workerpool/ +│ └── pool.go # Concurrent worker management +│ +└── pkg/ # Shared types + ├── signals/ # Signal constants & types + ├── dimensions/ # Dimensional analysis + ├── verdict/ # Verdict types + └── trust/ # Trust scoring +``` + +--- + +## 📁 File Structure Explained + +### 🔴 Entry Point: `cmd/main.go` + +```go +// Starts the analyzer with worker pool +func main() { + cfg := config.Load() + rabbit := rabbitmq.NewRabbitMQ(...) + analyzer := analyzer.NewAnalyzer(cfg, rabbit) + + // Worker pool for concurrent processing + analyzer.StartWithWorkerPool(ctx) +} +``` + +**Key Features:** +- HTTP health endpoints (`/health`, `/ready`, `/metrics`) +- Graceful shutdown support +- Configurable worker pool (WORKER_COUNT env var) + +--- + +### 🔴 Core Orchestrator: `internal/analyzer/analyzer.go` + +**This is the BRAIN.** It coordinates everything. + +```go +// Main analysis function +func (a *Analyzer) analyze(ctx context.Context, req AnalyzeRequest) (*ProjectSignals, error) { + // 1. Clone repo + repoPath := a.gitClient.CloneRepo(...) + + // 2. PARALLEL Phase (5 goroutines) + go fileParser.GetLanguageStats() + go fileParser.AnalyzeFolderStructure() + go fileParser.AnalyzeCodeSignals() + go infraExtractor.Extract() // ⭐ Heavy lifting + go gitAnalyzer.Analyze() + + // 3. Sequential Phase + result.ProjectType = DetectProjectType(...) + result.IndustryAnalysis = inferenceEngine.InferSkills(infraSignals) + + // 4. Intelligence Pipeline (7 stages) + intelligenceResult := intelligencePipeline.Run(ctx) + + // 5. Apply authorship penalties + applyAuthorshipPenaltyToSkills(...) + + return result, nil +} +``` + +**Worker Pool Mode:** +```go +// StartWithWorkerPool - Production recommended +func (a *Analyzer) StartWithWorkerPool(ctx context.Context) { + // Semaphore limits concurrent workers + sem := make(chan struct{}, workerCount) + + for msg := range msgs { + sem <- struct{}{} // Acquire slot + go func(msg) { + defer func() { <-sem }() // Release slot + a.handleMessage(ctx, msg) + }(msg) + } +} ``` -## 🔄 How It Works +--- + +### 🔴 Signal Detection: `internal/parser/infra_extractor.go` + +**Detects 400+ technologies** from code patterns. +```go +// Main extraction function +func (e *InfraExtractor) Extract() *InfrastructureSignals { + // Scan root files + e.extractRootFileSignals() // Dockerfile, docker-compose + e.extractConfigSignals() // .env files + e.extractDependencySignals() // package.json, go.mod + e.extractServiceStructureSignals() // Microservices detection + e.extractDeepServiceSignals() // Nested services + + return e.signals +} ``` -RabbitMQ (project.analyze) - ▼ - Git Clone → Parser (signals) → Inference (skills) → Intelligence (verdict) - ▼ -RabbitMQ (project.analyzed) → Aura Processor (save to DB) + +**Language-Specific Extractors:** + +| File | Purpose | Detects | +|------|---------|---------| +| `infra_extractor_node.go` | Node.js/TS | React, Express, NestJS, 200+ npm packages | +| `infra_extractor_go.go` | Go | Gin, Fiber, GORM, 50+ Go modules | +| `infra_extractor_python.go` | Python | Django, Flask, FastAPI, ML libraries | +| `infra_extractor_docker.go` | Docker | Dockerfile, compose, multi-stage builds | +| `infra_extractor_gateway.go` | Gateways | Nginx, Traefik, API patterns | +| `infra_extractor_deployment.go` | DevOps | K8s, Terraform, CI/CD | + +**Example Signal Detection:** +```go +// From infra_extractor_node.go +dependencyMap := map[string]InfraSignal{ + "@prisma/client": SignalPrisma, + "express": SignalExpress, + "socket.io": SignalWebSocket, + "kafkajs": SignalKafka, + "ioredis": SignalRedis, +} ``` -## 📖 Documentation +--- + +### 🔴 Skill Inference: `internal/parser/inference_engine.go` + +**Converts signals → verified skills** with 150+ rules. + +```go +type SkillRule struct { + SkillName string // "PostgreSQL" + Category SkillCategory // DATABASE + RequiredSignals []InfraSignal // Must ALL be present + OptionalSignals []InfraSignal // Boost confidence + BaseConfidence float64 // 0.70-0.90 + Weight int // Aura points +} + +// Example rule +{ + SkillName: "Microservices Architecture", + Category: CategoryArchitecture, + RequiredSignals: []InfraSignal{SignalDocker, SignalDockerCompose}, + OptionalSignals: []InfraSignal{SignalNginx, SignalKafka, SignalMultipleServices}, + BaseConfidence: 0.80, + Weight: 12, +} +``` -**For developers:** See [`DEVELOPER.md`](./DEVELOPER.md) for: -- Complete architecture & data flow -- Every file explained with functions -- Confidence scoring system -- How to add new skills -- Debugging guide +--- + +### 🔴 Intelligence Pipeline: `internal/intelligence/pipeline.go` + +**7-stage deep analysis** for accurate skill verification. + +```go +func (p *Pipeline) Run(ctx context.Context) (*PipelineResult, error) { + // Stage 1: Signal Scanning + signals, confidence := p.scanner.Scan() + + // Stage 2: Intent Inference + intent := p.inferIntent(signals) // PRODUCTION, HOBBY, LEARNING + + // Stage 3: Skill Extraction + skills := p.extractSkills(signals) + + // Stage 4: Usage Verification + verdicts := p.verifyUsage(skills) + + // Stage 5: Risk Modeling + risks := p.modelRisks(signals, skills) + + // Stage 6: Suggestion Generation + suggestions := p.generateSuggestions(risks) + + // Stage 7: Verdict Generation + verdict := p.verdictEngine.GenerateVerdict() + + return &PipelineResult{Verdict: verdict, Skills: skills} +} +``` -## ⚙️ Environment Variables +**Key Intelligence Modules:** + +| Module | Purpose | +|--------|---------| +| `signal_scanner.go` | Fast lightweight scanning | +| `usage_verifier.go` | Verify actual code usage (not just deps) | +| `verdict_engine.go` | Final recruiter-grade assessment | +| `confidence_calibrator.go` | Adjust scores based on project size | +| `skill_taxonomy.go` | Skill hierarchy & relationships | +| `security_scanner.go` | Vulnerability detection | +| `risk_modeling.go` | Uncertainty assessment | + +--- + +### 🔴 Git Forensics: `internal/parser/git_forensics.go` + +**Detects organic vs copied projects.** + +```go +// AuthorshipVerdict levels: +ORGANIC → Natural development (5% bonus) +SNAPSHOT → Bulk copy suspected (10% penalty) +SUSPICIOUS → Red flags detected (15% penalty) +UNCLEAR → Team project, can't determine + +// Detection rules: +if LargestCommitRatio > 0.80 { + // "80%+ code in single commit = likely copied" + level = "SNAPSHOT" +} +if projectAge < 24*time.Hour && refactorCount == 0 { + // "Completed in <24h with no refactors = suspicious" + level = "SUSPICIOUS" +} +``` + +--- + +## ⚙️ Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 8001 | HTTP server port | +| `WORKER_COUNT` | 4 | Concurrent analysis workers | +| `PREFETCH_COUNT` | 4 | RabbitMQ prefetch (match workers) | +| `ANALYSIS_TIMEOUT_SEC` | 120 | Per-repo timeout | +| `RABBITMQ_URL` | localhost | RabbitMQ connection | +| `CLONE_DIR` | /tmp/repos | Temp directory for clones | +| `GITHUB_TOKEN` | - | For private repos | + +### Performance Tuning ```bash -RABBITMQ_URL=amqp://user:pass@localhost:5672/ -ANALYSIS_TIMEOUT=300s -LOG_LEVEL=info +# High-CPU server (8+ cores) +WORKER_COUNT=8 +PREFETCH_COUNT=8 + +# Low-memory server +WORKER_COUNT=2 +ANALYSIS_TIMEOUT_SEC=60 + +# Production recommended +WORKER_COUNT=4 +PREFETCH_COUNT=4 +ANALYSIS_TIMEOUT_SEC=120 ``` -## 🧪 Testing +--- + +## 📡 API Reference + +### Health Endpoints ```bash -# Run tests -go test ./... +# Health check +GET /health +{ + "success": true, + "data": { + "service": "project-analyzer", + "version": "2.0.0-workerpool", + "workerCount": 4 + } +} + +# Ready check +GET /ready +{ + "success": true, + "message": "Ready to analyze with worker pool" +} -# Test specific package -go test ./internal/parser/... +# Metrics +GET /metrics +{ + "workerCount": 4, + "prefetchCount": 4 +} ``` -## 📊 Key Metrics +### RabbitMQ Messages -| Metric | Value | -|--------|-------| -| Analysis Time | 15-45 seconds | -| Max Repo Size | 500MB | -| Skill Rules | 150+ | -| Signal Types | 400+ | +**Input Queue:** `project.analyze.request` +```json +{ + "projectId": "abc123", + "userId": "user456", + "repoUrl": "https://github.com/user/repo", + "repoName": "repo", + "defaultBranch": "main", + "userProjectType": "backend", + "niche": "web" +} +``` + +**Output Queue:** `project.analyzed` +```json +{ + "projectId": "abc123", + "userId": "user456", + "languages": [...], + "frameworks": ["Express", "React"], + "databases": ["PostgreSQL"], + "tools": ["Docker", "GitHub Actions"], + "industryAnalysis": { + "verifiedSkills": [...], + "totalSkills": 15 + }, + "intelligenceVerdict": { + "overallScore": 85, + "hireSignal": "STRONG_HIRE" + } +} +``` --- -*For detailed documentation, see [DEVELOPER.md](./DEVELOPER.md)* +## 🔧 Adding New Technology Detection + +### Step 1: Add Signal Constant +```go +// pkg/signals/infrastructure.go +const SignalNewTech InfraSignal = "newtech" +``` + +### Step 2: Add Detection Logic +```go +// internal/parser/infra_extractor_node.go +dependencyMap["newtech-package"] = signals.SignalNewTech +``` + +### Step 3: Add Skill Rule +```go +// internal/parser/inference_engine.go +{ + SkillName: "NewTech", + Category: CategoryFramework, + RequiredSignals: []InfraSignal{SignalNewTech}, + BaseConfidence: 0.85, +} +``` + +--- + +## 📊 Output Example + +``` +📦 Analysis Complete for: verify-stack + +Languages: + TypeScript: 65% (15,000 lines) + Go: 25% (6,000 lines) + +Frameworks: [Next.js, Express, Gin, Prisma] +Databases: [PostgreSQL, Redis, MongoDB] +Tools: [Docker, Kubernetes, GitHub Actions] + +Verified Skills (15): + ✅ TypeScript (Advanced) - 95% confidence + ✅ Microservices Architecture - 90% confidence + ✅ PostgreSQL - 88% confidence + ✅ Docker & Kubernetes - 85% confidence + +Intelligence Verdict: + Project Type: PRODUCTION + Developer Level: SENIOR + Architecture: MICROSERVICES + Overall Score: 87/100 + Hire Signal: STRONG_HIRE + +Authorship: ORGANIC (5% bonus applied) +``` + +--- + +## 🔗 Related Documentation + +- [DEVELOPER.md](./DEVELOPER.md) - Detailed function-level documentation +- [Backend README](../README.md) - Full backend documentation + +--- + +## 📄 License + +MIT © VerifyDev diff --git a/project-analyzer/cmd/debug-scan/main.go b/project-analyzer/cmd/debug-scan/main.go deleted file mode 100644 index bfd902e..0000000 --- a/project-analyzer/cmd/debug-scan/main.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/verifydev/project-analyzer/internal/intelligence" -) - -func main() { - // Point to the root of the verify-stack repo - repoPath := "/Users/keshavsharma/verify-stack" - fmt.Printf("Scanning repository at: %s\n", repoPath) - - // Run Signal Scan - scanner := intelligence.NewSignalScanner(repoPath) - signals, confidence, err := scanner.Scan() - if err != nil { - fmt.Printf("Error scanning: %v\n", err) - os.Exit(1) - } - - // Run Usage Verification - fmt.Println("Running Usage Verification...") - verdicts := intelligence.VerifyAllUsage(repoPath, signals) - verdictsJSON, _ := json.MarshalIndent(verdicts, "", " ") - - // Pretty print results - sigJSON, _ := json.MarshalIndent(signals, "", " ") - confJSON, _ := json.MarshalIndent(confidence, "", " ") - - fmt.Println("---------------------------------------------------") - fmt.Println("DETECTED SIGNALS:") - fmt.Println(string(sigJSON)) - fmt.Println("---------------------------------------------------") - fmt.Println("VERIFICATION VERDICTS:") - fmt.Println(string(verdictsJSON)) - fmt.Println("---------------------------------------------------") - fmt.Println("CONFIDENCE SCORES:") - fmt.Println(string(confJSON)) - fmt.Println("---------------------------------------------------") -} diff --git a/project-analyzer/cmd/main.go b/project-analyzer/cmd/main.go index e3c87b5..7e74a1b 100644 --- a/project-analyzer/cmd/main.go +++ b/project-analyzer/cmd/main.go @@ -40,36 +40,46 @@ func main() { ║ • Extract signals (frameworks, patterns) ║ ║ • Publish signals to RabbitMQ ║ ║ ║ +║ 🚀 WORKER POOL ENABLED ║ +║ • Concurrent message processing ║ +║ • Configurable worker count ║ +║ ║ ╚═══════════════════════════════════════════════════════════╝ `) - // Connect to RabbitMQ + log.Info(). + Int("workerCount", cfg.WorkerCount). + Int("prefetchCount", cfg.PrefetchCount). + Msg("🔧 Worker pool configuration loaded") + + // Connect to RabbitMQ with worker pool support rabbit, err := rabbitmq.NewRabbitMQ( cfg.RabbitMQURL, cfg.ExchangeName, cfg.ConsumeQueue, cfg.PublishQueue, + cfg.PrefetchCount, // Match prefetch to worker count ) if err != nil { log.Fatal().Err(err).Msg("Failed to connect to RabbitMQ") } defer rabbit.Close() - // Create analyzer + // Create analyzer with worker pool anlzr := analyzer.NewAnalyzer(cfg, rabbit) // Context for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) - // Start analyzer in goroutine + // Start analyzer with worker pool in goroutine go func() { - if err := anlzr.Start(ctx); err != nil { + if err := anlzr.StartWithWorkerPool(ctx); err != nil { log.Error().Err(err).Msg("Analyzer stopped with error") } }() // Start HTTP server for health checks - router := setupRouter() + router := setupRouter(cfg) server := &http.Server{ Addr: ":" + cfg.Port, Handler: router, @@ -111,7 +121,7 @@ func setupLogger() { } } -func setupRouter() *gin.Engine { +func setupRouter(cfg *config.Config) *gin.Engine { if os.Getenv("ENV") == "production" { gin.SetMode(gin.ReleaseMode) } @@ -119,15 +129,17 @@ func setupRouter() *gin.Engine { router := gin.New() router.Use(gin.Recovery()) - // Health check + // Health check with worker pool info router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Project Analyzer is healthy", "data": gin.H{ - "service": "project-analyzer", - "version": "1.0.0", - "timestamp": time.Now().Format(time.RFC3339), + "service": "project-analyzer", + "version": "2.0.0-workerpool", + "timestamp": time.Now().Format(time.RFC3339), + "workerCount": cfg.WorkerCount, + "prefetchCount": cfg.PrefetchCount, }, }) }) @@ -136,7 +148,20 @@ func setupRouter() *gin.Engine { router.GET("/ready", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "Ready to analyze", + "message": "Ready to analyze with worker pool", + }) + }) + + // Metrics endpoint for worker pool stats + router.GET("/metrics", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Worker pool metrics", + "data": gin.H{ + "workerCount": cfg.WorkerCount, + "prefetchCount": cfg.PrefetchCount, + // Note: Actual runtime metrics would be added from analyzer.GetMetrics() + }, }) }) diff --git a/project-analyzer/internal/analyzer/analyzer.go b/project-analyzer/internal/analyzer/analyzer.go index 707770f..d4596de 100644 --- a/project-analyzer/internal/analyzer/analyzer.go +++ b/project-analyzer/internal/analyzer/analyzer.go @@ -3,6 +3,7 @@ package analyzer import ( "context" "encoding/json" + "fmt" "os" "path/filepath" "sync" @@ -18,6 +19,8 @@ import ( "github.com/verifydev/project-analyzer/internal/rabbitmq" "github.com/verifydev/project-analyzer/pkg/dimensions" "github.com/verifydev/project-analyzer/pkg/signals" + "github.com/verifydev/project-analyzer/pkg/trust" + "github.com/verifydev/project-analyzer/pkg/verdict" ) type Analyzer struct { @@ -34,7 +37,7 @@ func NewAnalyzer(cfg *config.Config, rabbit *rabbitmq.RabbitMQ) *Analyzer { } } -// Start begins consuming messages and analyzing projects +// Start begins consuming messages and analyzing projects (legacy single-threaded mode) func (a *Analyzer) Start(ctx context.Context) error { msgs, err := a.rabbit.Consume() if err != nil { @@ -60,6 +63,61 @@ func (a *Analyzer) Start(ctx context.Context) error { } } +// StartWithWorkerPool begins consuming messages with concurrent workers +// This is the recommended method for production use +func (a *Analyzer) StartWithWorkerPool(ctx context.Context) error { + msgs, err := a.rabbit.Consume() + if err != nil { + return err + } + + workerCount := a.config.WorkerCount + if workerCount <= 0 { + workerCount = 4 // Default fallback + } + + log.Info(). + Int("workerCount", workerCount). + Msg("🚀 Analyzer started with WORKER POOL - waiting for projects...") + + // Create a semaphore to limit concurrent workers + sem := make(chan struct{}, workerCount) + + // WaitGroup to track active workers for graceful shutdown + var wg sync.WaitGroup + + for { + select { + case <-ctx.Done(): + log.Info().Msg("Analyzer shutting down, waiting for active workers...") + wg.Wait() // Wait for all workers to finish + log.Info().Msg("All workers finished, shutdown complete") + return nil + + case msg, ok := <-msgs: + if !ok { + log.Warn().Msg("RabbitMQ channel closed") + wg.Wait() + return nil + } + + // Acquire semaphore slot (blocks if all workers are busy) + sem <- struct{}{} + wg.Add(1) + + // Process message in goroutine (worker) + go func(msg amqp.Delivery) { + defer func() { + <-sem // Release semaphore slot + wg.Done() + }() + + a.handleMessage(ctx, msg) + }(msg) + } + } +} + // handleMessage processes a single analyze request func (a *Analyzer) handleMessage(ctx context.Context, msg amqp.Delivery) { startTime := time.Now() @@ -363,10 +421,14 @@ func (a *Analyzer) analyze(ctx context.Context, req signals.AnalyzeRequest) (*si enrichVerdictWithDimensionalAnalysis(result, intelligenceResult, infraSignals) } - // Calculate final totals - for _, lang := range result.Languages { - result.TotalLines += lang.Lines - result.TotalFiles += lang.Files + // Calculate final totals (only set once — already computed in Phase 1) + // TotalLines was set in Phase 1 line ~244, TotalFiles was set by language parser. + // Only compute here if they haven't been set yet (defensive) + if result.TotalLines == 0 || result.TotalFiles == 0 { + for _, lang := range result.Languages { + result.TotalLines += lang.Lines + result.TotalFiles += lang.Files + } } // Filter signals based on project type for clean response @@ -509,6 +571,7 @@ func enrichTechStack(result *signals.ProjectSignals, infra *signals.Infrastructu // Frontend Frameworks "react": "React", "nextjs": "Next.js", "vue": "Vue.js", "angular": "Angular", "svelte": "Svelte", "tailwind": "Tailwind CSS", "redux": "Redux", "zustand": "Zustand", + "solidjs": "Solid.js", "preact": "Preact", "react_query": "React Query", "framer_motion": "Framer Motion", // Backend Frameworks @@ -562,6 +625,12 @@ func enrichTechStack(result *signals.ProjectSignals, infra *signals.Infrastructu if infra.HasSignal(signals.SignalReactQuery) { result.Frameworks = appendUnique(result.Frameworks, "React Query") } + if infra.HasSignal(signals.SignalSolidJS) { + result.Frameworks = appendUnique(result.Frameworks, "Solid.js") + } + if infra.HasSignal(signals.SignalPreact) { + result.Frameworks = appendUnique(result.Frameworks, "Preact") + } // Track what we've already added uniqueTech := make(map[string]bool) @@ -746,7 +815,12 @@ func mapToFastSignals(p *signals.ProjectSignals, infra *signals.InfrastructureSi // Infer Microservices if infra.HasSignal("multiple_services") || len(p.FolderStructure.TopLevelFolders) > 2 { fs.HasMicroservices = true - fs.ServiceCount = 2 // Minimal assumption + // Use actual service count from infra extraction, fallback to 2 + if infra.ServiceCount > 0 { + fs.ServiceCount = infra.ServiceCount + } else { + fs.ServiceCount = 2 // Minimal assumption when we can't determine exact count + } } // ML Markers @@ -873,6 +947,76 @@ func enrichVerdictWithDimensionalAnalysis( Float64("fundamentals", dimMatrix.Fundamentals.Score). Float64("engineering", dimMatrix.EngineeringDepth.Score). Msg("🎯 Dimensional analysis complete") + + // ============================================ + // TRUST ANALYSIS INTEGRATION + // ============================================ + var commitData *trust.CommitData + if result.GitForensics != nil { + commitData = &trust.CommitData{ + TotalCommits: result.GitForensics.CommitCount, + } + } + trustAnalyzer := trust.NewTrustAnalyzer(result, infraSignals, commitData) + trustResult := trustAnalyzer.Analyze() + if trustResult != nil { + result.IntelligenceVerdict.TrustAnalysis = &signals.TrustAnalysisDetailed{ + Score: trustResult.OverallTrust.Score, + Level: string(trustResult.OverallTrust.Classification), + EffortScore: trustResult.Effort.EffortScore, + EffortClass: string(trustResult.Effort.Classification), + AuthenticityScore: trustResult.Authenticity.AuthenticityScore, + IsLearning: trustResult.Learning.IsLikelyLearning, + LearningScore: trustResult.Learning.LearningScore * 100, + ConsistencyScore: trustResult.Consistency.ConsistencyScore, + HasOriginalWork: trustResult.Authenticity.AuthenticityScore >= 60, + Flags: extractTrustFlags(trustResult), + } + log.Info(). + Float64("trustScore", trustResult.OverallTrust.Score). + Str("trustLevel", string(trustResult.OverallTrust.Classification)). + Msg("🔒 Trust analysis complete") + } + + // ============================================ + // EXPERIENCE & VERDICT DETAILED INTEGRATION + // ============================================ + verdictGen := verdict.NewVerdictGenerator(dimMatrix) + verdictResult := verdictGen.Generate() + if verdictResult != nil { + // Map experience analysis + result.IntelligenceVerdict.ExperienceAnalysis = &signals.ExperienceAnalysis{ + Level: string(verdictResult.Experience.Level), + Confidence: verdictResult.Experience.Confidence, + YearsMin: int(verdictResult.Experience.EstimatedYears.Min), + YearsMax: int(verdictResult.Experience.EstimatedYears.Max), + YearsEstimate: verdictResult.Experience.EstimatedYears.Estimate, + MatchingFactors: verdictResult.Experience.SupportingSignals, + } + + // Map verdict detailed + strengthTexts := make([]string, 0, len(verdictResult.Strengths)) + for _, s := range verdictResult.Strengths { + strengthTexts = append(strengthTexts, s.Statement) + } + growthTexts := make([]string, 0, len(verdictResult.GrowthAreas)) + for _, g := range verdictResult.GrowthAreas { + growthTexts = append(growthTexts, g.Statement) + } + result.IntelligenceVerdict.VerdictDetailed = &signals.VerdictDetailed{ + Summary: verdictResult.Summary, + Strengths: strengthTexts, + GrowthAreas: growthTexts, + Cautions: verdictResult.Cautions, + Recommendation: verdictResult.HiringRecommendation, + } + log.Info(). + Str("experienceLevel", string(verdictResult.Experience.Level)). + Float64("experienceConfidence", verdictResult.Experience.Confidence). + Int("strengths", len(verdictResult.Strengths)). + Int("growthAreas", len(verdictResult.GrowthAreas)). + Msg("📋 Verdict generation complete") + } } // CRITICAL FIX: Sync Usage Verification from IntelligenceVerdict to IndustryAnalysis @@ -899,3 +1043,16 @@ func enrichVerdictWithDimensionalAnalysis( } } } + +// extractTrustFlags converts trust flags to string slice for JSON output +func extractTrustFlags(trustResult *trust.TrustAnalysis) []string { + flags := make([]string, 0, len(trustResult.Flags)) + for _, f := range trustResult.Flags { + flags = append(flags, fmt.Sprintf("[%s] %s", f.Type, f.Message)) + } + // Also include consistency issues as flags + for _, issue := range trustResult.Consistency.Issues { + flags = append(flags, fmt.Sprintf("[%s] %s: %s", issue.Severity, issue.Type, issue.Evidence)) + } + return flags +} diff --git a/project-analyzer/internal/analyzer/analyzer_test.go b/project-analyzer/internal/analyzer/analyzer_test.go deleted file mode 100644 index 2b98bd6..0000000 --- a/project-analyzer/internal/analyzer/analyzer_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package analyzer - -import ( - "testing" - - "github.com/verifydev/project-analyzer/pkg/signals" -) - -func TestFilterSignalsByProjectType_Frontend(t *testing.T) { - // Setup: A mixed result with backend noise - result := &signals.ProjectSignals{ - ProjectType: signals.ProjectTypeFrontend, - IndustryAnalysis: &signals.IndustryAnalysis{ - SkillsByCategory: map[signals.SkillCategory][]signals.VerifiedSkill{ - signals.CategoryDatabase: {{Name: "PostgreSQL"}}, - signals.CategoryFramework: {{Name: "React"}}, - }, - VerifiedSkills: []signals.VerifiedSkill{ - {Name: "PostgreSQL", Category: signals.CategoryDatabase}, - {Name: "React", Category: signals.CategoryFramework}, - }, - Architecture: signals.SystemArchitecture{ - Services: []string{"User Service"}, - }, - }, - Databases: []string{"PostgreSQL"}, - Tools: []string{"Kafka", "Jest"}, - NodeSignals: &signals.NodeSignals{ - // ProcessManagers removed as it doesn't exist in struct - MiddlewareCount: 2, - }, - } - - // Act - filterSignalsByProjectType(result, "frontend") - - // Assert: Backend signals should be GONE - if len(result.Databases) > 0 { - t.Errorf("Expected databases to be empty for frontend, got %v", result.Databases) - } - if len(result.IndustryAnalysis.VerifiedSkills) != 1 { - t.Errorf("Expected 1 skill (React), got %d", len(result.IndustryAnalysis.VerifiedSkills)) - } - if result.IndustryAnalysis.VerifiedSkills[0].Name == "PostgreSQL" { - t.Error("PostgreSQL should have been filtered out") - } - if _, ok := result.IndustryAnalysis.SkillsByCategory[signals.CategoryDatabase]; ok { - t.Error("Database category should be removed") - } - if len(result.IndustryAnalysis.Architecture.Services) > 0 { - // Wait, did I clear Architecture? Yes: result.IndustryAnalysis.Architecture = signals.SystemArchitecture{} - // But in test setup I set Services. The function clears the WHOLE struct. - // So checking Services should yield 0 (nil). - } else { - // It's fine. - } - - // Double check Services count - if len(result.IndustryAnalysis.Architecture.Services) > 0 { - t.Error("Architecture services should be cleared") - } - - if result.NodeSignals != nil { - t.Error("NodeSignals should be nil for frontend") - } -} - -func TestMapToFastSignals(t *testing.T) { - // Setup: Go Backend Project - p := &signals.ProjectSignals{ - PrimaryLanguage: "Go", - Languages: []signals.LanguageStats{ - {Name: "Go", Percentage: 90.0}, - {Name: "Makefile", Percentage: 10.0}, - }, - FolderStructure: signals.FolderAnalysis{ - HasCmd: true, - HasInternal: true, - HasPkg: true, - HasSrcFolder: false, - }, - CodeSignals: signals.CodeSignals{ - HasCI: true, - TestFilesCount: 5, - }, - } - - infra := &signals.InfrastructureSignals{} - infra.AddSignal(signals.SignalDockerCompose, 1.0, []string{"docker-compose.yml"}, "file") - - // Act - fs := mapToFastSignals(p, infra) - - // Assert - if fs.DominantLanguage != "Go" { - t.Errorf("Expected DominantLanguage Go, got %s", fs.DominantLanguage) - } - if !fs.HasInternalFolder { - t.Error("Expected HasInternalFolder to be true") - } - if !fs.HasCmdFolder { - t.Error("Expected HasCmdFolder to be true") - } - if !fs.HasDockerCompose { - t.Error("Expected HasDockerCompose to be true") - } -} diff --git a/project-analyzer/internal/config/config.go b/project-analyzer/internal/config/config.go index 6ba837d..45f4511 100644 --- a/project-analyzer/internal/config/config.go +++ b/project-analyzer/internal/config/config.go @@ -16,6 +16,9 @@ type Config struct { MaxRepoSizeMB int AnalysisTimeoutSec int GitHubToken string + // Worker Pool Configuration + WorkerCount int // Number of concurrent workers (default: 4) + PrefetchCount int // RabbitMQ prefetch count (default: 4, should match WorkerCount) } func Load() *Config { @@ -30,6 +33,9 @@ func Load() *Config { MaxRepoSizeMB: getEnvInt("MAX_REPO_SIZE_MB", 100), AnalysisTimeoutSec: getEnvInt("ANALYSIS_TIMEOUT_SEC", 120), GitHubToken: getEnv("GITHUB_TOKEN", ""), + // Worker Pool - 4 workers by default for optimal CPU utilization + WorkerCount: getEnvInt("WORKER_COUNT", 4), + PrefetchCount: getEnvInt("PREFETCH_COUNT", 4), } } diff --git a/project-analyzer/internal/intelligence/pipeline.go b/project-analyzer/internal/intelligence/pipeline.go index 6f6d04d..f016396 100644 --- a/project-analyzer/internal/intelligence/pipeline.go +++ b/project-analyzer/internal/intelligence/pipeline.go @@ -62,7 +62,7 @@ func NewPipeline(repoPath, niche, userProjectType string) *Pipeline { repoPath: repoPath, niche: niche, userProjectType: userProjectType, - timeout: 1 * time.Second, // Reduced from 2s for speed + timeout: 10 * time.Second, // Increased from 1s — 1s was too aggressive and caused premature termination } } @@ -520,8 +520,15 @@ func (p *Pipeline) extractSkills(signals *FastSignals, confidence *SignalConfide skills = append(skills, skill) } - // Infrastructure skills - if signals.HasDockerfile { + // Infrastructure skills — only add if NOT already in DetectedInfra (prevents duplicates) + dockerAlreadyAdded := false + for _, infra := range signals.DetectedInfra { + if infra == "Docker" || infra == "Docker & Containerization" { + dockerAlreadyAdded = true + } + } + + if signals.HasDockerfile && !dockerAlreadyAdded { skill := ExtractedSkill{ Name: "Docker", Category: "Infrastructure", @@ -635,7 +642,7 @@ func (p *Pipeline) extractSkills(signals *FastSignals, confidence *SignalConfide for i, cal := range calibratedSkills { // Update original skill confidence skills[i].Confidence = int(cal.Calibration.CalibratedScore) - skills[i].ResumeReady = skills[i].Confidence >= 40 // Strict 40% threshold based on usage + skills[i].ResumeReady = skills[i].Confidence >= 50 // Unified 50% threshold // Map multipliers for transparency if verdict, ok := usageVerdicts[skills[i].Name]; ok { diff --git a/project-analyzer/internal/intelligence/signal_scanner.go b/project-analyzer/internal/intelligence/signal_scanner.go index ed48b30..76705b9 100644 --- a/project-analyzer/internal/intelligence/signal_scanner.go +++ b/project-analyzer/internal/intelligence/signal_scanner.go @@ -196,6 +196,8 @@ func (s *SignalScanner) scanFileSystem() { s.manifestPaths = append(s.manifestPaths, path) case "package.json", "go.mod", "pom.xml": s.manifestPaths = append(s.manifestPaths, path) + case "schema.prisma": + s.manifestPaths = append(s.manifestPaths, path) } // CI detection @@ -295,7 +297,7 @@ func (s *SignalScanner) detectFrameworks() { if strings.Contains(fileContent, "\"prisma\"") || strings.Contains(fileContent, "\"@prisma/client\"") { seenFrameworks["Prisma"] = true seenDatabases["Prisma"] = true - seenDatabases["PostgreSQL"] = true // Prisma usually implies SQL + // Don't assume PostgreSQL — detect actual DB from schema.prisma provider } if strings.Contains(fileContent, "\"mongoose\"") || strings.Contains(fileContent, "\"mongodb\"") { seenDatabases["MongoDB"] = true @@ -386,6 +388,29 @@ func (s *SignalScanner) detectFrameworks() { strings.Contains(fileContent, "sklearn") || strings.Contains(fileContent, "keras") { s.signals.HasMLMarkers = true } + + case "schema.prisma": + // Detect actual database provider from Prisma schema + seenFrameworks["Prisma"] = true + seenDatabases["Prisma"] = true + if strings.Contains(lowerContent, "provider = \"postgresql\"") || strings.Contains(lowerContent, "provider = \"postgres\"") { + seenDatabases["PostgreSQL"] = true + } + if strings.Contains(lowerContent, "provider = \"mysql\"") { + seenDatabases["MySQL"] = true + } + if strings.Contains(lowerContent, "provider = \"mongodb\"") { + seenDatabases["MongoDB"] = true + } + if strings.Contains(lowerContent, "provider = \"sqlite\"") { + seenDatabases["SQLite"] = true + } + if strings.Contains(lowerContent, "provider = \"sqlserver\"") { + seenDatabases["SQL Server"] = true + } + if strings.Contains(lowerContent, "provider = \"cockroachdb\"") { + seenDatabases["CockroachDB"] = true + } } } diff --git a/project-analyzer/internal/intelligence/types.go b/project-analyzer/internal/intelligence/types.go index b76100c..c3919dc 100644 --- a/project-analyzer/internal/intelligence/types.go +++ b/project-analyzer/internal/intelligence/types.go @@ -305,8 +305,8 @@ func (s *ExtractedSkill) ComputeConfidence() { Msg("✨ Evidence boost applied") } - // Resume-ready if confidence > 60 and has evidence - s.ResumeReady = s.Confidence >= 60 && len(s.Evidence) > 0 + // Resume-ready if confidence >= 50 and has evidence (unified threshold) + s.ResumeReady = s.Confidence >= 50 && len(s.Evidence) > 0 } // ============================================ diff --git a/project-analyzer/internal/intelligence/verdict_engine.go b/project-analyzer/internal/intelligence/verdict_engine.go index 4ba1333..ec267f8 100644 --- a/project-analyzer/internal/intelligence/verdict_engine.go +++ b/project-analyzer/internal/intelligence/verdict_engine.go @@ -191,21 +191,21 @@ func (e *VerdictEngine) calculateOverallScore() float64 { baseScore += 2 } - // Penalties REMOVED for user happiness - // if !e.signals.HasTests && e.signals.CodeFiles > 10 { - // baseScore -= 10 - // } - // if !e.signals.HasCI && e.intent == IntentProduction { - // baseScore -= 5 - // } + // Penalties — restored with balanced values for accuracy + if !e.signals.HasTests && e.signals.CodeFiles > 10 { + baseScore -= 5 // Reduced from -10, still meaningful + } + if !e.signals.HasCI && e.intent == IntentProduction { + baseScore -= 3 // Reduced from -5 + } // Authenticity Adjustments (Git Forensics) if e.authorship != nil { if e.authorship.Level == "ORGANIC" && e.authorship.Confidence == "HIGH" { - baseScore += 20 // Major boost for verified organic growth + baseScore += 10 // Boost for verified organic growth (reduced from +20 to prevent inflation) } if e.authorship.Level == "SNAPSHOT" { - baseScore -= 40 // Major penalty for dump/fake projects + baseScore -= 25 // Penalty for dump/fake projects (reduced from -40 for less volatility) } } diff --git a/project-analyzer/internal/parser/advanced.go b/project-analyzer/internal/parser/advanced.go index a5b4645..69e059f 100644 --- a/project-analyzer/internal/parser/advanced.go +++ b/project-analyzer/internal/parser/advanced.go @@ -9,6 +9,18 @@ import ( "github.com/verifydev/project-analyzer/pkg/signals" ) +// Pre-compiled regex patterns (avoid recompiling per-file — was O(n×m) CPU waste) +var ( + reRESTPattern = regexp.MustCompile(`(app\.(get|post|put|delete|patch)|router\.(get|post|put|delete))`) + rePyTypeHints = regexp.MustCompile(`def\s+\w+\([^)]*:\s*\w+`) + rePyDecorators = regexp.MustCompile(`@\w+`) + rePyListComp = regexp.MustCompile(`\[\s*\w+\s+for\s+`) + rePyDictComp = regexp.MustCompile(`{\s*\w+:\s*\w+\s+for\s+`) + reGoInterface = regexp.MustCompile(`type\s+\w+\s+interface`) + reGoGoroutine = regexp.MustCompile(`go\s+\w+\(`) + reNodeRoutes = regexp.MustCompile(`\.(get|post|put|delete|patch)\s*\(`) +) + // AnalyzeAdvancedPatterns detects advanced coding patterns func (p *FileParser) AnalyzeAdvancedPatterns() *signals.AdvancedPatterns { ap := &signals.AdvancedPatterns{} @@ -76,7 +88,7 @@ func (p *FileParser) AnalyzeAdvancedPatterns() *signals.AdvancedPatterns { } // API Patterns - if regexp.MustCompile(`(app\.(get|post|put|delete|patch)|router\.(get|post|put|delete))`).MatchString(text) { + if reRESTPattern.MatchString(text) { ap.UsesREST = true } if strings.Contains(text, "graphql") || strings.Contains(text, "GraphQL") || @@ -283,7 +295,7 @@ func (p *FileParser) AnalyzePython() *signals.PythonSignals { text := string(content) // Type hints - if regexp.MustCompile(`def\s+\w+\([^)]*:\s*\w+`).MatchString(text) || + if rePyTypeHints.MatchString(text) || strings.Contains(text, "-> ") { ps.UsesTypeHints = true } @@ -304,7 +316,7 @@ func (p *FileParser) AnalyzePython() *signals.PythonSignals { } // Decorators - if regexp.MustCompile(`@\w+`).MatchString(text) { + if rePyDecorators.MatchString(text) { ps.UsesDecorators = true } @@ -319,8 +331,8 @@ func (p *FileParser) AnalyzePython() *signals.PythonSignals { } // Comprehensions - if regexp.MustCompile(`\[\s*\w+\s+for\s+`).MatchString(text) || - regexp.MustCompile(`{\s*\w+:\s*\w+\s+for\s+`).MatchString(text) { + if rePyListComp.MatchString(text) || + rePyDictComp.MatchString(text) { ps.UsesComprehensions = true } @@ -399,12 +411,12 @@ func (p *FileParser) AnalyzeGo() *signals.GoSignals { text := string(content) // Interfaces - if regexp.MustCompile(`type\s+\w+\s+interface`).MatchString(text) { + if reGoInterface.MatchString(text) { gs.UsesInterfaces = true } // Goroutines - if strings.Contains(text, "go ") && regexp.MustCompile(`go\s+\w+\(`).MatchString(text) { + if strings.Contains(text, "go ") && reGoGoroutine.MatchString(text) { gs.UsesGoroutines = true } @@ -522,7 +534,7 @@ func (p *FileParser) AnalyzeNode() *signals.NodeSignals { // Routes detection if strings.Contains(path, "routes") || strings.Contains(path, "router") { - matches := regexp.MustCompile(`\.(get|post|put|delete|patch)\s*\(`).FindAllStringIndex(fileText, -1) + matches := reNodeRoutes.FindAllStringIndex(fileText, -1) routesCount += len(matches) } diff --git a/project-analyzer/internal/parser/git_forensics.go b/project-analyzer/internal/parser/git_forensics.go index 650ec70..73078cc 100644 --- a/project-analyzer/internal/parser/git_forensics.go +++ b/project-analyzer/internal/parser/git_forensics.go @@ -231,15 +231,23 @@ func (g *GitAnalyzer) parseCommits(logData string) []commitInfo { // Parse stat line: " 5 files changed, 100 insertions(+), 50 deletions(-)" // We want total changes (insertions + deletions) as a proxy for "Diff Size" if strings.Contains(line, "changed") && (strings.Contains(line, "insertion") || strings.Contains(line, "deletion")) { - // Regex to extract numbers - re := regexp.MustCompile(`(\d+) insertion`) - insMatch := re.FindStringSubmatch(line) + // Extract insertions + reIns := regexp.MustCompile(`(\d+) insertion`) + insMatch := reIns.FindStringSubmatch(line) insertions := 0 if len(insMatch) > 1 { insertions, _ = strconv.Atoi(insMatch[1]) } - currentCommit.DiffLines += insertions + // Extract deletions (was missing — caused DiffLines to be ~50% too low) + reDel := regexp.MustCompile(`(\d+) deletion`) + delMatch := reDel.FindStringSubmatch(line) + deletions := 0 + if len(delMatch) > 1 { + deletions, _ = strconv.Atoi(delMatch[1]) + } + + currentCommit.DiffLines += insertions + deletions } } } diff --git a/project-analyzer/internal/parser/infra_extractor.go b/project-analyzer/internal/parser/infra_extractor.go index 3d959a2..1c7d341 100644 --- a/project-analyzer/internal/parser/infra_extractor.go +++ b/project-analyzer/internal/parser/infra_extractor.go @@ -183,7 +183,7 @@ func (e *InfraExtractor) extractRootFileSignals() { "kubernetes": signals.SignalKubernetes, "k8s": signals.SignalKubernetes, "helm": signals.SignalHelm, - "Makefile": signals.SignalDocker, // Often indicates build automation + "Makefile": signals.SignalBuildAutomation, // Build automation, not Docker ".travis.yml": signals.SignalTravisCI, "Jenkinsfile": signals.SignalJenkins, ".circleci": signals.SignalCircleCI, @@ -289,15 +289,16 @@ func (e *InfraExtractor) extractConfigSignals() { func (e *InfraExtractor) analyzeEnvFiles(files []string) { envPatterns := map[string]signals.InfraSignal{ // Databases - "DATABASE_URL": signals.SignalPostgres, - "POSTGRES": signals.SignalPostgres, - "PG_": signals.SignalPostgres, - "MYSQL": signals.SignalMySQL, - "MONGODB": signals.SignalMongoDB, - "MONGO_URI": signals.SignalMongoDB, - "REDIS_URL": signals.SignalRedis, - "REDIS_HOST": signals.SignalRedis, - "REDIS": signals.SignalRedis, + // DATABASE_URL is generic — don't assume PostgreSQL, only flag specific patterns + // "DATABASE_URL": removed — was causing false PostgreSQL detection + "POSTGRES": signals.SignalPostgres, + "PG_": signals.SignalPostgres, + "MYSQL": signals.SignalMySQL, + "MONGODB": signals.SignalMongoDB, + "MONGO_URI": signals.SignalMongoDB, + "REDIS_URL": signals.SignalRedis, + "REDIS_HOST": signals.SignalRedis, + "REDIS": signals.SignalRedis, // Message Queues "RABBITMQ": signals.SignalRabbitMQ, @@ -333,10 +334,10 @@ func (e *InfraExtractor) analyzeEnvFiles(files []string) { "PROMETHEUS": signals.SignalPrometheus, "GRAFANA": signals.SignalGrafana, - // Third-party Services - "STRIPE": signals.SignalAWS, // Using AWS as placeholder for integrations - "TWILIO": signals.SignalAWS, - "SENDGRID": signals.SignalAWS, + // Third-party Services (no longer mislabeled as AWS) + "STRIPE": signals.SignalThirdPartyIntegration, + "TWILIO": signals.SignalThirdPartyIntegration, + "SENDGRID": signals.SignalThirdPartyIntegration, // Search & Analytics "ELASTICSEARCH": signals.SignalElasticsearch, diff --git a/project-analyzer/internal/parser/infra_extractor_node.go b/project-analyzer/internal/parser/infra_extractor_node.go index c396544..ac1922b 100644 --- a/project-analyzer/internal/parser/infra_extractor_node.go +++ b/project-analyzer/internal/parser/infra_extractor_node.go @@ -43,8 +43,8 @@ func (e *InfraExtractor) scanServicePackageJSON(servicePath, serviceName string) "nuxt": signals.SignalVue, "@angular/core": signals.SignalAngular, "svelte": signals.SignalSvelte, - "solid-js": signals.SignalReact, - "preact": signals.SignalReact, + "solid-js": signals.SignalSolidJS, + "preact": signals.SignalPreact, // ===== BUILD TOOLS & BUNDLERS ===== // Note: vite, webpack, esbuild are framework-agnostic - don't assume React diff --git a/project-analyzer/internal/parser/infra_extractor_python.go b/project-analyzer/internal/parser/infra_extractor_python.go index acae696..bea5b38 100644 --- a/project-analyzer/internal/parser/infra_extractor_python.go +++ b/project-analyzer/internal/parser/infra_extractor_python.go @@ -83,10 +83,29 @@ func (e *InfraExtractor) analyzePythonDependencies(content, serviceName, sourceF "azure-storage": signals.SignalAzure, } - for pattern, signal := range depSignals { - if strings.Contains(contentStr, strings.ToLower(pattern)) { - evidence := fmt.Sprintf("%s/%s → %s", serviceName, sourceFile, pattern) - e.signals.AddSignal(signal, 0.9, []string{evidence}, "deep_service_scan") + // Line-based matching to prevent partial matches (e.g., "redis" matching "redis-lock") + lines := strings.Split(contentStr, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Extract package name (before ==, >=, ~=, [, etc.) + pkgName := line + for _, sep := range []string{"==", ">=", "<=", "~=", "!=", "[", ">", "<", " "} { + if idx := strings.Index(pkgName, sep); idx > 0 { + pkgName = pkgName[:idx] + } + } + pkgName = strings.TrimSpace(pkgName) + if pkgName == "" { + continue + } + for pattern, signal := range depSignals { + if pkgName == strings.ToLower(pattern) { + evidence := fmt.Sprintf("%s/%s → %s", serviceName, sourceFile, pattern) + e.signals.AddSignal(signal, 0.9, []string{evidence}, "deep_service_scan") + } } } } diff --git a/project-analyzer/internal/parser/language_detector.go b/project-analyzer/internal/parser/language_detector.go index a93350f..6bf14ed 100644 --- a/project-analyzer/internal/parser/language_detector.go +++ b/project-analyzer/internal/parser/language_detector.go @@ -19,6 +19,7 @@ import ( // LanguageDetector performs accurate language detection using go-enry type LanguageDetector struct { repoPath string + cache *LanguageDistribution // Cache result to avoid redundant scans } // LanguageDistribution contains language analysis results @@ -43,8 +44,13 @@ func NewLanguageDetector(repoPath string) *LanguageDetector { } } -// Detect performs accurate language detection +// Detect performs accurate language detection (cached — safe to call multiple times) func (d *LanguageDetector) Detect() (*LanguageDistribution, error) { + // Return cached result if available (was called 4+ times per analysis) + if d.cache != nil { + return d.cache, nil + } + log.Info().Str("path", d.repoPath).Msg("Starting enhanced language detection") dist := &LanguageDistribution{ @@ -148,6 +154,9 @@ func (d *LanguageDetector) Detect() (*LanguageDistribution, error) { Float64("accuracy", dist.AccuracyScore). Msg("Language detection completed") + // Cache result to avoid redundant scans + d.cache = dist + return dist, nil } diff --git a/project-analyzer/internal/rabbitmq/client.go b/project-analyzer/internal/rabbitmq/client.go index fa7aa10..6a3475f 100644 --- a/project-analyzer/internal/rabbitmq/client.go +++ b/project-analyzer/internal/rabbitmq/client.go @@ -11,15 +11,17 @@ import ( ) type RabbitMQ struct { - conn *amqp.Connection - channel *amqp.Channel - exchangeName string - consumeQueue string - publishQueue string + conn *amqp.Connection + channel *amqp.Channel + exchangeName string + consumeQueue string + publishQueue string + prefetchCount int // Number of messages to prefetch (should match worker count) } // NewRabbitMQ creates a new RabbitMQ connection -func NewRabbitMQ(url, exchangeName, consumeQueue, publishQueue string) (*RabbitMQ, error) { +// prefetchCount should match the number of workers for optimal performance +func NewRabbitMQ(url, exchangeName, consumeQueue, publishQueue string, prefetchCount int) (*RabbitMQ, error) { conn, err := amqp.Dial(url) if err != nil { return nil, fmt.Errorf("failed to connect to RabbitMQ: %w", err) @@ -31,12 +33,18 @@ func NewRabbitMQ(url, exchangeName, consumeQueue, publishQueue string) (*RabbitM return nil, fmt.Errorf("failed to open channel: %w", err) } + // Default to 4 if not specified + if prefetchCount <= 0 { + prefetchCount = 4 + } + rmq := &RabbitMQ{ - conn: conn, - channel: ch, - exchangeName: exchangeName, - consumeQueue: consumeQueue, - publishQueue: publishQueue, + conn: conn, + channel: ch, + exchangeName: exchangeName, + consumeQueue: consumeQueue, + publishQueue: publishQueue, + prefetchCount: prefetchCount, } // Setup exchange and queues @@ -45,7 +53,9 @@ func NewRabbitMQ(url, exchangeName, consumeQueue, publishQueue string) (*RabbitM return nil, err } - log.Info().Msg("✅ RabbitMQ connected") + log.Info(). + Int("prefetchCount", prefetchCount). + Msg("✅ RabbitMQ connected with worker pool support") return rmq, nil } @@ -155,11 +165,11 @@ func (r *RabbitMQ) setup() error { // Consume starts consuming messages from the consume queue func (r *RabbitMQ) Consume() (<-chan amqp.Delivery, error) { - // Set QoS - process one message at a time + // Set QoS - prefetch count matches worker pool size for optimal throughput err := r.channel.Qos( - 1, // prefetch count - 0, // prefetch size - false, // global + r.prefetchCount, // prefetch count (matches worker count) + 0, // prefetch size + false, // global ) if err != nil { return nil, fmt.Errorf("failed to set QoS: %w", err) @@ -178,7 +188,10 @@ func (r *RabbitMQ) Consume() (<-chan amqp.Delivery, error) { return nil, fmt.Errorf("failed to register consumer: %w", err) } - log.Info().Str("queue", r.consumeQueue).Msg("Started consuming messages") + log.Info(). + Str("queue", r.consumeQueue). + Int("prefetchCount", r.prefetchCount). + Msg("🚀 Started consuming messages with worker pool") return msgs, nil } diff --git a/project-analyzer/internal/workerpool/pool.go b/project-analyzer/internal/workerpool/pool.go new file mode 100644 index 0000000..f790d8e --- /dev/null +++ b/project-analyzer/internal/workerpool/pool.go @@ -0,0 +1,289 @@ +package workerpool + +import ( + "context" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// Job represents a unit of work to be processed +type Job struct { + ID string + Payload interface{} +} + +// Result represents the outcome of processing a job +type Result struct { + JobID string + Success bool + Error error + Duration time.Duration +} + +// WorkerFunc is the function signature for job processing +type WorkerFunc func(ctx context.Context, job Job) Result + +// Pool manages a pool of worker goroutines +type Pool struct { + name string + workerCount int + jobQueue chan Job + results chan Result + workerFunc WorkerFunc + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + metrics *Metrics +} + +// Metrics tracks worker pool statistics +type Metrics struct { + mu sync.RWMutex + TotalJobs int64 + CompletedJobs int64 + FailedJobs int64 + ActiveWorkers int + AvgDuration time.Duration + totalDuration time.Duration +} + +// NewMetrics creates a new Metrics instance +func NewMetrics() *Metrics { + return &Metrics{} +} + +// IncrementActive safely increments active workers +func (m *Metrics) IncrementActive() { + m.mu.Lock() + m.ActiveWorkers++ + m.mu.Unlock() +} + +// DecrementActive safely decrements active workers +func (m *Metrics) DecrementActive() { + m.mu.Lock() + m.ActiveWorkers-- + m.mu.Unlock() +} + +// RecordJob records a completed job +func (m *Metrics) RecordJob(success bool, duration time.Duration) { + m.mu.Lock() + defer m.mu.Unlock() + + m.TotalJobs++ + m.totalDuration += duration + + if success { + m.CompletedJobs++ + } else { + m.FailedJobs++ + } + + // Recalculate average + if m.TotalJobs > 0 { + m.AvgDuration = m.totalDuration / time.Duration(m.TotalJobs) + } +} + +// GetStats returns current metrics +func (m *Metrics) GetStats() (total, completed, failed int64, active int, avgDur time.Duration) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.TotalJobs, m.CompletedJobs, m.FailedJobs, m.ActiveWorkers, m.AvgDuration +} + +// Config holds worker pool configuration +type Config struct { + Name string + WorkerCount int + QueueSize int + ResultsBuffer int +} + +// DefaultConfig returns sensible defaults +func DefaultConfig(name string) Config { + return Config{ + Name: name, + WorkerCount: 4, // Default 4 concurrent workers + QueueSize: 100, // Buffer up to 100 jobs + ResultsBuffer: 100, + } +} + +// New creates a new worker pool +func New(cfg Config, workerFunc WorkerFunc) *Pool { + ctx, cancel := context.WithCancel(context.Background()) + + return &Pool{ + name: cfg.Name, + workerCount: cfg.WorkerCount, + jobQueue: make(chan Job, cfg.QueueSize), + results: make(chan Result, cfg.ResultsBuffer), + workerFunc: workerFunc, + ctx: ctx, + cancel: cancel, + metrics: NewMetrics(), + } +} + +// Start initializes and starts all workers +func (p *Pool) Start() { + log.Info(). + Str("pool", p.name). + Int("workers", p.workerCount). + Msg("🚀 Starting worker pool") + + for i := 0; i < p.workerCount; i++ { + p.wg.Add(1) + go p.worker(i) + } + + // Start metrics logger + go p.logMetrics() +} + +// worker is the main worker goroutine +func (p *Pool) worker(id int) { + defer p.wg.Done() + + log.Debug(). + Str("pool", p.name). + Int("workerId", id). + Msg("Worker started") + + for { + select { + case <-p.ctx.Done(): + log.Debug(). + Str("pool", p.name). + Int("workerId", id). + Msg("Worker shutting down") + return + + case job, ok := <-p.jobQueue: + if !ok { + // Channel closed + return + } + + // Track active workers + p.metrics.IncrementActive() + + // Process job + start := time.Now() + result := p.workerFunc(p.ctx, job) + result.Duration = time.Since(start) + result.JobID = job.ID + + // Record metrics + p.metrics.RecordJob(result.Success, result.Duration) + p.metrics.DecrementActive() + + // Send result (non-blocking with timeout) + select { + case p.results <- result: + case <-time.After(5 * time.Second): + log.Warn(). + Str("pool", p.name). + Str("jobId", job.ID). + Msg("Result channel full, dropping result") + } + } + } +} + +// Submit adds a job to the queue +// Returns false if the pool is full or shutting down +func (p *Pool) Submit(job Job) bool { + select { + case <-p.ctx.Done(): + return false + case p.jobQueue <- job: + return true + default: + // Queue is full + log.Warn(). + Str("pool", p.name). + Str("jobId", job.ID). + Msg("Job queue full, dropping job") + return false + } +} + +// SubmitWait adds a job and waits if queue is full +func (p *Pool) SubmitWait(job Job) bool { + select { + case <-p.ctx.Done(): + return false + case p.jobQueue <- job: + return true + } +} + +// Results returns the results channel for reading +func (p *Pool) Results() <-chan Result { + return p.results +} + +// Stop gracefully shuts down the worker pool +func (p *Pool) Stop() { + log.Info(). + Str("pool", p.name). + Msg("Stopping worker pool...") + + // Signal workers to stop + p.cancel() + + // Wait for workers to finish current jobs + p.wg.Wait() + + // Close channels + close(p.jobQueue) + close(p.results) + + total, completed, failed, _, avgDur := p.metrics.GetStats() + log.Info(). + Str("pool", p.name). + Int64("totalJobs", total). + Int64("completed", completed). + Int64("failed", failed). + Dur("avgDuration", avgDur). + Msg("Worker pool stopped") +} + +// logMetrics periodically logs pool statistics +func (p *Pool) logMetrics() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-p.ctx.Done(): + return + case <-ticker.C: + total, completed, failed, active, avgDur := p.metrics.GetStats() + log.Info(). + Str("pool", p.name). + Int64("total", total). + Int64("completed", completed). + Int64("failed", failed). + Int("active", active). + Dur("avgDuration", avgDur). + Int("queueLen", len(p.jobQueue)). + Msg("📊 Worker pool metrics") + } + } +} + +// QueueLength returns current queue size +func (p *Pool) QueueLength() int { + return len(p.jobQueue) +} + +// GetMetrics returns current metrics snapshot +func (p *Pool) GetMetrics() (total, completed, failed int64, active int, avgDur time.Duration) { + return p.metrics.GetStats() +} diff --git a/project-analyzer/pkg/matching/matcher.go b/project-analyzer/pkg/matching/matcher.go index fffe640..9c05156 100644 --- a/project-analyzer/pkg/matching/matcher.go +++ b/project-analyzer/pkg/matching/matcher.go @@ -3,6 +3,7 @@ package matching import ( "fmt" "sort" + "strings" "github.com/verifydev/project-analyzer/pkg/dimensions" "github.com/verifydev/project-analyzer/pkg/verdict" @@ -444,8 +445,12 @@ func categorizeFit(score float64) FitCategory { } func normalizeSkillName(name string) string { - // Simple normalization - lowercase and remove spaces - return name // In production, add proper normalization + // Normalize: lowercase, trim spaces, replace special chars for consistent matching + normalized := strings.ToLower(strings.TrimSpace(name)) + normalized = strings.ReplaceAll(normalized, " ", "-") + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, ".", "") + return normalized } func proficiencyMeets(have, need string) bool { diff --git a/project-analyzer/pkg/signals/infrastructure.go b/project-analyzer/pkg/signals/infrastructure.go index fc6c776..f1342c8 100644 --- a/project-analyzer/pkg/signals/infrastructure.go +++ b/project-analyzer/pkg/signals/infrastructure.go @@ -321,10 +321,12 @@ const ( SignalRollback InfraSignal = "rollback" // Project Quality Signals - SignalIncompleteProject InfraSignal = "incomplete_project" - SignalMonorepo InfraSignal = "monorepo" - SignalFrontendOnly InfraSignal = "frontend_only" - SignalBackendOnly InfraSignal = "backend_only" + SignalIncompleteProject InfraSignal = "incomplete_project" + SignalMonorepo InfraSignal = "monorepo" + SignalFrontendOnly InfraSignal = "frontend_only" + SignalBackendOnly InfraSignal = "backend_only" + SignalBuildAutomation InfraSignal = "build_automation" + SignalThirdPartyIntegration InfraSignal = "third_party_integration" // Frontend & Frameworks (Verification Targets) SignalReact InfraSignal = "react" @@ -333,6 +335,8 @@ const ( SignalVue InfraSignal = "vue" SignalAngular InfraSignal = "angular" SignalSvelte InfraSignal = "svelte" + SignalSolidJS InfraSignal = "solidjs" + SignalPreact InfraSignal = "preact" SignalRedux InfraSignal = "redux" SignalZustand InfraSignal = "zustand" SignalReactQuery InfraSignal = "react_query" diff --git a/project-analyzer/pkg/signals/types.go b/project-analyzer/pkg/signals/types.go index de7da66..1e2b184 100644 --- a/project-analyzer/pkg/signals/types.go +++ b/project-analyzer/pkg/signals/types.go @@ -402,6 +402,7 @@ type TrustAnalysisDetailed struct { EffortScore float64 `json:"effortScore"` // 0-100 EffortClass string `json:"effortClass"` // TRIVIAL/MODEST/SUBSTANTIAL/IMPRESSIVE AuthenticityScore float64 `json:"authenticityScore"` // 0-100 + HasOriginalWork bool `json:"hasOriginalWork"` // True if authenticity score >= 60 IsLearning bool `json:"isLearning"` LearningScore float64 `json:"learningScore"` // 0-100 ConsistencyScore float64 `json:"consistencyScore"` // 0-100 diff --git a/project-analyzer/pkg/trust/analyzer.go b/project-analyzer/pkg/trust/analyzer.go index 5900c74..899592b 100644 --- a/project-analyzer/pkg/trust/analyzer.go +++ b/project-analyzer/pkg/trust/analyzer.go @@ -220,9 +220,19 @@ func (ta *TrustAnalyzer) analyzeAuthenticity() AuthenticityAnalysis { } // Check for tutorial/boilerplate indicators using project type + // NOTE: Only check truly generic/template types, NOT valid project type classifications if ta.Signals != nil { projectType := string(ta.Signals.ProjectType) - if isGenericProjectName(projectType) { + // Only penalize explicitly tutorial/sample project types + tutorialTypes := []string{"unknown"} + isTutorial := false + for _, t := range tutorialTypes { + if strings.ToLower(projectType) == t { + isTutorial = true + break + } + } + if isTutorial { result.AuthenticityScore -= 10 result.Signals = append(result.Signals, AuthenticitySignal{ Type: SignalNegative, @@ -360,43 +370,98 @@ func (ta *TrustAnalyzer) analyzeConsistency() ConsistencyAnalysis { // Check for consistent infrastructure if ta.Infra != nil && ta.Signals != nil { - // Having tests but no CI is mildly inconsistent + // Having tests but no CI is inconsistent for production-grade projects if ta.Signals.FolderStructure.HasTests { if !ta.Infra.HasSignal(signals.SignalGitHubActions) && !ta.Infra.HasSignal(signals.SignalGitLabCI) { - score -= 5 + score -= 8 result.Issues = append(result.Issues, ConsistencyIssue{ Type: "tests_without_ci", - Severity: "low", - Evidence: "Has tests but no CI pipeline", + Severity: "medium", + Evidence: "Has tests but no CI pipeline to run them", }) } } - // Docker without compose or compose without docker is slightly odd + // Docker without compose or compose without docker hasDocker := ta.Infra.HasSignal(signals.SignalDocker) hasCompose := ta.Infra.HasSignal(signals.SignalDockerCompose) if hasCompose && !hasDocker { - score -= 3 + score -= 5 result.Issues = append(result.Issues, ConsistencyIssue{ Type: "compose_without_dockerfile", Severity: "low", Evidence: "Has docker-compose but no Dockerfile", }) } + + // Production signals without tests — significant inconsistency + hasProductionInfra := hasDocker || ta.Infra.HasSignal(signals.SignalKubernetes) || ta.Infra.HasSignal(signals.SignalAWS) || ta.Infra.HasSignal(signals.SignalGCP) + if hasProductionInfra && !ta.Signals.FolderStructure.HasTests { + score -= 15 + result.Issues = append(result.Issues, ConsistencyIssue{ + Type: "production_without_tests", + Severity: "high", + Evidence: "Has production infrastructure but no test directory", + }) + } + + // Has monitoring but no CI pipeline — inconsistent ops maturity + hasMonitoring := ta.Infra.HasSignal(signals.SignalPrometheus) || ta.Infra.HasSignal(signals.SignalGrafana) || ta.Infra.HasSignal(signals.SignalDatadog) + if hasMonitoring && !ta.Signals.CodeSignals.HasCI { + score -= 8 + result.Issues = append(result.Issues, ConsistencyIssue{ + Type: "monitoring_without_ci", + Severity: "medium", + Evidence: "Has monitoring setup but no CI/CD pipeline", + }) + } } // Check code quality consistency if ta.Signals != nil { code := ta.Signals.CodeSignals - // TypeScript project without types folder - if code.HasTypeScript && !ta.Signals.FolderStructure.HasTypes { - // This is minor, types can be co-located + // TypeScript project without strict typing indicators + if code.HasTypeScript && !ta.Signals.FolderStructure.HasTypes && !code.HasLinting { + score -= 5 + result.Issues = append(result.Issues, ConsistencyIssue{ + Type: "typescript_without_strictness", + Severity: "low", + Evidence: "Uses TypeScript but no types folder or linting configured", + }) } - // Has linting but not prettier (or vice versa) - minor inconsistency - if code.HasLinting != code.HasPrettier { - // Not really an issue, just noting + // No README in a non-trivial project + if ta.Signals.TotalFiles > 10 && !ta.Signals.FolderStructure.HasDocs { + score -= 5 + result.Issues = append(result.Issues, ConsistencyIssue{ + Type: "no_documentation", + Severity: "low", + Evidence: "Non-trivial project with no documentation folder", + }) + } + + // API project without middleware + if ta.Signals.FolderStructure.HasAPI && !ta.Signals.FolderStructure.HasMiddleware { + score -= 5 + result.Issues = append(result.Issues, ConsistencyIssue{ + Type: "api_without_middleware", + Severity: "low", + Evidence: "API project without middleware layer", + }) + } + } + + // Check commit pattern consistency + if ta.CommitData != nil && ta.CommitData.TotalCommits > 0 { + // Single commit for a large project is suspicious + if ta.CommitData.TotalCommits == 1 && ta.Signals != nil && ta.Signals.TotalFiles > 20 { + score -= 10 + result.Issues = append(result.Issues, ConsistencyIssue{ + Type: "single_commit_large_project", + Severity: "high", + Evidence: "Large project with only a single commit — likely squashed or copied", + }) } } diff --git a/project-analyzer/tests/e2e/main_test.go b/project-analyzer/tests/e2e/main_test.go deleted file mode 100644 index f1b67ac..0000000 --- a/project-analyzer/tests/e2e/main_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package e2e - -import ( - "path/filepath" - "runtime" - "testing" - - "github.com/verifydev/project-analyzer/internal/parser" - "github.com/verifydev/project-analyzer/pkg/signals" -) - -func getFixturePath(fixtureName string) string { - _, filename, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(filepath.Dir(filename)), "fixtures", fixtureName) -} - -func TestGhostReact(t *testing.T) { - path := getFixturePath("ghost-react") - - infraExtractor := parser.NewInfraExtractor(path, "") - infraSignals := infraExtractor.Extract() - t.Logf("Signals: %v", infraSignals.Signals) - - found := false - for _, sig := range infraSignals.Signals { - if sig == signals.SignalReact { - found = true - break - } - } - if !found { - t.Error("BLIND SPOT: React not detected from .jsx files alone") - } else { - t.Log("✅ React detected from .jsx files") - } -} - -func TestGhostBackends(t *testing.T) { - // Laravel - pathLaravel := getFixturePath("ghost-laravel") - extractorLaravel := parser.NewInfraExtractor(pathLaravel, "") - signalsLaravel := extractorLaravel.Extract() - foundLaravel := false - for _, sig := range signalsLaravel.Signals { - if sig == signals.SignalLaravel { - foundLaravel = true - break - } - } - if !foundLaravel { - t.Error("BLIND SPOT: Laravel not detected from artisan file") - } else { - t.Log("✅ Laravel detected from artisan file") - } - - // Rails - pathRails := getFixturePath("ghost-rails") - extractorRails := parser.NewInfraExtractor(pathRails, "") - signalsRails := extractorRails.Extract() - foundRails := false - for _, sig := range signalsRails.Signals { - if sig == signals.SignalRails { - foundRails = true - break - } - } - if !foundRails { - t.Error("BLIND SPOT: Rails not detected from config.ru") - } else { - t.Log("✅ Rails detected from config.ru") - } -} diff --git a/project-analyzer/tests/fixtures/ghost-laravel/artisan b/project-analyzer/tests/fixtures/ghost-laravel/artisan deleted file mode 100644 index 000fb2e..0000000 --- a/project-analyzer/tests/fixtures/ghost-laravel/artisan +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/env php diff --git a/project-analyzer/tests/fixtures/ghost-rails/config.ru b/project-analyzer/tests/fixtures/ghost-rails/config.ru deleted file mode 100644 index 027b676..0000000 --- a/project-analyzer/tests/fixtures/ghost-rails/config.ru +++ /dev/null @@ -1 +0,0 @@ -run Rails::Application diff --git a/project-analyzer/tests/fixtures/ghost-react/src/App.jsx b/project-analyzer/tests/fixtures/ghost-react/src/App.jsx deleted file mode 100644 index 984bb4c..0000000 --- a/project-analyzer/tests/fixtures/ghost-react/src/App.jsx +++ /dev/null @@ -1 +0,0 @@ -export default function App() { return
Hello
} diff --git a/user-service/package.json b/user-service/package.json index 21b95d7..b80be51 100644 --- a/user-service/package.json +++ b/user-service/package.json @@ -27,6 +27,7 @@ "@prisma/client": "^6.19.1", "amqplib": "^0.10.3", "axios": "^1.6.2", + "cloudinary": "^2.0.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", diff --git a/user-service/src/api/v1/controllers/user.controller.ts b/user-service/src/api/v1/controllers/user.controller.ts index 3efe4b2..c79f78d 100644 --- a/user-service/src/api/v1/controllers/user.controller.ts +++ b/user-service/src/api/v1/controllers/user.controller.ts @@ -4,6 +4,7 @@ import { AuraService } from '../../../domain/aura.service.js'; import { VisibilityService } from '../../../domain/visibility.service.js'; import { updateProfileSchema, updateSettingsSchema } from '../validators/user.schema.js'; import { logger } from '../../../utils/logger.js'; +import { uploadImage, isCloudinaryConfigured } from '../../../utils/cloudinary.js'; import type { AuthenticatedRequest, ApiResponse } from '../../../types/index.js'; export class UserController { @@ -215,6 +216,76 @@ export class UserController { } } + /** + * POST /users/me/avatar + * Upload custom avatar image + */ + static async uploadAvatar( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + if (!req.user) { + res.status(401).json({ success: false, message: 'Unauthorized', error: { code: 'UNAUTHORIZED' } }); + return; + } + + // Check if Cloudinary is configured + if (!isCloudinaryConfigured()) { + res.status(503).json({ + success: false, + message: 'Image upload service not configured', + error: { code: 'SERVICE_UNAVAILABLE' } + }); + return; + } + + const { image } = req.body as { image?: string }; + + if (!image) { + res.status(400).json({ + success: false, + message: 'Image is required. Provide base64 encoded image.', + error: { code: 'VALIDATION_ERROR' } + }); + return; + } + + // Validate base64 image size (max 5MB) + const base64Size = (image.length * 3) / 4; + if (base64Size > 5 * 1024 * 1024) { + res.status(400).json({ + success: false, + message: 'Image too large. Maximum size is 5MB.', + error: { code: 'VALIDATION_ERROR' } + }); + return; + } + + // Upload to Cloudinary + const result = await uploadImage(image, 'avatars', `user_${req.user.userId}`); + + // Update user's avatarUrl in database + const profile = await ProfileService.updateProfile(req.user.userId, { + avatarUrl: result.url, + }); + + logger.info({ userId: req.user.userId, avatarUrl: result.url }, 'Avatar uploaded successfully'); + + res.json({ + success: true, + message: 'Avatar uploaded successfully', + data: { + avatarUrl: result.url, + profile + }, + }); + } catch (error) { + logger.error({ error }, 'Failed to upload avatar'); + res.status(500).json({ success: false, message: 'Failed to upload avatar', error: { code: 'INTERNAL_ERROR' } }); + } + } + /** * GET /users/:userId/skills-summary * Internal endpoint for job service to get user skills diff --git a/user-service/src/api/v1/routes/internal.routes.ts b/user-service/src/api/v1/routes/internal.routes.ts index 03b0bf8..12ca0f9 100644 --- a/user-service/src/api/v1/routes/internal.routes.ts +++ b/user-service/src/api/v1/routes/internal.routes.ts @@ -170,9 +170,20 @@ router.get('/candidates/:userId', async (req: Request, res: Response ({ - id: p.id, - repoName: p.repoName, - repoUrl: p.githubRepoUrl, - description: p.description, - primaryLanguage: p.language, - overallScore: p.overallScore || 0, - codeQualityScore: p.codeQualityScore || 0, - structureScore: p.structureScore || 0, - stars: p.stars, - forks: p.forks, - isPinned: p.isPinned, - analyzedAt: p.analyzedAt?.toISOString(), - })), + // Projects (with full analysis data) + analyzedProjects: user.projects.map((p) => { + const analysis = p.analysis; + return { + id: p.id, + repoName: p.repoName, + repoUrl: p.githubRepoUrl, + description: p.description, + primaryLanguage: analysis?.primaryLanguage || p.language, + // Technologies from analysis + technologies: [ + ...(analysis?.frameworks || []), + ...(analysis?.databases || []), + ...(analysis?.tools || []), + ...(analysis?.infrastructure || []), + ], + overallScore: p.overallScore || 0, + codeQualityScore: p.codeQualityScore || analysis?.codeQualityScore || 0, + structureScore: p.structureScore || analysis?.structureScore || 0, + stars: p.stars, + forks: p.forks, + isPinned: p.isPinned, + // Full analysis details + analysis: analysis ? { + folderStructure: { + hasSrcFolder: analysis.hasSrcFolder, + hasComponents: analysis.hasComponents, + hasTests: analysis.hasTests, + hasTypes: analysis.hasTypes, + hasUtils: analysis.hasUtils, + hasConfig: analysis.hasConfig, + organizationScore: analysis.organizationScore, + }, + codeQuality: { + hasLinting: analysis.hasLinting, + hasPrettier: analysis.hasPrettier, + hasTypeScript: analysis.hasTypeScript, + hasDockerfile: analysis.hasDockerfile, + hasCI: analysis.hasCI, + ciPlatform: analysis.ciPlatform, + testFilesCount: analysis.testFilesCount, + hasReadme: analysis.hasReadme, + }, + optimizations: (analysis.optimizationSuggestions || []).map(o => ({ + title: o.title, + description: o.description, + category: o.category, + priority: o.priority, + impact: o.impact, + })), + bestPractices: { + followed: analysis.bestPracticesFollowed || [], + missing: analysis.bestPracticesMissing || [], + score: analysis.bestPracticesScore, + }, + // React-specific if available + reactAnalysis: analysis.reactAnalysis ? { + usesHooks: analysis.reactAnalysis.usesHooks, + usesContext: analysis.reactAnalysis.usesContext, + componentCount: analysis.reactAnalysis.componentCount, + customHooksCount: analysis.reactAnalysis.customHooksCount, + stateManagement: analysis.reactAnalysis.stateManagement, + patterns: analysis.reactAnalysis.patternsDetected, + } : null, + // Verified skills from this project + verifiedSkills: (analysis.verifiedSkills || []).map(s => ({ + name: s.name, + category: s.category, + confidence: s.confidence, + evidence: s.evidence, + })), + // Architecture info + architectureType: analysis.architectureType, + serviceCount: analysis.serviceCount, + engineeringLevel: analysis.engineeringLevel, + // Languages breakdown + languages: (analysis.languageStats || []).map(l => ({ + name: l.name, + percentage: l.percentage, + lines: l.lines, + })), + } : { + folderStructure: {}, + codeQuality: {}, + optimizations: [], + bestPractices: { followed: [], missing: [] }, + }, + analyzedAt: p.analyzedAt?.toISOString(), + }; + }), topProjects: user.projects.slice(0, 3).map((p) => ({ name: p.repoName, score: p.overallScore || 0, diff --git a/user-service/src/domain/profile.service.ts b/user-service/src/domain/profile.service.ts index 3118d4a..9374ed4 100644 --- a/user-service/src/domain/profile.service.ts +++ b/user-service/src/domain/profile.service.ts @@ -59,6 +59,7 @@ export class ProfileService { company: data.company, website: data.website, twitterHandle: data.twitterHandle, + avatarUrl: data.avatarUrl, updatedAt: new Date(), }, }); diff --git a/user-service/src/types/index.ts b/user-service/src/types/index.ts index dc66d07..863b309 100644 --- a/user-service/src/types/index.ts +++ b/user-service/src/types/index.ts @@ -149,6 +149,7 @@ export interface UpdateProfileDto { company?: string; website?: string; twitterHandle?: string; + avatarUrl?: string; } export interface UpdateSettingsDto { diff --git a/user-service/src/utils/cloudinary.ts b/user-service/src/utils/cloudinary.ts new file mode 100644 index 0000000..59fd78d --- /dev/null +++ b/user-service/src/utils/cloudinary.ts @@ -0,0 +1,92 @@ +import { v2 as cloudinary } from 'cloudinary'; +import { logger } from './logger.js'; + +// Configure Cloudinary +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export interface UploadResult { + url: string; + publicId: string; + width: number; + height: number; +} + +/** + * Upload an image to Cloudinary + * @param base64Image - Base64 encoded image (with or without data:image prefix) + * @param folder - Cloudinary folder to upload to + * @param publicId - Optional custom public ID + */ +export async function uploadImage( + base64Image: string, + folder: string = 'avatars', + publicId?: string +): Promise { + try { + // Ensure base64 has proper prefix + let imageData = base64Image; + if (!imageData.startsWith('data:')) { + imageData = `data:image/png;base64,${imageData}`; + } + + const uploadOptions: any = { + folder: `verifydev/${folder}`, + resource_type: 'image', + transformation: [ + { width: 400, height: 400, crop: 'fill', gravity: 'face' }, + { quality: 'auto:good' }, + { format: 'webp' } + ] + }; + + if (publicId) { + uploadOptions.public_id = publicId; + uploadOptions.overwrite = true; + } + + const result = await cloudinary.uploader.upload(imageData, uploadOptions); + + logger.info({ publicId: result.public_id, url: result.secure_url }, 'Image uploaded to Cloudinary'); + + return { + url: result.secure_url, + publicId: result.public_id, + width: result.width, + height: result.height, + }; + } catch (error) { + logger.error({ error }, 'Failed to upload image to Cloudinary'); + throw new Error('Failed to upload image'); + } +} + +/** + * Delete an image from Cloudinary + * @param publicId - The public ID of the image to delete + */ +export async function deleteImage(publicId: string): Promise { + try { + const result = await cloudinary.uploader.destroy(publicId); + return result.result === 'ok'; + } catch (error) { + logger.error({ error, publicId }, 'Failed to delete image from Cloudinary'); + return false; + } +} + +/** + * Check if Cloudinary is configured + */ +export function isCloudinaryConfigured(): boolean { + return !!( + process.env.CLOUDINARY_CLOUD_NAME && + process.env.CLOUDINARY_API_KEY && + process.env.CLOUDINARY_API_SECRET + ); +} + +export default cloudinary;