Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.ask
node_modules
backend/yarn.lock
2 changes: 1 addition & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ jspm_packages/
.fusebox/

.idea/
Dockerfile
Dockerfile
29,607 changes: 19,481 additions & 10,126 deletions backend/package-lock.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"prepare": "test -d node_modules/husky && husky install || echo \"husky is not installed\"",
"lint": "eslint --fix --ext .js .",
"lint:staged": "lint-staged",
"test": "jest",
"dev": "nodemon --exec npx babel-node src/core/bin/www.js",
"start": "npm run build && node ./dist/core/bin/www.js",
"clean": "rm -rf dist && mkdir dist",
Expand All @@ -18,6 +19,7 @@
"db:reset": "yarn knex migrate:rollback && yarn knex migrate:latest && yarn knex seed:run"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@sentry/node": "^7.64.0",
"axios": "^1.4.0",
"bcrypt": "^5.0.1",
Expand All @@ -39,7 +41,8 @@
"pg": "^8.7.1",
"prettier": "^2.3.0",
"swagger-ui-express": "^4.1.6",
"winston": "^3.3.3"
"winston": "^3.3.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.10.4",
Expand All @@ -56,13 +59,15 @@
"@commitlint/cli": "^17.5.0",
"@commitlint/config-conventional": "^17.4.4",
"babel-eslint": "^10.1.0",
"babel-jest": "^30.4.1",
"babel-plugin-module-resolver": "^4.1.0",
"babel-plugin-transform-runtime": "^6.23.0",
"eslint": "^7.3.1",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-plugin-import": "^2.21.2",
"faker": "^5.5.3",
"husky": "^8.0.3",
"jest": "^30.4.2",
"lint-staged": "^15.0.2",
"nodemon": "^2.0.7"
}
Expand Down
16 changes: 16 additions & 0 deletions backend/src/core/api/ai/ai.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ValidHttpResponse } from '../../../packages/handler/response/validHttp.response';
import { matchJobsForProfile } from '../../modules/ai/agents/match/match.agent';

class Controller {
matchForProfile = async req => {
const { profileId } = req.params;
const matches = await matchJobsForProfile(profileId);
return ValidHttpResponse.toOkResponse({
success: true,
data: matches,
message: 'Tìm việc làm phù hợp thành công.'
});
};
}

export const AIController = new Controller();
17 changes: 17 additions & 0 deletions backend/src/core/api/ai/ai.resolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from 'packages/handler/Module';
import { AIController } from './ai.controller';

export const AIResolver = Module.builder()
.addPrefix({
prefixPath: '/ai',
tag: 'ai',
module: 'AIModule'
})
.register([
{
route: '/match/:profileId',
method: 'get',
controller: AIController.matchForProfile,
preAuthorization: true
}
]);
4 changes: 3 additions & 1 deletion backend/src/core/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { UserResolver } from 'core/api/user/user.resolver';
import { ApiDocument } from 'core/config/swagger.config';
import { HandlerResolver } from '../../packages/handler/HandlerResolver';
import { AuthResolver } from './auth/auth.resolver';
import { AIResolver } from './ai/ai.resolver';

export const ModuleResolver = HandlerResolver
.builder()
.addSwaggerBuilder(ApiDocument)
.addModule([
AuthResolver,
UserResolver,
MediaResolver
MediaResolver,
AIResolver
]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
exports.up = async function(knex) {
await knex.raw('CREATE EXTENSION IF NOT EXISTS vector;');

await knex.schema.alterTable('jobs', table => {
table.specificType('embedding', 'vector(768)');
table.jsonb('weights_json');
});

await knex.schema.alterTable('profiles', table => {
table.specificType('narrative_embedding', 'vector(768)');
});
};

exports.down = async function(knex) {
await knex.schema.alterTable('profiles', table => {
table.dropColumn('narrative_embedding');
});

await knex.schema.alterTable('jobs', table => {
table.dropColumn('embedding');
table.dropColumn('weights_json');
});

await knex.raw('DROP EXTENSION IF EXISTS vector;');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { matchJobsForProfile } from '../match.agent.js';
import * as inference from '../match.inference.js';
import * as scoring from '../match.scoring.js';
import knex from '../../../../../database/index.js';

jest.mock('../match.inference.js');
jest.mock('../match.scoring.js');
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
first: jest.fn(),
};

jest.mock('../../../../../database/index.js', () => {
const fn = jest.fn(() => mockQueryBuilder);
fn.raw = jest.fn();
return fn;
});

describe('AI Matching - Agent/Orchestrator', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should throw error if profile not found', async () => {
mockQueryBuilder.first.mockResolvedValue(null);
await expect(matchJobsForProfile('user1')).rejects.toThrow('Profile not found or missing narrative_embedding');
});

it('should throw error if profile missing narrative_embedding', async () => {
mockQueryBuilder.first.mockResolvedValue({ id: 'user1' }); // missing narrative_embedding
await expect(matchJobsForProfile('user1')).rejects.toThrow('Profile not found or missing narrative_embedding');
});

