@odla-ai/kg 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 +75 -0
- package/dist/index.cjs +947 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +435 -0
- package/dist/index.d.ts +435 -0
- package/dist/index.js +924 -0
- package/dist/index.js.map +1 -0
- package/llms.txt +517 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
function emptyUsage() {
|
|
3
|
+
return { calls: 0, inputTokens: 0, outputTokens: 0 };
|
|
4
|
+
}
|
|
5
|
+
function addUsage(into, more) {
|
|
6
|
+
into.calls += more.calls;
|
|
7
|
+
into.inputTokens += more.inputTokens;
|
|
8
|
+
into.outputTokens += more.outputTokens;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/ontology.ts
|
|
12
|
+
function naturalKeysOf(o) {
|
|
13
|
+
const m = {};
|
|
14
|
+
for (const [type, e] of Object.entries(o.entities)) m[type] = e.naturalKey;
|
|
15
|
+
return m;
|
|
16
|
+
}
|
|
17
|
+
function mergeRulesOf(o) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [type, e] of Object.entries(o.entities)) {
|
|
20
|
+
out[type] = {};
|
|
21
|
+
for (const [attr, spec] of Object.entries(e.attrs)) out[type][attr] = spec.merge ?? "replace";
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function humanAttrsOf(o) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [type, e] of Object.entries(o.entities)) {
|
|
28
|
+
out[type] = new Set(Object.entries(e.attrs).filter(([, a]) => a.human).map(([n]) => n));
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function toSerializedSchema(o) {
|
|
33
|
+
const entities = {};
|
|
34
|
+
for (const [type, e] of Object.entries(o.entities)) {
|
|
35
|
+
const attrs = {};
|
|
36
|
+
for (const [name, a] of Object.entries(e.attrs)) {
|
|
37
|
+
attrs[name] = {
|
|
38
|
+
type: a.type,
|
|
39
|
+
unique: !!a.unique,
|
|
40
|
+
indexed: !!a.indexed,
|
|
41
|
+
optional: !!a.optional
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
entities[type] = { attrs };
|
|
45
|
+
}
|
|
46
|
+
const links = {};
|
|
47
|
+
for (const [name, ed] of Object.entries(o.edges)) {
|
|
48
|
+
links[name] = {
|
|
49
|
+
forward: { on: ed.from, has: ed.forwardHas ?? "many", label: ed.forwardLabel },
|
|
50
|
+
reverse: { on: ed.to, has: ed.reverseHas ?? "many", label: ed.reverseLabel }
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { entities, links };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/graph/merge.ts
|
|
57
|
+
function isEmpty(v) {
|
|
58
|
+
return v === void 0 || v === null || v === "";
|
|
59
|
+
}
|
|
60
|
+
function mergeScalar(rule, existing, incoming) {
|
|
61
|
+
if (isEmpty(incoming)) return existing;
|
|
62
|
+
if (isEmpty(existing)) return incoming;
|
|
63
|
+
switch (rule) {
|
|
64
|
+
case "prefer":
|
|
65
|
+
return existing;
|
|
66
|
+
// first non-empty value sticks
|
|
67
|
+
case "earliest":
|
|
68
|
+
return String(incoming) < String(existing) ? incoming : existing;
|
|
69
|
+
case "latest":
|
|
70
|
+
return String(incoming) > String(existing) ? incoming : existing;
|
|
71
|
+
case "replace":
|
|
72
|
+
default:
|
|
73
|
+
return incoming;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function mergeAttrs(rules, existing, incoming) {
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [attr, val] of Object.entries(incoming)) {
|
|
79
|
+
const merged = mergeScalar(rules[attr] ?? "replace", existing[attr], val);
|
|
80
|
+
if (merged !== void 0) out[attr] = merged;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/graph/writer.ts
|
|
86
|
+
function fragmentToOps(fragment, keys) {
|
|
87
|
+
const ref = (type, key) => {
|
|
88
|
+
const attr = keys[type];
|
|
89
|
+
if (!attr) throw new Error(`No natural key configured for node type "${type}"`);
|
|
90
|
+
return { ns: type, attr, value: key };
|
|
91
|
+
};
|
|
92
|
+
const ops = [];
|
|
93
|
+
for (const n of fragment.nodes) {
|
|
94
|
+
const attr = keys[n.type];
|
|
95
|
+
if (!attr) throw new Error(`No natural key configured for node type "${n.type}"`);
|
|
96
|
+
ops.push({ t: "update", ns: n.type, id: { ns: n.type, attr, value: n.key }, attrs: { ...n.attrs, [attr]: n.key } });
|
|
97
|
+
}
|
|
98
|
+
for (const e of fragment.edges) {
|
|
99
|
+
ops.push({ t: "link", ns: e.from.type, id: ref(e.from.type, e.from.key), label: e.label, target: ref(e.to.type, e.to.key) });
|
|
100
|
+
}
|
|
101
|
+
return ops;
|
|
102
|
+
}
|
|
103
|
+
async function writeFragment(db, fragment, ontology, mutationId) {
|
|
104
|
+
const keys = naturalKeysOf(ontology);
|
|
105
|
+
const rules = mergeRulesOf(ontology);
|
|
106
|
+
const humans = humanAttrsOf(ontology);
|
|
107
|
+
const byType = /* @__PURE__ */ new Map();
|
|
108
|
+
for (const n of fragment.nodes) {
|
|
109
|
+
if (!keys[n.type]) throw new Error(`No natural key configured for node type "${n.type}"`);
|
|
110
|
+
const human = humans[n.type] ?? /* @__PURE__ */ new Set();
|
|
111
|
+
const clean = {};
|
|
112
|
+
for (const [k, v] of Object.entries(n.attrs)) if (!human.has(k)) clean[k] = v;
|
|
113
|
+
const typeMap = byType.get(n.type) ?? /* @__PURE__ */ new Map();
|
|
114
|
+
const prev = typeMap.get(n.key);
|
|
115
|
+
typeMap.set(n.key, prev ? mergeAttrs(rules[n.type], prev, clean) : clean);
|
|
116
|
+
byType.set(n.type, typeMap);
|
|
117
|
+
}
|
|
118
|
+
const query = {};
|
|
119
|
+
for (const [type, m] of byType) {
|
|
120
|
+
query[type] = { $: { where: { [keys[type]]: { $in: [...m.keys()] } } } };
|
|
121
|
+
}
|
|
122
|
+
const existing = Object.keys(query).length ? await db.query(query) : {};
|
|
123
|
+
const priorOf = (type, key) => {
|
|
124
|
+
const attr = keys[type];
|
|
125
|
+
for (const row of existing[type] ?? []) {
|
|
126
|
+
if (row[attr] === key) return row;
|
|
127
|
+
}
|
|
128
|
+
return {};
|
|
129
|
+
};
|
|
130
|
+
const ops = [];
|
|
131
|
+
for (const [type, m] of byType) {
|
|
132
|
+
const attr = keys[type];
|
|
133
|
+
for (const [key, incoming] of m) {
|
|
134
|
+
const merged = mergeAttrs(rules[type], priorOf(type, key), incoming);
|
|
135
|
+
ops.push({ t: "update", ns: type, id: { ns: type, attr, value: key }, attrs: { ...merged, [attr]: key } });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const ref = (type, key) => ({ ns: type, attr: keys[type], value: key });
|
|
139
|
+
for (const e of fragment.edges) {
|
|
140
|
+
ops.push({ t: "link", ns: e.from.type, id: ref(e.from.type, e.from.key), label: e.label, target: ref(e.to.type, e.to.key) });
|
|
141
|
+
}
|
|
142
|
+
if (ops.length === 0) return { txId: 0, ops: 0 };
|
|
143
|
+
const txId = await db.transact(ops, { mutationId });
|
|
144
|
+
return { txId, ops: ops.length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/graph/resolve.ts
|
|
148
|
+
async function searchEntities(db, type, q, limit = 5) {
|
|
149
|
+
if (!q.trim()) return [];
|
|
150
|
+
const res = await db.query({ [type]: { $: { search: q, limit } } });
|
|
151
|
+
return res[type] ?? [];
|
|
152
|
+
}
|
|
153
|
+
var norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
154
|
+
async function resolveName(db, type, keyAttr, name) {
|
|
155
|
+
const target = norm(name);
|
|
156
|
+
if (!target) return null;
|
|
157
|
+
for (const h of await searchEntities(db, type, name, 3)) {
|
|
158
|
+
const key = h[keyAttr];
|
|
159
|
+
const hname = h["name"];
|
|
160
|
+
if (typeof key !== "string") continue;
|
|
161
|
+
if (typeof hname === "string" && norm(hname) === target || norm(key) === target) return key;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
async function canonicalizeFragment(db, fragment, ontology) {
|
|
166
|
+
const remap = /* @__PURE__ */ new Map();
|
|
167
|
+
for (const n of fragment.nodes) {
|
|
168
|
+
const spec = ontology.entities[n.type];
|
|
169
|
+
if (!spec?.searchable?.length) continue;
|
|
170
|
+
const name = typeof n.attrs.name === "string" ? n.attrs.name : "";
|
|
171
|
+
if (!name) continue;
|
|
172
|
+
const canon = await resolveName(db, n.type, spec.naturalKey, name);
|
|
173
|
+
if (canon && canon !== n.key) remap.set(`${n.type}:${n.key}`, canon);
|
|
174
|
+
}
|
|
175
|
+
if (remap.size === 0) return fragment;
|
|
176
|
+
const rk = (type, key) => remap.get(`${type}:${key}`) ?? key;
|
|
177
|
+
return {
|
|
178
|
+
nodes: fragment.nodes.map((n) => ({ ...n, key: rk(n.type, n.key) })),
|
|
179
|
+
edges: fragment.edges.map((e) => ({
|
|
180
|
+
from: { type: e.from.type, key: rk(e.from.type, e.from.key) },
|
|
181
|
+
to: { type: e.to.type, key: rk(e.to.type, e.to.key) },
|
|
182
|
+
label: e.label
|
|
183
|
+
}))
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/llm/provider.ts
|
|
188
|
+
import { init, extractText } from "@odla-ai/ai";
|
|
189
|
+
var MODEL_TIERS = {
|
|
190
|
+
claude: { cheap: "claude-haiku-4-5", extract: "claude-sonnet-5", search: "claude-sonnet-5" },
|
|
191
|
+
openai: { cheap: "gpt-5-nano", extract: "gpt-5-mini", search: "gpt-5-search-api" }
|
|
192
|
+
};
|
|
193
|
+
function toUsage(u) {
|
|
194
|
+
return { calls: 1, inputTokens: u.inputTokens, outputTokens: u.outputTokens };
|
|
195
|
+
}
|
|
196
|
+
function createProvider(cfg) {
|
|
197
|
+
const tiers = MODEL_TIERS[cfg.provider];
|
|
198
|
+
const ai = cfg.ai ?? init({ keys: { anthropic: cfg.anthropicKey, openai: cfg.openaiKey } });
|
|
199
|
+
return {
|
|
200
|
+
id: cfg.provider,
|
|
201
|
+
ai,
|
|
202
|
+
async extract(c) {
|
|
203
|
+
const { value, usage } = await ai.extract({
|
|
204
|
+
model: c.model ?? tiers.extract,
|
|
205
|
+
system: c.system,
|
|
206
|
+
user: c.user,
|
|
207
|
+
tool: c.tool,
|
|
208
|
+
maxTokens: c.maxTokens
|
|
209
|
+
});
|
|
210
|
+
return { value, usage: toUsage(usage) };
|
|
211
|
+
},
|
|
212
|
+
async search(c) {
|
|
213
|
+
const { text, usage } = await ai.search({
|
|
214
|
+
model: c.model ?? tiers.search,
|
|
215
|
+
system: c.system,
|
|
216
|
+
user: c.user,
|
|
217
|
+
maxTokens: c.maxTokens
|
|
218
|
+
});
|
|
219
|
+
return { text, usage: toUsage(usage) };
|
|
220
|
+
},
|
|
221
|
+
async write(c) {
|
|
222
|
+
const res = await ai.chat({
|
|
223
|
+
model: c.model ?? tiers.extract,
|
|
224
|
+
system: c.system,
|
|
225
|
+
messages: [{ role: "user", content: c.user }],
|
|
226
|
+
maxTokens: c.maxTokens ?? 2048
|
|
227
|
+
});
|
|
228
|
+
return { text: extractText(res.content), usage: toUsage(res.usage) };
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function getProvider(env) {
|
|
233
|
+
const which = (env.LLM_PROVIDER || "claude").toLowerCase() === "openai" ? "openai" : "claude";
|
|
234
|
+
if (which === "openai" && !env.OPENAI_API_KEY) throw new Error("LLM_PROVIDER=openai but OPENAI_API_KEY is unset");
|
|
235
|
+
if (which === "claude" && !env.ANTHROPIC_API_KEY) throw new Error("LLM_PROVIDER=claude but ANTHROPIC_API_KEY is unset");
|
|
236
|
+
return createProvider({ provider: which, anthropicKey: env.ANTHROPIC_API_KEY, openaiKey: env.OPENAI_API_KEY });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/llm/json.ts
|
|
240
|
+
function extractJson(text) {
|
|
241
|
+
const m = text.match(/\{[\s\S]*\}/);
|
|
242
|
+
if (!m) return null;
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(m[0]);
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function extractJsonArray(text) {
|
|
250
|
+
const m = text.match(/\[[\s\S]*\]/);
|
|
251
|
+
if (!m) return [];
|
|
252
|
+
try {
|
|
253
|
+
const v = JSON.parse(m[0]);
|
|
254
|
+
return Array.isArray(v) ? v : [];
|
|
255
|
+
} catch {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/util/content.ts
|
|
261
|
+
var MAX_CHARS = 8e3;
|
|
262
|
+
function htmlToText(html, maxChars = MAX_CHARS) {
|
|
263
|
+
let t = html;
|
|
264
|
+
t = t.replace(/<script[\s\S]*?<\/script>/gi, " ");
|
|
265
|
+
t = t.replace(/<style[\s\S]*?<\/style>/gi, " ");
|
|
266
|
+
t = t.replace(/<!--[\s\S]*?-->/g, " ");
|
|
267
|
+
t = t.replace(/<\/(p|div|section|article|li|h[1-6]|br)>/gi, "\n");
|
|
268
|
+
t = t.replace(/<[^>]+>/g, " ");
|
|
269
|
+
t = decodeEntities(t);
|
|
270
|
+
t = t.replace(/[ \t\f\r]+/g, " ").replace(/\n\s*\n\s*/g, "\n\n").trim();
|
|
271
|
+
return t.slice(0, maxChars);
|
|
272
|
+
}
|
|
273
|
+
function decodeEntities(s) {
|
|
274
|
+
return s.replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d))).replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
275
|
+
}
|
|
276
|
+
async function fetchText(url, maxChars = MAX_CHARS) {
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch(url, {
|
|
279
|
+
headers: {
|
|
280
|
+
"user-agent": "odla-kg/0.1 (+https://github.com/cory/odla-kg)",
|
|
281
|
+
accept: "text/html,application/xhtml+xml"
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
if (!res.ok) return "";
|
|
285
|
+
const ct = res.headers.get("content-type") || "";
|
|
286
|
+
const body = await res.text();
|
|
287
|
+
if (ct.includes("html") || /<\w+[\s>]/.test(body.slice(0, 200))) {
|
|
288
|
+
return htmlToText(body, maxChars);
|
|
289
|
+
}
|
|
290
|
+
return body.slice(0, maxChars);
|
|
291
|
+
} catch {
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/connectors/rss.ts
|
|
297
|
+
var UA = "Mozilla/5.0 (compatible; odla-kg/0.1)";
|
|
298
|
+
function extractTag(xml, tag) {
|
|
299
|
+
const cdata = xml.match(new RegExp(`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${tag}>`, "i"));
|
|
300
|
+
if (cdata) return cdata[1].trim();
|
|
301
|
+
const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"));
|
|
302
|
+
return m ? m[1].trim() : "";
|
|
303
|
+
}
|
|
304
|
+
function extractAttr(xml, tag, attr) {
|
|
305
|
+
const m = xml.match(new RegExp(`<${tag}[^>]*\\s${attr}=["']([^"']*)["']`, "i"));
|
|
306
|
+
return m ? m[1].trim() : "";
|
|
307
|
+
}
|
|
308
|
+
function splitItems(xml) {
|
|
309
|
+
return xml.match(/<item[\s>][\s\S]*?<\/item>/gi) || xml.match(/<entry[\s>][\s\S]*?<\/entry>/gi) || [];
|
|
310
|
+
}
|
|
311
|
+
var rssConnector = {
|
|
312
|
+
id: "rss",
|
|
313
|
+
capabilities: { fetch: true, structured: false },
|
|
314
|
+
parseConfig(raw) {
|
|
315
|
+
const r = raw;
|
|
316
|
+
if (typeof r.url !== "string" || !r.url) throw new Error("rss connector requires a `url`");
|
|
317
|
+
return { url: r.url, name: String(r.name ?? r.url), watch: r.watch ?? null };
|
|
318
|
+
},
|
|
319
|
+
async fetch(cfg, ctx) {
|
|
320
|
+
let xml;
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(cfg.url, { headers: { "User-Agent": UA } });
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
if (res.status === 429 || res.status === 403 || res.status >= 500) return [];
|
|
325
|
+
throw new Error(`feed ${cfg.url} \u2192 ${res.status}`);
|
|
326
|
+
}
|
|
327
|
+
xml = await res.text();
|
|
328
|
+
} catch (e) {
|
|
329
|
+
console.log(`rss fetch failed (non-blocking): ${cfg.url}: ${e}`);
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const out = [];
|
|
333
|
+
for (const raw of splitItems(xml)) {
|
|
334
|
+
const title = extractTag(raw, "title");
|
|
335
|
+
let url = extractTag(raw, "link") || extractAttr(raw, "link", "href");
|
|
336
|
+
if (!title || !url) continue;
|
|
337
|
+
url = url.trim();
|
|
338
|
+
const date = extractTag(raw, "pubDate") || extractTag(raw, "published") || extractTag(raw, "updated") || void 0;
|
|
339
|
+
const content = extractTag(raw, "content:encoded") || extractTag(raw, "content") || extractTag(raw, "description") || extractTag(raw, "summary") || "";
|
|
340
|
+
let text = htmlToText(content);
|
|
341
|
+
if (text.length < 200) {
|
|
342
|
+
const page = await ctx.fetchText(url);
|
|
343
|
+
if (page.length > text.length) text = page;
|
|
344
|
+
}
|
|
345
|
+
out.push({ url, title, date, sourceName: cfg.name, sourceKind: "rss", watch: cfg.watch, text });
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// src/connectors/url.ts
|
|
352
|
+
async function fetchUrl(url) {
|
|
353
|
+
const text = await fetchText(url);
|
|
354
|
+
const firstLine = text.split("\n").find((l) => l.trim().length > 0) ?? "";
|
|
355
|
+
let host = url;
|
|
356
|
+
try {
|
|
357
|
+
host = new URL(url).hostname;
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
return { url, title: (firstLine || url).slice(0, 200).trim(), sourceName: host, sourceKind: "url", text };
|
|
361
|
+
}
|
|
362
|
+
var urlConnector = {
|
|
363
|
+
id: "url",
|
|
364
|
+
capabilities: { fetch: true, structured: false },
|
|
365
|
+
parseConfig(raw) {
|
|
366
|
+
const r = raw;
|
|
367
|
+
if (typeof r.url !== "string" || !r.url) throw new Error("url connector requires a `url`");
|
|
368
|
+
return { url: r.url, watch: r.watch ?? null };
|
|
369
|
+
},
|
|
370
|
+
async fetch(cfg) {
|
|
371
|
+
const item = await fetchUrl(cfg.url);
|
|
372
|
+
if (cfg.watch !== void 0) item.watch = cfg.watch;
|
|
373
|
+
return [item];
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// src/connectors/gdelt.ts
|
|
378
|
+
var UA2 = "Mozilla/5.0 (compatible; odla-kg/0.1)";
|
|
379
|
+
var MAX_ITEMS = 15;
|
|
380
|
+
function isoFromSeendate(s) {
|
|
381
|
+
const m = s.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/);
|
|
382
|
+
return m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z` : void 0;
|
|
383
|
+
}
|
|
384
|
+
var gdeltConnector = {
|
|
385
|
+
id: "gdelt",
|
|
386
|
+
capabilities: { fetch: true, structured: false },
|
|
387
|
+
parseConfig(raw) {
|
|
388
|
+
const r = raw;
|
|
389
|
+
if (typeof r.query !== "string" || !r.query) throw new Error("gdelt connector requires a `query`");
|
|
390
|
+
return { query: r.query, name: String(r.name ?? `GDELT: ${r.query}`), watch: r.watch ?? null };
|
|
391
|
+
},
|
|
392
|
+
async fetch(cfg, ctx) {
|
|
393
|
+
const api = `https://api.gdeltproject.org/api/v2/doc/doc?query=${encodeURIComponent(cfg.query)}&mode=artlist&format=json&maxrecords=40×pan=2d`;
|
|
394
|
+
let articles = [];
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetch(api, { headers: { "User-Agent": UA2 } });
|
|
397
|
+
if (!res.ok) return [];
|
|
398
|
+
const text = await res.text();
|
|
399
|
+
try {
|
|
400
|
+
articles = JSON.parse(text).articles ?? [];
|
|
401
|
+
} catch {
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
} catch (e) {
|
|
405
|
+
console.log(`gdelt fetch failed (non-blocking): ${e}`);
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
const seen = /* @__PURE__ */ new Set();
|
|
409
|
+
const picked = articles.filter((a) => a?.url && a?.title && a.language === "English" && !seen.has(a.url) && (seen.add(a.url), true)).slice(0, MAX_ITEMS);
|
|
410
|
+
const out = [];
|
|
411
|
+
for (const a of picked) {
|
|
412
|
+
const body = await ctx.fetchText(a.url);
|
|
413
|
+
if (body.length < 200) continue;
|
|
414
|
+
out.push({
|
|
415
|
+
url: a.url,
|
|
416
|
+
title: a.title,
|
|
417
|
+
date: isoFromSeendate(a.seendate ?? ""),
|
|
418
|
+
sourceName: cfg.name,
|
|
419
|
+
sourceKind: "gdelt",
|
|
420
|
+
watch: cfg.watch,
|
|
421
|
+
text: body
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// src/util/identity.ts
|
|
429
|
+
function slugify(input) {
|
|
430
|
+
return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "");
|
|
431
|
+
}
|
|
432
|
+
function normalizeUrl(raw) {
|
|
433
|
+
try {
|
|
434
|
+
const u = new URL(raw);
|
|
435
|
+
u.hash = "";
|
|
436
|
+
const drop = [/^utm_/i, /^ref$/i, /^ref_/i, /^fbclid$/i, /^gclid$/i, /^mc_/i];
|
|
437
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
438
|
+
if (drop.some((re) => re.test(key))) u.searchParams.delete(key);
|
|
439
|
+
}
|
|
440
|
+
let s = u.toString();
|
|
441
|
+
s = s.replace(/\/$/, "");
|
|
442
|
+
return s;
|
|
443
|
+
} catch {
|
|
444
|
+
return raw.trim().replace(/#.*$/, "").replace(/\/$/, "");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function urlHash(raw) {
|
|
448
|
+
const s = normalizeUrl(raw);
|
|
449
|
+
let h = 2166136261;
|
|
450
|
+
for (let i = 0; i < s.length; i++) {
|
|
451
|
+
h ^= s.charCodeAt(i);
|
|
452
|
+
h = Math.imul(h, 16777619);
|
|
453
|
+
}
|
|
454
|
+
return (h >>> 0).toString(36);
|
|
455
|
+
}
|
|
456
|
+
function isLikelyPersonName(name) {
|
|
457
|
+
const n = name.trim();
|
|
458
|
+
if (!n || /\d/.test(n)) return false;
|
|
459
|
+
const parts = n.split(/\s+/);
|
|
460
|
+
if (parts.length < 2 || parts.length > 4) return false;
|
|
461
|
+
return parts.every((p) => /^[A-Z][a-zA-Z'’.-]*$/.test(p));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/connectors/fred.ts
|
|
465
|
+
async function fetchSeries(seriesId, apiKey, start) {
|
|
466
|
+
const url = `https://api.stlouisfed.org/fred/series/observations?series_id=${encodeURIComponent(seriesId)}&api_key=${encodeURIComponent(apiKey)}&file_type=json&observation_start=${encodeURIComponent(start)}`;
|
|
467
|
+
try {
|
|
468
|
+
const res = await fetch(url);
|
|
469
|
+
if (!res.ok) return [];
|
|
470
|
+
const data = await res.json();
|
|
471
|
+
const out = [];
|
|
472
|
+
for (const o of data.observations ?? []) {
|
|
473
|
+
if (!o?.date || o.value === void 0 || o.value === ".") continue;
|
|
474
|
+
const v = Number(o.value);
|
|
475
|
+
if (Number.isFinite(v)) out.push({ date: o.date, value: v });
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
} catch (e) {
|
|
479
|
+
console.log(`fred fetch failed for ${seriesId} (non-blocking): ${e}`);
|
|
480
|
+
return [];
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
var fredConnector = {
|
|
484
|
+
id: "fred",
|
|
485
|
+
capabilities: { fetch: true, structured: true },
|
|
486
|
+
parseConfig(raw) {
|
|
487
|
+
const r = raw;
|
|
488
|
+
const series = Array.isArray(r.series) ? r.series : [];
|
|
489
|
+
if (!series.length) throw new Error("fred connector requires a non-empty `series`");
|
|
490
|
+
return { series, region: r.region, regionName: r.regionName };
|
|
491
|
+
},
|
|
492
|
+
async fetch(cfg, ctx) {
|
|
493
|
+
const apiKey = ctx.secret("FRED_API_KEY");
|
|
494
|
+
if (!apiKey) {
|
|
495
|
+
console.log("fred: FRED_API_KEY unset \u2014 skipping");
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
const start = new Date(ctx.now.getTime() - 400 * 24 * 3600 * 1e3).toISOString().slice(0, 10);
|
|
499
|
+
const out = [];
|
|
500
|
+
for (const s of cfg.series) {
|
|
501
|
+
const obs = await fetchSeries(s.id, apiKey, start);
|
|
502
|
+
if (!obs.length) continue;
|
|
503
|
+
const latest = obs[obs.length - 1];
|
|
504
|
+
const prior = obs.length > 1 ? obs[obs.length - 2] : void 0;
|
|
505
|
+
const attrs = { label: s.label, latest: String(latest.value), as_of: latest.date };
|
|
506
|
+
if (prior) attrs.prior = String(prior.value);
|
|
507
|
+
if (s.unit) attrs.unit = s.unit;
|
|
508
|
+
const fragment = { nodes: [{ type: "indicator", key: s.id, attrs }], edges: [] };
|
|
509
|
+
if (cfg.region) {
|
|
510
|
+
const rslug = slugify(cfg.region);
|
|
511
|
+
if (rslug) {
|
|
512
|
+
fragment.nodes.push({ type: "region", key: rslug, attrs: { name: cfg.regionName ?? cfg.region } });
|
|
513
|
+
fragment.edges.push({ from: { type: "region", key: rslug }, to: { type: "indicator", key: s.id }, label: "has_indicator" });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
out.push({
|
|
517
|
+
url: `https://fred.stlouisfed.org/series/${s.id}#${latest.date}`,
|
|
518
|
+
title: `${s.label}: ${latest.value} (${latest.date})`,
|
|
519
|
+
date: latest.date,
|
|
520
|
+
sourceName: "FRED",
|
|
521
|
+
sourceKind: "fred",
|
|
522
|
+
fragment
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// src/connectors/edgar.ts
|
|
530
|
+
var SEARCH_BASE = "https://efts.sec.gov/LATEST/search-index";
|
|
531
|
+
var MAX_FILINGS = 10;
|
|
532
|
+
var cikTrim = (cik) => cik.replace(/^0+/, "") || cik;
|
|
533
|
+
var indexUrl = (cik, adsh) => `https://www.sec.gov/Archives/edgar/data/${cikTrim(cik)}/${adsh.replace(/-/g, "")}/${adsh}-index.htm`;
|
|
534
|
+
function tagText(scope, tag) {
|
|
535
|
+
const m = scope.match(new RegExp(`<${tag}[^>]*>\\s*([\\s\\S]*?)\\s*</${tag}>`, "i"));
|
|
536
|
+
return m ? m[1].trim() : "";
|
|
537
|
+
}
|
|
538
|
+
var edgarConnector = {
|
|
539
|
+
id: "edgar",
|
|
540
|
+
capabilities: { fetch: true, structured: true },
|
|
541
|
+
parseConfig(raw) {
|
|
542
|
+
const r = raw;
|
|
543
|
+
const codes = Array.isArray(r.locationCodes) ? r.locationCodes : [];
|
|
544
|
+
if (!codes.length) throw new Error('edgar connector requires `locationCodes` (e.g. ["UT"])');
|
|
545
|
+
return {
|
|
546
|
+
locationCodes: codes,
|
|
547
|
+
cityFilter: Array.isArray(r.cityFilter) ? r.cityFilter : void 0,
|
|
548
|
+
days: typeof r.days === "number" ? r.days : 7
|
|
549
|
+
};
|
|
550
|
+
},
|
|
551
|
+
async fetch(cfg, ctx) {
|
|
552
|
+
const ua = ctx.secret("EDGAR_USER_AGENT");
|
|
553
|
+
if (!ua) {
|
|
554
|
+
console.log("edgar: EDGAR_USER_AGENT unset \u2014 skipping");
|
|
555
|
+
return [];
|
|
556
|
+
}
|
|
557
|
+
const enddt = ctx.now.toISOString().slice(0, 10);
|
|
558
|
+
const startdt = new Date(ctx.now.getTime() - (cfg.days ?? 7) * 24 * 3600 * 1e3).toISOString().slice(0, 10);
|
|
559
|
+
const searchUrl = `${SEARCH_BASE}?q=&forms=D&locationCodes=${encodeURIComponent(cfg.locationCodes.join(","))}&startdt=${startdt}&enddt=${enddt}`;
|
|
560
|
+
let hits = [];
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(searchUrl, { headers: { "User-Agent": ua } });
|
|
563
|
+
if (!res.ok) return [];
|
|
564
|
+
hits = (await res.json()).hits?.hits ?? [];
|
|
565
|
+
} catch (e) {
|
|
566
|
+
console.log(`edgar search failed (non-blocking): ${e}`);
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
const out = [];
|
|
570
|
+
const seen = /* @__PURE__ */ new Set();
|
|
571
|
+
for (const hit of hits.slice(0, MAX_FILINGS)) {
|
|
572
|
+
const src = hit._source ?? {};
|
|
573
|
+
const adsh = src.adsh ?? "";
|
|
574
|
+
if (!adsh || seen.has(adsh)) continue;
|
|
575
|
+
seen.add(adsh);
|
|
576
|
+
const cik = src.ciks?.[0] ?? "";
|
|
577
|
+
const name = (src.display_names?.[0] ?? "").replace(/\s*\(CIK\s*\d+\)\s*$/i, "").trim();
|
|
578
|
+
if (!name || !cik) continue;
|
|
579
|
+
const loc = src.biz_locations?.[0] ?? "";
|
|
580
|
+
let city = loc.trim();
|
|
581
|
+
let state = src.biz_states?.[0] ?? "";
|
|
582
|
+
const comma = loc.lastIndexOf(",");
|
|
583
|
+
if (comma >= 0) {
|
|
584
|
+
city = loc.slice(0, comma).trim();
|
|
585
|
+
state = loc.slice(comma + 1).trim() || state;
|
|
586
|
+
}
|
|
587
|
+
if (cfg.cityFilter?.length && !cfg.cityFilter.some((c) => c.toLowerCase() === city.toLowerCase())) continue;
|
|
588
|
+
const url = indexUrl(cik, adsh);
|
|
589
|
+
const fileDate = src.file_date ?? "";
|
|
590
|
+
let industry = "";
|
|
591
|
+
let offering = "";
|
|
592
|
+
const persons = [];
|
|
593
|
+
try {
|
|
594
|
+
const detailUrl = `https://www.sec.gov/Archives/edgar/data/${cikTrim(cik)}/${adsh.replace(/-/g, "")}/primary_doc.xml`;
|
|
595
|
+
const dres = await fetch(detailUrl, { headers: { "User-Agent": ua } });
|
|
596
|
+
if (dres.ok) {
|
|
597
|
+
const xml = await dres.text();
|
|
598
|
+
industry = tagText(xml, "industryGroupType");
|
|
599
|
+
offering = tagText(xml, "totalOfferingAmount");
|
|
600
|
+
for (const block of xml.match(/<relatedPersonInfo>[\s\S]*?<\/relatedPersonInfo>/gi) ?? []) {
|
|
601
|
+
const pname = [tagText(block, "firstName"), tagText(block, "lastName")].filter((p) => p && p !== "-").join(" ").trim();
|
|
602
|
+
if (!pname) continue;
|
|
603
|
+
const roles = [];
|
|
604
|
+
const relRe = /<relationship>\s*([\s\S]*?)\s*<\/relationship>/gi;
|
|
605
|
+
let m;
|
|
606
|
+
while ((m = relRe.exec(block)) !== null) {
|
|
607
|
+
const r = m[1].trim();
|
|
608
|
+
if (r && !roles.includes(r)) roles.push(r);
|
|
609
|
+
}
|
|
610
|
+
persons.push({ name: pname, roles });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
const cslug = slugify(name);
|
|
616
|
+
if (!cslug) continue;
|
|
617
|
+
const f = { nodes: [], edges: [] };
|
|
618
|
+
const cattrs = { name };
|
|
619
|
+
if (city) cattrs.city = city;
|
|
620
|
+
if (state) cattrs.region = state;
|
|
621
|
+
if (industry) cattrs.sector = industry;
|
|
622
|
+
if (fileDate) {
|
|
623
|
+
cattrs.first_seen = fileDate;
|
|
624
|
+
cattrs.last_seen = fileDate;
|
|
625
|
+
}
|
|
626
|
+
f.nodes.push({ type: "company", key: cslug, attrs: cattrs });
|
|
627
|
+
if (state) {
|
|
628
|
+
const rslug = slugify(state);
|
|
629
|
+
if (rslug) {
|
|
630
|
+
f.nodes.push({ type: "region", key: rslug, attrs: { name: state } });
|
|
631
|
+
f.edges.push({ from: { type: "company", key: cslug }, to: { type: "region", key: rslug }, label: "located_in" });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const rkey = `${cslug}:form-d:${fileDate}`;
|
|
635
|
+
const rattrs = { round: "Form D", source_url: url };
|
|
636
|
+
if (offering) rattrs.amount = offering;
|
|
637
|
+
if (fileDate) rattrs.date = fileDate;
|
|
638
|
+
f.nodes.push({ type: "funding_round", key: rkey, attrs: rattrs });
|
|
639
|
+
f.edges.push({ from: { type: "funding_round", key: rkey }, to: { type: "company", key: cslug }, label: "round_of" });
|
|
640
|
+
for (const p of persons) {
|
|
641
|
+
if (!isLikelyPersonName(p.name)) continue;
|
|
642
|
+
const pslug = slugify(p.name);
|
|
643
|
+
if (!pslug) continue;
|
|
644
|
+
const pattrs = { name: p.name };
|
|
645
|
+
if (p.roles.length) pattrs.role = p.roles.join(", ");
|
|
646
|
+
f.nodes.push({ type: "person", key: pslug, attrs: pattrs });
|
|
647
|
+
f.edges.push({ from: { type: "person", key: pslug }, to: { type: "company", key: cslug }, label: "works_at" });
|
|
648
|
+
}
|
|
649
|
+
out.push({
|
|
650
|
+
url,
|
|
651
|
+
title: `Form D: ${name}${offering ? ` \u2014 ${offering}` : ""}`,
|
|
652
|
+
date: fileDate || void 0,
|
|
653
|
+
sourceName: "SEC EDGAR",
|
|
654
|
+
sourceKind: "edgar",
|
|
655
|
+
fragment: f
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return out;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// src/connectors/govdata.ts
|
|
663
|
+
var govDataConnector = {
|
|
664
|
+
id: "gov-data",
|
|
665
|
+
capabilities: { fetch: true, structured: true },
|
|
666
|
+
parseConfig(raw) {
|
|
667
|
+
const r = raw;
|
|
668
|
+
return { dataset: String(r.dataset ?? ""), watch: r.watch ?? null };
|
|
669
|
+
},
|
|
670
|
+
async fetch(cfg, _ctx) {
|
|
671
|
+
console.log(`gov-data: template connector for dataset "${cfg.dataset}" \u2014 implement fetch() to emit RawItems`);
|
|
672
|
+
return [];
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// src/connectors/web-search.ts
|
|
677
|
+
var SWEEP_SYSTEM = "You are a research scout building a knowledge graph of entrepreneurs, startups, and business trends. Search the web and report concrete, real companies and the people leading them, with specifics (what they do, where, who founded them). Tie every claim to something you actually found \u2014 never invent names, numbers, or companies. If a search turns up nothing solid, say so plainly.";
|
|
678
|
+
var webSearchConnector = {
|
|
679
|
+
id: "web-search",
|
|
680
|
+
capabilities: { fetch: false, discover: true, structured: false },
|
|
681
|
+
parseConfig(raw) {
|
|
682
|
+
const r = raw;
|
|
683
|
+
const queries = Array.isArray(r.queries) ? r.queries.filter((q) => typeof q === "string") : typeof r.query === "string" ? [r.query] : [];
|
|
684
|
+
if (!queries.length) throw new Error("web-search connector requires `queries` (or a `query`)");
|
|
685
|
+
return { queries, name: String(r.name ?? "web-search"), watch: r.watch ?? null };
|
|
686
|
+
},
|
|
687
|
+
async fetch() {
|
|
688
|
+
return [];
|
|
689
|
+
},
|
|
690
|
+
async discover(cfg, ctx) {
|
|
691
|
+
const provider = ctx.provider();
|
|
692
|
+
const today = ctx.now.toISOString().slice(0, 10);
|
|
693
|
+
const out = [];
|
|
694
|
+
for (const q of cfg.queries) {
|
|
695
|
+
const { text } = await provider.search({
|
|
696
|
+
system: SWEEP_SYSTEM,
|
|
697
|
+
user: `Search task: ${q}
|
|
698
|
+
Today's date: ${today}
|
|
699
|
+
|
|
700
|
+
Report what you find, naming specific companies and the real people leading them.`
|
|
701
|
+
});
|
|
702
|
+
if (text.trim().length < 80) continue;
|
|
703
|
+
out.push({
|
|
704
|
+
url: `search://web-search/${urlHash(q)}#${today}`,
|
|
705
|
+
// synthetic; changes daily so re-sweeps aren't deduped away
|
|
706
|
+
title: q,
|
|
707
|
+
date: today,
|
|
708
|
+
sourceName: cfg.name,
|
|
709
|
+
sourceKind: "web-search",
|
|
710
|
+
watch: cfg.watch,
|
|
711
|
+
text
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// src/connectors/registry.ts
|
|
719
|
+
var REGISTRY = /* @__PURE__ */ new Map([
|
|
720
|
+
[rssConnector.id, rssConnector],
|
|
721
|
+
[urlConnector.id, urlConnector],
|
|
722
|
+
[gdeltConnector.id, gdeltConnector],
|
|
723
|
+
[fredConnector.id, fredConnector],
|
|
724
|
+
// structured (RawItem.fragment)
|
|
725
|
+
[edgarConnector.id, edgarConnector],
|
|
726
|
+
// structured (RawItem.fragment)
|
|
727
|
+
[webSearchConnector.id, webSearchConnector],
|
|
728
|
+
// discovery (web search)
|
|
729
|
+
[govDataConnector.id, govDataConnector]
|
|
730
|
+
// template — zero core change to add
|
|
731
|
+
]);
|
|
732
|
+
function getConnector(id) {
|
|
733
|
+
return REGISTRY.get(id);
|
|
734
|
+
}
|
|
735
|
+
function connectorIds() {
|
|
736
|
+
return [...REGISTRY.keys()];
|
|
737
|
+
}
|
|
738
|
+
function makeContext(cfg) {
|
|
739
|
+
let provider;
|
|
740
|
+
return {
|
|
741
|
+
now: cfg.now ?? /* @__PURE__ */ new Date(),
|
|
742
|
+
fetchText: (u) => fetchText(u),
|
|
743
|
+
secret: (name) => cfg.secrets?.[name],
|
|
744
|
+
ontology: cfg.ontology,
|
|
745
|
+
provider: () => provider ??= cfg.provider()
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// src/sources.ts
|
|
750
|
+
function enabledSources(sources) {
|
|
751
|
+
return sources.filter((s) => s.enabled !== false);
|
|
752
|
+
}
|
|
753
|
+
function toJob(s) {
|
|
754
|
+
return { kind: s.kind, name: s.name, watch: s.watch ?? null, cfg: s };
|
|
755
|
+
}
|
|
756
|
+
function jobsForHour(sources, hour) {
|
|
757
|
+
return enabledSources(sources).filter((s) => hour % Math.max(1, Math.floor(s.cadenceHours ?? 1)) === 0).map(toJob);
|
|
758
|
+
}
|
|
759
|
+
function allJobs(sources) {
|
|
760
|
+
return enabledSources(sources).map(toJob);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/state/kv.ts
|
|
764
|
+
var SEEN_TTL_S = 120 * 24 * 60 * 60;
|
|
765
|
+
var ENRICHED_TTL_S = 30 * 24 * 60 * 60;
|
|
766
|
+
var Store = class {
|
|
767
|
+
constructor(kv) {
|
|
768
|
+
this.kv = kv;
|
|
769
|
+
}
|
|
770
|
+
kv;
|
|
771
|
+
seenKey(url) {
|
|
772
|
+
return `seen:${urlHash(url)}`;
|
|
773
|
+
}
|
|
774
|
+
async hasSeen(url) {
|
|
775
|
+
return await this.kv.get(this.seenKey(url)) !== null;
|
|
776
|
+
}
|
|
777
|
+
async markSeen(url) {
|
|
778
|
+
await this.kv.put(this.seenKey(url), "1", { expirationTtl: SEEN_TTL_S });
|
|
779
|
+
}
|
|
780
|
+
/** Drop items whose URL we've already processed (checked BEFORE any LLM work). */
|
|
781
|
+
async filterNew(items) {
|
|
782
|
+
const flags = await Promise.all(items.map((it) => this.hasSeen(it.url)));
|
|
783
|
+
return items.filter((_, i) => !flags[i]);
|
|
784
|
+
}
|
|
785
|
+
async setLastRun(kind, at = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
786
|
+
await this.kv.put(`lastrun:${kind}`, at);
|
|
787
|
+
}
|
|
788
|
+
async getLastRun(kind) {
|
|
789
|
+
return this.kv.get(`lastrun:${kind}`);
|
|
790
|
+
}
|
|
791
|
+
/** Per-entity enrichment cooldown so we don't re-search the same node constantly. */
|
|
792
|
+
async isEnrichCooling(key) {
|
|
793
|
+
return await this.kv.get(`enriched:${key}`) !== null;
|
|
794
|
+
}
|
|
795
|
+
async markEnriched(key) {
|
|
796
|
+
await this.kv.put(`enriched:${key}`, (/* @__PURE__ */ new Date()).toISOString(), { expirationTtl: ENRICHED_TTL_S });
|
|
797
|
+
}
|
|
798
|
+
/** Runtime source config (edit without redeploy). null = fall back to the static SOURCES. */
|
|
799
|
+
async getConfigSources() {
|
|
800
|
+
const raw = await this.kv.get("config:sources");
|
|
801
|
+
if (!raw) return null;
|
|
802
|
+
try {
|
|
803
|
+
const v = JSON.parse(raw);
|
|
804
|
+
return Array.isArray(v) ? v : null;
|
|
805
|
+
} catch {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async setConfigSources(list) {
|
|
810
|
+
await this.kv.put("config:sources", JSON.stringify(list));
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// src/workflow/helpers.ts
|
|
815
|
+
async function resilientStep(stepName, fn) {
|
|
816
|
+
try {
|
|
817
|
+
return await fn();
|
|
818
|
+
} catch (err) {
|
|
819
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
820
|
+
const stack = err instanceof Error ? err.stack : void 0;
|
|
821
|
+
console.error(`[STEP_ERROR] ${stepName}: ${msg}`);
|
|
822
|
+
if (stack) console.error(`[STEP_STACK] ${stepName}: ${stack}`);
|
|
823
|
+
throw err;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
var IO_STEP_CONFIG = {
|
|
827
|
+
retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
|
|
828
|
+
timeout: "2 minutes"
|
|
829
|
+
};
|
|
830
|
+
var COMMIT_STEP_CONFIG = {
|
|
831
|
+
retries: { limit: 3, delay: "3 seconds", backoff: "exponential" },
|
|
832
|
+
timeout: "90 seconds"
|
|
833
|
+
};
|
|
834
|
+
var LLM_STEP_CONFIG = {
|
|
835
|
+
retries: { limit: 2, delay: "5 seconds", backoff: "exponential" },
|
|
836
|
+
timeout: "3 minutes"
|
|
837
|
+
};
|
|
838
|
+
function asStepLike(step) {
|
|
839
|
+
return step;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/pipeline/run-items.ts
|
|
843
|
+
async function ingestFresh(step, deps, fresh, kind, resolve = false) {
|
|
844
|
+
const { db, provider, ontology, extract, store } = deps;
|
|
845
|
+
if (fresh.length === 0) {
|
|
846
|
+
await store.setLastRun(kind);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
for (const item of fresh) {
|
|
850
|
+
const id = urlHash(item.url);
|
|
851
|
+
await step.do(
|
|
852
|
+
`item-${id}`,
|
|
853
|
+
LLM_STEP_CONFIG,
|
|
854
|
+
() => resilientStep(`item-${id}`, async () => {
|
|
855
|
+
try {
|
|
856
|
+
let fragment = item.fragment;
|
|
857
|
+
if (!fragment) {
|
|
858
|
+
const r = await extract(provider, item);
|
|
859
|
+
if (!r) {
|
|
860
|
+
await store.markSeen(item.url);
|
|
861
|
+
return { skipped: true };
|
|
862
|
+
}
|
|
863
|
+
fragment = r.fragment;
|
|
864
|
+
}
|
|
865
|
+
if (resolve) fragment = await canonicalizeFragment(db, fragment, ontology);
|
|
866
|
+
const { txId, ops } = await writeFragment(db, fragment, ontology, `ingest:${id}:v1`);
|
|
867
|
+
await store.markSeen(item.url);
|
|
868
|
+
return { txId, ops };
|
|
869
|
+
} catch (e) {
|
|
870
|
+
console.log(`ingest item failed (non-blocking): ${item.url}: ${e instanceof Error ? e.message : e}`);
|
|
871
|
+
return { error: true };
|
|
872
|
+
}
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
await step.do(
|
|
877
|
+
`stamp-${kind}`,
|
|
878
|
+
() => resilientStep(`stamp-${kind}`, async () => {
|
|
879
|
+
await store.setLastRun(kind);
|
|
880
|
+
return { ok: true };
|
|
881
|
+
})
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
export {
|
|
885
|
+
COMMIT_STEP_CONFIG,
|
|
886
|
+
IO_STEP_CONFIG,
|
|
887
|
+
LLM_STEP_CONFIG,
|
|
888
|
+
MODEL_TIERS,
|
|
889
|
+
Store,
|
|
890
|
+
addUsage,
|
|
891
|
+
allJobs,
|
|
892
|
+
asStepLike,
|
|
893
|
+
canonicalizeFragment,
|
|
894
|
+
connectorIds,
|
|
895
|
+
createProvider,
|
|
896
|
+
emptyUsage,
|
|
897
|
+
enabledSources,
|
|
898
|
+
extractJson,
|
|
899
|
+
extractJsonArray,
|
|
900
|
+
fetchText,
|
|
901
|
+
fetchUrl,
|
|
902
|
+
fragmentToOps,
|
|
903
|
+
getConnector,
|
|
904
|
+
getProvider,
|
|
905
|
+
htmlToText,
|
|
906
|
+
humanAttrsOf,
|
|
907
|
+
ingestFresh,
|
|
908
|
+
isLikelyPersonName,
|
|
909
|
+
jobsForHour,
|
|
910
|
+
makeContext,
|
|
911
|
+
mergeAttrs,
|
|
912
|
+
mergeRulesOf,
|
|
913
|
+
mergeScalar,
|
|
914
|
+
naturalKeysOf,
|
|
915
|
+
normalizeUrl,
|
|
916
|
+
resilientStep,
|
|
917
|
+
resolveName,
|
|
918
|
+
searchEntities,
|
|
919
|
+
slugify,
|
|
920
|
+
toSerializedSchema,
|
|
921
|
+
urlHash,
|
|
922
|
+
writeFragment
|
|
923
|
+
};
|
|
924
|
+
//# sourceMappingURL=index.js.map
|