feat(er): expose canonical ER candidate engine to internal pipeline
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<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`);
|
||||
}
|
||||
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<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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user