feat: Enhance ODMDB query handling with multi-schema support and intelligent routing
- Updated `poc.js` to support queries for multiple object types (seekers, jobads, recruiters, etc.) with intelligent routing based on natural language input. - Implemented a query validation mechanism to prevent excessive or sensitive requests. - Introduced a mapping manager for dynamic schema handling and object detection. - Enhanced the response schema generation to accommodate various object types and their respective fields. - Added a new script `verify-mapping.js` to verify and display the mapping details for the seekers schema, including available properties, indexes, access rights, and synonyms.
This commit is contained in:
702
poc.js
702
poc.js
@@ -1,4 +1,4 @@
|
||||
// PoC: NL → ODMDB query (seekers), no zod — validate via ODMDB schema
|
||||
// PoC: NL → ODMDB query (ALL OBJECTS) - Multi-schema support with intelligent routing
|
||||
// Usage:
|
||||
// 1) export OPENAI_API_KEY=sk-...
|
||||
// 2) node poc.js
|
||||
@@ -7,6 +7,7 @@ import fs from "node:fs";
|
||||
import OpenAI from "openai";
|
||||
import axios from "axios";
|
||||
import jq from "node-jq";
|
||||
import { ODMDBMappingManager } from "./schema-mappings/mapping-manager.js";
|
||||
|
||||
// ---- Config ----
|
||||
const MODEL = process.env.OPENAI_MODEL || "gpt-5";
|
||||
@@ -21,425 +22,219 @@ const ODMDB_BASE_URL = process.env.ODMDB_BASE_URL || "http://localhost:3000";
|
||||
const ODMDB_TRIBE = process.env.ODMDB_TRIBE || "smatchit";
|
||||
const EXECUTE_QUERY = process.env.EXECUTE_QUERY === "true"; // Set to "true" to execute queries
|
||||
|
||||
// Hardcoded NL query for the PoC (no multi-turn)
|
||||
const NL_QUERY =
|
||||
"find seekers looking for jobs urgently with their contact info and salary expectations";
|
||||
|
||||
// ---- Load schemas (safe) ----
|
||||
function loadJsonSafe(path) {
|
||||
try {
|
||||
if (fs.existsSync(path)) {
|
||||
return JSON.parse(fs.readFileSync(path, "utf-8"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Warning: Could not load ${path}:`, e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load actual ODMDB schemas
|
||||
const SCHEMAS = {
|
||||
seekers: loadJsonSafe(`${SCHEMA_PATH}/seekers.json`),
|
||||
main: loadJsonSafe("./main.json"), // Fallback consolidated schema
|
||||
// Test queries for different objects
|
||||
const TEST_QUERIES = {
|
||||
seekers:
|
||||
"find seekers looking for jobs urgently with their contact info and salary expectations",
|
||||
jobads: "show me recent job postings with salary range and requirements",
|
||||
recruiters: "get active recruiters with their contact information",
|
||||
persons: "find people with their basic profile information",
|
||||
sirets: "show me companies with their business information",
|
||||
};
|
||||
|
||||
// ---- Helpers to read seekers field names from your ODMDB custom schema ----
|
||||
function extractSeekersPropsFromOdmdbSchema(main) {
|
||||
if (!main) return [];
|
||||
// Hardcoded NL query for the PoC (no multi-turn) - can be overridden by TEST_OBJECT env var
|
||||
const TEST_OBJECT = process.env.TEST_OBJECT || "seekers";
|
||||
const NL_QUERY = TEST_QUERIES[TEST_OBJECT] || TEST_QUERIES.seekers;
|
||||
|
||||
// Try common shapes
|
||||
// 1) { objects: { seekers: { properties: {...} } } }
|
||||
if (
|
||||
main.objects?.seekers?.properties &&
|
||||
typeof main.objects.seekers.properties === "object"
|
||||
) {
|
||||
return Object.keys(main.objects.seekers.properties);
|
||||
}
|
||||
// ---- Initialize Mapping Manager ----
|
||||
console.log("🚀 Initializing ODMDB Multi-Schema PoC");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// 2) If main is an array, search for an item that looks like seekers schema
|
||||
if (Array.isArray(main)) {
|
||||
for (const entry of main) {
|
||||
const keys = extractSeekersPropsFromOdmdbSchema(entry);
|
||||
if (keys.length) return keys;
|
||||
}
|
||||
}
|
||||
const mappingManager = new ODMDBMappingManager();
|
||||
|
||||
// 3) Fallback: deep search for a { seekers: { properties: {...} } } node
|
||||
try {
|
||||
const stack = [main];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
if (node && typeof node === "object") {
|
||||
if (
|
||||
node.seekers?.properties &&
|
||||
typeof node.seekers.properties === "object"
|
||||
) {
|
||||
return Object.keys(node.seekers.properties);
|
||||
}
|
||||
for (const v of Object.values(node)) {
|
||||
if (v && typeof v === "object") stack.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
// Query validation - detect outrageous requests
|
||||
function validateQuery(nlQuery) {
|
||||
const query = nlQuery.toLowerCase();
|
||||
|
||||
return [];
|
||||
}
|
||||
// Check for reasonable data limits
|
||||
const excessiveKeywords = [
|
||||
"all users",
|
||||
"all people",
|
||||
"everyone",
|
||||
"entire database",
|
||||
"complete list",
|
||||
"every",
|
||||
"dump",
|
||||
"export everything",
|
||||
"all data",
|
||||
"full database",
|
||||
"everything",
|
||||
];
|
||||
|
||||
// ---- Schema-based mapping system ----
|
||||
class SchemaMapper {
|
||||
constructor(schemas) {
|
||||
// Use direct seekers schema if available, otherwise search in consolidated main schema
|
||||
this.seekersSchema =
|
||||
schemas.seekers || this.findSchemaByType("seekers", schemas.main);
|
||||
this.fieldMappings = this.buildFieldMappings();
|
||||
this.indexMappings = this.buildIndexMappings();
|
||||
const hasExcessiveRequest = excessiveKeywords.some((keyword) =>
|
||||
query.includes(keyword)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`📋 Loaded seekers schema with ${
|
||||
Object.keys(this.seekersSchema?.properties || {}).length
|
||||
} properties`
|
||||
);
|
||||
}
|
||||
|
||||
findSchemaByType(objectType, schemas) {
|
||||
if (!schemas || !Array.isArray(schemas)) return null;
|
||||
return schemas.find(
|
||||
(schema) => schema.$id && schema.$id.includes(`/${objectType}`)
|
||||
);
|
||||
}
|
||||
|
||||
buildFieldMappings() {
|
||||
if (!this.seekersSchema) return {};
|
||||
|
||||
const mappings = {};
|
||||
const properties = this.seekersSchema.properties || {};
|
||||
|
||||
Object.entries(properties).forEach(([fieldName, fieldDef]) => {
|
||||
const synonyms = this.generateSynonyms(fieldName, fieldDef);
|
||||
mappings[fieldName] = {
|
||||
field: fieldName,
|
||||
title: fieldDef.title?.toLowerCase(),
|
||||
description: fieldDef.description?.toLowerCase(),
|
||||
type: fieldDef.type,
|
||||
synonyms,
|
||||
};
|
||||
|
||||
// Index by title and synonyms
|
||||
if (fieldDef.title) {
|
||||
mappings[fieldDef.title.toLowerCase()] = fieldName;
|
||||
}
|
||||
synonyms.forEach((synonym) => {
|
||||
mappings[synonym.toLowerCase()] = fieldName;
|
||||
});
|
||||
});
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
buildIndexMappings() {
|
||||
if (!this.seekersSchema?.apxidx) return {};
|
||||
|
||||
const indexes = {};
|
||||
this.seekersSchema.apxidx.forEach((idx) => {
|
||||
indexes[idx.name] = {
|
||||
name: idx.name,
|
||||
type: idx.type,
|
||||
keyval: idx.keyval,
|
||||
};
|
||||
});
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
generateSynonyms(fieldName, fieldDef) {
|
||||
const synonyms = [];
|
||||
|
||||
// Comprehensive mappings based on actual seekers schema (62 properties)
|
||||
const commonMappings = {
|
||||
// Contact & Identity
|
||||
email: ["contact", "mail", "contact email", "e-mail"],
|
||||
alias: ["id", "identifier", "username", "user id"],
|
||||
shortdescription: ["description", "bio", "summary", "about"],
|
||||
|
||||
// Work Experience & Status
|
||||
seekworkingyear: [
|
||||
"experience",
|
||||
"years of experience",
|
||||
"work experience",
|
||||
"working years",
|
||||
"career length",
|
||||
],
|
||||
seekjobtitleexperience: [
|
||||
"job titles",
|
||||
"job experience",
|
||||
"positions",
|
||||
"roles",
|
||||
"previous jobs",
|
||||
"work history",
|
||||
],
|
||||
seekstatus: [
|
||||
"status",
|
||||
"availability",
|
||||
"looking",
|
||||
"job search status",
|
||||
"urgency",
|
||||
],
|
||||
employmentstatus: [
|
||||
"employment",
|
||||
"current status",
|
||||
"work status",
|
||||
"job status",
|
||||
],
|
||||
|
||||
// Location & Geography
|
||||
seeklocation: [
|
||||
"location",
|
||||
"where",
|
||||
"place",
|
||||
"work location",
|
||||
"preferred location",
|
||||
],
|
||||
lastlocation: ["last location", "current location", "previous location"],
|
||||
countryavailabletowork: [
|
||||
"countries",
|
||||
"available countries",
|
||||
"work countries",
|
||||
"country availability",
|
||||
],
|
||||
|
||||
// Salary & Compensation
|
||||
salaryexpectation: [
|
||||
"salary",
|
||||
"pay",
|
||||
"compensation",
|
||||
"wage",
|
||||
"salary expectation",
|
||||
"expected salary",
|
||||
],
|
||||
salarydevise: ["currency", "salary currency", "pay currency"],
|
||||
salaryunit: [
|
||||
"salary unit",
|
||||
"pay unit",
|
||||
"compensation unit",
|
||||
"salary period",
|
||||
],
|
||||
|
||||
// Job Preferences
|
||||
seekjobtype: [
|
||||
"job type",
|
||||
"job types",
|
||||
"employment type",
|
||||
"contract type",
|
||||
],
|
||||
lookingforjobtype: [
|
||||
"looking for",
|
||||
"desired job type",
|
||||
"preferred job type",
|
||||
],
|
||||
lookingforaction: ["actions", "desired actions", "preferred activities"],
|
||||
lookingforother: ["other preferences", "additional requirements"],
|
||||
|
||||
// Skills & Competencies
|
||||
skills: ["skills", "competencies", "abilities", "technical skills"],
|
||||
languageskills: ["languages", "language skills", "linguistic skills"],
|
||||
knowhow: ["knowledge", "expertise", "know-how", "competence"],
|
||||
myworkexperience: [
|
||||
"work experience",
|
||||
"professional experience",
|
||||
"career experience",
|
||||
],
|
||||
|
||||
// Personality & Profile
|
||||
mbti: ["personality", "type", "profile", "MBTI", "personality type"],
|
||||
mywords: ["keywords", "profile words", "descriptive words"],
|
||||
thingsilike: ["likes", "preferences", "interests", "things I like"],
|
||||
thingsidislike: [
|
||||
"dislikes",
|
||||
"avoid",
|
||||
"not interested",
|
||||
"things I dislike",
|
||||
],
|
||||
|
||||
// Availability & Schedule
|
||||
preferedworkinghours: [
|
||||
"working hours",
|
||||
"preferred hours",
|
||||
"work schedule",
|
||||
"availability",
|
||||
],
|
||||
notavailabletowork: [
|
||||
"unavailable",
|
||||
"not available",
|
||||
"blocked times",
|
||||
"unavailable days",
|
||||
],
|
||||
|
||||
// Job Search Activity
|
||||
myjobradar: [
|
||||
"job radar",
|
||||
"tracked jobs",
|
||||
"job interests",
|
||||
"monitored jobs",
|
||||
],
|
||||
jobadview: ["viewed jobs", "job views", "seen jobs"],
|
||||
jobadnotinterested: ["not interested", "rejected jobs", "dismissed jobs"],
|
||||
jobadapply: ["applied jobs", "applications", "job applications"],
|
||||
jobadinvitedtoapply: [
|
||||
"invitations",
|
||||
"invited to apply",
|
||||
"job invitations",
|
||||
],
|
||||
jobadsaved: ["saved jobs", "bookmarked jobs", "favorite jobs"],
|
||||
|
||||
// Dates & Timestamps
|
||||
dt_create: [
|
||||
"created",
|
||||
"creation date",
|
||||
"new",
|
||||
"recent",
|
||||
"since",
|
||||
"registration date",
|
||||
],
|
||||
dt_update: ["updated", "last update", "modified", "last modified"],
|
||||
matchinglastdate: ["last matching", "matching date", "last match"],
|
||||
|
||||
// Education & Training
|
||||
educations: [
|
||||
"education",
|
||||
"degree",
|
||||
"diploma",
|
||||
"qualification",
|
||||
"studies",
|
||||
],
|
||||
tipsadvice: ["tips", "advice", "articles", "guidance"],
|
||||
receivecommercialtraining: ["commercial training", "sales training"],
|
||||
receivejobandinterviewtips: [
|
||||
"interview tips",
|
||||
"job tips",
|
||||
"career advice",
|
||||
],
|
||||
|
||||
// Notifications & Communication
|
||||
notificationformatches: ["match notifications", "matching alerts"],
|
||||
notificationforsupermatches: [
|
||||
"super match notifications",
|
||||
"premium matches",
|
||||
],
|
||||
notificationinvitedtoapply: [
|
||||
"application invitations",
|
||||
"invite notifications",
|
||||
],
|
||||
notificationrecruitprocessupdate: [
|
||||
"recruitment updates",
|
||||
"process updates",
|
||||
],
|
||||
notificationupcominginterview: [
|
||||
"interview notifications",
|
||||
"upcoming interviews",
|
||||
],
|
||||
notificationdirectmessage: ["direct messages", "chat notifications"],
|
||||
emailactivityreportweekly: ["weekly reports", "weekly emails"],
|
||||
emailactivityreportbiweekly: ["biweekly reports", "biweekly emails"],
|
||||
emailactivityreportmonthly: ["monthly reports", "monthly emails"],
|
||||
emailpersonnalizedcontent: ["personalized content", "custom content"],
|
||||
emailnewsletter: ["newsletter", "news updates"],
|
||||
|
||||
// External IDs
|
||||
polemploiid: ["pole emploi", "unemployment office", "job center ID"],
|
||||
|
||||
// System Fields
|
||||
owner: ["owner", "account owner"],
|
||||
activequizz: ["active quiz", "current quiz", "quiz"],
|
||||
if (hasExcessiveRequest) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "Query requests excessive data - please be more specific",
|
||||
suggestion:
|
||||
"Try requesting specific criteria or a limited number of results",
|
||||
};
|
||||
|
||||
if (commonMappings[fieldName]) {
|
||||
synonyms.push(...commonMappings[fieldName]);
|
||||
}
|
||||
|
||||
return synonyms;
|
||||
}
|
||||
|
||||
mapNLToFields(nlTerms) {
|
||||
const mappedFields = [];
|
||||
// Check for sensitive/inappropriate requests
|
||||
const sensitiveKeywords = [
|
||||
"password",
|
||||
"private",
|
||||
"confidential",
|
||||
"secret",
|
||||
"admin",
|
||||
"delete",
|
||||
"remove",
|
||||
"drop",
|
||||
"destroy",
|
||||
"hack",
|
||||
];
|
||||
|
||||
nlTerms.forEach((term) => {
|
||||
const normalizedTerm = term.toLowerCase();
|
||||
const mapping = this.fieldMappings[normalizedTerm];
|
||||
const hasSensitiveRequest = sensitiveKeywords.some((keyword) =>
|
||||
query.includes(keyword)
|
||||
);
|
||||
|
||||
if (mapping) {
|
||||
if (typeof mapping === "string") {
|
||||
mappedFields.push(mapping);
|
||||
} else if (mapping.field) {
|
||||
mappedFields.push(mapping.field);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(mappedFields)]; // Remove duplicates
|
||||
if (hasSensitiveRequest) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: "Query contains inappropriate or sensitive terms",
|
||||
suggestion:
|
||||
"Please rephrase your request with appropriate business terms",
|
||||
};
|
||||
}
|
||||
|
||||
getRecruiterReadableFields() {
|
||||
if (!this.seekersSchema?.apxaccessrights?.recruiters?.R) {
|
||||
// Fallback to basic fields
|
||||
return ["alias", "email", "seekstatus", "seekworkingyear"];
|
||||
}
|
||||
return this.seekersSchema.apxaccessrights.recruiters.R;
|
||||
}
|
||||
|
||||
getAllSeekersFields() {
|
||||
if (!this.seekersSchema?.properties) return [];
|
||||
return Object.keys(this.seekersSchema.properties);
|
||||
}
|
||||
|
||||
getAvailableIndexes() {
|
||||
return Object.keys(this.indexMappings);
|
||||
}
|
||||
|
||||
getIndexByField(fieldName) {
|
||||
const index = Object.values(this.indexMappings).find(
|
||||
(idx) => idx.keyval === fieldName
|
||||
);
|
||||
return index ? `idx.${index.name}` : null;
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Initialize schema mapper
|
||||
const schemaMapper = new SchemaMapper(SCHEMAS);
|
||||
// ---- Multi-Object Query Processing ----
|
||||
function detectTargetObject(nlQuery) {
|
||||
console.log(`🔍 Analyzing query: "${nlQuery}"`);
|
||||
|
||||
const SEEKERS_FIELDS_FROM_SCHEMA = schemaMapper.getAllSeekersFields();
|
||||
// Use mapping manager to detect target object
|
||||
const detectedObjects = mappingManager.detectObjectFromQuery(nlQuery);
|
||||
|
||||
console.log(
|
||||
`🔍 Available seekers fields: ${SEEKERS_FIELDS_FROM_SCHEMA.slice(0, 10).join(
|
||||
", "
|
||||
)}${
|
||||
SEEKERS_FIELDS_FROM_SCHEMA.length > 10
|
||||
? `... (${SEEKERS_FIELDS_FROM_SCHEMA.length} total)`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
if (detectedObjects.length === 0) {
|
||||
return {
|
||||
object: "seekers", // Default fallback
|
||||
confidence: 0.1,
|
||||
reason: "No specific object detected, defaulting to seekers",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Minimal mapping config (for prompting + default fields) ----
|
||||
const seekersMapping = {
|
||||
object: "seekers",
|
||||
defaultReadableFields: schemaMapper.getRecruiterReadableFields().slice(0, 5), // First 5 readable fields
|
||||
};
|
||||
// Sort by confidence and return the best match
|
||||
detectedObjects.sort((a, b) => b.confidence - a.confidence);
|
||||
const bestMatch = detectedObjects[0];
|
||||
|
||||
console.log(
|
||||
`🎯 Detected object: ${bestMatch.object} (confidence: ${bestMatch.confidence})`
|
||||
);
|
||||
console.log(` Reason: ${bestMatch.reason}`);
|
||||
|
||||
// Check if data is available for this object
|
||||
const availability = mappingManager.dataAvailability.get(bestMatch.object);
|
||||
if (!availability?.dataAvailable) {
|
||||
console.log(
|
||||
`⚠️ No data available for ${bestMatch.object}, checking alternatives...`
|
||||
);
|
||||
|
||||
// Find alternative with available data
|
||||
const alternativeWithData = detectedObjects.find((detection) => {
|
||||
const alt = mappingManager.dataAvailability.get(detection.object);
|
||||
return alt?.dataAvailable;
|
||||
});
|
||||
|
||||
if (alternativeWithData) {
|
||||
console.log(`✅ Using alternative: ${alternativeWithData.object}`);
|
||||
return alternativeWithData;
|
||||
} else {
|
||||
return {
|
||||
object: bestMatch.object,
|
||||
confidence: bestMatch.confidence,
|
||||
reason: bestMatch.reason,
|
||||
dataUnavailable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
// ---- Dynamic Query Schema Generation ----
|
||||
function getObjectMapping(objectName) {
|
||||
return mappingManager.mappings.get(objectName);
|
||||
}
|
||||
|
||||
function getReadableFields(objectName) {
|
||||
const mapping = getObjectMapping(objectName);
|
||||
if (!mapping?.available) return [];
|
||||
|
||||
// Try to get readable fields from access rights (for recruiters, seekers, etc.)
|
||||
const accessRights = mapping.accessRights;
|
||||
if (accessRights) {
|
||||
// For seekers, check recruiters.R
|
||||
if (
|
||||
accessRights.recruiters?.R &&
|
||||
Array.isArray(accessRights.recruiters.R)
|
||||
) {
|
||||
return accessRights.recruiters.R;
|
||||
}
|
||||
// For jobads/recruiters, check seekers.R
|
||||
if (accessRights.seekers?.R && Array.isArray(accessRights.seekers.R)) {
|
||||
return accessRights.seekers.R;
|
||||
}
|
||||
// For other objects, check owner.R
|
||||
if (accessRights.owner?.R && Array.isArray(accessRights.owner.R)) {
|
||||
return accessRights.owner.R;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to all available properties (first 10 for safety)
|
||||
return mapping?.properties
|
||||
? Object.keys(mapping.properties).slice(0, 10)
|
||||
: [];
|
||||
}
|
||||
|
||||
function getAllObjectFields(objectName) {
|
||||
const mapping = getObjectMapping(objectName);
|
||||
if (!mapping?.available) return [];
|
||||
return mapping?.properties ? Object.keys(mapping.properties) : [];
|
||||
}
|
||||
|
||||
function getObjectFallbackFields(objectName) {
|
||||
// Object-specific fallback fields when no readable fields are available
|
||||
const fallbacks = {
|
||||
seekers: ["alias", "email"],
|
||||
jobads: ["jobadid", "jobtitle"],
|
||||
recruiters: ["alias", "email"],
|
||||
persons: ["alias", "email"],
|
||||
sirets: ["alias", "name"],
|
||||
jobsteps: ["alias", "name"],
|
||||
jobtitles: ["jobtitleid", "name"],
|
||||
};
|
||||
|
||||
return fallbacks[objectName] || ["id", "name"];
|
||||
}
|
||||
|
||||
// ---- JSON Schema for Structured Outputs (no zod, no oneOf) ----
|
||||
function buildResponseJsonSchema() {
|
||||
const recruiterReadableFields = schemaMapper.getRecruiterReadableFields();
|
||||
function buildResponseJsonSchema(targetObject) {
|
||||
const availableObjects = Array.from(mappingManager.mappings.keys());
|
||||
const readableFields = getReadableFields(targetObject);
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
object: { type: "string", enum: ["seekers"] },
|
||||
object: {
|
||||
type: "string",
|
||||
enum: availableObjects.length > 0 ? availableObjects : ["seekers"],
|
||||
},
|
||||
condition: { type: "array", items: { type: "string" }, minItems: 1 },
|
||||
fields: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: recruiterReadableFields,
|
||||
enum:
|
||||
readableFields.length > 0
|
||||
? readableFields
|
||||
: getObjectFallbackFields(targetObject),
|
||||
},
|
||||
minItems: 1,
|
||||
},
|
||||
@@ -449,10 +244,21 @@ function buildResponseJsonSchema() {
|
||||
}
|
||||
|
||||
// ---- Prompt builders ----
|
||||
function systemPrompt() {
|
||||
const availableFields = schemaMapper.getAllSeekersFields();
|
||||
const recruiterReadableFields = schemaMapper.getRecruiterReadableFields();
|
||||
const availableIndexes = schemaMapper.getAvailableIndexes();
|
||||
function systemPrompt(targetObject) {
|
||||
const objectMapping = getObjectMapping(targetObject);
|
||||
const availableFields = getAllObjectFields(targetObject);
|
||||
const readableFields = getReadableFields(targetObject);
|
||||
const availableObjects = Array.from(mappingManager.mappings.keys());
|
||||
|
||||
// Get object-specific synonyms from mapping
|
||||
const synonyms = objectMapping?.synonyms || {};
|
||||
const synonymList = Object.entries(synonyms)
|
||||
.slice(0, 10)
|
||||
.map(([field, syns]) => {
|
||||
const synArray = Array.isArray(syns) ? syns : [syns];
|
||||
return `- '${synArray.slice(0, 2).join("', '")}' → ${field}`;
|
||||
})
|
||||
.join("\n ");
|
||||
|
||||
return [
|
||||
"You convert a natural language request into an ODMDB search payload.",
|
||||
@@ -463,45 +269,35 @@ function systemPrompt() {
|
||||
"- idx.<indexName>(value) - for indexed fields",
|
||||
"- prop.<field>(operator:value) - for direct property queries",
|
||||
"",
|
||||
"Available seekers fields:",
|
||||
`Available objects: ${availableObjects.join(", ")}`,
|
||||
`Target object: ${targetObject}`,
|
||||
"",
|
||||
`Available ${targetObject} fields:`,
|
||||
availableFields.slice(0, 15).join(", ") +
|
||||
(availableFields.length > 15 ? "..." : ""),
|
||||
"",
|
||||
"Available indexes for optimization:",
|
||||
availableIndexes.join(", "),
|
||||
"",
|
||||
"Recruiter-readable fields (use these for field selection):",
|
||||
recruiterReadableFields.join(", "),
|
||||
`Readable fields for ${targetObject} (use these for field selection):`,
|
||||
readableFields.join(", "),
|
||||
"",
|
||||
"Field mappings for natural language:",
|
||||
"- 'email', 'contact info' → email",
|
||||
"- 'experience', 'years of experience' → seekworkingyear",
|
||||
"- 'job titles', 'positions', 'roles' → seekjobtitleexperience",
|
||||
"- 'status', 'availability' → seekstatus",
|
||||
"- 'salary', 'pay', 'compensation' → salaryexpectation",
|
||||
"- 'location', 'where' → seeklocation",
|
||||
"- 'skills', 'competencies' → skills",
|
||||
"- 'languages' → languageskills",
|
||||
"- 'personality', 'MBTI' → mbti",
|
||||
"- 'new/recent' → dt_create (use prop.dt_create(>=:YYYY-MM-DD))",
|
||||
synonymList || "- No specific mappings available",
|
||||
"",
|
||||
"Status value mappings:",
|
||||
"- 'urgent', 'urgently', 'ASAP', 'quickly' → startasap",
|
||||
"- 'no rush', 'taking time', 'leisurely' → norush",
|
||||
"- 'not looking', 'not active' → notlooking",
|
||||
"Date handling:",
|
||||
"- 'new/recent' → dt_create (use prop.dt_create(>=:YYYY-MM-DD))",
|
||||
"- 'updated' → dt_update",
|
||||
"",
|
||||
"Rules:",
|
||||
"- Object must be 'seekers'.",
|
||||
"- Use indexes when possible (idx.seekstatus_alias for status queries)",
|
||||
"- For date filters, use prop.dt_create with absolute dates",
|
||||
"- Only return recruiter-readable fields in 'fields' array",
|
||||
`- Default fields if request is generic: ${recruiterReadableFields
|
||||
`- Object should be '${targetObject}' unless query clearly indicates another object`,
|
||||
"- Use indexes when available for better performance",
|
||||
"- For date filters, use prop.dt_create/dt_update with absolute dates",
|
||||
"- Only return readable fields in 'fields' array",
|
||||
`- Default fields if request is generic: ${readableFields
|
||||
.slice(0, 5)
|
||||
.join(", ")}`,
|
||||
"",
|
||||
"Timezone is Europe/Paris. Today is 2025-10-14.",
|
||||
"Interpret 'last week' as now minus 7 days → 2025-10-07.",
|
||||
"Interpret 'yesterday' as → 2025-10-13.",
|
||||
"Timezone is Europe/Paris. Today is 2025-10-15.",
|
||||
"Interpret 'last week' as now minus 7 days → 2025-10-08.",
|
||||
"Interpret 'yesterday' as → 2025-10-14.",
|
||||
].join("\n");
|
||||
}
|
||||
function userPrompt(nl) {
|
||||
@@ -511,18 +307,18 @@ function userPrompt(nl) {
|
||||
// ---- OpenAI call using Responses API (text.format) ----
|
||||
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
async function inferQuery(nlText) {
|
||||
async function inferQuery(nlText, targetObject) {
|
||||
const resp = await client.responses.create({
|
||||
model: MODEL,
|
||||
input: [
|
||||
{ role: "system", content: systemPrompt() },
|
||||
{ role: "system", content: systemPrompt(targetObject) },
|
||||
{ role: "user", content: userPrompt(nlText) },
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
name: "OdmdbQuery",
|
||||
type: "json_schema",
|
||||
schema: buildResponseJsonSchema(),
|
||||
schema: buildResponseJsonSchema(targetObject),
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
@@ -540,12 +336,20 @@ async function inferQuery(nlText) {
|
||||
}
|
||||
|
||||
// ---- Validate using the ODMDB schema (not zod) ----
|
||||
function validateWithOdmdbSchema(candidate) {
|
||||
function validateWithOdmdbSchema(candidate, targetObject) {
|
||||
// Basic shape checks (already enforced by Structured Outputs, but keep defensive)
|
||||
if (!candidate || typeof candidate !== "object")
|
||||
throw new Error("Invalid response (not an object).");
|
||||
if (candidate.object !== "seekers")
|
||||
throw new Error("Invalid object; must be 'seekers'.");
|
||||
|
||||
const availableObjects = Array.from(mappingManager.mappings.keys());
|
||||
if (!availableObjects.includes(candidate.object)) {
|
||||
throw new Error(
|
||||
`Invalid object '${
|
||||
candidate.object
|
||||
}'; must be one of: ${availableObjects.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(candidate.condition) || candidate.condition.length === 0) {
|
||||
throw new Error(
|
||||
"Invalid 'condition'; must be a non-empty array of strings."
|
||||
@@ -555,17 +359,19 @@ function validateWithOdmdbSchema(candidate) {
|
||||
throw new Error("Invalid 'fields'; must be a non-empty array of strings.");
|
||||
}
|
||||
|
||||
// Validate fields against schema
|
||||
const availableFields = schemaMapper.getAllSeekersFields();
|
||||
const recruiterReadableFields = schemaMapper.getRecruiterReadableFields();
|
||||
// Validate fields against schema for the specific object
|
||||
const availableFields = getAllObjectFields(candidate.object);
|
||||
const readableFields = getReadableFields(candidate.object);
|
||||
|
||||
for (const field of candidate.fields) {
|
||||
if (!availableFields.includes(field)) {
|
||||
throw new Error(`Invalid field '${field}'; not found in seekers schema.`);
|
||||
throw new Error(
|
||||
`Invalid field '${field}'; not found in ${candidate.object} schema.`
|
||||
);
|
||||
}
|
||||
if (!recruiterReadableFields.includes(field)) {
|
||||
if (!readableFields.includes(field)) {
|
||||
console.warn(
|
||||
`Warning: Field '${field}' may not be readable by recruiters.`
|
||||
`Warning: Field '${field}' may not be readable for ${candidate.object}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -580,34 +386,33 @@ function validateWithOdmdbSchema(candidate) {
|
||||
if (!tokenOK || !ascii) throw new Error(`Malformed condition: ${c}`);
|
||||
}
|
||||
|
||||
// Field existence check against ODMDB custom schema (seekers properties)
|
||||
if (SEEKERS_FIELDS_FROM_SCHEMA.length) {
|
||||
// Additional field validation and cleanup
|
||||
const objectAvailableFields = getAllObjectFields(candidate.object);
|
||||
if (objectAvailableFields.length) {
|
||||
const unknown = candidate.fields.filter(
|
||||
(f) => !SEEKERS_FIELDS_FROM_SCHEMA.includes(f)
|
||||
(f) => !objectAvailableFields.includes(f)
|
||||
);
|
||||
if (unknown.length) {
|
||||
// Drop unknown but continue (PoC behavior)
|
||||
console.warn(
|
||||
"⚠️ Dropping unknown fields (not in seekers schema):",
|
||||
`⚠️ Dropping unknown fields (not in ${candidate.object} schema):`,
|
||||
unknown
|
||||
);
|
||||
candidate.fields = candidate.fields.filter((f) =>
|
||||
SEEKERS_FIELDS_FROM_SCHEMA.includes(f)
|
||||
objectAvailableFields.includes(f)
|
||||
);
|
||||
if (!candidate.fields.length) {
|
||||
// If all dropped, fallback to default shortlist intersected with schema
|
||||
const fallback = seekersMapping.defaultReadableFields.filter((f) =>
|
||||
SEEKERS_FIELDS_FROM_SCHEMA.includes(f)
|
||||
);
|
||||
if (!fallback.length)
|
||||
throw new Error(
|
||||
"No valid fields remain after validation and no fallback available."
|
||||
);
|
||||
// If all dropped, fallback to object-specific default fields
|
||||
const fallback = getObjectFallbackFields(candidate.object);
|
||||
candidate.fields = fallback;
|
||||
console.warn(
|
||||
`🔄 Using fallback fields for ${candidate.object}:`,
|
||||
fallback
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If we can't read the schema (main.json shape unknown), at least ensure strings & dedupe
|
||||
// If we can't read the schema, at least ensure strings & dedupe
|
||||
candidate.fields = [
|
||||
...new Set(
|
||||
candidate.fields.filter((f) => typeof f === "string" && f.trim())
|
||||
@@ -769,11 +574,12 @@ async function processResults(results, jqFilter = ".") {
|
||||
throw new Error("Missing OPENAI_API_KEY env var.");
|
||||
|
||||
console.log(`🤖 Processing NL query: "${NL_QUERY}"`);
|
||||
console.log(`🎯 Target object: ${TEST_OBJECT}`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Step 1: Generate ODMDB query from natural language
|
||||
const out = await inferQuery(NL_QUERY);
|
||||
const validated = validateWithOdmdbSchema(out);
|
||||
const out = await inferQuery(NL_QUERY, TEST_OBJECT);
|
||||
const validated = validateWithOdmdbSchema(out, TEST_OBJECT);
|
||||
|
||||
console.log("✅ Generated ODMDB Query:");
|
||||
const generatedQuery = {
|
||||
|
Reference in New Issue
Block a user