@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,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KB slice precedence (plan, packet contract): every record cited by the
|
|
5
|
+
* frozen scoring evidence and every pinned id is required and serialized
|
|
6
|
+
* first, pinned ahead of evidence; the top five by rankEvidence for each
|
|
7
|
+
* category at weight 7 or above fill the rest under a byte budget. If the
|
|
8
|
+
* required set alone exceeds the budget the build fails with a
|
|
9
|
+
* deterministic error rather than dropping evidence. When the frozen
|
|
10
|
+
* scoring carries no categories (a tracker import), the fill ranks against
|
|
11
|
+
* the posting's title and opening text instead, so the slice is never empty.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { rankEvidence } = require("../scoring");
|
|
15
|
+
const { serializeRecord, toScoringShape, bytesOf } = require("./serialize");
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SLICE_BYTES = 24000;
|
|
18
|
+
const FILL_WEIGHT = 7;
|
|
19
|
+
const PER_CATEGORY = 5;
|
|
20
|
+
|
|
21
|
+
class SliceError extends Error {
|
|
22
|
+
constructor(message, detail) { super(message); this.name = "SliceError"; this.detail = detail; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function selectSlice({ records, categories = [], pinned_ids = [], posting = null, budget = DEFAULT_SLICE_BYTES, recordBytes }) {
|
|
26
|
+
const byId = new Map(records.map((r) => [r.id, r]));
|
|
27
|
+
const order = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const add = (id, why) => { if (id && byId.has(id) && !seen.has(id)) { seen.add(id); order.push({ id, why }); } };
|
|
30
|
+
|
|
31
|
+
for (const id of pinned_ids) add(id, "pinned");
|
|
32
|
+
for (const c of categories) for (const id of c.evidence_ids || []) add(id, "evidence");
|
|
33
|
+
const required = order.map((o) => ({ ...o, json: serializeRecord(byId.get(o.id), recordBytes) }));
|
|
34
|
+
let bytes = required.reduce((n, o) => n + bytesOf(o.json) + 1, 0);
|
|
35
|
+
if (bytes > budget) {
|
|
36
|
+
throw new SliceError(`required slice is ${bytes} bytes against a ${budget} byte budget; raise the budget or unpin`, {
|
|
37
|
+
required_ids: required.map((o) => o.id), bytes, budget });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const KB = records.map(toScoringShape);
|
|
41
|
+
const queries = categories.filter((c) => Number(c.weight) >= FILL_WEIGHT)
|
|
42
|
+
.sort((a, b) => Number(b.weight) - Number(a.weight))
|
|
43
|
+
.map((c) => [c.category, ...(c.jd_evidence || [])].join(" "));
|
|
44
|
+
if (!queries.length && posting) queries.push([posting.title, String(posting.raw_text || "").slice(0, 2000)].join(" "));
|
|
45
|
+
const fill = [];
|
|
46
|
+
for (const q of queries) {
|
|
47
|
+
for (const e of rankEvidence(q, KB, PER_CATEGORY)) {
|
|
48
|
+
if (seen.has(e.accomplishmentId)) continue;
|
|
49
|
+
const json = serializeRecord(byId.get(e.accomplishmentId), recordBytes);
|
|
50
|
+
const size = bytesOf(json) + 1;
|
|
51
|
+
if (bytes + size > budget) continue;
|
|
52
|
+
seen.add(e.accomplishmentId);
|
|
53
|
+
bytes += size;
|
|
54
|
+
fill.push({ id: e.accomplishmentId, why: "ranked", json });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const all = [...required, ...fill];
|
|
58
|
+
return { records: all.map((o) => o.json), ids: all.map((o) => o.id), reasons: Object.fromEntries(all.map((o) => [o.id, o.why])), bytes, budget };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { selectSlice, SliceError, DEFAULT_SLICE_BYTES, FILL_WEIGHT };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Severity of each style validator per document kind (decision 9). Integrity
|
|
5
|
+
* validators have no switch. A candidate's style_config may override any
|
|
6
|
+
* cell with block, warn, or off; these are the engine defaults, which are
|
|
7
|
+
* the operator's house rules: outbound kinds block, internal kinds warn.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const OUTBOUND = ["resume", "cover", "onepager", "email"];
|
|
11
|
+
const INTERNAL = ["prep", "deep_answers", "practice", "cheatsheet"];
|
|
12
|
+
|
|
13
|
+
const STYLE_RULES = ["no_dashes", "style_config"];
|
|
14
|
+
/** Bullet shape on the resume: result first (the RAS default), action first (verb leads), or let the posting decide. */
|
|
15
|
+
const REGISTERS = ["result_first", "action_first", "posting"];
|
|
16
|
+
const INTEGRITY_RULES = ["provenance", "no_derived_tenure", "metric_pairing", "verb_ladder", "highlight_body_match", "no_confabulated_history", "schema"];
|
|
17
|
+
|
|
18
|
+
const DEFAULT_STYLE = {
|
|
19
|
+
no_dashes: Object.fromEntries([...OUTBOUND.map((k) => [k, "block"]), ...INTERNAL.map((k) => [k, "warn"])]),
|
|
20
|
+
style_config: Object.fromEntries([...OUTBOUND.map((k) => [k, "block"]), ...INTERNAL.map((k) => [k, "warn"])]),
|
|
21
|
+
hyphen_allow: [],
|
|
22
|
+
bullet_register: "result_first",
|
|
23
|
+
vocabulary: [
|
|
24
|
+
{ pattern: "\\be-commerce\\b", flags: "i", message: 'write "ecommerce"' },
|
|
25
|
+
{ pattern: "\\b(\\d+)\\s+plus\\b", flags: "i", message: 'write "N+"' },
|
|
26
|
+
{ pattern: "\\b(\\d+(?:\\.\\d+)?)\\s*percent\\b", flags: "i", message: 'write "N%"' },
|
|
27
|
+
{ pattern: "\\bP and L\\b", flags: "", message: 'write "P&L"' },
|
|
28
|
+
{ pattern: "\\butili[sz]e\\b", flags: "i", message: 'write "use"' },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function severity(house, rule, kind) {
|
|
33
|
+
if (INTEGRITY_RULES.includes(rule)) return "block";
|
|
34
|
+
const table = (house && house[rule]) || DEFAULT_STYLE[rule] || {};
|
|
35
|
+
return table[kind] || (OUTBOUND.includes(kind) ? "block" : "warn");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function houseFor(styleConfig) {
|
|
39
|
+
const cfg = styleConfig || {};
|
|
40
|
+
return {
|
|
41
|
+
no_dashes: { ...DEFAULT_STYLE.no_dashes, ...(cfg.no_dashes || {}) },
|
|
42
|
+
style_config: { ...DEFAULT_STYLE.style_config, ...(cfg.style_config || {}) },
|
|
43
|
+
hyphen_allow: [...DEFAULT_STYLE.hyphen_allow, ...(cfg.hyphen_allow || [])],
|
|
44
|
+
bullet_register: REGISTERS.includes(cfg.bullet_register) ? cfg.bullet_register : DEFAULT_STYLE.bullet_register,
|
|
45
|
+
vocabulary: cfg.vocabulary ? cfg.vocabulary : DEFAULT_STYLE.vocabulary,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { DEFAULT_STYLE, STYLE_RULES, INTEGRITY_RULES, REGISTERS, OUTBOUND, INTERNAL, severity, houseFor };
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turns the legacy knowledge base (accomplishments.json + bullet-variations.json)
|
|
5
|
+
* into accomplishment rows with per-dimension review state (plan principle 9).
|
|
6
|
+
*
|
|
7
|
+
* The rule for review state at import: a metric, outcome key, verb, or
|
|
8
|
+
* verbatim claim is confirmed only if a must-pass fixture exercises it on that
|
|
9
|
+
* record, because the candidate shipped those documents. Everything else is
|
|
10
|
+
* review state and no integrity validator may rely on it until the candidate
|
|
11
|
+
* confirms it. With no fixtures every dimension is review.
|
|
12
|
+
*
|
|
13
|
+
* Metric cleanup: tokenization fragments are dropped ("000 returns"), values
|
|
14
|
+
* that share digits are folded into one entry with aliases ("$1M" and "1M";
|
|
15
|
+
* "1900 cases" and "1900 returns"), and each metric gets an outcome key
|
|
16
|
+
* derived from the record it belongs to.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no I/O, no clock.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { metricKey, words } = require("./scoring");
|
|
22
|
+
const { extractFigures, figureKey } = require("./text");
|
|
23
|
+
const { leadVerb, tier } = require("./verbs");
|
|
24
|
+
|
|
25
|
+
const NUMBER_WORDS = "one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve";
|
|
26
|
+
const DURATION_RE = new RegExp(
|
|
27
|
+
`\\b(?:(?:over|in|within|nearly|almost|about|roughly|under)\\s+)?(?:the\\s+first\\s+)?(?:\\d+\\+?|${NUMBER_WORDS})\\s*(?:years?|months?|weeks?|days?)\\b`, "gi");
|
|
28
|
+
const TENURE_RE = /\b(?:since\s+(?:19|20)\d{2}|\d+\+?\s*years?\s+(?:of\s+)?(?:experience|tenure|in\s+sales|selling|career))\b/gi;
|
|
29
|
+
|
|
30
|
+
function norm(s) {
|
|
31
|
+
return String(s || "").toLowerCase().replace(/[^a-z0-9%$+.]+/g, " ").replace(/\s+/g, " ").trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function slug(text) {
|
|
35
|
+
return words(text).join("_") || "record";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const NUMBER_WORD_VALUES = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, dozen: 12 };
|
|
39
|
+
|
|
40
|
+
function digitsOf(value) {
|
|
41
|
+
const m = String(value).match(/\d[\d,]*(?:\.\d+)?/);
|
|
42
|
+
if (m) return m[0].replace(/,/g, "");
|
|
43
|
+
const w = String(value).toLowerCase().match(/^(?:roughly|about|approximately|~)?\s*(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|dozen)\b/);
|
|
44
|
+
return w ? String(NUMBER_WORD_VALUES[w[1]]) : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isFragment(value) {
|
|
48
|
+
const v = String(value).trim();
|
|
49
|
+
if (/^0\d/.test(v) || /^000\b/.test(v)) return true;
|
|
50
|
+
const d = digitsOf(v);
|
|
51
|
+
return d === "" || (Number(d) === 0 && !/^\$?0(\.0+)?%?$/.test(v));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasUnit(value) {
|
|
55
|
+
return /[$%]/.test(String(value)) || /\b(?:person|member|people|clients?|employees?|staff|years?|months?|weeks?|days?|cases|returns|products|repositories|lines|tests|countries)\b/i.test(String(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fold a record's raw metrics into canonical entries with aliases. */
|
|
59
|
+
function normalizeMetrics(rawMetrics, outcome) {
|
|
60
|
+
const groups = new Map();
|
|
61
|
+
for (const m of rawMetrics || []) {
|
|
62
|
+
const value = String(m.value || "").trim();
|
|
63
|
+
if (!value || isFragment(value)) continue;
|
|
64
|
+
const d = digitsOf(value);
|
|
65
|
+
const scale = /k\b/i.test(value) ? 1e3 : /m\b/i.test(value) ? 1e6 : 1;
|
|
66
|
+
const groupKey = String(Math.round(parseFloat(d) * scale));
|
|
67
|
+
const entry = { value, key: /\d/.test(value) ? metricKey(value) : d, type: m.type || "count", source_variation_ids: m.sourceVariationIds || [] };
|
|
68
|
+
const g = groups.get(groupKey);
|
|
69
|
+
if (!g) { groups.set(groupKey, { canonical: entry, aliases: [] }); continue; }
|
|
70
|
+
// prefer the entry with a currency or percent symbol or a noun as canonical
|
|
71
|
+
if (!hasUnit(g.canonical.value) && hasUnit(value)) {
|
|
72
|
+
g.aliases.push(g.canonical.value);
|
|
73
|
+
g.canonical = { ...entry, source_variation_ids: [...new Set([...g.canonical.source_variation_ids, ...entry.source_variation_ids])] };
|
|
74
|
+
} else {
|
|
75
|
+
g.aliases.push(value);
|
|
76
|
+
g.canonical.source_variation_ids = [...new Set([...g.canonical.source_variation_ids, ...entry.source_variation_ids])];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...groups.values()].map(({ canonical, aliases }) => ({
|
|
80
|
+
value: canonical.value, key: canonical.key, type: canonical.type,
|
|
81
|
+
keys: [...new Set([canonical.key, ...extractFigures(canonical.value).map((f) => f.key), ...aliases.map(figureKey)])],
|
|
82
|
+
outcome_key: outcome.key, outcome: outcome.text,
|
|
83
|
+
aliases, source_variation_ids: canonical.source_variation_ids, review_state: "review",
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractClaims(wordings) {
|
|
88
|
+
const claims = new Map();
|
|
89
|
+
for (const w of wordings) {
|
|
90
|
+
for (const re of [DURATION_RE, TENURE_RE]) {
|
|
91
|
+
re.lastIndex = 0;
|
|
92
|
+
let m;
|
|
93
|
+
while ((m = re.exec(w.text))) {
|
|
94
|
+
const text = m[0].replace(/\s+/g, " ").trim();
|
|
95
|
+
const type = re === TENURE_RE ? "tenure" : "duration";
|
|
96
|
+
const k = type + ":" + norm(text);
|
|
97
|
+
const c = claims.get(k) || { text, type, source_variation_ids: [], review_state: "review" };
|
|
98
|
+
c.source_variation_ids.push(w.id);
|
|
99
|
+
claims.set(k, c);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return [...claims.values()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractVerbs(wordings) {
|
|
107
|
+
const verbs = new Map();
|
|
108
|
+
for (const w of wordings) {
|
|
109
|
+
const v = leadVerb(w.text);
|
|
110
|
+
if (!v) continue;
|
|
111
|
+
const e = verbs.get(v) || { verb: v, tier: tier(v), source_variation_ids: [], review_state: "review" };
|
|
112
|
+
e.source_variation_ids.push(w.id);
|
|
113
|
+
verbs.set(v, e);
|
|
114
|
+
}
|
|
115
|
+
return [...verbs.values()];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Sentences of the fixture set, each as a stem set plus its normalized text. */
|
|
119
|
+
function fixtureSentences(fixtureTexts) {
|
|
120
|
+
const out = [];
|
|
121
|
+
// A line (one bullet) is the unit of attribution; its sentences are also offered on
|
|
122
|
+
// their own so a short wording still matches exactly.
|
|
123
|
+
for (const t of fixtureTexts || []) {
|
|
124
|
+
for (const line of String(t).split(/\r?\n/)) {
|
|
125
|
+
const parts = line.split(/(?<=[.;:])\s+/);
|
|
126
|
+
for (const s of parts.length > 1 ? [line, ...parts] : parts) {
|
|
127
|
+
const n = norm(s);
|
|
128
|
+
if (n.length < 12) continue;
|
|
129
|
+
out.push({ raw: s, norm: n, stems: new Set(words(s)) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function containment(needleStems, hay) {
|
|
137
|
+
if (needleStems.length === 0) return 0;
|
|
138
|
+
let hit = 0;
|
|
139
|
+
for (const s of needleStems) if (hay.has(s)) hit++;
|
|
140
|
+
return hit / needleStems.length;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A wording is exercised when a fixture sentence contains most of its stems. */
|
|
144
|
+
function exercisedWordings(wordings, sentences) {
|
|
145
|
+
const hit = new Set();
|
|
146
|
+
for (const w of wordings) {
|
|
147
|
+
const stems = [...new Set(words(w.text))];
|
|
148
|
+
const n = norm(w.text);
|
|
149
|
+
for (const s of sentences) {
|
|
150
|
+
if (stems.length < 4 ? s.norm.includes(n) : containment(stems, s.stems) >= 0.8) { hit.add(w.id); break; }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return hit;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyFixtureRule(row, wordings, sentences) {
|
|
157
|
+
if (!sentences.length) return row;
|
|
158
|
+
const hit = exercisedWordings(wordings, sentences);
|
|
159
|
+
const titleStems = new Set(words(row.title));
|
|
160
|
+
const shared = (s) => { let c = 0; for (const t of titleStems) if (s.stems.has(t)) c++; return c; };
|
|
161
|
+
// A distinctive figure (money, percent, four or more digits) needs one title stem in the
|
|
162
|
+
// sentence; a bare count like "6" or "375" needs two, so a shared small number never
|
|
163
|
+
// confirms across records.
|
|
164
|
+
const distinctive = (k) => /[$%]/.test(k) || k.replace(/\D/g, "").length >= 4;
|
|
165
|
+
for (const m of row.metrics_json) {
|
|
166
|
+
const byWording = m.source_variation_ids.some((id) => hit.has(id));
|
|
167
|
+
const keys = [...new Set(m.keys || [m.key])];
|
|
168
|
+
const byValue = sentences.some((s) => {
|
|
169
|
+
const n = shared(s);
|
|
170
|
+
if (n < 1) return false;
|
|
171
|
+
s.figureKeys || (s.figureKeys = new Set(extractFigures(s.raw).map((f) => f.key)));
|
|
172
|
+
return keys.some((k) => s.figureKeys.has(k) && (n >= 2 || distinctive(k)));
|
|
173
|
+
});
|
|
174
|
+
if (byWording || byValue) m.review_state = "confirmed";
|
|
175
|
+
}
|
|
176
|
+
for (const v of row.verbs_json) {
|
|
177
|
+
if (v.source_variation_ids.some((id) => hit.has(id))) v.review_state = "confirmed";
|
|
178
|
+
}
|
|
179
|
+
// A fixture sentence that belongs to this record (most of the title's stems, or one of its
|
|
180
|
+
// confirmed metrics) confirms the ownership verb it leads with, adding it if the wordings
|
|
181
|
+
// never used it. The candidate shipped that verb on that record; that is the confirmation.
|
|
182
|
+
const titleList = [...titleStems];
|
|
183
|
+
const confirmedValues = row.metrics_json.filter((m) => m.review_state === "confirmed").map((m) => norm(m.value).replace(/\s+/g, ""));
|
|
184
|
+
for (const s of sentences) {
|
|
185
|
+
const v = leadVerb(s.norm);
|
|
186
|
+
if (!v) continue;
|
|
187
|
+
let shared = 0;
|
|
188
|
+
for (const t of titleList) if (s.stems.has(t)) shared++;
|
|
189
|
+
const byTitle = titleList.length >= 2 && shared >= Math.max(2, Math.ceil(titleList.length / 2));
|
|
190
|
+
const flat = s.norm.replace(/\s+/g, "");
|
|
191
|
+
const byMetric = confirmedValues.some((val) => val.length >= 3 && flat.includes(val));
|
|
192
|
+
if (!byTitle && !byMetric) continue;
|
|
193
|
+
const existing = row.verbs_json.find((x) => x.verb === v);
|
|
194
|
+
if (existing) existing.review_state = "confirmed";
|
|
195
|
+
else row.verbs_json.push({ verb: v, tier: tier(v), source_variation_ids: [], source: "fixture", review_state: "confirmed" });
|
|
196
|
+
}
|
|
197
|
+
for (const c of row.verbatim_claims_json) {
|
|
198
|
+
const n = norm(c.text);
|
|
199
|
+
if (sentences.some((s) => s.norm.includes(n))) c.review_state = "confirmed";
|
|
200
|
+
}
|
|
201
|
+
return row;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function toIso(dateDisplay) {
|
|
205
|
+
// "Sep 2018" -> "2018-09"; "Present" -> null
|
|
206
|
+
const m = String(dateDisplay || "").match(/([A-Z][a-z]{2})\w*\s+(\d{4})/);
|
|
207
|
+
if (!m) return null;
|
|
208
|
+
const month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].indexOf(m[1].toLowerCase()) + 1;
|
|
209
|
+
return month ? `${m[2]}-${String(month).padStart(2, "0")}` : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {object[]} accomplishments legacy records
|
|
214
|
+
* @param {object[]} variations legacy bullet wordings
|
|
215
|
+
* @param {string[]} [fixtureTexts] must-pass fixture texts; empty means all review
|
|
216
|
+
*/
|
|
217
|
+
function buildAccomplishmentRows(accomplishments, variations, fixtureTexts = []) {
|
|
218
|
+
const byRecord = new Map();
|
|
219
|
+
for (const v of variations || []) {
|
|
220
|
+
const list = byRecord.get(v.accomplishmentId) || [];
|
|
221
|
+
list.push({ id: v.id, text: v.text, recommended: v.isRecommended === true, score: v.variationScore || 0, quality_flags: v.qualityFlags || [] });
|
|
222
|
+
byRecord.set(v.accomplishmentId, list);
|
|
223
|
+
}
|
|
224
|
+
const sentences = fixtureSentences(fixtureTexts);
|
|
225
|
+
const rows = [];
|
|
226
|
+
for (const a of accomplishments) {
|
|
227
|
+
const wordings = (byRecord.get(a.id) || []).sort((x, y) => (y.recommended - x.recommended) || (y.score - x.score));
|
|
228
|
+
const outcome = { key: slug(a.title), text: a.title };
|
|
229
|
+
const row = {
|
|
230
|
+
id: a.id, title: a.title, company: a.company, role: a.role,
|
|
231
|
+
date_start: toIso(a.dates && a.dates.start), date_end: toIso(a.dates && a.dates.end),
|
|
232
|
+
summary: a.summary || a.action || null,
|
|
233
|
+
metrics_json: normalizeMetrics(a.metrics, outcome),
|
|
234
|
+
verbatim_claims_json: extractClaims(wordings),
|
|
235
|
+
verbs_json: extractVerbs(wordings),
|
|
236
|
+
subjects_json: [],
|
|
237
|
+
wordings_json: wordings.map((w) => ({ id: w.id, text: w.text, recommended: w.recommended, score: w.score })),
|
|
238
|
+
tags_json: [...new Set([...(a.tags || []), ...(a.skills || [])])],
|
|
239
|
+
strength_score: a.strengthScore ?? null,
|
|
240
|
+
};
|
|
241
|
+
rows.push(applyFixtureRule(row, wordings, sentences));
|
|
242
|
+
}
|
|
243
|
+
return rows;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Every dimension still in review state, flattened for the review list. */
|
|
247
|
+
function reviewList(rows) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const r of rows) {
|
|
250
|
+
for (const m of r.metrics_json) if (m.review_state !== "confirmed") out.push({ id: r.id, dimension: "metric", value: m.value, outcome_key: m.outcome_key });
|
|
251
|
+
for (const v of r.verbs_json) if (v.review_state !== "confirmed") out.push({ id: r.id, dimension: "verb", value: v.verb });
|
|
252
|
+
for (const c of r.verbatim_claims_json) if (c.review_state !== "confirmed") out.push({ id: r.id, dimension: c.type, value: c.text });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { buildAccomplishmentRows, normalizeMetrics, extractClaims, extractVerbs, reviewList, isFragment, norm };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
-- v3: the discover operation (plan section U, "shakedown"): the AI reads the confirmed records and
|
|
2
|
+
-- asks the candidate what is missing; every answer lands pending against the record it extends.
|
|
3
|
+
-- ai_run's operation check gains 'discover', a candidate-level run with no posting, interview, or
|
|
4
|
+
-- source. SQLite cannot alter a CHECK in place, so the table is rebuilt (foreign keys are off for
|
|
5
|
+
-- the migration transaction; migrate.js runs foreign_key_check before it commits).
|
|
6
|
+
CREATE TABLE ai_run_v3 (
|
|
7
|
+
id TEXT PRIMARY KEY,
|
|
8
|
+
candidate_id TEXT NOT NULL REFERENCES candidate(id),
|
|
9
|
+
route TEXT NOT NULL CHECK (route IN ('mcp', 'paste', 'byo_key', 'credits', 'manual')),
|
|
10
|
+
operation TEXT NOT NULL CHECK (operation IN ('resume', 'cover', 'prep', 'onepager', 'deep_answers', 'followup', 'practice', 'categories', 'extract', 'style', 'dedup', 'discover')),
|
|
11
|
+
interview_id TEXT REFERENCES interview(id),
|
|
12
|
+
posting_id TEXT REFERENCES posting(id),
|
|
13
|
+
source_document_id TEXT REFERENCES source_document(id),
|
|
14
|
+
onboarding_batch_id TEXT REFERENCES onboarding_batch(id),
|
|
15
|
+
rules_version TEXT NOT NULL,
|
|
16
|
+
schema_version TEXT NOT NULL,
|
|
17
|
+
packet_version TEXT NOT NULL,
|
|
18
|
+
kb_revision INTEGER NOT NULL,
|
|
19
|
+
packet_hash TEXT NOT NULL,
|
|
20
|
+
kb_ids_json TEXT NOT NULL,
|
|
21
|
+
started_at TEXT NOT NULL,
|
|
22
|
+
finished_at TEXT,
|
|
23
|
+
final_result TEXT NOT NULL DEFAULT 'pending' CHECK (final_result IN ('pending', 'passed', 'blocked', 'error', 'abandoned')),
|
|
24
|
+
CHECK ((operation IN ('resume', 'cover', 'categories') AND posting_id IS NOT NULL)
|
|
25
|
+
OR (operation IN ('prep', 'onepager', 'deep_answers', 'followup', 'practice') AND interview_id IS NOT NULL)
|
|
26
|
+
OR (operation IN ('extract', 'style') AND source_document_id IS NOT NULL)
|
|
27
|
+
OR (operation = 'dedup' AND onboarding_batch_id IS NOT NULL)
|
|
28
|
+
OR (operation = 'discover'))
|
|
29
|
+
);
|
|
30
|
+
INSERT INTO ai_run_v3 SELECT id, candidate_id, route, operation, interview_id, posting_id, source_document_id, onboarding_batch_id, rules_version, schema_version, packet_version, kb_revision, packet_hash, kb_ids_json, started_at, finished_at, final_result FROM ai_run;
|
|
31
|
+
DROP TABLE ai_run;
|
|
32
|
+
ALTER TABLE ai_run_v3 RENAME TO ai_run;
|
|
33
|
+
|
|
34
|
+
-- The questions a discovery run asked. Answering one writes a pending row (source 'manual', draft.replaces = the
|
|
35
|
+
-- record) and links it here; confirming that row extends the record. Nothing on the record changes until then.
|
|
36
|
+
CREATE TABLE kb_question (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
candidate_id TEXT NOT NULL REFERENCES candidate(id),
|
|
39
|
+
ai_run_id TEXT NOT NULL REFERENCES ai_run(id),
|
|
40
|
+
accomplishment_id TEXT NOT NULL REFERENCES accomplishment(id),
|
|
41
|
+
kind TEXT NOT NULL CHECK (kind IN ('metric', 'scope', 'context', 'story', 'tenure', 'skill')),
|
|
42
|
+
question TEXT NOT NULL,
|
|
43
|
+
why TEXT,
|
|
44
|
+
asked_at TEXT NOT NULL,
|
|
45
|
+
answered_at TEXT,
|
|
46
|
+
pending_id TEXT REFERENCES pending_accomplishment(id) ON DELETE SET NULL,
|
|
47
|
+
dismissed_at TEXT
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX kb_question_open ON kb_question(candidate_id) WHERE answered_at IS NULL AND dismissed_at IS NULL;
|
package/core/parsers.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text extraction for uploaded sources (plan section U). Parsers are inputs
|
|
5
|
+
* from strangers once a second user exists, so every one runs under a cap:
|
|
6
|
+
* raw size, decompressed docx entries and bytes, PDF pages, and a timeout.
|
|
7
|
+
* An empty result is returned as a stored failure, never thrown away.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
|
|
12
|
+
const LIMITS = { raw_bytes: 10 * 1024 * 1024, docx_entries: 200, docx_inflated_bytes: 50 * 1024 * 1024, pdf_pages: 60, timeout_ms: 30000, text_chars: 1048576 };
|
|
13
|
+
const KINDS = { ".docx": "docx", ".pdf": "pdf", ".md": "md", ".txt": "txt" };
|
|
14
|
+
|
|
15
|
+
const sha = (b) => crypto.createHash("sha256").update(b).digest("hex");
|
|
16
|
+
|
|
17
|
+
function kindOf(filename) {
|
|
18
|
+
const m = String(filename).toLowerCase().match(/\.[a-z0-9]+$/);
|
|
19
|
+
return (m && KINDS[m[0]]) || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withTimeout(promise, ms, what) {
|
|
23
|
+
let t;
|
|
24
|
+
const timer = new Promise((_, reject) => { t = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms); });
|
|
25
|
+
return Promise.race([promise, timer]).finally(() => clearTimeout(t));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeText(s) {
|
|
29
|
+
return String(s || "").replace(/\r\n?/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function parseDocx(buffer, limits) {
|
|
33
|
+
const JSZip = require("jszip");
|
|
34
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
35
|
+
const entries = Object.values(zip.files);
|
|
36
|
+
if (entries.length > limits.docx_entries) throw new Error(`docx has ${entries.length} entries; the cap is ${limits.docx_entries}`);
|
|
37
|
+
let inflated = 0;
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
// JSZip exposes the uncompressed size on the internal data object
|
|
40
|
+
const size = (e._data && e._data.uncompressedSize) || 0;
|
|
41
|
+
inflated += size;
|
|
42
|
+
if (inflated > limits.docx_inflated_bytes) throw new Error("docx inflates past the cap");
|
|
43
|
+
}
|
|
44
|
+
const mammoth = require("mammoth");
|
|
45
|
+
const r = await mammoth.extractRawText({ buffer });
|
|
46
|
+
return { text: normalizeText(r.value), extractor: "mammoth", extractor_version: require("mammoth/package.json").version, pages: null, warnings: (r.messages || []).map((m) => m.message).slice(0, 5) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function parsePdf(buffer, limits) {
|
|
50
|
+
const { getDocumentProxy, extractText } = require("unpdf");
|
|
51
|
+
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
|
52
|
+
if (pdf.numPages > limits.pdf_pages) throw new Error(`pdf has ${pdf.numPages} pages; the cap is ${limits.pdf_pages}`);
|
|
53
|
+
const r = await extractText(pdf, { mergePages: true });
|
|
54
|
+
return { text: normalizeText(r.text), extractor: "unpdf", extractor_version: "1.8.1", pages: pdf.numPages, warnings: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @returns {Promise<{ kind, file_hash, text, text_hash, extractor, extractor_version, pages, empty, failure }>}
|
|
59
|
+
*/
|
|
60
|
+
async function extractSource(filename, buffer, limits = LIMITS) {
|
|
61
|
+
const kind = kindOf(filename);
|
|
62
|
+
if (!kind) throw Object.assign(new Error("only docx, pdf, md, and txt are accepted"), { status: 415 });
|
|
63
|
+
if (buffer.length > limits.raw_bytes) throw Object.assign(new Error(`file is ${buffer.length} bytes; the cap is ${limits.raw_bytes}`), { status: 413 });
|
|
64
|
+
const file_hash = sha(buffer);
|
|
65
|
+
let out;
|
|
66
|
+
try {
|
|
67
|
+
if (kind === "docx") out = await withTimeout(parseDocx(buffer, limits), limits.timeout_ms, "docx extraction");
|
|
68
|
+
else if (kind === "pdf") out = await withTimeout(parsePdf(buffer, limits), limits.timeout_ms, "pdf extraction");
|
|
69
|
+
else out = { text: normalizeText(buffer.toString("utf8")), extractor: "plain", extractor_version: "1", pages: null, warnings: [] };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
return { kind, file_hash, text: "", text_hash: sha(""), extractor: kind === "docx" ? "mammoth" : kind === "pdf" ? "unpdf" : "plain", extractor_version: "failed", pages: null, empty: true, failure: e.message };
|
|
72
|
+
}
|
|
73
|
+
if (out.text.length > limits.text_chars) out.text = out.text.slice(0, limits.text_chars);
|
|
74
|
+
const empty = out.text.length < 20;
|
|
75
|
+
return { kind, file_hash, text: out.text, text_hash: sha(out.text), extractor: out.extractor, extractor_version: out.extractor_version, pages: out.pages, empty,
|
|
76
|
+
failure: empty ? (kind === "pdf" ? "no text layer; OCR is not in v1, export the PDF from the source document or paste the text as .txt" : "no text found in the file") : null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { extractSource, kindOf, LIMITS, normalizeText };
|
package/core/practice.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The practice workbook (Codification 3.6), built from a passed practice
|
|
5
|
+
* document: sheets of scenario data with the planted anomalies the manifest
|
|
6
|
+
* names, a Tasks tab, an AnswerKey tab with formulas as text, and a
|
|
7
|
+
* Solution tab. Deterministic from the document id so a grader can rebuild
|
|
8
|
+
* the same workbook. Pure: returns a description exceljs writes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function rng(seed) {
|
|
12
|
+
let s = 0;
|
|
13
|
+
for (const ch of String(seed)) s = (s * 31 + ch.charCodeAt(0)) >>> 0;
|
|
14
|
+
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NBSP = String.fromCharCode(160);
|
|
18
|
+
|
|
19
|
+
function cellFor(col, r, row) {
|
|
20
|
+
const c = col.toLowerCase();
|
|
21
|
+
if (/id|key|number|no\b|#/.test(c)) return `${col.replace(/[^A-Za-z]/g, "").slice(0, 3).toUpperCase() || "ID"}-${1000 + row}`;
|
|
22
|
+
if (/date/.test(c)) { const d = new Date(Date.UTC(2026, 0, 1 + Math.floor(r() * 240))); return d.toISOString().slice(0, 10); }
|
|
23
|
+
if (/qty|count|units|pieces|weight|miles|hours/.test(c)) return Math.round(1 + r() * 400);
|
|
24
|
+
if (/rate|price|cost|amount|total|bill|charge|freight|fee|invoice/.test(c)) return Math.round((20 + r() * 980) * 100) / 100;
|
|
25
|
+
if (/tier|class|category|service|type|lane|mode|status/.test(c)) return ["A", "B", "C", "D"][Math.floor(r() * 4)];
|
|
26
|
+
if (/carrier|vendor|customer|client|name|shipper|consignee/.test(c)) return ["Northwind", "Harbor Goods", "Acme", "Globex", "Initech", "Umbrella"][Math.floor(r() * 6)];
|
|
27
|
+
if (/state|region/.test(c)) return ["IL", "IN", "MO", "IA", "OH", "MI"][Math.floor(r() * 6)];
|
|
28
|
+
return `${col} ${row}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Apply one planted anomaly to a row of cells (by column name), returning what changed. */
|
|
32
|
+
function plant(kind, cells, cols, r) {
|
|
33
|
+
const money = cols.filter((c) => /rate|price|cost|amount|total|bill|charge|freight|fee|invoice/i.test(c));
|
|
34
|
+
const numeric = cols.filter((c) => typeof cells[c] === "number");
|
|
35
|
+
const text = cols.filter((c) => typeof cells[c] === "string" && !/id|key|date/i.test(c));
|
|
36
|
+
const pick = (list) => list[Math.floor(r() * list.length)];
|
|
37
|
+
switch (kind) {
|
|
38
|
+
case "overbill": { const c = pick(money.length ? money : numeric); if (c == null) return null; const was = cells[c]; cells[c] = Math.round(was * (1.15 + r() * 0.4) * 100) / 100; return { column: c, was, now: cells[c] }; }
|
|
39
|
+
case "underbill": { const c = pick(money.length ? money : numeric); if (c == null) return null; const was = cells[c]; cells[c] = Math.round(was * (0.55 + r() * 0.3) * 100) / 100; return { column: c, was, now: cells[c] }; }
|
|
40
|
+
case "duplicate": return { column: "*", was: null, now: "row repeated" };
|
|
41
|
+
case "orphan_key": { const c = cols.find((x) => /id|key/i.test(x)) || cols[0]; const was = cells[c]; cells[c] = "ZZ-9" + Math.floor(r() * 900 + 100); return { column: c, was, now: cells[c] }; }
|
|
42
|
+
case "whitespace": { const c = pick(text.length ? text : cols); const was = cells[c]; cells[c] = " " + was + " "; return { column: c, was, now: cells[c] }; }
|
|
43
|
+
case "nbsp": { const c = pick(text.length ? text : cols); const was = cells[c]; cells[c] = String(was) + NBSP; return { column: c, was, now: cells[c] }; }
|
|
44
|
+
case "text_number": { const c = pick(numeric.length ? numeric : cols); const was = cells[c]; cells[c] = String(was); return { column: c, was, now: cells[c] }; }
|
|
45
|
+
case "mislabel": { const c = cols.find((x) => /tier|class|category|service|type|status/i.test(x)) || pick(text.length ? text : cols); const was = cells[c]; cells[c] = "Misc"; return { column: c, was, now: cells[c] }; }
|
|
46
|
+
case "wrong_tier": { const c = cols.find((x) => /tier|class|category|service|type/i.test(x)); if (!c) return null; const was = cells[c]; cells[c] = was === "A" ? "D" : "A"; return { column: c, was, now: cells[c], note: "the amount is right for the tier shown; the tier is what is wrong" }; }
|
|
47
|
+
default: return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @returns { sheets: [{name, columns, rows: [[...]]}], answerKey: [...], solution: [...], planted: [...] } */
|
|
52
|
+
function buildWorkbook(content, seed) {
|
|
53
|
+
const r = rng(seed || content.scenario);
|
|
54
|
+
const sheets = [];
|
|
55
|
+
const planted = [];
|
|
56
|
+
for (const spec of content.sheets) {
|
|
57
|
+
const rows = [];
|
|
58
|
+
const anomalies = content.anomalies.filter((a) => a.sheet === spec.name);
|
|
59
|
+
for (let i = 1; i <= spec.rows; i++) {
|
|
60
|
+
const cells = {};
|
|
61
|
+
for (const col of spec.columns) cells[col] = cellFor(col, r, i);
|
|
62
|
+
rows.push(cells);
|
|
63
|
+
}
|
|
64
|
+
for (const a of anomalies) {
|
|
65
|
+
const idx = Math.min(Math.max(1, a.row), rows.length) - 1;
|
|
66
|
+
const change = plant(a.kind, rows[idx], spec.columns, r);
|
|
67
|
+
if (a.kind === "duplicate") rows.splice(idx + 1, 0, { ...rows[idx] });
|
|
68
|
+
planted.push({ sheet: spec.name, row: idx + 2, kind: a.kind, description: a.description, ...(change || {}) });
|
|
69
|
+
}
|
|
70
|
+
sheets.push({ name: spec.name, columns: spec.columns, rows: rows.map((c) => spec.columns.map((col) => c[col])) });
|
|
71
|
+
}
|
|
72
|
+
return { sheets, tasks: content.tasks, answerKey: content.answer_key, planted, lesson: content.lesson || "" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { buildWorkbook, plant, NBSP };
|