job-application-agent 3.4.2 → 3.6.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 +23 -0
- package/installer/src/cli.mjs +8 -1
- package/installer/src/installer.mjs +8 -0
- package/job-application-agent/SKILL.md +32 -2
- package/job-application-agent/capabilities.json +3 -1
- package/job-application-agent/references/ACCOUNTING.md +104 -0
- package/job-application-agent/references/AUTONOMY.md +2 -0
- package/job-application-agent/references/CLOUD_STATE.md +2 -0
- package/job-application-agent/references/FREE_AI.md +39 -0
- package/job-application-agent/references/OUTREACH.md +210 -0
- package/job-application-agent/references/RUNS.md +35 -0
- package/job-application-agent/references/SCHEMAS.md +12 -2
- package/job-application-agent/references/agent-box/README.md +77 -0
- package/job-application-agent/references/agent-box/novnc.service.example +16 -0
- package/job-application-agent/scripts/application-accounting.mjs +246 -0
- package/job-application-agent/scripts/ats/answer-inject.mjs +203 -0
- package/job-application-agent/scripts/ats/submit-adapters.mjs +194 -0
- package/job-application-agent/scripts/attention-questions.mjs +111 -0
- package/job-application-agent/scripts/attention-resume-submit.mjs +450 -0
- package/job-application-agent/scripts/attention-runner-poll.mjs +328 -0
- package/job-application-agent/scripts/captcha-vendor.mjs +328 -0
- package/job-application-agent/scripts/cloud-state-client.mjs +62 -8
- package/job-application-agent/scripts/job-application.mjs +243 -27
- package/job-application-agent/scripts/novnc-display-guard.mjs +300 -0
- package/job-application-agent/scripts/outreach-cli.mjs +95 -0
- package/job-application-agent/scripts/outreach-domain.mjs +287 -0
- package/job-application-agent/scripts/outreach-store.mjs +72 -0
- package/job-application-agent/scripts/session-binding.mjs +474 -0
- package/job-application-agent/scripts/version.mjs +1 -1
- package/job-application-agent/tests/accounting-cli.test.mjs +86 -0
- package/job-application-agent/tests/accounting-cloud-client.test.mjs +183 -0
- package/job-application-agent/tests/accounting-retry-cli.test.mjs +96 -0
- package/job-application-agent/tests/accounting-source-race.test.mjs +109 -0
- package/job-application-agent/tests/answer-inject-captcha.test.mjs +169 -0
- package/job-application-agent/tests/application-accounting.test.mjs +315 -0
- package/job-application-agent/tests/attention-resume-submit.test.mjs +119 -0
- package/job-application-agent/tests/attention-runner-poll.test.mjs +135 -0
- package/job-application-agent/tests/fixtures/outreach.mjs +22 -0
- package/job-application-agent/tests/job-application.test.mjs +5 -0
- package/job-application-agent/tests/novnc-display-guard.test.mjs +50 -0
- package/job-application-agent/tests/outreach-cli.test.mjs +73 -0
- package/job-application-agent/tests/outreach.test.mjs +178 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +2 -0
- package/job-application-agent/tests/review-cadence.test.mjs +66 -0
- package/job-application-agent/tests/session-binding.test.mjs +102 -0
- package/job-application-agent/tests/skill-contract.test.mjs +25 -0
- package/job-application-agent/tests/workflow-state.test.mjs +30 -3
- package/package.json +6 -3
|
@@ -166,11 +166,17 @@ Use `round source --stdin` for per-source search/blocker reports and optional at
|
|
|
166
166
|
"url": "https://jobs.example.com/role",
|
|
167
167
|
"stage": "submission",
|
|
168
168
|
"blocker": "captcha",
|
|
169
|
-
"requiredActions": ["complete-captcha"]
|
|
169
|
+
"requiredActions": ["complete-captcha"],
|
|
170
|
+
"company": "Optional notify hint",
|
|
171
|
+
"role": "Optional notify hint",
|
|
172
|
+
"browserProfilePath": "/home/runner/.jaa-chrome-fill",
|
|
173
|
+
"display": ":99",
|
|
174
|
+
"vncPort": 5900,
|
|
175
|
+
"tabHint": { "urlContains": "/application" }
|
|
170
176
|
}
|
|
171
177
|
```
|
|
172
178
|
|
|
173
|
-
Allowed blockers: `authentication`, `mfa`, `captcha`, `legal-attestation`, `demographic`, `government-id`, `ambiguous-authorization`, `ambiguous-compensation`, `unverifiable-claim`, `judgment`, `video`, `upload`, `site-error`, and `other`. Resolve with `{ "id": "attention-..." }`. The queue never stores
|
|
179
|
+
Allowed blockers: `authentication`, `mfa`, `captcha`, `legal-attestation`, `demographic`, `government-id`, `ambiguous-authorization`, `ambiguous-compensation`, `unverifiable-claim`, `judgment`, `video`, `upload`, `site-error`, and `other`. Resolve with `{ "id": "attention-..." }`. The queue never stores CAPTCHA, MFA, cookies, demographic answers, or government IDs. Optional `questions[]` (prompts only) and `aiAssistanceDiscouraged` may be packaged for hosted attention UI; candidate responses travel via site signal `answers[]` / answer bank, not the attention.ndjson queue. Optional `company` / `role` are notify-only hints (not persisted on the attention event); the hosted notify path uses profile email when `ATTENTION_NOTIFY_URL` and `ATTENTION_NOTIFY_SECRET` are set. Optional `browserProfilePath` / `display` / `vncPort` / `tabHint` write a **local-only** session binding under `session-bindings/` (never cloud; `display` must be `:99`, `vncPort` must be `5900`). See `site/docs/ATTENTION.md` and `references/agent-box/`.
|
|
174
180
|
|
|
175
181
|
## Friction input
|
|
176
182
|
|
|
@@ -221,3 +227,7 @@ Generate `ledger review` first. Acknowledge only after the candidate has reviewe
|
|
|
221
227
|
The acknowledgement stores the current canonical unique-submission and mature-application counts in append-only `reviews.ndjson`.
|
|
222
228
|
|
|
223
229
|
`ledger review` also returns `interviewLearningSegments`. Each row combines the canonical application's source and ten-point fit-score band with the latest recorded interview quality and failure point, plus a count. This supports evidence-based targeting reviews without exposing private notes or automatically changing score weights.
|
|
230
|
+
|
|
231
|
+
## Delivery and per-lead accounting
|
|
232
|
+
|
|
233
|
+
See [ACCOUNTING.md](ACCOUNTING.md) for versioned delivery events, explicit corrections, linked retry evidence, lead dispositions/revisions, and effective round/review output fields. These streams are private and append-only; they never become telemetry properties.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# agent-box — fill display + live noVNC binding
|
|
2
|
+
|
|
3
|
+
Hosted fill and the buyer live panel **must share one session**.
|
|
4
|
+
|
|
5
|
+
| Piece | Value | Notes |
|
|
6
|
+
|-------|-------|--------|
|
|
7
|
+
| Fill display | `DISPLAY=:99` | Xvfb headed Chrome |
|
|
8
|
+
| Fill VNC | x11vnc → **`localhost:5900`** | Mirrors `:99` |
|
|
9
|
+
| Live HTTP front | noVNC / websockify historically **`:6080`** | Buyer iframe / magic-link target |
|
|
10
|
+
| Forbidden | TigerVNC **`:1` / `5901`** | Cold XFCE / wrong desktop = product failure |
|
|
11
|
+
|
|
12
|
+
## Hard rule
|
|
13
|
+
|
|
14
|
+
**Live noVNC = fill display 5900.**
|
|
15
|
+
`websockify` must proxy `localhost:5900`. Never point buyer live at `5901`.
|
|
16
|
+
|
|
17
|
+
Verify on the box:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
node job-application-agent/scripts/novnc-display-guard.mjs --unit /etc/systemd/system/novnc.service
|
|
21
|
+
# or:
|
|
22
|
+
node job-application-agent/scripts/novnc-display-guard.mjs --text "$(systemctl cat novnc.service)"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Example unit: [`novnc.service.example`](./novnc.service.example).
|
|
26
|
+
|
|
27
|
+
## Session binding (pause)
|
|
28
|
+
|
|
29
|
+
When attention opens on a filled form, record a **local-only** binding:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Prefer optional fields on attention add (not synced to cloud):
|
|
33
|
+
node scripts/job-application.mjs attention add --stdin <<'JSON'
|
|
34
|
+
{
|
|
35
|
+
"roundId": "round-…",
|
|
36
|
+
"applicationId": "app-…",
|
|
37
|
+
"url": "https://jobs.ashbyhq.com/…/application",
|
|
38
|
+
"stage": "submission",
|
|
39
|
+
"blocker": "captcha",
|
|
40
|
+
"requiredActions": ["complete-captcha"],
|
|
41
|
+
"browserProfilePath": "/home/runner/.jaa-chrome-fill",
|
|
42
|
+
"display": ":99",
|
|
43
|
+
"vncPort": 5900,
|
|
44
|
+
"tabHint": { "urlContains": "/application" }
|
|
45
|
+
}
|
|
46
|
+
JSON
|
|
47
|
+
|
|
48
|
+
# Or write explicitly:
|
|
49
|
+
node scripts/session-binding.mjs write --stdin <<'JSON'
|
|
50
|
+
{
|
|
51
|
+
"attentionId": "attention-…",
|
|
52
|
+
"jobUrl": "https://jobs.ashbyhq.com/…/application",
|
|
53
|
+
"browserProfilePath": "/home/runner/.jaa-chrome-fill",
|
|
54
|
+
"display": ":99",
|
|
55
|
+
"vncPort": 5900
|
|
56
|
+
}
|
|
57
|
+
JSON
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Keep Chrome on that **exact filled tab** (same user-data-dir / CDP). Do not wipe the profile mid-pause. Do not navigate to a jobs listing.
|
|
61
|
+
|
|
62
|
+
## Resume → submit
|
|
63
|
+
|
|
64
|
+
After `attention-runner-poll.mjs` exit `0`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node scripts/attention-resume-submit.mjs --checklist
|
|
68
|
+
node scripts/attention-resume-submit.mjs --attention-id attention-… --stdin <<'JSON'
|
|
69
|
+
{
|
|
70
|
+
"pageUrl": "https://jobs.ashbyhq.com/…/application",
|
|
71
|
+
"submitEnabled": true,
|
|
72
|
+
"leaseHeld": true
|
|
73
|
+
}
|
|
74
|
+
JSON
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Submit if the DOM is clear; ledger only after a **visible** ATS confirmation.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=noVNC for JobAppAgent fill display (Xvfb :99 / x11vnc 5900)
|
|
3
|
+
After=network.target x11vnc-fill.service
|
|
4
|
+
Requires=x11vnc-fill.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
# HARD RULE: proxy fill x11vnc on localhost:5900 — NEVER TigerVNC 5901.
|
|
9
|
+
# Buyer live panel (ATTENTION_LIVE_SESSION_BASE_URL → :6080) must show the
|
|
10
|
+
# paused filled ATS form on DISPLAY=:99, not a cold desktop.
|
|
11
|
+
ExecStart=/usr/bin/websockify --web=/usr/share/novnc 6080 localhost:5900
|
|
12
|
+
Restart=on-failure
|
|
13
|
+
RestartSec=3
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Shared by the packaged CLI and private Worker. No candidate state or network access.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const ACCOUNTING_CAPABILITY = 'application-accounting-v1';
|
|
5
|
+
export const DISPOSITIONS = Object.freeze(['qualified', 'duplicate', 'no-relevant-opening', 'location-authorization-conflict', 'compensation-below-floor', 'seniority-mismatch', 'insufficient-must-have-coverage', 'closed-stale', 'blocked']);
|
|
6
|
+
const DELIVERY_TYPES = ['receipt-confirmed', 'delivery-failed', 'correction', 'retry-confirmed'];
|
|
7
|
+
const EVIDENCE_TYPES = ['employer-acknowledgement', 'final-delivery-failure', 'browser-confirmation', 'sent-email', 'ambiguous', 'delivery-delay'];
|
|
8
|
+
function required(value, name, max = 300) {
|
|
9
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} must be nonempty text (maximum ${max}).`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function date(value, name) { required(value, name, 80); if (!Number.isFinite(Date.parse(value))) throw new Error(`${name} must be an ISO date.`); return new Date(value).toISOString(); }
|
|
13
|
+
function object(input, allowed) {
|
|
14
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Accounting event must be an object.');
|
|
15
|
+
for (const key of Object.keys(input)) if (!allowed.includes(key)) throw new Error(`Unknown accounting field: ${key}.`);
|
|
16
|
+
}
|
|
17
|
+
export function canonicalUrl(value) {
|
|
18
|
+
const url = new URL(required(value, 'url', 2048));
|
|
19
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('A public HTTP(S) URL is required.');
|
|
20
|
+
url.hash = '';
|
|
21
|
+
// Unknown parameters may identify a requisition (for example SAP career_job_req_id).
|
|
22
|
+
for (const key of [...url.searchParams.keys()]) if (/^(utm_.*|ref|refid|referrer|source|gh_src|tracking_?id|trk|trkinfo|gclid|fbclid|msclkid|mc_cid|mc_eid|lever-source|lever-origin)$/i.test(key)) url.searchParams.delete(key);
|
|
23
|
+
url.searchParams.sort();
|
|
24
|
+
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
25
|
+
return url.href;
|
|
26
|
+
}
|
|
27
|
+
export function stableJson(value) {
|
|
28
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
29
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableJson(value[k])}`).join(',')}}`;
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
const digest = value => createHash('sha256').update(stableJson(value)).digest('hex');
|
|
33
|
+
export function eventId(value, prefix) { return `${prefix}-${digest(value)}`; }
|
|
34
|
+
export function validateDelivery(input) {
|
|
35
|
+
object(input, ['version','id','applicationId','attemptId','type','occurredAt','evidenceType','evidence','messageRef','supersedes','status','channel','url','channelVerifiedAt','approval']);
|
|
36
|
+
const v = { ...input, version: 1 };
|
|
37
|
+
if (input.version != null && input.version !== 1) throw new Error('Unsupported delivery version.');
|
|
38
|
+
for (const name of ['applicationId','attemptId']) required(v[name], name);
|
|
39
|
+
if (!DELIVERY_TYPES.includes(v.type)) throw new Error('Invalid delivery type.');
|
|
40
|
+
if (!EVIDENCE_TYPES.includes(v.evidenceType)) throw new Error('Invalid evidenceType.');
|
|
41
|
+
required(v.evidence, 'evidence', 2000);
|
|
42
|
+
v.occurredAt = date(v.occurredAt, 'occurredAt');
|
|
43
|
+
if (v.messageRef != null) required(v.messageRef, 'messageRef', 500);
|
|
44
|
+
if (v.type === 'correction') {
|
|
45
|
+
if (!['receipt-confirmed','delivery-failed','unknown'].includes(v.status)) throw new Error('Invalid correction status.');
|
|
46
|
+
const refs = Array.isArray(v.supersedes) ? v.supersedes : [v.supersedes];
|
|
47
|
+
if (!refs.length || refs.length > 100) throw new Error('Correction requires superseded event IDs.');
|
|
48
|
+
refs.forEach(ref => required(ref,'supersedes'));
|
|
49
|
+
v.supersedes = [...new Set(refs)].sort();
|
|
50
|
+
} else if (v.supersedes != null || v.status != null) throw new Error('Only corrections supersede delivery evidence.');
|
|
51
|
+
if (v.type === 'retry-confirmed') {
|
|
52
|
+
if (!['email','browser'].includes(v.channel)) throw new Error('Retry channel must be email or browser.');
|
|
53
|
+
v.url = canonicalUrl(v.url);
|
|
54
|
+
v.channelVerifiedAt = date(v.channelVerifiedAt,'channelVerifiedAt');
|
|
55
|
+
if (Date.parse(v.channelVerifiedAt) > Date.parse(v.occurredAt) || Date.parse(v.occurredAt) - Date.parse(v.channelVerifiedAt) > 86400000) throw new Error('Retry channel must be verified within 24 hours before transmission.');
|
|
56
|
+
if (!['STANDING AUTHORIZATION','APPROVE SUBMIT'].includes(v.approval)) throw new Error('Retry requires existing candidate authorization.');
|
|
57
|
+
if (v.evidenceType !== (v.channel === 'email' ? 'sent-email' : 'browser-confirmation')) throw new Error('Retry requires verified transmission evidence.');
|
|
58
|
+
if (v.attemptId.startsWith('initial:')) throw new Error('Retry requires a new attemptId.');
|
|
59
|
+
} else if (['channel','url','channelVerifiedAt','approval'].some(k => v[k] != null)) throw new Error('Transmission fields are only allowed on retry-confirmed.');
|
|
60
|
+
v.id ??= eventId(v, 'delivery');
|
|
61
|
+
required(v.id, 'id');
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A revision graph, never last-write-wins. Different children of one revision conflict.
|
|
66
|
+
function heads(events) {
|
|
67
|
+
const versions = new Map();
|
|
68
|
+
for (const e of events) {
|
|
69
|
+
const key = stableJson(e);
|
|
70
|
+
if (!versions.has(e.id)) versions.set(e.id, new Map());
|
|
71
|
+
versions.get(e.id).set(key, e);
|
|
72
|
+
}
|
|
73
|
+
const all = [...versions.values()].flatMap(v => [...v.values()]);
|
|
74
|
+
const invalidIds = new Set([...versions].filter(([,v]) => v.size > 1).map(([id]) => id));
|
|
75
|
+
const referenced = new Set(all.flatMap(e => e.supersedes == null ? [] : Array.isArray(e.supersedes) ? e.supersedes : [e.supersedes]));
|
|
76
|
+
const missing = [...referenced].some(id => !versions.has(id));
|
|
77
|
+
const current = all.filter(e => !referenced.has(e.id));
|
|
78
|
+
return { current, conflict: invalidIds.size > 0 || missing || (all.length > 0 && !current.length) };
|
|
79
|
+
}
|
|
80
|
+
function refs(event) { return event.supersedes == null ? [] : Array.isArray(event.supersedes) ? event.supersedes : [event.supersedes]; }
|
|
81
|
+
const normalized = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
82
|
+
export function accountingApplicationKey(entry, fallback = '') {
|
|
83
|
+
if (entry.employerJobId) return `job:${normalized(entry.company)}:${String(entry.employerJobId).toLowerCase()}`;
|
|
84
|
+
if (entry.company && entry.role) return `legacy-role:${normalized(entry.company)}:${normalized(entry.role)}`;
|
|
85
|
+
if (entry.url) return `url:${canonicalUrl(entry.url).replace(/\/$/, '').toLowerCase()}`;
|
|
86
|
+
return `id:${entry.id ?? fallback}`;
|
|
87
|
+
}
|
|
88
|
+
export function deliveryProjection(entries, events = []) {
|
|
89
|
+
const applications = entries.map(app => {
|
|
90
|
+
const selected = events.filter(e => e.version === 1 && e.applicationId === app.id && DELIVERY_TYPES.includes(e.type));
|
|
91
|
+
const initial = { attemptId: `initial:${app.id}`, channel: (app.applicationChannel ?? app.source) === 'email' ? 'email' : 'browser', url: app.url };
|
|
92
|
+
// A stable representative keeps even conflicting retry projections independent of arrival order.
|
|
93
|
+
const retries = [...new Map(selected.filter(e => e.type === 'retry-confirmed').sort((a, b) => stableJson(a).localeCompare(stableJson(b))).map(e => [e.attemptId, e])).values()];
|
|
94
|
+
const attempts = [initial, ...retries].map(attempt => {
|
|
95
|
+
const observations = selected.filter(e => e.attemptId === attempt.attemptId && e.type !== 'retry-confirmed');
|
|
96
|
+
const graph = heads(observations);
|
|
97
|
+
const states = new Set();
|
|
98
|
+
const ancestors = event => {
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const pending = [...refs(event)];
|
|
101
|
+
while (pending.length) {
|
|
102
|
+
const id = pending.pop();
|
|
103
|
+
if (seen.has(id)) continue;
|
|
104
|
+
seen.add(id);
|
|
105
|
+
const parent = observations.find(e => e.id === id);
|
|
106
|
+
if (parent) pending.push(...refs(parent));
|
|
107
|
+
}
|
|
108
|
+
return seen;
|
|
109
|
+
};
|
|
110
|
+
const correctionFork = graph.current.some((left, index) => graph.current.slice(index + 1).some(right => {
|
|
111
|
+
if (left.status === right.status) return false;
|
|
112
|
+
const lineage = ancestors(left);
|
|
113
|
+
return [...ancestors(right)].some(id => lineage.has(id));
|
|
114
|
+
}));
|
|
115
|
+
let ambiguous = graph.conflict || correctionFork;
|
|
116
|
+
for (const e of graph.current) {
|
|
117
|
+
const status = e.type === 'correction' ? e.status : e.type;
|
|
118
|
+
if (status === 'unknown') continue;
|
|
119
|
+
if (e.evidenceType === 'delivery-delay') continue;
|
|
120
|
+
if (e.evidenceType === 'ambiguous') { ambiguous = true; continue; }
|
|
121
|
+
if (status === 'delivery-failed') {
|
|
122
|
+
if (e.evidenceType !== 'final-delivery-failure') { ambiguous = true; continue; }
|
|
123
|
+
if (attempt.channel === 'email') states.add('failed');
|
|
124
|
+
} else if (status === 'receipt-confirmed') {
|
|
125
|
+
if (!['employer-acknowledgement','browser-confirmation'].includes(e.evidenceType)) { ambiguous = true; continue; }
|
|
126
|
+
states.add('received');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const retryCopies = selected.filter(e => e.type === 'retry-confirmed' && e.attemptId === attempt.attemptId);
|
|
130
|
+
if (new Set(retryCopies.map(stableJson)).size > 1) ambiguous = true;
|
|
131
|
+
const conflict = ambiguous || states.size > 1;
|
|
132
|
+
const failed = !conflict && states.has('failed');
|
|
133
|
+
return { ...attempt, failed, conflict, counted: !failed, receiptUnknown: attempt.channel === 'email' && !failed && !states.has('received'), status: conflict ? 'needs-review' : failed ? 'delivery-failed' : attempt.channel !== 'email' || states.has('received') ? 'receipt-confirmed' : 'receipt-unknown' };
|
|
134
|
+
});
|
|
135
|
+
const orphan = selected.some(e => !attempts.some(a => a.attemptId === e.attemptId));
|
|
136
|
+
return { applicationId: app.id, counted: attempts.some(a => a.counted), failed: attempts.every(a => a.failed), receiptUnknown: attempts.some(a => a.counted && a.receiptUnknown), conflict: orphan || attempts.some(a => a.conflict), attempts };
|
|
137
|
+
});
|
|
138
|
+
// Preserve the established canonical grouping contract (job ID, otherwise URL).
|
|
139
|
+
const groups = new Map();
|
|
140
|
+
entries.forEach((app,index) => {
|
|
141
|
+
const key = accountingApplicationKey(app, index);
|
|
142
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
143
|
+
groups.get(key).push(applications[index]);
|
|
144
|
+
});
|
|
145
|
+
const canonical = [...groups.values()];
|
|
146
|
+
return { applications, recordedSubmissionCount: canonical.length, effectiveSubmissionCount: canonical.filter(g => g.some(a => a.counted)).length, failedDeliveryCount: canonical.filter(g => g.every(a => a.failed)).length, receiptUnknownEmailCount: canonical.filter(g => g.some(a => a.receiptUnknown)).length };
|
|
147
|
+
}
|
|
148
|
+
export function validateDeliveryReferences(event, applications, events, { preparedRetry = false } = {}) {
|
|
149
|
+
const app = applications.find(a => a.id === event.applicationId);
|
|
150
|
+
if (!app) throw new Error('Delivery applicationId is not recorded.');
|
|
151
|
+
const existing = events.filter(e => e.id === event.id);
|
|
152
|
+
if (existing.some(e => stableJson(e) !== stableJson(event))) throw new Error('Conflicting event ID; append a correction with a new ID.');
|
|
153
|
+
if (existing.length) return;
|
|
154
|
+
if (event.type === 'retry-confirmed') {
|
|
155
|
+
const related = applications.filter(a => accountingApplicationKey(a) === accountingApplicationKey(app));
|
|
156
|
+
const all = deliveryProjection(related, events).applications;
|
|
157
|
+
const current = all.find(a => a.applicationId === app.id);
|
|
158
|
+
if (!preparedRetry && all.some(a => !a.failed || a.conflict)) throw new Error('Retry requires verified failure of every prior attempt and no conflicts.');
|
|
159
|
+
if (current.attempts.some(a => a.attemptId === event.attemptId)) throw new Error('Retry attemptId already exists.');
|
|
160
|
+
} else if (event.attemptId !== `initial:${app.id}` && !events.some(e => e.type === 'retry-confirmed' && e.attemptId === event.attemptId && e.applicationId === app.id)) throw new Error('Delivery attemptId is not recorded.');
|
|
161
|
+
for (const id of refs(event)) {
|
|
162
|
+
const parent = events.find(e => e.id === id);
|
|
163
|
+
if (!parent || parent.type === 'retry-confirmed' || parent.applicationId !== event.applicationId || parent.attemptId !== event.attemptId) throw new Error('Correction must reference existing evidence for the same attempt.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validateLead(input) {
|
|
168
|
+
object(input,['version','type','id','roundId','sourceId','url','company','role','employerJobId','disposition','observedAt','evidence','applicationId','supersedes']);
|
|
169
|
+
if (input.version != null && input.version !== 1) throw new Error('Unsupported discovery version.');
|
|
170
|
+
if (input.type != null && input.type !== 'lead-reviewed') throw new Error('Invalid discovery event type.');
|
|
171
|
+
const v = { ...input, version: 1, type: 'lead-reviewed', url: canonicalUrl(input.url) };
|
|
172
|
+
for (const key of ['roundId','sourceId','company']) required(v[key],key);
|
|
173
|
+
for (const key of ['role','employerJobId','applicationId']) if(v[key] != null) required(v[key],key);
|
|
174
|
+
if (!DISPOSITIONS.includes(v.disposition)) throw new Error('Invalid lead disposition.');
|
|
175
|
+
v.observedAt = date(v.observedAt,'observedAt');
|
|
176
|
+
required(v.evidence,'evidence',2000);
|
|
177
|
+
if (v.supersedes != null) {
|
|
178
|
+
const parents = Array.isArray(v.supersedes) ? v.supersedes : [v.supersedes];
|
|
179
|
+
if (!parents.length || parents.length > 100) throw new Error('Invalid lead supersedes.');
|
|
180
|
+
parents.forEach(id => required(id,'supersedes'));
|
|
181
|
+
v.supersedes = [...new Set(parents)].sort();
|
|
182
|
+
}
|
|
183
|
+
v.id ??= eventId(v, 'lead'); required(v.id,'id');
|
|
184
|
+
return v;
|
|
185
|
+
}
|
|
186
|
+
function requisitionKey(e) { return e.employerJobId ? `${normalized(e.company)}:${e.employerJobId.toLowerCase()}` : canonicalUrl(e.url); }
|
|
187
|
+
export function leadKey(e) { return `${e.roundId}:${e.sourceId}:${requisitionKey(e)}`; }
|
|
188
|
+
export function validateLeadReferences(event, events) {
|
|
189
|
+
for (const previous of events.filter(e => e.id === event.id)) if (stableJson(previous) !== stableJson(event)) throw new Error('Conflicting lead event ID.');
|
|
190
|
+
const history = new Map(events.filter(e => e.version === 1 && e.type === 'lead-reviewed').map(e => [e.id, e]));
|
|
191
|
+
const parents = refs(event).map(id => history.get(id));
|
|
192
|
+
const sameScope = parent => parent && parent.roundId === event.roundId && parent.sourceId === event.sourceId && normalized(parent.company) === normalized(event.company);
|
|
193
|
+
const ancestors = parent => {
|
|
194
|
+
const seen = new Set(); const pending = [parent.id];
|
|
195
|
+
while (pending.length) {
|
|
196
|
+
const id = pending.pop(); const previous = history.get(id);
|
|
197
|
+
if (seen.has(id) || !sameScope(previous)) continue;
|
|
198
|
+
seen.add(id); pending.push(...refs(previous));
|
|
199
|
+
}
|
|
200
|
+
return seen;
|
|
201
|
+
};
|
|
202
|
+
// A disputed enrichment can choose an already-recorded ID only by explicitly
|
|
203
|
+
// superseding branches from the same lineage, never unrelated requisitions.
|
|
204
|
+
const lineages = parents.length > 1 && parents.every(sameScope) ? parents.map(ancestors) : [];
|
|
205
|
+
const resolvesFork = Boolean(event.employerJobId) && lineages.length > 1 && parents.some(parent => leadKey(parent) === leadKey(event)) && [...lineages[0]].some(id => lineages.every(lineage => lineage.has(id)));
|
|
206
|
+
for (const parent of parents) {
|
|
207
|
+
const enrichment = sameScope(parent) && canonicalUrl(parent.url) === canonicalUrl(event.url) && (!parent.employerJobId && Boolean(event.employerJobId));
|
|
208
|
+
if (!parent || (leadKey(parent) !== leadKey(event) && !enrichment && !resolvesFork)) throw new Error('Lead revision must reference the same round, source and requisition.');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export function discoveryProjection(events, { roundId } = {}) {
|
|
212
|
+
const selected = events.filter(e => e.version === 1 && e.type === 'lead-reviewed' && (roundId == null || e.roundId === roundId));
|
|
213
|
+
const aliases = new Map();
|
|
214
|
+
const aliasKey = e => `${e.roundId}:${normalized(e.company)}:${canonicalUrl(e.url)}`;
|
|
215
|
+
for (const e of selected.filter(e => e.employerJobId)) {
|
|
216
|
+
const key = aliasKey(e);
|
|
217
|
+
if (!aliases.has(key)) aliases.set(key, new Set());
|
|
218
|
+
aliases.get(key).add(requisitionKey(e));
|
|
219
|
+
}
|
|
220
|
+
const resolvedKey = e => !e.employerJobId && aliases.get(aliasKey(e))?.size === 1 ? [...aliases.get(aliasKey(e))][0] : requisitionKey(e);
|
|
221
|
+
const keyFor = e => `${e.roundId}:${e.sourceId}:${resolvedKey(e)}`;
|
|
222
|
+
const roots = new Map(selected.map(e => [keyFor(e), keyFor(e)]));
|
|
223
|
+
const root = key => { while (roots.get(key) !== key) key = roots.get(key); return key; };
|
|
224
|
+
const byId = new Map();
|
|
225
|
+
for (const e of selected) { if (!byId.has(e.id)) byId.set(e.id, []); byId.get(e.id).push(e); }
|
|
226
|
+
// Explicit revisions keep their lineage even when a shared careers URL later
|
|
227
|
+
// acquires another requisition. Forked enrichments stay together for conflict review.
|
|
228
|
+
for (const e of selected) for (const id of [e.id, ...refs(e)]) for (const parent of byId.get(id) ?? []) {
|
|
229
|
+
if (parent.roundId !== e.roundId || parent.sourceId !== e.sourceId) continue;
|
|
230
|
+
const left = root(keyFor(e)); const right = root(keyFor(parent));
|
|
231
|
+
if (left !== right) roots.set(left < right ? right : left, left < right ? left : right);
|
|
232
|
+
}
|
|
233
|
+
const groups = new Map();
|
|
234
|
+
for (const e of selected) { const key = root(keyFor(e)); if (!groups.has(key)) groups.set(key,[]); groups.get(key).push(e); }
|
|
235
|
+
const leads = []; const conflicts = [];
|
|
236
|
+
for (const [key, values] of groups) {
|
|
237
|
+
const graph = heads(values);
|
|
238
|
+
const comparable = e => stableJson(Object.fromEntries(Object.entries({ ...e, company: normalized(e.company), ...(e.employerJobId ? { employerJobId: e.employerJobId.toLowerCase() } : { url: canonicalUrl(e.url) }) }).filter(([k]) => !['id','observedAt','supersedes', ...(e.employerJobId ? ['url'] : [])].includes(k))));
|
|
239
|
+
const conflict = graph.conflict || new Set(graph.current.map(comparable)).size > 1;
|
|
240
|
+
if (conflict) conflicts.push({ key, eventIds: [...new Set(values.map(e => e.id))].sort() });
|
|
241
|
+
const lead = [...graph.current].sort((a,b) => a.id.localeCompare(b.id))[0] ?? values[0];
|
|
242
|
+
leads.push({ ...lead, key, conflict });
|
|
243
|
+
}
|
|
244
|
+
leads.sort((a,b) => a.key.localeCompare(b.key));
|
|
245
|
+
return { leads, conflicts, reviewedCount: leads.length, qualifiedCount: leads.filter(e => !e.conflict && e.disposition === 'qualified').length, uniqueLeadCount: new Set(leads.map(resolvedKey)).size, dispositionCounts: Object.fromEntries(DISPOSITIONS.map(d => [d,leads.filter(e => !e.conflict && e.disposition === d).length])) };
|
|
246
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map approved attention answers → ATS narrative fields (Ashby-first).
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers for the resume→submit loop. Browser automation stays outside —
|
|
5
|
+
* the runner receives fill instructions and injects into matching textareas.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { fingerprintPrompt } from "../attention-questions.mjs";
|
|
9
|
+
|
|
10
|
+
export { fingerprintPrompt };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* id?: string,
|
|
15
|
+
* label?: string,
|
|
16
|
+
* name?: string,
|
|
17
|
+
* placeholder?: string,
|
|
18
|
+
* selector?: string,
|
|
19
|
+
* required?: boolean,
|
|
20
|
+
* value?: string,
|
|
21
|
+
* }} NarrativeField
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{
|
|
26
|
+
* questionId: string,
|
|
27
|
+
* text: string,
|
|
28
|
+
* source?: string,
|
|
29
|
+
* prompt?: string,
|
|
30
|
+
* }} ApprovedAnswer
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const ASHBY_NARRATIVE_SELECTORS = Object.freeze([
|
|
34
|
+
'textarea[name*="question" i]',
|
|
35
|
+
'textarea[id*="question" i]',
|
|
36
|
+
'textarea[aria-label]',
|
|
37
|
+
"textarea",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} value
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeFieldLabel(value) {
|
|
44
|
+
return String(value ?? "")
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[^\p{L}\p{N}\s]+/gu, " ")
|
|
47
|
+
.replace(/\s+/g, " ")
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Token Jaccard similarity in [0, 1].
|
|
53
|
+
* @param {string} left
|
|
54
|
+
* @param {string} right
|
|
55
|
+
*/
|
|
56
|
+
export function labelSimilarity(left, right) {
|
|
57
|
+
const a = new Set(normalizeFieldLabel(left).split(" ").filter((t) => t.length > 2));
|
|
58
|
+
const b = new Set(normalizeFieldLabel(right).split(" ").filter((t) => t.length > 2));
|
|
59
|
+
if (!a.size || !b.size) return 0;
|
|
60
|
+
let overlap = 0;
|
|
61
|
+
for (const token of a) if (b.has(token)) overlap += 1;
|
|
62
|
+
return overlap / (a.size + b.size - overlap);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Prefer exact questionId → field.id, then best prompt/label similarity.
|
|
67
|
+
*
|
|
68
|
+
* @param {ApprovedAnswer[]} answers
|
|
69
|
+
* @param {NarrativeField[]} fields
|
|
70
|
+
* @param {{ minScore?: number }} [options]
|
|
71
|
+
*/
|
|
72
|
+
export function mapAnswersToFields(answers, fields, options = {}) {
|
|
73
|
+
const minScore = Number.isFinite(options.minScore) ? options.minScore : 0.25;
|
|
74
|
+
const remaining = [...(Array.isArray(fields) ? fields : [])];
|
|
75
|
+
const mappings = [];
|
|
76
|
+
const unmatchedAnswers = [];
|
|
77
|
+
|
|
78
|
+
for (const answer of Array.isArray(answers) ? answers : []) {
|
|
79
|
+
const text = String(answer?.text ?? "").trim();
|
|
80
|
+
const questionId = String(answer?.questionId ?? "").trim();
|
|
81
|
+
if (!text || !questionId) continue;
|
|
82
|
+
|
|
83
|
+
let bestIndex = -1;
|
|
84
|
+
let bestScore = 0;
|
|
85
|
+
let matchedBy = "none";
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < remaining.length; i += 1) {
|
|
88
|
+
const field = remaining[i];
|
|
89
|
+
const fieldId = String(field.id ?? "").trim();
|
|
90
|
+
if (fieldId && fieldId === questionId) {
|
|
91
|
+
bestIndex = i;
|
|
92
|
+
bestScore = 1;
|
|
93
|
+
matchedBy = "question_id";
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
const label = String(field.label ?? field.name ?? field.placeholder ?? "").trim();
|
|
97
|
+
const prompt = String(answer.prompt ?? "").trim();
|
|
98
|
+
const score = Math.max(
|
|
99
|
+
labelSimilarity(prompt, label),
|
|
100
|
+
labelSimilarity(questionId, label),
|
|
101
|
+
labelSimilarity(prompt, fieldId),
|
|
102
|
+
);
|
|
103
|
+
if (score > bestScore) {
|
|
104
|
+
bestScore = score;
|
|
105
|
+
bestIndex = i;
|
|
106
|
+
matchedBy = "label_similarity";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (bestIndex < 0 || bestScore < minScore) {
|
|
111
|
+
unmatchedAnswers.push(answer);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const [field] = remaining.splice(bestIndex, 1);
|
|
116
|
+
const label = String(field.label ?? field.name ?? field.placeholder ?? field.id ?? "").trim();
|
|
117
|
+
mappings.push({
|
|
118
|
+
questionId,
|
|
119
|
+
fieldId: field.id ? String(field.id) : null,
|
|
120
|
+
selector: field.selector ? String(field.selector) : null,
|
|
121
|
+
label,
|
|
122
|
+
text,
|
|
123
|
+
source: String(answer.source ?? "typed"),
|
|
124
|
+
score: bestScore,
|
|
125
|
+
matchedBy,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
mappings,
|
|
131
|
+
unmatchedAnswers,
|
|
132
|
+
unmatchedFields: remaining,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build Playwright/CDP-oriented inject plan for Ashby (and generic) textareas.
|
|
138
|
+
*
|
|
139
|
+
* @param {{
|
|
140
|
+
* answers?: ApprovedAnswer[],
|
|
141
|
+
* questions?: { id: string, prompt: string }[],
|
|
142
|
+
* fields?: NarrativeField[],
|
|
143
|
+
* pageUrl?: string,
|
|
144
|
+
* }} input
|
|
145
|
+
*/
|
|
146
|
+
export function buildAnswerInjectPlan(input = {}) {
|
|
147
|
+
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
148
|
+
const answers = (Array.isArray(input.answers) ? input.answers : []).map((answer) => {
|
|
149
|
+
const question = questions.find((item) => item.id === answer.questionId);
|
|
150
|
+
return {
|
|
151
|
+
...answer,
|
|
152
|
+
prompt: answer.prompt ?? question?.prompt ?? "",
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const fields = Array.isArray(input.fields) && input.fields.length
|
|
157
|
+
? input.fields
|
|
158
|
+
: answers.map((answer, index) => ({
|
|
159
|
+
id: answer.questionId,
|
|
160
|
+
label: answer.prompt || answer.questionId,
|
|
161
|
+
selector: `textarea >> nth=${index}`,
|
|
162
|
+
required: true,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const mapped = mapAnswersToFields(answers, fields);
|
|
166
|
+
return {
|
|
167
|
+
adapterHint: /ashbyhq\.com/i.test(String(input.pageUrl ?? "")) ? "ashby" : "generic",
|
|
168
|
+
textareaSelectors: [...ASHBY_NARRATIVE_SELECTORS],
|
|
169
|
+
fills: mapped.mappings.map((item) => ({
|
|
170
|
+
questionId: item.questionId,
|
|
171
|
+
selector: item.selector,
|
|
172
|
+
label: item.label,
|
|
173
|
+
text: item.text,
|
|
174
|
+
source: item.source,
|
|
175
|
+
matchedBy: item.matchedBy,
|
|
176
|
+
score: item.score,
|
|
177
|
+
action: "fill_textarea",
|
|
178
|
+
})),
|
|
179
|
+
unmatchedAnswers: mapped.unmatchedAnswers,
|
|
180
|
+
unmatchedFields: mapped.unmatchedFields,
|
|
181
|
+
notes: [
|
|
182
|
+
"Inject approved answers into matching textareas on the BOUND filled tab only.",
|
|
183
|
+
"Never silent-paste model drafts — only typed / draft_approved / bank answers from the resume signal.",
|
|
184
|
+
"After fill, re-inspect; if required narrative remains empty → attention again.",
|
|
185
|
+
"Then continue existing resume→submit adapters.",
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {{ answers?: ApprovedAnswer[], blockers?: string[] }} input
|
|
192
|
+
*/
|
|
193
|
+
export function shouldInjectAnswersBeforeSubmit(input = {}) {
|
|
194
|
+
const answers = Array.isArray(input.answers)
|
|
195
|
+
? input.answers.filter((a) => String(a?.text ?? "").trim())
|
|
196
|
+
: [];
|
|
197
|
+
if (!answers.length) return { inject: false, reason: "no_answers" };
|
|
198
|
+
return {
|
|
199
|
+
inject: true,
|
|
200
|
+
reason: "approved_answers_present",
|
|
201
|
+
answerCount: answers.length,
|
|
202
|
+
};
|
|
203
|
+
}
|