111 lines
5.2 KiB
TypeScript
111 lines
5.2 KiB
TypeScript
import { SiaxError } from "../platform/errors";
|
|
import type { ErCandidateRequest, ErRecord } from "./entity-resolution-service";
|
|
|
|
function finiteThreshold(value: unknown, field: string, fallback: number): number {
|
|
if (value === undefined) return fallback;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `${field} must be a finite number between 0 and 1`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function parseErCandidateRequest(raw: unknown): ErCandidateRequest {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "JSON object body is required");
|
|
}
|
|
const body = raw as Record<string, unknown>;
|
|
const tenantId = typeof body.tenant_id === "string" ? body.tenant_id.trim() : "";
|
|
const projectId = typeof body.project_id === "string" ? body.project_id.trim() : "";
|
|
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.er", "tenant_id is required");
|
|
if (!projectId) throw new SiaxError("INVALID_REQUEST", "0pt.er", "project_id is required");
|
|
|
|
if (!Array.isArray(body.records) || body.records.length < 2 || body.records.length > 5_000) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "records must contain between 2 and 5000 items");
|
|
}
|
|
if (
|
|
!Array.isArray(body.blocking_fields) ||
|
|
body.blocking_fields.length === 0 ||
|
|
body.blocking_fields.length > 20 ||
|
|
!body.blocking_fields.every((v) => typeof v === "string" && v.trim().length > 0)
|
|
) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "blocking_fields must contain 1-20 non-empty strings");
|
|
}
|
|
if (!Array.isArray(body.compare_fields) || body.compare_fields.length === 0 || body.compare_fields.length > 50) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must contain 1-50 items");
|
|
}
|
|
|
|
const records: ErRecord[] = body.records.map((value, index) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}] must be an object`);
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.record_id !== "string" || !record.record_id.trim()) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].record_id is required`);
|
|
}
|
|
if (!record.fields || typeof record.fields !== "object" || Array.isArray(record.fields)) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].fields must be an object`);
|
|
}
|
|
const fields = record.fields as Record<string, unknown>;
|
|
if (Object.keys(fields).length > 200) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].fields exceeds 200 fields`);
|
|
}
|
|
const normalizedFields: ErRecord["fields"] = {};
|
|
for (const [field, fieldValue] of Object.entries(fields)) {
|
|
if (!field.trim()) throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}] contains an empty field name`);
|
|
if (
|
|
fieldValue !== null &&
|
|
fieldValue !== undefined &&
|
|
typeof fieldValue !== "string" &&
|
|
typeof fieldValue !== "number"
|
|
) {
|
|
throw new SiaxError(
|
|
"INVALID_REQUEST",
|
|
"0pt.er",
|
|
`records[${index}].fields.${field} must be string, number, null or undefined`,
|
|
);
|
|
}
|
|
normalizedFields[field.trim()] = fieldValue as string | number | null | undefined;
|
|
}
|
|
return { record_id: record.record_id.trim(), fields: normalizedFields };
|
|
});
|
|
|
|
if (new Set(records.map((record) => record.record_id)).size !== records.length) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "record_id values must be unique within the request");
|
|
}
|
|
|
|
const compareFields = body.compare_fields.map((value, index) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}] must be an object`);
|
|
}
|
|
const item = value as Record<string, unknown>;
|
|
if (typeof item.field !== "string" || !item.field.trim()) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].field is required`);
|
|
}
|
|
const weight = item.weight === undefined ? undefined : Number(item.weight);
|
|
if (weight !== undefined && (!Number.isFinite(weight) || weight < 0 || weight > 100)) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].weight must be between 0 and 100`);
|
|
}
|
|
return { field: item.field.trim(), ...(weight !== undefined ? { weight } : {}) };
|
|
});
|
|
|
|
const autoThreshold = finiteThreshold(body.auto_match_threshold, "auto_match_threshold", 0.9);
|
|
const reviewThreshold = finiteThreshold(body.review_threshold, "review_threshold", 0.6);
|
|
if (reviewThreshold >= autoThreshold) {
|
|
throw new SiaxError("INVALID_REQUEST", "0pt.er", "review_threshold must be lower than auto_match_threshold");
|
|
}
|
|
|
|
return {
|
|
tenant_id: tenantId,
|
|
project_id: projectId,
|
|
records,
|
|
blocking_fields: (body.blocking_fields as string[]).map((value) => value.trim()),
|
|
compare_fields: compareFields,
|
|
auto_match_threshold: autoThreshold,
|
|
review_threshold: reviewThreshold,
|
|
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
|
|
? { correlation_id: body.correlation_id.trim() }
|
|
: {}),
|
|
};
|
|
}
|