@gafj/gafj 0.1.1
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 +99 -0
- package/bin/cli.js +628 -0
- package/bridge/byo_key.js +50 -0
- package/bridge/credits.js +72 -0
- package/bridge/paste.js +44 -0
- package/bridge/providers/anthropic.js +16 -0
- package/bridge/providers/gemini.js +15 -0
- package/bridge/providers/index.js +39 -0
- package/bridge/providers/openai_compatible.js +17 -0
- package/core/extract.js +55 -0
- package/core/generators/excerpt.js +64 -0
- package/core/generators/packet.js +197 -0
- package/core/generators/serialize.js +56 -0
- package/core/generators/slice.js +61 -0
- package/core/house_rules.js +49 -0
- package/core/kb_import.js +257 -0
- package/core/migrations/002-profile.sql +3 -0
- package/core/migrations/003-discover.sql +49 -0
- package/core/parsers.js +79 -0
- package/core/practice.js +75 -0
- package/core/provenance.js +90 -0
- package/core/qr.js +185 -0
- package/core/rules/categories.md +5 -0
- package/core/rules/deep_answers.md +7 -0
- package/core/rules/discover.md +30 -0
- package/core/rules/extract.md +31 -0
- package/core/rules/followup.md +9 -0
- package/core/rules/index.js +32 -0
- package/core/rules/onepager.md +15 -0
- package/core/rules/practice.md +10 -0
- package/core/rules/prep.md +47 -0
- package/core/rules/resume.md +49 -0
- package/core/rules/rubric.md +120 -0
- package/core/schema.sql +342 -0
- package/core/schemas/categories.js +16 -0
- package/core/schemas/cover.js +10 -0
- package/core/schemas/deep_answers.js +16 -0
- package/core/schemas/discover.js +19 -0
- package/core/schemas/extract.js +32 -0
- package/core/schemas/followup.js +10 -0
- package/core/schemas/index.js +32 -0
- package/core/schemas/lang.js +111 -0
- package/core/schemas/onepager.js +26 -0
- package/core/schemas/practice.js +18 -0
- package/core/schemas/prep.js +15 -0
- package/core/schemas/resume.js +25 -0
- package/core/scoring.js +586 -0
- package/core/text.js +98 -0
- package/core/tracker_map.js +94 -0
- package/core/ulid.js +38 -0
- package/core/validators/highlight_body_match.js +49 -0
- package/core/validators/index.js +43 -0
- package/core/validators/metric_pairing.js +52 -0
- package/core/validators/no_confabulated_history.js +63 -0
- package/core/validators/no_dashes.js +31 -0
- package/core/validators/no_derived_tenure.js +60 -0
- package/core/validators/style_config.js +18 -0
- package/core/validators/verb_ladder.js +51 -0
- package/core/verbs.js +43 -0
- package/http/api.js +260 -0
- package/http/guard.js +106 -0
- package/http/headers.js +17 -0
- package/http/hosted.js +273 -0
- package/http/identity.js +70 -0
- package/http/server.js +136 -0
- package/http/sse.js +41 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +237 -0
- package/package.json +59 -0
- package/store/backup.js +63 -0
- package/store/backup_dir.js +60 -0
- package/store/config.js +32 -0
- package/store/context.js +74 -0
- package/store/db.js +37 -0
- package/store/discover.js +120 -0
- package/store/events.js +28 -0
- package/store/export_xlsx.js +62 -0
- package/store/home.js +36 -0
- package/store/import_kb.js +88 -0
- package/store/import_tracker.js +195 -0
- package/store/ingest.js +140 -0
- package/store/kb.js +202 -0
- package/store/log.js +88 -0
- package/store/migrate.js +55 -0
- package/store/onboarding.js +267 -0
- package/store/packets.js +110 -0
- package/store/practice.js +52 -0
- package/store/proposals.js +119 -0
- package/store/reads.js +110 -0
- package/store/render.js +111 -0
- package/store/reply.js +19 -0
- package/store/review.js +64 -0
- package/store/scoring.js +134 -0
- package/store/settings.js +142 -0
- package/store/snapshot.js +88 -0
- package/store/transitions.js +391 -0
- package/store/tx.js +71 -0
- package/store/tz.js +24 -0
- package/ui/app.css +137 -0
- package/ui/app.js +75 -0
- package/ui/fonts/plexmono-400.woff2 +0 -0
- package/ui/fonts/plexmono-500.woff2 +0 -0
- package/ui/fonts/plexmono-600.woff2 +0 -0
- package/ui/fonts/spacegrotesk-var.woff2 +0 -0
- package/ui/hosted/signin.css +8 -0
- package/ui/hosted/signin.html +30 -0
- package/ui/hosted/signin.js +28 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/index.html +20 -0
- package/ui/lib.js +92 -0
- package/ui/manifest.webmanifest +18 -0
- package/ui/screens/application.js +73 -0
- package/ui/screens/dashboard.js +37 -0
- package/ui/screens/document.js +64 -0
- package/ui/screens/interview.js +85 -0
- package/ui/screens/kb.js +142 -0
- package/ui/screens/packet.js +122 -0
- package/ui/screens/postings.js +92 -0
- package/ui/screens/settings.js +115 -0
- package/ui/vendor/preact.mjs +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* byo_key route (phase 6): the local server calls the provider the user
|
|
5
|
+
* configured through a one-file adapter, then hands the reply to
|
|
6
|
+
* ingest_document. Keys live in config.json and never in the store or a
|
|
7
|
+
* log. https unless loopback; no redirects; a fixed hello for the test.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { readConfig } = require("../store/config");
|
|
11
|
+
const providers = require("./providers");
|
|
12
|
+
|
|
13
|
+
function providerById(homeDir, id) {
|
|
14
|
+
const cfg = readConfig(homeDir);
|
|
15
|
+
const p = (cfg.providers || []).find((x) => x.id === id);
|
|
16
|
+
if (!p) throw Object.assign(new Error("unknown provider"), { status: 404 });
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scrub(message, key) {
|
|
21
|
+
return key ? String(message).split(key).join("[key]") : String(message);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function testProvider(homeDir, id, fetchImpl) {
|
|
25
|
+
const p = providerById(homeDir, id);
|
|
26
|
+
const adapter = providers.adapterFor(p.kind);
|
|
27
|
+
const started = Date.now();
|
|
28
|
+
try {
|
|
29
|
+
const r = await adapter.complete({ provider: p, system: "Reply with the single word: ready", user: "hello", max_tokens: 16, fetch: fetchImpl });
|
|
30
|
+
return { ok: true, message: `${p.kind} ${p.model || ""}: ${String(r.text).trim().slice(0, 40)} (${Date.now() - started} ms)`, tokens_in: r.tokens_in, tokens_out: r.tokens_out };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return { ok: false, message: scrub(e.message, p.api_key) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Run one packet through the provider; returns what ingest needs plus attempt metadata. */
|
|
37
|
+
async function runPacket(homeDir, id, { packet, fetchImpl }) {
|
|
38
|
+
const p = providerById(homeDir, id);
|
|
39
|
+
const adapter = providers.adapterFor(p.kind);
|
|
40
|
+
const { renderPasteText } = require("./paste");
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
try {
|
|
43
|
+
const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: 8000, fetch: fetchImpl });
|
|
44
|
+
return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started };
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw Object.assign(new Error(scrub(e.message, p.api_key)), { status: 502 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { testProvider, runPacket, providerById };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* credits route (phase 7, local half): the local server posts the packet
|
|
5
|
+
* the app built to the relay with the user's Google ID token and hands the
|
|
6
|
+
* reply to ingest_document like every other route. The relay URL and the
|
|
7
|
+
* token live in config.json under the user only, never in the store or a
|
|
8
|
+
* log; the token is masked on read. https only, except loopback for a
|
|
9
|
+
* local dry run. The relay is not deployed yet; this is the interface
|
|
10
|
+
* decision 7 reserved, built so the go decision needs no local change.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { readConfig } = require("../store/config");
|
|
14
|
+
const { ulid } = require("../core/ulid");
|
|
15
|
+
|
|
16
|
+
class CreditsError extends Error {
|
|
17
|
+
constructor(message, status = 502) { super(message); this.status = status; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function creditsConfig(homeDir) {
|
|
21
|
+
const cfg = readConfig(homeDir);
|
|
22
|
+
const c = cfg.credits || {};
|
|
23
|
+
if (!c.relay_url) throw new CreditsError("no relay url; set it in settings", 409);
|
|
24
|
+
if (!c.id_token) throw new CreditsError("sign in to credits first; paste the token in settings", 409);
|
|
25
|
+
if (!/^https:\/\//i.test(c.relay_url) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(c.relay_url)) throw new CreditsError("relay url must be https", 400);
|
|
26
|
+
return { relay_url: String(c.relay_url).replace(/\/+$/, ""), id_token: String(c.id_token) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scrub(message, token) {
|
|
30
|
+
return token ? String(message).split(token).join("[token]") : String(message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* auth, when given, replaces the config: the hosted server calls the relay with a service key and
|
|
35
|
+
* the user's uid ({ relay_url, headers }); a user token is never stored on the hosted side.
|
|
36
|
+
*/
|
|
37
|
+
async function relayFetch(homeDir, path, { method = "GET", body, fetchImpl, auth } = {}) {
|
|
38
|
+
const c = auth ? { relay_url: String(auth.relay_url).replace(/\/+$/, ""), id_token: null, headers: auth.headers } : creditsConfig(homeDir);
|
|
39
|
+
const f = fetchImpl || fetch;
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
res = await f(c.relay_url + path, { method, redirect: "error", headers: { ...(c.id_token ? { authorization: `Bearer ${c.id_token}` } : c.headers || {}), "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new CreditsError("relay unreachable: " + scrub(e.message, c.id_token), 502);
|
|
45
|
+
}
|
|
46
|
+
let data = null;
|
|
47
|
+
try { data = await res.json(); } catch (_) { data = null; }
|
|
48
|
+
if (!res.ok) throw new CreditsError(`relay refused (${res.status}): ${scrub((data && data.error) || "no detail", c.id_token)}`, res.status === 401 || res.status === 402 || res.status === 429 ? res.status : 502);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Run one packet through the relay; returns what ingest needs plus attempt metadata. */
|
|
53
|
+
async function runPacket(homeDir, { packet, fetchImpl, auth }) {
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
const op_id = ulid(started);
|
|
56
|
+
const r = await relayFetch(homeDir, "/op", { method: "POST", body: { op: packet.op, op_id, packet }, fetchImpl, auth });
|
|
57
|
+
if (!r || typeof r.content !== "string") throw new CreditsError("relay reply had no content", 502);
|
|
58
|
+
return { content: r.content, provider: "credits", model: r.model || null, tokens_in: r.tokens_in ?? null, tokens_out: r.tokens_out ?? null, latency_ms: Date.now() - started, charged: r.charged, credits_left: r.credits_left };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function balance(homeDir, fetchImpl, auth) {
|
|
62
|
+
return relayFetch(homeDir, "/balance", { fetchImpl, auth });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Start a purchase: the relay makes the Stripe Checkout session; the browser goes to its url. */
|
|
66
|
+
async function checkout(homeDir, pack, fetchImpl, auth) {
|
|
67
|
+
const r = await relayFetch(homeDir, "/checkout", { method: "POST", body: { pack: String(pack || "") }, fetchImpl, auth });
|
|
68
|
+
if (!r || typeof r.url !== "string" || !/^https:\/\//.test(r.url)) throw new CreditsError("relay returned no checkout url", 502);
|
|
69
|
+
return { url: r.url };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { runPacket, balance, checkout, creditsConfig, CreditsError };
|
package/bridge/paste.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paste route serialization. The packet becomes one text file you copy into
|
|
5
|
+
* any chat window; the reply is one fenced JSON block you save and hand to
|
|
6
|
+
* gafj ingest with the run handle printed at the top. Serialization is the
|
|
7
|
+
* adapter's job; the packet itself is route-agnostic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function block(title, body) {
|
|
11
|
+
return `\n\n## ${title}\n\n${body}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function json(o) {
|
|
15
|
+
return "```json\n" + JSON.stringify(o, null, 1) + "\n```";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderPasteText(packet, { run_id } = {}) {
|
|
19
|
+
const c = packet.context;
|
|
20
|
+
const head = [
|
|
21
|
+
`# GAF-J packet: ${packet.operation}${run_id ? ` (run ${run_id})` : ""}`,
|
|
22
|
+
"",
|
|
23
|
+
"Read everything below, then reply with exactly one fenced ```json block that matches OUTPUT SCHEMA. No prose before or after the block.",
|
|
24
|
+
"Cite knowledge base records by the id shown beside each record, in the claims array of every claim-bearing field. Never invent an id, a figure, or a date.",
|
|
25
|
+
run_id ? `The reply is saved to a file and ingested with: gafj ingest --run ${run_id} --file <reply file>` : "",
|
|
26
|
+
].filter(Boolean).join("\n");
|
|
27
|
+
let out = head;
|
|
28
|
+
out += block(`RULES (engine ${packet.rules_version})`, packet.rules.trim());
|
|
29
|
+
if (c.source) out += block(`SOURCE (${c.source.filename}, ${c.source.chars} characters; spans index into this text from 0)`, c.source.text);
|
|
30
|
+
if (c.profile) out += block("PROFILE", json(c.profile));
|
|
31
|
+
if (c.posting) out += block("POSTING (excerpt)", json(c.posting));
|
|
32
|
+
if (c.scoring) out += block("SCORING (frozen)", json(c.scoring));
|
|
33
|
+
if (c.application) out += block("APPLICATION (this application only)", json(c.application));
|
|
34
|
+
if (c.budget) out += block("CONTENT BUDGET", json(c.budget));
|
|
35
|
+
if (c.house_rules) out += block("HOUSE RULES", json(c.house_rules));
|
|
36
|
+
if (packet.kb_slice.length) {
|
|
37
|
+
out += block(`KNOWLEDGE BASE SLICE (${packet.kb_slice.length} records; cite ids exactly as shown)`,
|
|
38
|
+
packet.kb_slice.map((r) => JSON.stringify(r)).join("\n"));
|
|
39
|
+
}
|
|
40
|
+
out += block(`OUTPUT SCHEMA (schema ${packet.schema_version}, JSON Schema)`, json(packet.output_schema));
|
|
41
|
+
return out + "\n";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { renderPasteText };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_URL = "https://api.anthropic.com";
|
|
4
|
+
const DEFAULT_MODEL = "claude-fable-5-1";
|
|
5
|
+
|
|
6
|
+
async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
|
|
7
|
+
const { post } = require("./index");
|
|
8
|
+
const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
|
|
9
|
+
const model = provider.model || DEFAULT_MODEL;
|
|
10
|
+
const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" },
|
|
11
|
+
{ model, max_tokens, system, messages: [{ role: "user", content: user }] });
|
|
12
|
+
const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
13
|
+
return { text, model: data.model || model, tokens_in: data.usage && data.usage.input_tokens, tokens_out: data.usage && data.usage.output_tokens };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { complete, DEFAULT_URL, DEFAULT_MODEL };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_URL = "https://generativelanguage.googleapis.com/v1beta";
|
|
4
|
+
|
|
5
|
+
async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
|
|
6
|
+
const { post } = require("./index");
|
|
7
|
+
const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
|
|
8
|
+
const model = provider.model || "gemini-2.5-pro";
|
|
9
|
+
const data = await post(fetch, `${base}/models/${encodeURIComponent(model)}:generateContent`, { "x-goog-api-key": provider.api_key || "" },
|
|
10
|
+
{ systemInstruction: { parts: [{ text: system }] }, contents: [{ role: "user", parts: [{ text: user }] }], generationConfig: { maxOutputTokens: max_tokens } });
|
|
11
|
+
const text = ((data.candidates && data.candidates[0] && data.candidates[0].content && data.candidates[0].content.parts) || []).map((p) => p.text || "").join("\n");
|
|
12
|
+
return { text, model, tokens_in: data.usageMetadata && data.usageMetadata.promptTokenCount, tokens_out: data.usageMetadata && data.usageMetadata.candidatesTokenCount };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { complete, DEFAULT_URL };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provider registry: one adapter file per vendor, several stored, one
|
|
5
|
+
* active per run. Each adapter exposes complete({ provider, system, user,
|
|
6
|
+
* max_tokens, fetch }) -> { text, model, tokens_in, tokens_out } and uses
|
|
7
|
+
* fetch only: no vendor SDK, no redirects, https unless loopback.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ADAPTERS = { anthropic: require("./anthropic"), openai_compatible: require("./openai_compatible"), gemini: require("./gemini") };
|
|
11
|
+
|
|
12
|
+
function adapterFor(kind) {
|
|
13
|
+
const a = ADAPTERS[kind];
|
|
14
|
+
if (!a) throw Object.assign(new Error(`no adapter for ${kind}`), { status: 400 });
|
|
15
|
+
return a;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checkUrl(url) {
|
|
19
|
+
const u = new URL(url);
|
|
20
|
+
const loop = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]";
|
|
21
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loop)) throw new Error("provider base_url must be https, or http on loopback");
|
|
22
|
+
return u.toString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function post(fetchImpl, url, headers, body, timeoutMs = 120000) {
|
|
26
|
+
const f = fetchImpl || globalThis.fetch;
|
|
27
|
+
const ac = new AbortController();
|
|
28
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const r = await f(checkUrl(url), { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body), redirect: "error", signal: ac.signal });
|
|
31
|
+
const text = await r.text();
|
|
32
|
+
let data;
|
|
33
|
+
try { data = JSON.parse(text); } catch (_) { data = { raw: text.slice(0, 300) }; }
|
|
34
|
+
if (!r.ok) throw new Error(`${r.status} from provider: ${(data.error && (data.error.message || data.error)) || data.raw || r.statusText}`.slice(0, 300));
|
|
35
|
+
return data;
|
|
36
|
+
} finally { clearTimeout(t); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { adapterFor, post, checkUrl, ADAPTERS };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** OpenAI, Groq, Together, OpenRouter, Mistral, DeepSeek, xAI, Ollama, LM Studio: chat completions. */
|
|
4
|
+
|
|
5
|
+
const DEFAULT_URL = "https://api.openai.com/v1";
|
|
6
|
+
|
|
7
|
+
async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
|
|
8
|
+
const { post } = require("./index");
|
|
9
|
+
const base = (provider.base_url || DEFAULT_URL).replace(/\/$/, "");
|
|
10
|
+
const model = provider.model || "gpt-5";
|
|
11
|
+
const headers = provider.api_key ? { authorization: `Bearer ${provider.api_key}` } : {};
|
|
12
|
+
const data = await post(fetch, `${base}/chat/completions`, headers, { model, max_tokens, messages: [{ role: "system", content: system }, { role: "user", content: user }] });
|
|
13
|
+
const text = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || "";
|
|
14
|
+
return { text, model: data.model || model, tokens_in: data.usage && data.usage.prompt_tokens, tokens_out: data.usage && data.usage.completion_tokens };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { complete, DEFAULT_URL };
|
package/core/extract.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Model text to JSON. Strips code fences and prose around the object. A
|
|
5
|
+
* failure here is a schema block, not a crash: the paste loop is expected
|
|
6
|
+
* to fail the first time. Two hard limits apply before parsing: one
|
|
7
|
+
* megabyte of content, nesting no deeper than 64 levels, and no __proto__
|
|
8
|
+
* key at any depth (JSON.parse makes it an own property, and nothing
|
|
9
|
+
* downstream should have to think about it).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_BYTES = 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
const MAX_DEPTH = 64;
|
|
15
|
+
|
|
16
|
+
/** The first problem with a parsed value: a __proto__ key anywhere, or nesting past MAX_DEPTH (JSON.stringify would overflow on it later). */
|
|
17
|
+
function inspect(v, depth = 0) {
|
|
18
|
+
if (!v || typeof v !== "object") return null;
|
|
19
|
+
if (depth > MAX_DEPTH) return `nested deeper than ${MAX_DEPTH} levels`;
|
|
20
|
+
if (Array.isArray(v)) { for (const x of v) { const e = inspect(x, depth + 1); if (e) return e; } return null; }
|
|
21
|
+
for (const k of Object.keys(v)) { if (k === "__proto__") return "rejected key __proto__"; const e = inspect(v[k], depth + 1); if (e) return e; }
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractJson(text, { maxBytes = MAX_BYTES } = {}) {
|
|
26
|
+
if (text && typeof text === "object") {
|
|
27
|
+
if (Array.isArray(text)) return { error: "expected an object, got an array" };
|
|
28
|
+
let size;
|
|
29
|
+
try { size = Buffer.byteLength(JSON.stringify(text)); } catch (e) { return { error: "content is not serializable: " + e.message }; }
|
|
30
|
+
if (size > maxBytes) return { error: `content larger than ${Math.round(maxBytes / 1024 / 1024)} MB` };
|
|
31
|
+
const bad = inspect(text);
|
|
32
|
+
if (bad) return { error: bad };
|
|
33
|
+
return { value: text };
|
|
34
|
+
}
|
|
35
|
+
let s = String(text ?? "");
|
|
36
|
+
if (Buffer.byteLength(s) > maxBytes) return { error: `content larger than ${Math.round(maxBytes / 1024 / 1024)} MB` };
|
|
37
|
+
s = s.trim();
|
|
38
|
+
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
39
|
+
if (fence) s = fence[1].trim();
|
|
40
|
+
const start = s.indexOf("{");
|
|
41
|
+
const end = s.lastIndexOf("}");
|
|
42
|
+
if (start === -1 || end === -1 || end < start) return { error: "no JSON object found" };
|
|
43
|
+
let value;
|
|
44
|
+
try {
|
|
45
|
+
value = JSON.parse(s.slice(start, end + 1));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { error: "JSON parse failed: " + String(e.message).slice(0, 200) };
|
|
48
|
+
}
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { error: "expected an object" };
|
|
50
|
+
const bad = inspect(value);
|
|
51
|
+
if (bad) return { error: bad };
|
|
52
|
+
return { value };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { extractJson, MAX_BYTES, MAX_DEPTH };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Posting excerpt, not posting text. The packet carries title, location,
|
|
5
|
+
* comp, the scan's evidence lines, and a bounded window of body around
|
|
6
|
+
* them. The full raw_text stays on disk for scoring and validator 7.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { scanJobDescription } = require("../scoring");
|
|
10
|
+
const { clip } = require("./serialize");
|
|
11
|
+
|
|
12
|
+
const DEFAULT_EXCERPT_BYTES = 6000;
|
|
13
|
+
const DEFAULT_WINDOW = 240;
|
|
14
|
+
|
|
15
|
+
function mergeRanges(ranges) {
|
|
16
|
+
const sorted = ranges.slice().sort((a, b) => a[0] - b[0]);
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const r of sorted) {
|
|
19
|
+
const last = out[out.length - 1];
|
|
20
|
+
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
|
|
21
|
+
else out.push([r[0], r[1]]);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function postingExcerpt(posting, { categories = [], budget = DEFAULT_EXCERPT_BYTES, window = DEFAULT_WINDOW } = {}) {
|
|
27
|
+
const text = String(posting.raw_text || "");
|
|
28
|
+
const scan = scanJobDescription(text);
|
|
29
|
+
const lines = [];
|
|
30
|
+
const push = (l) => { const t = String(l || "").trim(); if (t && !lines.includes(t)) lines.push(t); };
|
|
31
|
+
for (const c of categories) for (const e of c.jd_evidence || []) push(e);
|
|
32
|
+
for (const l of scan.hardGateLines) push(l);
|
|
33
|
+
for (const a of scan.aiTriggers) push(a.line);
|
|
34
|
+
|
|
35
|
+
let body;
|
|
36
|
+
let truncated = false;
|
|
37
|
+
if (!lines.length) {
|
|
38
|
+
body = clip(text, budget);
|
|
39
|
+
truncated = body.length < text.length;
|
|
40
|
+
} else {
|
|
41
|
+
const ranges = [];
|
|
42
|
+
for (const l of lines) {
|
|
43
|
+
const i = text.indexOf(l);
|
|
44
|
+
if (i >= 0) ranges.push([Math.max(0, i - window), Math.min(text.length, i + l.length + window)]);
|
|
45
|
+
}
|
|
46
|
+
const parts = [];
|
|
47
|
+
let used = 0;
|
|
48
|
+
for (const [a, b] of mergeRanges(ranges)) {
|
|
49
|
+
const piece = text.slice(a, b).trim();
|
|
50
|
+
if (used + piece.length > budget) { truncated = true; break; }
|
|
51
|
+
parts.push((a > 0 ? "… " : "") + piece + (b < text.length ? " …" : ""));
|
|
52
|
+
used += piece.length;
|
|
53
|
+
}
|
|
54
|
+
body = parts.join("\n\n");
|
|
55
|
+
if (!body) { body = clip(text, budget); truncated = body.length < text.length; }
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
company: posting.company, title: posting.title, location: posting.location || null, comp_text: posting.comp_text || null,
|
|
59
|
+
source_url: posting.source_url || null, evidence_lines: lines.slice(0, 40), body, truncated, raw_length: text.length,
|
|
60
|
+
ai_trigger_fired: scan.aiTriggerFired,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { postingExcerpt, DEFAULT_EXCERPT_BYTES };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One semantic packet per operation: rules (engine, versioned), context,
|
|
5
|
+
* the KB slice with ids, and the output JSON schema. Pure: everything comes
|
|
6
|
+
* in as plain objects; the store gathers, the bridge serializes per route.
|
|
7
|
+
*
|
|
8
|
+
* Rules the builder enforces (plan, packet contract):
|
|
9
|
+
* - resume, cover, prep, onepager, deep_answers refuse to build without a frozen scoring row
|
|
10
|
+
* - prep-family packets carry this application's facts only
|
|
11
|
+
* - the slice follows precedence and a byte budget; a required set that overflows fails closed
|
|
12
|
+
* - the posting travels as an excerpt
|
|
13
|
+
* - no field of the packet carries the comp floor; the builder never receives it
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require("crypto");
|
|
17
|
+
const { rulesFor, rulesVersion } = require("../rules");
|
|
18
|
+
const { SCHEMAS, SCHEMA_VERSION, toJsonSchema } = require("../schemas");
|
|
19
|
+
const { BUDGET } = require("../schemas/onepager");
|
|
20
|
+
const { selectSlice } = require("./slice");
|
|
21
|
+
const { postingExcerpt } = require("./excerpt");
|
|
22
|
+
const { clip, serializeRecord, bytesOf } = require("./serialize");
|
|
23
|
+
|
|
24
|
+
const PACKET_VERSION = "1";
|
|
25
|
+
const POSTING_OPS = new Set(["resume", "cover", "categories"]);
|
|
26
|
+
const NEEDS_SCORING = new Set(["resume", "cover", "prep", "onepager", "deep_answers"]);
|
|
27
|
+
const NO_SLICE = new Set(["practice", "categories", "extract"]);
|
|
28
|
+
const DISCOVER_SLICE_BYTES = 40000;
|
|
29
|
+
const PRIOR_QUESTIONS_CAP = 60;
|
|
30
|
+
const SOURCE_TEXT_CAP = 60000;
|
|
31
|
+
const DEBRIEF_CAP = 4000;
|
|
32
|
+
const DEBRIEF_TOTAL_CAP = 12000;
|
|
33
|
+
const PREP_CONTENT_CAP = 14000;
|
|
34
|
+
|
|
35
|
+
const INTERVIEW_PROFILE_KEYS = ["people_leadership", "capability_notes", "framing_notes", "retired_phrasings", "style", "interview_do_nots", "education", "native_industries"];
|
|
36
|
+
|
|
37
|
+
class PacketError extends Error {
|
|
38
|
+
constructor(message, detail) { super(message); this.name = "PacketError"; this.detail = detail; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function profileFor(op, profile) {
|
|
42
|
+
const p = profile || {};
|
|
43
|
+
if (POSTING_OPS.has(op)) { const { _note, ...rest } = p; return rest; }
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const k of INTERVIEW_PROFILE_KEYS) if (k in p) out[k] = p[k];
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function scoringContext(scoring) {
|
|
50
|
+
if (!scoring) return null;
|
|
51
|
+
return {
|
|
52
|
+
fit_score: scoring.fit_score, verdict: scoring.verdict, verdict_reason: scoring.verdict_reason || null,
|
|
53
|
+
categories: (scoring.categories || []).map((c) => ({ category: c.category, weight: c.weight, is_hard_gate: !!c.is_hard_gate,
|
|
54
|
+
jd_evidence: c.jd_evidence || [], score: c.score ?? null, evidence_ids: c.evidence_ids || [] })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function person(p) {
|
|
59
|
+
return { person_id: p.id, name: p.name, title: p.title || null, org: p.org || null, role: p.role || null, intel: clip(p.intel_text, 600) || null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function applicationContext(op, app) {
|
|
63
|
+
if (!app) return null;
|
|
64
|
+
const rounds = (app.interviews || []).filter((i) => i.status !== "superseded");
|
|
65
|
+
const thisRound = rounds.find((i) => i.id === app.interview_id);
|
|
66
|
+
if (!thisRound) throw new PacketError("interview is not on this application");
|
|
67
|
+
let debriefBytes = 0;
|
|
68
|
+
const prior = rounds.filter((i) => i.id !== thisRound.id && (i.status === "completed" || i.scheduled_at < thisRound.scheduled_at))
|
|
69
|
+
.sort((a, b) => a.scheduled_at.localeCompare(b.scheduled_at))
|
|
70
|
+
.map((i) => {
|
|
71
|
+
let debrief = i.debrief_text ? clip(i.debrief_text, DEBRIEF_CAP) : null;
|
|
72
|
+
if (debrief && debriefBytes + debrief.length > DEBRIEF_TOTAL_CAP) debrief = clip(debrief, Math.max(0, DEBRIEF_TOTAL_CAP - debriefBytes));
|
|
73
|
+
if (debrief) debriefBytes += debrief.length;
|
|
74
|
+
return { interview_id: i.id, round_label: i.round_label, format: i.format, status: i.status, outcome: i.outcome, debrief, people: (i.people || []).map(person) };
|
|
75
|
+
});
|
|
76
|
+
const documents = (app.documents || []).filter((d) => d.status === "passed").map((d) => ({ document_id: d.id, kind: d.kind, interview_id: d.interview_id || null }));
|
|
77
|
+
const ctx = {
|
|
78
|
+
company: app.company, title: app.title, status: app.status,
|
|
79
|
+
this_round: { interview_id: thisRound.id, round_label: thisRound.round_label, format: thisRound.format, status: thisRound.status,
|
|
80
|
+
scheduled_at: thisRound.scheduled_at, tz: thisRound.tz, people: (thisRound.people || []).map(person),
|
|
81
|
+
debrief: op === "followup" && thisRound.debrief_text ? clip(thisRound.debrief_text, DEBRIEF_CAP) : null },
|
|
82
|
+
prior_rounds: prior,
|
|
83
|
+
people: (app.people || []).map(person),
|
|
84
|
+
documents,
|
|
85
|
+
};
|
|
86
|
+
// The schema bounds a passed prep, so it travels whole; the cap is a guard against a hand-edited row.
|
|
87
|
+
if (["onepager", "deep_answers"].includes(op) && app.prep && JSON.stringify(app.prep).length <= PREP_CONTENT_CAP) ctx.prep = app.prep;
|
|
88
|
+
return ctx;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Discovery is a candidate-level packet: no posting, no interview, no scoring. The slice is the
|
|
93
|
+
* confirmed records, thinnest first (records the holes name, then fewest confirmed figures), under
|
|
94
|
+
* its own budget; the context carries the engine's holes and every prior question so the model
|
|
95
|
+
* digs instead of repeating.
|
|
96
|
+
*/
|
|
97
|
+
function discoverPacket(a) {
|
|
98
|
+
const records = a.records || [];
|
|
99
|
+
if (!records.length) throw new PacketError("discovery needs at least one confirmed record; confirm the pending rows first");
|
|
100
|
+
const holes = a.holes || [];
|
|
101
|
+
const holeIds = new Set(holes.map((h) => h.accomplishment_id));
|
|
102
|
+
const confirmedMetrics = (r) => (r.metrics_json || []).filter((m) => m.review_state === "confirmed").length;
|
|
103
|
+
const ordered = [...records].sort((x, y) => (holeIds.has(y.id) - holeIds.has(x.id)) || (confirmedMetrics(x) - confirmedMetrics(y)) || ((y.strength_score || 0) - (x.strength_score || 0)) || x.id.localeCompare(y.id));
|
|
104
|
+
const budget = (a.budgets && a.budgets.slice_bytes) || DISCOVER_SLICE_BYTES;
|
|
105
|
+
const slice = [];
|
|
106
|
+
let bytes = 0;
|
|
107
|
+
for (const r of ordered) {
|
|
108
|
+
const json = serializeRecord(r, a.budgets && a.budgets.record_bytes);
|
|
109
|
+
const size = bytesOf(json) + 1;
|
|
110
|
+
if (bytes + size > budget) break;
|
|
111
|
+
bytes += size;
|
|
112
|
+
slice.push(json);
|
|
113
|
+
}
|
|
114
|
+
const ids = slice.map((r) => r.id);
|
|
115
|
+
const inSlice = new Set(ids);
|
|
116
|
+
const packet = {
|
|
117
|
+
packet_version: PACKET_VERSION, operation: "discover", rules: rulesFor("discover"), rules_version: rulesVersion(), schema_version: SCHEMA_VERSION,
|
|
118
|
+
output_schema: toJsonSchema(SCHEMAS.discover),
|
|
119
|
+
context: {
|
|
120
|
+
candidate: a.candidate.name,
|
|
121
|
+
records_confirmed: records.length, records_in_slice: ids.length,
|
|
122
|
+
holes: holes.filter((h) => inSlice.has(h.accomplishment_id)).map((h) => ({ accomplishment_id: h.accomplishment_id, kind: h.kind, hint: clip(h.question, 200) })),
|
|
123
|
+
prior_questions: (a.prior_questions || []).filter((q) => inSlice.has(q.accomplishment_id)).slice(0, PRIOR_QUESTIONS_CAP).map((q) => ({ accomplishment_id: q.accomplishment_id, kind: q.kind, question: clip(q.question, 200), status: q.status })),
|
|
124
|
+
note: "Ask about records in the slice only, by id. Answers are written by the candidate and land pending against that record.",
|
|
125
|
+
},
|
|
126
|
+
kb_slice: slice, kb_ids: ids, slice_reasons: Object.fromEntries(ids.map((id) => [id, holeIds.has(id) ? "hole" : "thin"])), kb_revision: a.candidate.kb_revision ?? 0,
|
|
127
|
+
};
|
|
128
|
+
packet.packet_hash = hashPacket(packet);
|
|
129
|
+
return packet;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hashPacket(p) {
|
|
133
|
+
const h = crypto.createHash("sha256");
|
|
134
|
+
h.update(JSON.stringify({ operation: p.operation, rules_version: p.rules_version, schema_version: p.schema_version, context: p.context, kb_slice: p.kb_slice }));
|
|
135
|
+
return h.digest("hex");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {object} a { op, candidate:{name, profile, kb_revision}, posting, scoring|null, application|null, records:[parsed], pinned_ids, budgets }
|
|
140
|
+
*/
|
|
141
|
+
function buildPacket(a) {
|
|
142
|
+
const op = a.op;
|
|
143
|
+
if (!SCHEMAS[op]) throw new PacketError(`unknown operation ${op}`);
|
|
144
|
+
if (op === "extract") {
|
|
145
|
+
if (!a.source) throw new PacketError("extract needs a source document");
|
|
146
|
+
if (!a.source.text || a.source.text.length < 20) throw new PacketError("this source has no text; extraction failed at upload");
|
|
147
|
+
const packet = {
|
|
148
|
+
packet_version: PACKET_VERSION, operation: op, rules: rulesFor(op), rules_version: rulesVersion(), schema_version: SCHEMA_VERSION,
|
|
149
|
+
output_schema: toJsonSchema(SCHEMAS[op]),
|
|
150
|
+
context: { candidate: a.candidate.name, source: { source_document_id: a.source.id, filename: a.source.filename, kind: a.source.kind, chars: a.source.text.length,
|
|
151
|
+
text: a.source.text.slice(0, SOURCE_TEXT_CAP), truncated: a.source.text.length > SOURCE_TEXT_CAP,
|
|
152
|
+
note: "Spans are character offsets into this text exactly as given, starting at 0." } },
|
|
153
|
+
kb_slice: [], kb_ids: [], slice_reasons: {}, kb_revision: a.candidate.kb_revision ?? 0,
|
|
154
|
+
};
|
|
155
|
+
packet.packet_hash = hashPacket(packet);
|
|
156
|
+
return packet;
|
|
157
|
+
}
|
|
158
|
+
if (op === "discover") return discoverPacket(a);
|
|
159
|
+
if (POSTING_OPS.has(op) && a.application) throw new PacketError(`${op} is a posting packet; it does not take an interview`);
|
|
160
|
+
if (!POSTING_OPS.has(op) && !a.application) throw new PacketError(`${op} needs an interview on an application`);
|
|
161
|
+
if (!a.posting) throw new PacketError("no posting");
|
|
162
|
+
if (a.posting.status !== "accepted") throw new PacketError("posting is not accepted");
|
|
163
|
+
if (NEEDS_SCORING.has(op) && (!a.scoring || a.scoring.status !== "frozen")) {
|
|
164
|
+
throw new PacketError(`${op} needs frozen scoring for ${a.posting.company}: ${a.posting.title}; run gafj score-freeze first`);
|
|
165
|
+
}
|
|
166
|
+
const budgets = a.budgets || {};
|
|
167
|
+
const categories = a.scoring && a.scoring.status === "frozen" ? a.scoring.categories || [] : [];
|
|
168
|
+
const slice = NO_SLICE.has(op)
|
|
169
|
+
? { records: [], ids: [], reasons: {}, bytes: 0 }
|
|
170
|
+
: selectSlice({ records: a.records || [], categories, pinned_ids: a.pinned_ids || [], posting: a.posting, budget: budgets.slice_bytes, recordBytes: budgets.record_bytes });
|
|
171
|
+
const context = {
|
|
172
|
+
candidate: a.candidate.name,
|
|
173
|
+
profile: profileFor(op, a.candidate.profile),
|
|
174
|
+
posting: postingExcerpt(a.posting, { categories, budget: budgets.excerpt_bytes }),
|
|
175
|
+
scoring: NEEDS_SCORING.has(op) ? scoringContext(a.scoring) : null,
|
|
176
|
+
application: applicationContext(op, a.application),
|
|
177
|
+
};
|
|
178
|
+
if (op === "onepager") context.budget = BUDGET;
|
|
179
|
+
if (a.house) context.house_rules = a.house;
|
|
180
|
+
const packet = {
|
|
181
|
+
packet_version: PACKET_VERSION,
|
|
182
|
+
operation: op,
|
|
183
|
+
rules: rulesFor(op),
|
|
184
|
+
rules_version: rulesVersion(),
|
|
185
|
+
schema_version: SCHEMA_VERSION,
|
|
186
|
+
output_schema: toJsonSchema(SCHEMAS[op]),
|
|
187
|
+
context,
|
|
188
|
+
kb_slice: slice.records,
|
|
189
|
+
kb_ids: slice.ids,
|
|
190
|
+
slice_reasons: slice.reasons,
|
|
191
|
+
kb_revision: a.candidate.kb_revision ?? 0,
|
|
192
|
+
};
|
|
193
|
+
packet.packet_hash = hashPacket(packet);
|
|
194
|
+
return packet;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { buildPacket, PacketError, PACKET_VERSION, POSTING_OPS, NEEDS_SCORING, hashPacket };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One knowledge base record as the packet shows it: id beside the text in
|
|
5
|
+
* the exact form the schema wants back, confirmed dimensions only, bounded
|
|
6
|
+
* per record so a single record cannot eat the slice budget.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RECORD_BYTES = 1800;
|
|
10
|
+
|
|
11
|
+
function clip(s, n) {
|
|
12
|
+
const t = String(s || "");
|
|
13
|
+
return t.length <= n ? t : t.slice(0, Math.max(0, n - 1)).replace(/\s+\S*$/, "") + "…";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dates(r) {
|
|
17
|
+
const a = r.date_start ? String(r.date_start).slice(0, 7) : "";
|
|
18
|
+
const b = r.date_end ? String(r.date_end).slice(0, 7) : "";
|
|
19
|
+
return a && b ? `${a} to ${b}` : a || b || "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function bytesOf(o) {
|
|
23
|
+
return Buffer.byteLength(JSON.stringify(o), "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function serializeRecord(r, maxBytes = DEFAULT_RECORD_BYTES) {
|
|
27
|
+
const confirmed = (xs) => (xs || []).filter((x) => x.review_state === "confirmed");
|
|
28
|
+
const out = {
|
|
29
|
+
id: r.id,
|
|
30
|
+
company: r.company,
|
|
31
|
+
role: r.role,
|
|
32
|
+
dates: dates(r),
|
|
33
|
+
title: r.title,
|
|
34
|
+
summary: clip(r.summary, 400),
|
|
35
|
+
metrics: confirmed(r.metrics_json).map((m) => ({ value: m.value, outcome_key: m.outcome_key, outcome: clip(m.outcome, 120) })),
|
|
36
|
+
verbs: confirmed(r.verbs_json).map((v) => ({ verb: v.verb, tier: v.tier })),
|
|
37
|
+
claims: confirmed(r.verbatim_claims_json).map((c) => ({ type: c.type, text: c.text })),
|
|
38
|
+
wordings: (r.wordings_json || []).slice(0, 3).map((w) => ({ id: w.id, text: clip(w.text, 300) })),
|
|
39
|
+
};
|
|
40
|
+
// Trim from the least load-bearing end until the record fits: wordings, then summary.
|
|
41
|
+
while (bytesOf(out) > maxBytes && out.wordings.length) out.wordings.pop();
|
|
42
|
+
if (bytesOf(out) > maxBytes) out.summary = clip(out.summary, 160);
|
|
43
|
+
if (bytesOf(out) > maxBytes) out.claims = out.claims.slice(0, 4);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The scoring.js shape for rankEvidence. */
|
|
48
|
+
function toScoringShape(r) {
|
|
49
|
+
return {
|
|
50
|
+
id: r.id, title: r.title, company: r.company, summary: r.summary || "",
|
|
51
|
+
wordings: (r.wordings_json || []).map((w) => ({ text: w.text })),
|
|
52
|
+
tags: r.tags_json || [], skills: [], industries: [], strengthScore: r.strength_score || 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { serializeRecord, toScoringShape, bytesOf, clip, DEFAULT_RECORD_BYTES };
|