it('should fetch jobs, calculate scores, and return top matches', async () => {
const mockProfile = { id: 'user1', narrative_embedding: '[0.1, 0.2]' };
mockQueryBuilder.first.mockResolvedValue(mockProfile);

// Mock knex.raw for vector search
knex.raw = jest.fn().mockResolvedValue({
rows: [
{ id: 'job1', title: 'Frontend Developer', semantic_score: 0.9 },
{ id: 'job2', title: 'Backend Developer', semantic_score: 0.7 }
]
});

// Mock inference weights
inference.inferWeights.mockResolvedValue({ skill: 0.5, exp: 0.5, at: 0, geo: 0, culture_fit: 0 });

// Mock hybrid scoring
scoring.hybridScore
.mockReturnValueOnce(85) // for job1
.mockReturnValueOnce(30); // for job2

const matches = await matchJobsForProfile('user1');

expect(knex.raw).toHaveBeenCalled();
expect(inference.inferWeights).toHaveBeenCalledTimes(2);
expect(scoring.hybridScore).toHaveBeenCalledTimes(2);

// Only job1 should be returned because job2 score (30) < MIN_SCORE (40)
expect(matches).toHaveLength(1);
expect(matches[0].id).toBe('job1');
expect(matches[0].final_score).toBe(85);
expect(matches[0].explanation).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { inferWeights } from '../match.inference.js';
import * as genai from '@google/genai';

// Mock the external dependencies
jest.mock('@google/genai', () => {
return {
GoogleGenAI: jest.fn().mockImplementation(() => ({
models: {
generateContent: jest.fn()
}
}))
};
});
jest.mock('../../../../../database/index.js', () => {
return jest.fn(() => ({
where: jest.fn().mockReturnThis(),
update: jest.fn().mockResolvedValue(1)
}));
});

describe('AI Matching - Inference Logic', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.GEMINI_API_KEY = 'test_key';
});

it('should return cached weights if job.weights_json exists', async () => {
const job = { id: 1, weights_json: '{"skill": 0.5, "exp": 0.5, "at": 0, "geo": 0, "culture_fit": 0}' };
const weights = await inferWeights(job);
expect(weights).toEqual({ skill: 0.5, exp: 0.5, at: 0, geo: 0, culture_fit: 0 });
});

it('should parse object weights_json if not string', async () => {
const job = { id: 2, weights_json: { skill: 0.4, exp: 0.6, at: 0, geo: 0, culture_fit: 0 } };
const weights = await inferWeights(job);
expect(weights.skill).toBe(0.4);
});

it('should fallback to DEFAULTS if api fails', async () => {
// We simulate failure by forcing GoogleGenAI mock to reject
const mockInstance = new genai.GoogleGenAI();
mockInstance.models.generateContent.mockRejectedValue(new Error('API Error'));

const job = { id: 3, description: 'Some job' };

// Since we import inferWeights, the mock instance is actually instantiated inside match.inference.js.
// We will just test the general failure fallback (when GEMINI_API_KEY isn't set or network fails).
// For strict testing, we can delete the API key to test the missing key fallback.
const originalKey = process.env.GEMINI_API_KEY;
delete process.env.GEMINI_API_KEY;

const weights = await inferWeights(job);
expect(weights).toEqual({ skill: 0.40, exp: 0.20, at: 0.25, geo: 0.15, culture_fit: 0 });

process.env.GEMINI_API_KEY = originalKey;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { geoScore, atMatchScore, skillScore, expScore, hybridScore } from '../match.scoring';

describe('AI Matching - Scoring Logic', () => {
describe('geoScore', () => {
it('should return 1 for distance < 10km', () => {
expect(geoScore(10.762622, 106.660172, 10.772622, 106.670172)).toBe(1);
});
it('should return 0.1 for very far distance', () => {
expect(geoScore(10.762622, 106.660172, 21.028511, 105.804817)).toBe(0.1);
});
it('should return 0 if coordinates are missing', () => {
expect(geoScore(null, null, 1, 1)).toBe(0);
});
});

describe('atMatchScore', () => {
it('should boost score for screen_reader matched in environment', () => {
const job = { work_environment: 'Support NVDA', accessibility_level: 'AA' };
const score = atMatchScore(['screen_reader'], job);
expect(score).toBe(0.6); // 0.5 + 0.1
});
it('should give safety floor 0.3 if needs exist but no match', () => {
const job = { work_environment: 'Normal office', accessibility_level: 'A' };
const score = atMatchScore(['screen_reader'], job);
expect(score).toBe(0.3);
});
});

describe('skillScore', () => {
it('should score exactly 1 if all skills match', () => {
expect(skillScore(['React', 'NodeJS'], ['react', 'nodejs'], 0)).toBe(1);
});
it('should include semantic_score boost', () => {
expect(skillScore(['Vue'], ['React'], 0.5)).toBe(0.15); // 0 + 0.5 * 0.3
});
});

describe('hybridScore', () => {
it('should calculate final score based on weights', () => {
const profile = {
skills: ['React'],
experiences: ['Job 1', 'Job 2', 'Job 3'], // expScore = 1
accessibility_needs: [],
latitude: 10, longitude: 106
};
const job = {
skills: ['React'], // skillScore = 1
latitude: 10, longitude: 106, // geoScore = 1
work_environment: ''
};
const weights = { skill: 0.5, exp: 0.2, at: 0.1, geo: 0.1, culture_fit: 0.1 };
const semantic = 0.8;

// Expected: 0.5(1) + 0.2(1) + 0.1(0) + 0.1(1) + 0.1(0.8) = 0.5 + 0.2 + 0 + 0.1 + 0.08 = 0.88 -> 88
const score = hybridScore(profile, job, semantic, weights);
expect(score).toBe(88);
});
});
});
Loading