diff --git a/app/api/0pt/internal/er/candidates/route.ts b/app/api/0pt/internal/er/candidates/route.ts new file mode 100644 index 0000000..1a5c3e0 --- /dev/null +++ b/app/api/0pt/internal/er/candidates/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { verifyInternalServiceToken } from "@/src/auth/internal-token"; +import { + EntityResolutionService, + type ErCandidateRequest, + type ErRecord, +} from "@/src/er/entity-resolution-service"; +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) { + const av = stringField(a, "lei"); + const bv = stringField(b, "lei"); + if (!av || !bv) return "abstain"; + return av === bv ? "force_match" : "force_non_match"; + }, +}); +er.registerHardRule({ + rule_id: "0pt.company.org_nr.v1", + decide(a, b) { + const av = stringField(a, "org_nr"); + const bv = stringField(b, "org_nr"); + if (!av || !bv) return "abstain"; + return av === bv ? "force_match" : "force_non_match"; + }, +}); + +function stringField(record: ErRecord, field: string): string | undefined { + const value = record.fields[field]; + if (value === null || value === undefined) return undefined; + const normalized = String(value).trim().toUpperCase(); + return normalized || undefined; +} + +function assertInternal(request: NextRequest): NextResponse | undefined { + if (verifyInternalServiceToken(request.headers)) return undefined; + return NextResponse.json( + { error: "UNAUTHENTICATED", message: "valid X-Internal-Token is required" }, + { status: 401 }, + ); +} + +function parseRequest(raw: unknown): ErCandidateRequest { + if (!raw || typeof raw !== "object") { + 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"); + } + 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.compare_fields)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must be an array"); + } + + const records: ErRecord[] = body.records.map((value, index) => { + if (!value || typeof value !== "object") { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}] must be an object`); + } + const record = value as Record; + 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`); + } + return { + record_id: record.record_id, + fields: record.fields as ErRecord["fields"], + }; + }); + + const ids = new Set(records.map((record) => record.record_id)); + if (ids.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") { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}] must be an object`); + } + const item = value as Record; + 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)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].weight must be >= 0`); + } + return { field: item.field, ...(weight !== undefined ? { weight } : {}) }; + }); + + return { + tenant_id: typeof body.tenant_id === "string" ? body.tenant_id : "", + project_id: typeof body.project_id === "string" ? body.project_id : "", + records, + blocking_fields: body.blocking_fields as string[], + compare_fields: compareFields, + ...(body.auto_match_threshold !== undefined + ? { auto_match_threshold: Number(body.auto_match_threshold) } + : {}), + ...(body.review_threshold !== undefined + ? { review_threshold: Number(body.review_threshold) } + : {}), + ...(typeof body.correlation_id === "string" ? { correlation_id: body.correlation_id } : {}), + }; +} + +export async function POST(request: NextRequest) { + const denied = assertInternal(request); + if (denied) return denied; + + try { + const body = parseRequest(await request.json()); + return NextResponse.json(er.generateCandidates(body)); + } catch (error: unknown) { + return siaxErrorResponse(error); + } +}