goldenmatch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -0
- package/dist/cli.cjs +6079 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +6076 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/index.cjs +8449 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +1972 -0
- package/dist/core/index.d.ts +1972 -0
- package/dist/core/index.js +8318 -0
- package/dist/core/index.js.map +1 -0
- package/dist/index.cjs +8449 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8318 -0
- package/dist/index.js.map +1 -0
- package/dist/node/backends/score-worker.cjs +934 -0
- package/dist/node/backends/score-worker.cjs.map +1 -0
- package/dist/node/backends/score-worker.d.cts +14 -0
- package/dist/node/backends/score-worker.d.ts +14 -0
- package/dist/node/backends/score-worker.js +932 -0
- package/dist/node/backends/score-worker.js.map +1 -0
- package/dist/node/index.cjs +11430 -0
- package/dist/node/index.cjs.map +1 -0
- package/dist/node/index.d.cts +554 -0
- package/dist/node/index.d.ts +554 -0
- package/dist/node/index.js +11277 -0
- package/dist/node/index.js.map +1 -0
- package/dist/types-DhUdX5Rc.d.cts +304 -0
- package/dist/types-DhUdX5Rc.d.ts +304 -0
- package/examples/01-basic-dedupe.ts +60 -0
- package/examples/02-match-two-datasets.ts +48 -0
- package/examples/03-csv-file-pipeline.ts +62 -0
- package/examples/04-string-scoring.ts +63 -0
- package/examples/05-custom-config.ts +94 -0
- package/examples/06-probabilistic-fs.ts +72 -0
- package/examples/07-pprl-privacy.ts +76 -0
- package/examples/08-streaming.ts +79 -0
- package/examples/09-llm-scorer.ts +79 -0
- package/examples/10-explain.ts +60 -0
- package/examples/11-evaluate.ts +61 -0
- package/examples/README.md +53 -0
- package/package.json +66 -0
- package/src/cli.ts +372 -0
- package/src/core/ann-blocker.ts +593 -0
- package/src/core/api.ts +220 -0
- package/src/core/autoconfig.ts +363 -0
- package/src/core/autofix.ts +102 -0
- package/src/core/blocker.ts +655 -0
- package/src/core/cluster.ts +699 -0
- package/src/core/compare-clusters.ts +176 -0
- package/src/core/config/loader.ts +869 -0
- package/src/core/cross-encoder.ts +614 -0
- package/src/core/data.ts +430 -0
- package/src/core/domain.ts +277 -0
- package/src/core/embedder.ts +562 -0
- package/src/core/evaluate.ts +156 -0
- package/src/core/explain.ts +352 -0
- package/src/core/golden.ts +524 -0
- package/src/core/graph-er.ts +371 -0
- package/src/core/index.ts +314 -0
- package/src/core/ingest.ts +112 -0
- package/src/core/learned-blocking.ts +305 -0
- package/src/core/lineage.ts +221 -0
- package/src/core/llm/budget.ts +258 -0
- package/src/core/llm/cluster.ts +542 -0
- package/src/core/llm/scorer.ts +396 -0
- package/src/core/match-one.ts +95 -0
- package/src/core/matchkey.ts +97 -0
- package/src/core/memory/corrections.ts +179 -0
- package/src/core/memory/learner.ts +218 -0
- package/src/core/memory/store.ts +114 -0
- package/src/core/pipeline.ts +366 -0
- package/src/core/pprl/protocol.ts +216 -0
- package/src/core/probabilistic.ts +511 -0
- package/src/core/profiler.ts +212 -0
- package/src/core/quality.ts +197 -0
- package/src/core/review-queue.ts +177 -0
- package/src/core/scorer.ts +855 -0
- package/src/core/sensitivity.ts +196 -0
- package/src/core/standardize.ts +279 -0
- package/src/core/streaming.ts +128 -0
- package/src/core/transforms.ts +599 -0
- package/src/core/types.ts +570 -0
- package/src/core/validate.ts +243 -0
- package/src/index.ts +8 -0
- package/src/node/a2a/server.ts +470 -0
- package/src/node/api/server.ts +412 -0
- package/src/node/backends/duckdb.ts +130 -0
- package/src/node/backends/score-worker.ts +41 -0
- package/src/node/backends/workers.ts +212 -0
- package/src/node/config-file.ts +66 -0
- package/src/node/connectors/base.ts +57 -0
- package/src/node/connectors/bigquery.ts +61 -0
- package/src/node/connectors/databricks.ts +69 -0
- package/src/node/connectors/file.ts +350 -0
- package/src/node/connectors/hubspot.ts +62 -0
- package/src/node/connectors/index.ts +43 -0
- package/src/node/connectors/salesforce.ts +93 -0
- package/src/node/connectors/snowflake.ts +73 -0
- package/src/node/db/postgres.ts +173 -0
- package/src/node/db/sync.ts +103 -0
- package/src/node/dedupe-file.ts +156 -0
- package/src/node/index.ts +89 -0
- package/src/node/mcp/server.ts +940 -0
- package/src/node/tui/app.ts +756 -0
- package/src/node/tui/index.ts +6 -0
- package/src/node/tui/widgets.ts +128 -0
- package/tests/parity/scorer-ground-truth.test.ts +118 -0
- package/tests/smoke.test.ts +46 -0
- package/tests/unit/a2a-server.test.ts +175 -0
- package/tests/unit/ann-blocker.test.ts +117 -0
- package/tests/unit/api-server.test.ts +239 -0
- package/tests/unit/api.test.ts +77 -0
- package/tests/unit/autoconfig.test.ts +103 -0
- package/tests/unit/autofix.test.ts +71 -0
- package/tests/unit/blocker.test.ts +164 -0
- package/tests/unit/buildBlocksAsync.test.ts +63 -0
- package/tests/unit/cluster.test.ts +213 -0
- package/tests/unit/compare-clusters.test.ts +42 -0
- package/tests/unit/config-loader.test.ts +301 -0
- package/tests/unit/connectors-base.test.ts +48 -0
- package/tests/unit/cross-encoder-model.test.ts +198 -0
- package/tests/unit/cross-encoder.test.ts +173 -0
- package/tests/unit/db-connectors.test.ts +37 -0
- package/tests/unit/domain.test.ts +80 -0
- package/tests/unit/embedder.test.ts +151 -0
- package/tests/unit/evaluate.test.ts +85 -0
- package/tests/unit/explain.test.ts +73 -0
- package/tests/unit/golden.test.ts +97 -0
- package/tests/unit/graph-er.test.ts +173 -0
- package/tests/unit/hnsw-ann.test.ts +283 -0
- package/tests/unit/hubspot-connector.test.ts +118 -0
- package/tests/unit/ingest.test.ts +97 -0
- package/tests/unit/learned-blocking.test.ts +134 -0
- package/tests/unit/lineage.test.ts +135 -0
- package/tests/unit/match-one.test.ts +129 -0
- package/tests/unit/matchkey.test.ts +97 -0
- package/tests/unit/mcp-server.test.ts +183 -0
- package/tests/unit/memory.test.ts +119 -0
- package/tests/unit/pipeline.test.ts +118 -0
- package/tests/unit/pprl-protocol.test.ts +381 -0
- package/tests/unit/probabilistic.test.ts +494 -0
- package/tests/unit/profiler.test.ts +68 -0
- package/tests/unit/review-queue.test.ts +68 -0
- package/tests/unit/salesforce-connector.test.ts +148 -0
- package/tests/unit/scorer.test.ts +301 -0
- package/tests/unit/sensitivity.test.ts +154 -0
- package/tests/unit/standardize.test.ts +84 -0
- package/tests/unit/streaming.test.ts +82 -0
- package/tests/unit/transforms.test.ts +208 -0
- package/tests/unit/tui-widgets.test.ts +42 -0
- package/tests/unit/tui.test.ts +24 -0
- package/tests/unit/validate.test.ts +145 -0
- package/tests/unit/workers-parallel.test.ts +99 -0
- package/tests/unit/workers.test.ts +74 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +37 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2a/server.ts -- GoldenMatch A2A (Agent-to-Agent) protocol server.
|
|
3
|
+
*
|
|
4
|
+
* Node-only: uses node:http, node:crypto. NOT edge-safe.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints:
|
|
7
|
+
* GET /.well-known/agent.json - agent card (10+ skills)
|
|
8
|
+
* POST /tasks - create a task (skill + input)
|
|
9
|
+
* GET /tasks/{id} - fetch task status/result
|
|
10
|
+
*
|
|
11
|
+
* Ports ideas from goldenmatch/a2a/server.py. This is a simpler
|
|
12
|
+
* synchronous variant (no SSE streaming, no persistent store).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createServer,
|
|
17
|
+
type IncomingMessage,
|
|
18
|
+
type ServerResponse,
|
|
19
|
+
} from "node:http";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { dedupe, match, scoreStrings } from "../../core/api.js";
|
|
22
|
+
import { profileRows } from "../../core/profiler.js";
|
|
23
|
+
import { explainPair } from "../../core/explain.js";
|
|
24
|
+
import type { Row } from "../../core/types.js";
|
|
25
|
+
import {
|
|
26
|
+
makeMatchkeyConfig,
|
|
27
|
+
makeMatchkeyField,
|
|
28
|
+
VALID_SCORERS,
|
|
29
|
+
VALID_TRANSFORMS,
|
|
30
|
+
VALID_STRATEGIES,
|
|
31
|
+
} from "../../core/types.js";
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Agent card
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface AgentSkill {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly description: string;
|
|
40
|
+
readonly inputModes: readonly string[];
|
|
41
|
+
readonly outputModes: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const AGENT_CARD: {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly description: string;
|
|
47
|
+
readonly version: string;
|
|
48
|
+
readonly provider: {
|
|
49
|
+
readonly organization: string;
|
|
50
|
+
readonly url: string;
|
|
51
|
+
};
|
|
52
|
+
readonly capabilities: Readonly<Record<string, boolean>>;
|
|
53
|
+
readonly skills: readonly AgentSkill[];
|
|
54
|
+
} = {
|
|
55
|
+
name: "goldenmatch-js",
|
|
56
|
+
description:
|
|
57
|
+
"Entity resolution agent -- dedupe, match, profile, score, explain, evaluate.",
|
|
58
|
+
version: "0.1.0",
|
|
59
|
+
provider: {
|
|
60
|
+
organization: "goldenmatch",
|
|
61
|
+
url: "https://github.com/benzsevern/goldenmatch",
|
|
62
|
+
},
|
|
63
|
+
capabilities: {
|
|
64
|
+
streaming: false,
|
|
65
|
+
pushNotifications: false,
|
|
66
|
+
stateTransitionHistory: false,
|
|
67
|
+
},
|
|
68
|
+
skills: [
|
|
69
|
+
{
|
|
70
|
+
name: "dedupe",
|
|
71
|
+
description: "Deduplicate a list of records and return golden records plus clusters.",
|
|
72
|
+
inputModes: ["data/json"],
|
|
73
|
+
outputModes: ["data/json"],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "match",
|
|
77
|
+
description: "Match target records against reference records.",
|
|
78
|
+
inputModes: ["data/json"],
|
|
79
|
+
outputModes: ["data/json"],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "score",
|
|
83
|
+
description: "Score similarity between two strings.",
|
|
84
|
+
inputModes: ["text"],
|
|
85
|
+
outputModes: ["text"],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "profile",
|
|
89
|
+
description: "Profile a dataset (types, null rates, cardinality).",
|
|
90
|
+
inputModes: ["data/json"],
|
|
91
|
+
outputModes: ["data/json"],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "suggest_config",
|
|
95
|
+
description: "Auto-generate a shorthand dedupe config from a dataset profile.",
|
|
96
|
+
inputModes: ["data/json"],
|
|
97
|
+
outputModes: ["data/json"],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "explain_pair",
|
|
101
|
+
description: "Explain why two records match using weighted field scorers.",
|
|
102
|
+
inputModes: ["data/json"],
|
|
103
|
+
outputModes: ["data/json"],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "evaluate",
|
|
107
|
+
description: "Evaluate predicted pairs vs ground truth (precision/recall/F1).",
|
|
108
|
+
inputModes: ["data/json"],
|
|
109
|
+
outputModes: ["data/json"],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "list_scorers",
|
|
113
|
+
description: "List all available similarity scorers.",
|
|
114
|
+
inputModes: ["text"],
|
|
115
|
+
outputModes: ["data/json"],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "list_transforms",
|
|
119
|
+
description: "List all available field transforms.",
|
|
120
|
+
inputModes: ["text"],
|
|
121
|
+
outputModes: ["data/json"],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "list_strategies",
|
|
125
|
+
description: "List all golden-record survivorship strategies.",
|
|
126
|
+
inputModes: ["text"],
|
|
127
|
+
outputModes: ["data/json"],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Task store
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface Task {
|
|
137
|
+
readonly id: string;
|
|
138
|
+
readonly skill: string;
|
|
139
|
+
status: "pending" | "running" | "completed" | "failed";
|
|
140
|
+
readonly createdAt: string;
|
|
141
|
+
completedAt?: string;
|
|
142
|
+
result?: unknown;
|
|
143
|
+
error?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Skill dispatch
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async function dispatchSkill(
|
|
151
|
+
skill: string,
|
|
152
|
+
input: Record<string, unknown>,
|
|
153
|
+
): Promise<unknown> {
|
|
154
|
+
switch (skill) {
|
|
155
|
+
case "dedupe": {
|
|
156
|
+
if (!Array.isArray(input["rows"])) throw new Error("rows must be an array");
|
|
157
|
+
const rows = input["rows"] as Row[];
|
|
158
|
+
const opts: {
|
|
159
|
+
exact?: readonly string[];
|
|
160
|
+
fuzzy?: Readonly<Record<string, number>>;
|
|
161
|
+
blocking?: readonly string[];
|
|
162
|
+
threshold?: number;
|
|
163
|
+
} = {};
|
|
164
|
+
if (Array.isArray(input["exact"])) opts.exact = input["exact"].map(String);
|
|
165
|
+
if (Array.isArray(input["blocking"])) opts.blocking = input["blocking"].map(String);
|
|
166
|
+
if (input["fuzzy"] && typeof input["fuzzy"] === "object" && !Array.isArray(input["fuzzy"])) {
|
|
167
|
+
const f: Record<string, number> = {};
|
|
168
|
+
for (const [k, v] of Object.entries(input["fuzzy"] as Record<string, unknown>)) {
|
|
169
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
170
|
+
if (Number.isFinite(n)) f[k] = n;
|
|
171
|
+
}
|
|
172
|
+
opts.fuzzy = f;
|
|
173
|
+
}
|
|
174
|
+
if (typeof input["threshold"] === "number") opts.threshold = input["threshold"];
|
|
175
|
+
const result = dedupe(rows, opts);
|
|
176
|
+
return {
|
|
177
|
+
stats: {
|
|
178
|
+
total_records: result.stats.totalRecords,
|
|
179
|
+
total_clusters: result.stats.totalClusters,
|
|
180
|
+
match_rate: result.stats.matchRate,
|
|
181
|
+
},
|
|
182
|
+
golden_records: result.goldenRecords,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "match": {
|
|
187
|
+
if (!Array.isArray(input["target"])) throw new Error("target must be an array");
|
|
188
|
+
if (!Array.isArray(input["reference"])) throw new Error("reference must be an array");
|
|
189
|
+
const target = (input["target"] as Row[]).map((r) => ({ ...r, __source__: "target" }));
|
|
190
|
+
const reference = (input["reference"] as Row[]).map((r) => ({
|
|
191
|
+
...r,
|
|
192
|
+
__source__: "reference",
|
|
193
|
+
}));
|
|
194
|
+
const opts: {
|
|
195
|
+
exact?: readonly string[];
|
|
196
|
+
fuzzy?: Readonly<Record<string, number>>;
|
|
197
|
+
blocking?: readonly string[];
|
|
198
|
+
threshold?: number;
|
|
199
|
+
} = {};
|
|
200
|
+
if (Array.isArray(input["exact"])) opts.exact = input["exact"].map(String);
|
|
201
|
+
if (Array.isArray(input["blocking"])) opts.blocking = input["blocking"].map(String);
|
|
202
|
+
if (input["fuzzy"] && typeof input["fuzzy"] === "object" && !Array.isArray(input["fuzzy"])) {
|
|
203
|
+
const f: Record<string, number> = {};
|
|
204
|
+
for (const [k, v] of Object.entries(input["fuzzy"] as Record<string, unknown>)) {
|
|
205
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
206
|
+
if (Number.isFinite(n)) f[k] = n;
|
|
207
|
+
}
|
|
208
|
+
opts.fuzzy = f;
|
|
209
|
+
}
|
|
210
|
+
if (typeof input["threshold"] === "number") opts.threshold = input["threshold"];
|
|
211
|
+
const result = match(target, reference, opts);
|
|
212
|
+
return {
|
|
213
|
+
matched: result.matched,
|
|
214
|
+
unmatched: result.unmatched,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "score": {
|
|
219
|
+
const a = String(input["a"] ?? "");
|
|
220
|
+
const b = String(input["b"] ?? "");
|
|
221
|
+
const scorer = typeof input["scorer"] === "string" ? (input["scorer"] as string) : "jaro_winkler";
|
|
222
|
+
return { scorer, score: scoreStrings(a, b, scorer) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "profile": {
|
|
226
|
+
if (!Array.isArray(input["rows"])) throw new Error("rows must be an array");
|
|
227
|
+
const profile = profileRows(input["rows"] as Row[]);
|
|
228
|
+
return {
|
|
229
|
+
row_count: profile.rowCount,
|
|
230
|
+
columns: profile.columns.map((c) => ({
|
|
231
|
+
name: c.name,
|
|
232
|
+
inferred_type: c.inferredType,
|
|
233
|
+
null_rate: c.nullRate,
|
|
234
|
+
cardinality_ratio: c.cardinalityRatio,
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case "suggest_config": {
|
|
240
|
+
if (!Array.isArray(input["rows"])) throw new Error("rows must be an array");
|
|
241
|
+
const profile = profileRows(input["rows"] as Row[]);
|
|
242
|
+
const exact: string[] = [];
|
|
243
|
+
const fuzzy: Record<string, number> = {};
|
|
244
|
+
const blocking: string[] = [];
|
|
245
|
+
for (const col of profile.columns) {
|
|
246
|
+
if (col.nullRate > 0.2) continue;
|
|
247
|
+
if (col.inferredType === "email" && col.cardinalityRatio >= 0.5) exact.push(col.name);
|
|
248
|
+
else if (col.inferredType === "phone" && col.cardinalityRatio >= 0.5) exact.push(col.name);
|
|
249
|
+
else if (col.inferredType === "zip" || col.inferredType === "geo") blocking.push(col.name);
|
|
250
|
+
else if (col.inferredType === "name") fuzzy[col.name] = 0.85;
|
|
251
|
+
else if (col.inferredType === "text" && col.avgLength > 4) fuzzy[col.name] = 0.8;
|
|
252
|
+
}
|
|
253
|
+
return { suggested: { exact, fuzzy, blocking, threshold: 0.85 } };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case "explain_pair": {
|
|
257
|
+
const rowA = input["row_a"] as Row | undefined;
|
|
258
|
+
const rowB = input["row_b"] as Row | undefined;
|
|
259
|
+
if (!rowA || !rowB) throw new Error("row_a and row_b are required");
|
|
260
|
+
const fieldsRaw = input["fields"];
|
|
261
|
+
if (!Array.isArray(fieldsRaw)) throw new Error("fields must be an array");
|
|
262
|
+
const fields = fieldsRaw.map((entry) => {
|
|
263
|
+
const e = entry as Record<string, unknown>;
|
|
264
|
+
return makeMatchkeyField({
|
|
265
|
+
field: String(e["field"]),
|
|
266
|
+
transforms: Array.isArray(e["transforms"])
|
|
267
|
+
? (e["transforms"] as unknown[]).map(String)
|
|
268
|
+
: ["lowercase", "strip"],
|
|
269
|
+
scorer: typeof e["scorer"] === "string" ? (e["scorer"] as string) : "jaro_winkler",
|
|
270
|
+
weight: typeof e["weight"] === "number" ? (e["weight"] as number) : 1.0,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
const mk = makeMatchkeyConfig({
|
|
274
|
+
name: "adhoc",
|
|
275
|
+
type: "weighted",
|
|
276
|
+
fields,
|
|
277
|
+
threshold: typeof input["threshold"] === "number" ? (input["threshold"] as number) : 0.85,
|
|
278
|
+
});
|
|
279
|
+
const result = explainPair(rowA, rowB, mk);
|
|
280
|
+
return {
|
|
281
|
+
score: result.score,
|
|
282
|
+
confidence: result.confidence,
|
|
283
|
+
explanation: result.explanation,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
case "evaluate": {
|
|
288
|
+
// Accept pre-computed predicted/truth pairs for simplicity.
|
|
289
|
+
const predicted = Array.isArray(input["predicted"])
|
|
290
|
+
? (input["predicted"] as unknown[]).map((p) => {
|
|
291
|
+
const pair = p as Record<string, unknown>;
|
|
292
|
+
return [Number(pair["id_a"]), Number(pair["id_b"])] as const;
|
|
293
|
+
})
|
|
294
|
+
: [];
|
|
295
|
+
const truth = Array.isArray(input["truth"])
|
|
296
|
+
? (input["truth"] as unknown[]).map((p) => {
|
|
297
|
+
const pair = p as Record<string, unknown>;
|
|
298
|
+
return [Number(pair["id_a"]), Number(pair["id_b"])] as const;
|
|
299
|
+
})
|
|
300
|
+
: [];
|
|
301
|
+
const truthSet = new Set(truth.map(([a, b]) => `${Math.min(a, b)}:${Math.max(a, b)}`));
|
|
302
|
+
const predSet = new Set(
|
|
303
|
+
predicted.map(([a, b]) => `${Math.min(a, b)}:${Math.max(a, b)}`),
|
|
304
|
+
);
|
|
305
|
+
let tp = 0;
|
|
306
|
+
let fp = 0;
|
|
307
|
+
for (const p of predSet) {
|
|
308
|
+
if (truthSet.has(p)) tp++;
|
|
309
|
+
else fp++;
|
|
310
|
+
}
|
|
311
|
+
let fn = 0;
|
|
312
|
+
for (const t of truthSet) {
|
|
313
|
+
if (!predSet.has(t)) fn++;
|
|
314
|
+
}
|
|
315
|
+
const precision = tp + fp > 0 ? tp / (tp + fp) : 0;
|
|
316
|
+
const recall = tp + fn > 0 ? tp / (tp + fn) : 0;
|
|
317
|
+
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
318
|
+
return { tp, fp, fn, precision, recall, f1 };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case "list_scorers":
|
|
322
|
+
return { scorers: [...VALID_SCORERS] };
|
|
323
|
+
|
|
324
|
+
case "list_transforms":
|
|
325
|
+
return { transforms: [...VALID_TRANSFORMS] };
|
|
326
|
+
|
|
327
|
+
case "list_strategies":
|
|
328
|
+
return { strategies: [...VALID_STRATEGIES] };
|
|
329
|
+
|
|
330
|
+
default:
|
|
331
|
+
throw new Error(`Unknown skill: ${skill}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Helpers
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
340
|
+
let body = "";
|
|
341
|
+
for await (const chunk of req) {
|
|
342
|
+
body += typeof chunk === "string" ? chunk : (chunk as Buffer).toString("utf8");
|
|
343
|
+
}
|
|
344
|
+
if (!body) return {};
|
|
345
|
+
const parsed = JSON.parse(body);
|
|
346
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
347
|
+
throw new Error("body must be a JSON object");
|
|
348
|
+
}
|
|
349
|
+
return parsed as Record<string, unknown>;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sendJson(res: ServerResponse, status: number, data: unknown): void {
|
|
353
|
+
res.statusCode = status;
|
|
354
|
+
res.setHeader("Content-Type", "application/json");
|
|
355
|
+
res.end(JSON.stringify(data));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Public: startA2aServer
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
export interface StartA2aOptions {
|
|
363
|
+
readonly port?: number;
|
|
364
|
+
readonly host?: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function startA2aServer(options: StartA2aOptions = {}): ReturnType<typeof createServer> {
|
|
368
|
+
const port = options.port ?? 8200;
|
|
369
|
+
const host = options.host ?? "127.0.0.1";
|
|
370
|
+
const tasks = new Map<string, Task>();
|
|
371
|
+
|
|
372
|
+
const server = createServer(async (req, res) => {
|
|
373
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
374
|
+
const pathname = url.pathname;
|
|
375
|
+
const methodName = req.method ?? "GET";
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
if (pathname === "/.well-known/agent.json" && methodName === "GET") {
|
|
379
|
+
sendJson(res, 200, AGENT_CARD);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (pathname === "/health" && methodName === "GET") {
|
|
384
|
+
sendJson(res, 200, { status: "ok", agent: "goldenmatch-js" });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (pathname === "/tasks" && methodName === "POST") {
|
|
389
|
+
const body = await readJsonBody(req);
|
|
390
|
+
const skill = String(body["skill"] ?? "");
|
|
391
|
+
const input =
|
|
392
|
+
(body["input"] as Record<string, unknown> | undefined) ??
|
|
393
|
+
(body["params"] as Record<string, unknown> | undefined) ??
|
|
394
|
+
{};
|
|
395
|
+
if (!skill) {
|
|
396
|
+
sendJson(res, 400, { error: "skill is required" });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const id = randomUUID();
|
|
400
|
+
const createdAt = new Date().toISOString();
|
|
401
|
+
const task: Task = {
|
|
402
|
+
id,
|
|
403
|
+
skill,
|
|
404
|
+
status: "running",
|
|
405
|
+
createdAt,
|
|
406
|
+
};
|
|
407
|
+
tasks.set(id, task);
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const result = await dispatchSkill(skill, input);
|
|
411
|
+
task.status = "completed";
|
|
412
|
+
task.completedAt = new Date().toISOString();
|
|
413
|
+
task.result = result;
|
|
414
|
+
sendJson(res, 200, {
|
|
415
|
+
id,
|
|
416
|
+
status: task.status,
|
|
417
|
+
skill,
|
|
418
|
+
created_at: createdAt,
|
|
419
|
+
completed_at: task.completedAt,
|
|
420
|
+
result,
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
424
|
+
task.status = "failed";
|
|
425
|
+
task.completedAt = new Date().toISOString();
|
|
426
|
+
task.error = msg;
|
|
427
|
+
sendJson(res, 200, {
|
|
428
|
+
id,
|
|
429
|
+
status: task.status,
|
|
430
|
+
skill,
|
|
431
|
+
created_at: createdAt,
|
|
432
|
+
completed_at: task.completedAt,
|
|
433
|
+
error: msg,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (pathname.startsWith("/tasks/") && methodName === "GET") {
|
|
440
|
+
const id = pathname.slice("/tasks/".length);
|
|
441
|
+
const task = tasks.get(id);
|
|
442
|
+
if (!task) {
|
|
443
|
+
sendJson(res, 404, { error: `Task not found: ${id}` });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
sendJson(res, 200, {
|
|
447
|
+
id: task.id,
|
|
448
|
+
skill: task.skill,
|
|
449
|
+
status: task.status,
|
|
450
|
+
created_at: task.createdAt,
|
|
451
|
+
completed_at: task.completedAt ?? null,
|
|
452
|
+
result: task.result ?? null,
|
|
453
|
+
error: task.error ?? null,
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
sendJson(res, 404, { error: `Not found: ${methodName} ${pathname}` });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
461
|
+
sendJson(res, 500, { error: msg });
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
server.listen(port, host, () => {
|
|
466
|
+
// eslint-disable-next-line no-console
|
|
467
|
+
console.log(`GoldenMatch A2A agent listening on http://${host}:${port}`);
|
|
468
|
+
});
|
|
469
|
+
return server;
|
|
470
|
+
}
|