diff --git a/app/api/0pt/internal/er/candidates/route.ts b/app/api/0pt/internal/er/candidates/route.ts index 1a5c3e0..f03206b 100644 --- a/app/api/0pt/internal/er/candidates/route.ts +++ b/app/api/0pt/internal/er/candidates/route.ts @@ -9,17 +9,8 @@ import { import { siaxErrorResponse } from "@/src/http/siax-error-response"; import { SiaxError } from "@/src/platform/errors"; -/** - * One canonical ER engine for the legacy Python order pipeline. - * - * The route is intentionally internal-only. Python may shape records and apply - * merge output, but it may not score or decide identity. That decision belongs - * to EntityResolutionService here. - */ const er = new EntityResolutionService(); -// Company-pipeline hard rules. Exact stable identifiers dominate fuzzy scores, -// including explicit negative evidence when both records carry conflicting IDs. er.registerHardRule({ rule_id: "0pt.company.lei.v1", decide(a, b) { @@ -29,6 +20,7 @@ er.registerHardRule({ return av === bv ? "force_match" : "force_non_match"; }, }); + er.registerHardRule({ rule_id: "0pt.company.org_nr.v1", decide(a, b) { @@ -54,23 +46,41 @@ function assertInternal(request: NextRequest): NextResponse | undefined { ); } +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; +} + function parseRequest(raw: unknown): ErCandidateRequest { - if (!raw || typeof raw !== "object") { + 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; - if (!Array.isArray(body.records)) { - throw new SiaxError("INVALID_REQUEST", "0pt.er", "records must be an array"); + 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.every((v) => typeof v === "string")) { - throw new SiaxError("INVALID_REQUEST", "0pt.er", "blocking_fields must be a string array"); + if ( + !Array.isArray(body.blocking_fields) || + body.blocking_fields.length === 0 || + !body.blocking_fields.every((v) => typeof v === "string" && v.trim().length > 0) + ) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "blocking_fields must be a non-empty string array"); } - if (!Array.isArray(body.compare_fields)) { - throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must be an array"); + if (!Array.isArray(body.compare_fields) || body.compare_fields.length === 0) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must be a non-empty array"); } const records: ErRecord[] = body.records.map((value, index) => { - if (!value || typeof value !== "object") { + 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; @@ -80,19 +90,15 @@ function parseRequest(raw: unknown): ErCandidateRequest { 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`); } - return { - record_id: record.record_id, - fields: record.fields as ErRecord["fields"], - }; + return { record_id: record.record_id.trim(), fields: record.fields as ErRecord["fields"] }; }); - const ids = new Set(records.map((record) => record.record_id)); - if (ids.size !== records.length) { + 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") { + 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; @@ -100,25 +106,29 @@ function parseRequest(raw: unknown): ErCandidateRequest { 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)) { - throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].weight must be >= 0`); + 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, ...(weight !== undefined ? { weight } : {}) }; + 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: typeof body.tenant_id === "string" ? body.tenant_id : "", - project_id: typeof body.project_id === "string" ? body.project_id : "", + tenant_id: tenantId, + project_id: projectId, records, - blocking_fields: body.blocking_fields as string[], + blocking_fields: (body.blocking_fields as string[]).map((value) => value.trim()), compare_fields: compareFields, - ...(body.auto_match_threshold !== undefined - ? { auto_match_threshold: Number(body.auto_match_threshold) } + auto_match_threshold: autoThreshold, + review_threshold: reviewThreshold, + ...(typeof body.correlation_id === "string" && body.correlation_id.trim() + ? { correlation_id: body.correlation_id.trim() } : {}), - ...(body.review_threshold !== undefined - ? { review_threshold: Number(body.review_threshold) } - : {}), - ...(typeof body.correlation_id === "string" ? { correlation_id: body.correlation_id } : {}), }; } @@ -127,8 +137,7 @@ export async function POST(request: NextRequest) { if (denied) return denied; try { - const body = parseRequest(await request.json()); - return NextResponse.json(er.generateCandidates(body)); + return NextResponse.json(er.generateCandidates(parseRequest(await request.json()))); } catch (error: unknown) { return siaxErrorResponse(error); }