linkedin-apply-assistant 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.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +72 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  3. package/.github/ISSUE_TEMPLATE/config_help.yml +49 -0
  4. package/.github/ISSUE_TEMPLATE/docs.yml +40 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +45 -0
  6. package/.github/ISSUE_TEMPLATE/safety_compliance.yml +48 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
  8. package/CHANGELOG.md +47 -0
  9. package/CODE_OF_CONDUCT.md +47 -0
  10. package/CONTRIBUTING.md +64 -0
  11. package/GOVERNANCE.md +41 -0
  12. package/LEGAL.md +38 -0
  13. package/LICENSE +22 -0
  14. package/MIGRATION.md +50 -0
  15. package/README.md +167 -0
  16. package/RELEASE_CHECKLIST.md +454 -0
  17. package/SAFETY.md +33 -0
  18. package/SECURITY.md +37 -0
  19. package/SUPPORT.md +44 -0
  20. package/THIRD_PARTY_NOTICES.md +67 -0
  21. package/bin/linkedin-apply-assistant.mjs +95 -0
  22. package/configs/config.example.yml +24 -0
  23. package/configs/qa_bank.example.yml +35 -0
  24. package/docs/apply.md +40 -0
  25. package/docs/assist.md +35 -0
  26. package/docs/browser-session.md +45 -0
  27. package/docs/ci-and-release-policy.md +105 -0
  28. package/docs/commands.md +176 -0
  29. package/docs/install-and-configuration.md +265 -0
  30. package/docs/registry-publication-strategy.md +169 -0
  31. package/docs/reports.md +35 -0
  32. package/docs/search.md +39 -0
  33. package/docs/troubleshooting.md +57 -0
  34. package/examples/dry_run_input.example.json +25 -0
  35. package/examples/reports/apply-audit.example.json +31 -0
  36. package/examples/reports/search-report.example.json +40 -0
  37. package/install.ps1 +178 -0
  38. package/package.json +59 -0
  39. package/pyproject.toml +51 -0
  40. package/src/linkedin_apply_assistant/__init__.py +8 -0
  41. package/src/linkedin_apply_assistant/apply_reports.py +229 -0
  42. package/src/linkedin_apply_assistant/ats_handlers.py +217 -0
  43. package/src/linkedin_apply_assistant/browser_sessions.py +155 -0
  44. package/src/linkedin_apply_assistant/cli.py +570 -0
  45. package/src/linkedin_apply_assistant/config.py +109 -0
  46. package/src/linkedin_apply_assistant/contracts.py +255 -0
  47. package/src/linkedin_apply_assistant/form_engine.py +180 -0
  48. package/src/linkedin_apply_assistant/linkedin_layer.py +436 -0
  49. package/src/linkedin_apply_assistant/page_actions.py +110 -0
  50. package/src/linkedin_apply_assistant/page_selectors.py +88 -0
  51. package/src/linkedin_apply_assistant/paths.py +135 -0
  52. package/src/linkedin_apply_assistant/qa_bank.py +352 -0
  53. package/src/linkedin_apply_assistant/redaction.py +119 -0
  54. package/src/linkedin_apply_assistant/safety.py +230 -0
  55. package/src/linkedin_apply_assistant/workflows.py +435 -0
@@ -0,0 +1,230 @@
1
+ """Safety policy, bounded audit metadata, and automation limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+ from urllib.parse import urlparse, urlunparse
9
+
10
+ from .contracts import SubmissionResult, SubmitDecision
11
+
12
+
13
+ POLICY_NAME = "phase16-disabled-submit-policy"
14
+ POLICY_VERSION = "2026-06-07"
15
+ DISABLED_SUBMISSION_REASON = "Browser submission is disabled in this package boundary."
16
+ FUTURE_SUBMIT_POLICY = (
17
+ "Any future submit-capable release must require per-application interactive "
18
+ "confirmation immediately before the specific application is sent. Broad approvals, "
19
+ "background sending, and unattended modes are outside this package boundary."
20
+ )
21
+
22
+ SEARCH_LIMIT_DEFAULT = 10
23
+ SEARCH_LIMIT_CAP = 25
24
+ ASSIST_CYCLES_DEFAULT = 1
25
+ ASSIST_CYCLES_CAP = 25
26
+
27
+ BROWSER_PROFILE_WARNING = (
28
+ "Browser profile warning: visible browser profiles can contain cookies, sessions, "
29
+ "and local form data. Keep the profile directory local, ignored, and under your "
30
+ "control."
31
+ )
32
+
33
+ RISK_STATUSES = frozenset({"login", "mfa", "checkpoint", "captcha", "rate_limited", "throttled"})
34
+
35
+
36
+ def utc_timestamp() -> str:
37
+ """Return a stable UTC timestamp for audit records."""
38
+
39
+ return datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ def domain_from_url(url: str | None) -> str:
43
+ """Return a lower-case hostname without userinfo or port."""
44
+
45
+ value = str(url or "").strip()
46
+ parsed = urlparse(value)
47
+ hostname = parsed.hostname
48
+ if not hostname and "://" not in value and "/" not in value:
49
+ hostname = value
50
+ return (hostname or "").lower().rstrip(".")
51
+
52
+
53
+ def normalize_url_for_audit(url: str | None) -> str:
54
+ """Return bounded URL metadata suitable for local reports."""
55
+
56
+ parsed = urlparse(str(url or ""))
57
+ if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
58
+ return ""
59
+ netloc = parsed.hostname.lower().rstrip(".")
60
+ if parsed.port:
61
+ netloc = f"{netloc}:{parsed.port}"
62
+ path = (parsed.path or "").rstrip("/")
63
+ return urlunparse((parsed.scheme.lower(), netloc, path, "", "", ""))
64
+
65
+
66
+ def clamp_search_limit(value: int | str | None) -> int:
67
+ """Clamp public search requests to the package safety boundary."""
68
+
69
+ return _clamp_non_negative_int(value, SEARCH_LIMIT_CAP)
70
+
71
+
72
+ def clamp_assist_cycles(value: int | str | None) -> int:
73
+ """Clamp public assist cycles to the package safety boundary."""
74
+
75
+ return _clamp_non_negative_int(value, ASSIST_CYCLES_CAP)
76
+
77
+
78
+ def _clamp_non_negative_int(value: int | str | None, cap: int) -> int:
79
+ try:
80
+ parsed = int(value) if value is not None else 0
81
+ except (TypeError, ValueError):
82
+ parsed = 0
83
+ return min(max(parsed, 0), cap)
84
+
85
+
86
+ def backoff_delay(
87
+ attempt: int,
88
+ *,
89
+ base_seconds: float = 1.0,
90
+ cap_seconds: float = 30.0,
91
+ ) -> float:
92
+ """Return deterministic exponential backoff seconds without sleeping."""
93
+
94
+ if attempt <= 0:
95
+ return 0.0
96
+ delay = base_seconds * (2 ** (attempt - 1))
97
+ return min(float(delay), float(cap_seconds))
98
+
99
+
100
+ class DisabledSubmissionPolicy:
101
+ """Submission policy that blocks every browser submission action."""
102
+
103
+ policy = POLICY_NAME
104
+
105
+ def decide(
106
+ self,
107
+ action: str,
108
+ context: dict[str, Any] | None = None,
109
+ ) -> SubmissionResult:
110
+ return SubmissionResult(
111
+ status="disabled",
112
+ reason=DISABLED_SUBMISSION_REASON,
113
+ allowed=False,
114
+ )
115
+
116
+ def submit_decision(
117
+ self,
118
+ action: str,
119
+ *,
120
+ command: str = "apply",
121
+ context: dict[str, Any] | None = None,
122
+ confirmation_state: str = "",
123
+ ) -> SubmitDecision:
124
+ ctx = dict(context or {})
125
+ url = normalize_url_for_audit(ctx.get("url") or ctx.get("apply_url") or "")
126
+ domain = domain_from_url(url or ctx.get("domain") or "")
127
+ return SubmitDecision(
128
+ timestamp=utc_timestamp(),
129
+ command=command,
130
+ policy=f"{POLICY_NAME}@{POLICY_VERSION}",
131
+ action=str(action or "submit"),
132
+ allowed=False,
133
+ status="disabled",
134
+ reason=DISABLED_SUBMISSION_REASON,
135
+ company=str(ctx.get("company") or ""),
136
+ role=str(ctx.get("role") or ctx.get("title") or ""),
137
+ url=url,
138
+ domain=domain,
139
+ ats=str(ctx.get("ats") or ""),
140
+ confirmation_state=str(confirmation_state or "not_confirmed"),
141
+ )
142
+
143
+ def audit_event(
144
+ self,
145
+ action: str,
146
+ *,
147
+ command: str = "apply",
148
+ context: dict[str, Any] | None = None,
149
+ confirmation_state: str = "",
150
+ ) -> dict[str, Any]:
151
+ """Return a dictionary audit event for report writers."""
152
+
153
+ return asdict(
154
+ self.submit_decision(
155
+ action,
156
+ command=command,
157
+ context=context,
158
+ confirmation_state=confirmation_state,
159
+ )
160
+ )
161
+
162
+
163
+ def disabled_submit_audit_payload(
164
+ *,
165
+ command: str = "apply",
166
+ action: str = "submit",
167
+ context: dict[str, Any] | None = None,
168
+ confirmation_state: str = "",
169
+ ) -> dict[str, Any]:
170
+ """Build a privacy-bounded disabled submit audit payload."""
171
+
172
+ decision = DisabledSubmissionPolicy().audit_event(
173
+ action,
174
+ command=command,
175
+ context=context,
176
+ confirmation_state=confirmation_state,
177
+ )
178
+ return {
179
+ "command": command,
180
+ "timestamp": decision["timestamp"],
181
+ "decision": decision,
182
+ "events": [
183
+ {
184
+ "type": "submit_decision",
185
+ "action": action,
186
+ "status": decision["status"],
187
+ "allowed": decision["allowed"],
188
+ "policy": decision["policy"],
189
+ "reason": decision["reason"],
190
+ "company": decision["company"],
191
+ "role": decision["role"],
192
+ "url": decision["url"],
193
+ "domain": decision["domain"],
194
+ "ats": decision["ats"],
195
+ "confirmation_state": decision["confirmation_state"],
196
+ "timestamp": decision["timestamp"],
197
+ }
198
+ ],
199
+ "summary": {
200
+ "command": command,
201
+ "policy": decision["policy"],
202
+ "action": action,
203
+ "status": decision["status"],
204
+ "allowed": decision["allowed"],
205
+ "submitted": 0,
206
+ "reason": decision["reason"],
207
+ "confirmation_state": decision["confirmation_state"],
208
+ },
209
+ }
210
+
211
+
212
+ __all__ = [
213
+ "ASSIST_CYCLES_CAP",
214
+ "ASSIST_CYCLES_DEFAULT",
215
+ "BROWSER_PROFILE_WARNING",
216
+ "DISABLED_SUBMISSION_REASON",
217
+ "DisabledSubmissionPolicy",
218
+ "FUTURE_SUBMIT_POLICY",
219
+ "POLICY_NAME",
220
+ "POLICY_VERSION",
221
+ "RISK_STATUSES",
222
+ "SEARCH_LIMIT_CAP",
223
+ "SEARCH_LIMIT_DEFAULT",
224
+ "backoff_delay",
225
+ "clamp_assist_cycles",
226
+ "clamp_search_limit",
227
+ "disabled_submit_audit_payload",
228
+ "domain_from_url",
229
+ "normalize_url_for_audit",
230
+ ]
@@ -0,0 +1,435 @@
1
+ """Selector-light workflow orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Mapping, Sequence
7
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
8
+
9
+ from .contracts import (
10
+ AssistEvent,
11
+ AssistRequest,
12
+ AssistResult,
13
+ BrowserSession,
14
+ BrowserSessionFactory,
15
+ FillAdapter,
16
+ JobRecord,
17
+ LinkedInDiscovery,
18
+ ReportArtifact,
19
+ ReportSink,
20
+ SearchRequest,
21
+ SearchResult,
22
+ SubmissionPolicy,
23
+ SurfaceIdentity,
24
+ )
25
+ from .form_engine import DetectionResult, FillResult, normalize_space
26
+ from .linkedin_layer import DEFAULT_SEARCH_URL, sanitize_linkedin_search_url
27
+ from .safety import (
28
+ clamp_assist_cycles,
29
+ clamp_search_limit,
30
+ domain_from_url,
31
+ normalize_url_for_audit,
32
+ )
33
+
34
+
35
+ def utc_timestamp() -> str:
36
+ """Return an ISO timestamp for reports."""
37
+
38
+ return datetime.now(timezone.utc).isoformat()
39
+
40
+
41
+ def build_search_url(request: SearchRequest) -> str:
42
+ """Build or sanitize the LinkedIn jobs search URL for a request."""
43
+
44
+ if request.search_url:
45
+ return sanitize_linkedin_search_url(request.search_url)
46
+
47
+ parsed = urlparse(DEFAULT_SEARCH_URL)
48
+ query: dict[str, list[str]] = {}
49
+ if request.query:
50
+ query["keywords"] = [request.query]
51
+ if request.location:
52
+ query["location"] = [request.location]
53
+ return urlunparse(
54
+ (
55
+ parsed.scheme,
56
+ parsed.netloc,
57
+ parsed.path,
58
+ "",
59
+ urlencode(query, doseq=True),
60
+ "",
61
+ )
62
+ )
63
+
64
+
65
+ def normalized_job_url(url: str) -> str:
66
+ """Normalize a job URL enough for within-run deduplication."""
67
+
68
+ parsed = urlparse(url or "")
69
+ query = parse_qs(parsed.query)
70
+ for key in ("trk", "refId", "trackingId", "position", "pageNum"):
71
+ query.pop(key, None)
72
+ return urlunparse(
73
+ (
74
+ parsed.scheme.lower(),
75
+ parsed.netloc.lower(),
76
+ parsed.path.rstrip("/"),
77
+ "",
78
+ urlencode(query, doseq=True),
79
+ "",
80
+ )
81
+ )
82
+
83
+
84
+ def job_from_record(record: JobRecord | Mapping[str, Any], *, search_url: str = "") -> JobRecord:
85
+ """Convert a mapping or JobRecord into the stable JobRecord shape."""
86
+
87
+ if isinstance(record, JobRecord):
88
+ if search_url and not record.search_url:
89
+ return JobRecord(**{**record.__dict__, "search_url": search_url})
90
+ return record
91
+
92
+ raw = dict(record)
93
+ job_id = normalize_space(raw.get("job_id") or raw.get("linkedin_job_id") or raw.get("id") or "")
94
+ url = normalize_space(raw.get("url") or raw.get("linkedin_url") or "")
95
+ return JobRecord(
96
+ job_id=job_id,
97
+ title=normalize_space(raw.get("title") or raw.get("role") or ""),
98
+ company=normalize_space(raw.get("company") or ""),
99
+ url=url,
100
+ location=normalize_space(raw.get("location") or ""),
101
+ source=normalize_space(raw.get("source") or "linkedin"),
102
+ search_url=normalize_space(raw.get("search_url") or search_url),
103
+ ats=normalize_space(raw.get("ats") or ""),
104
+ raw=raw,
105
+ )
106
+
107
+
108
+ def dedupe_jobs(
109
+ records: Sequence[JobRecord | Mapping[str, Any]], *, search_url: str
110
+ ) -> list[JobRecord]:
111
+ """Deduplicate by LinkedIn job id first, then normalized URL fallback."""
112
+
113
+ seen: set[str] = set()
114
+ jobs: list[JobRecord] = []
115
+ for record in records:
116
+ job = job_from_record(record, search_url=search_url)
117
+ dedupe_key = f"id:{job.job_id}" if job.job_id else f"url:{normalized_job_url(job.url)}"
118
+ if not dedupe_key or dedupe_key in seen:
119
+ continue
120
+ seen.add(dedupe_key)
121
+ jobs.append(job)
122
+ return jobs
123
+
124
+
125
+ def _job_payload(job: JobRecord) -> dict[str, Any]:
126
+ url = normalize_url_for_audit(job.url)
127
+ search_url = normalize_url_for_audit(job.search_url)
128
+ return {
129
+ "job_id": job.job_id,
130
+ "title": job.title,
131
+ "company": job.company,
132
+ "url": url,
133
+ "domain": domain_from_url(url),
134
+ "location": job.location,
135
+ "source": job.source,
136
+ "search_url": search_url,
137
+ "ats": job.ats,
138
+ }
139
+
140
+
141
+ def _report_artifacts(artifacts: Sequence[ReportArtifact] | None) -> list[ReportArtifact]:
142
+ return list(artifacts or [])
143
+
144
+
145
+ def run_search_workflow(
146
+ request: SearchRequest,
147
+ discovery: LinkedInDiscovery,
148
+ report_sink: ReportSink,
149
+ submission_policy: SubmissionPolicy | None = None,
150
+ ) -> SearchResult:
151
+ """Run search-only discovery, deduplication, and reporting."""
152
+
153
+ search_url = build_search_url(request)
154
+ effective_limit = clamp_search_limit(request.limit)
155
+ records: Sequence[JobRecord | Mapping[str, Any]] = []
156
+ if effective_limit > 0:
157
+ records = discovery.discover(
158
+ SearchRequest(
159
+ limit=effective_limit,
160
+ search_url=search_url,
161
+ query=request.query,
162
+ location=request.location,
163
+ profile=dict(request.profile),
164
+ paths=request.paths,
165
+ )
166
+ )
167
+
168
+ jobs = dedupe_jobs(records, search_url=search_url)[:effective_limit]
169
+ timestamp = utc_timestamp()
170
+ events = [
171
+ {
172
+ "type": "job",
173
+ "action": "discovered",
174
+ "status": "recorded",
175
+ "surface": "linkedin_search",
176
+ "ats": job.ats,
177
+ "blocked_reason": "",
178
+ "unknown_questions": [],
179
+ "required_empty_count": 0,
180
+ **_job_payload(job),
181
+ }
182
+ for job in jobs
183
+ ]
184
+ summary = {
185
+ "command": "search",
186
+ "requested_limit": request.limit,
187
+ "effective_limit": effective_limit,
188
+ "discovered": len(records),
189
+ "deduplicated": len(jobs),
190
+ "submitted": 0,
191
+ }
192
+ report = {
193
+ "command": "search",
194
+ "timestamp": timestamp,
195
+ "search_url": search_url,
196
+ "jobs": [_job_payload(job) for job in jobs],
197
+ "events": events,
198
+ "summary": summary,
199
+ }
200
+ artifacts = _report_artifacts(report_sink.write("search", report))
201
+ return SearchResult(
202
+ timestamp=timestamp,
203
+ search_url=search_url,
204
+ jobs=jobs,
205
+ events=events,
206
+ summary=summary,
207
+ reports=artifacts,
208
+ )
209
+
210
+
211
+ def surface_identity_from_detection(detection: DetectionResult) -> SurfaceIdentity:
212
+ """Build a stable fill-once identity for a detected surface."""
213
+
214
+ page = detection.page
215
+ url = normalize_space(getattr(page, "url", "") if page is not None else "")
216
+ title_attr = getattr(page, "title", "")
217
+ title = title_attr() if callable(title_attr) else title_attr
218
+ context = dict(detection.job_context or {})
219
+ return SurfaceIdentity(
220
+ url=url or normalize_space(context.get("apply_url") or context.get("url") or ""),
221
+ title=normalize_space(title or context.get("title") or context.get("role") or ""),
222
+ surface=normalize_space(detection.surface),
223
+ ats=normalize_space(detection.ats),
224
+ job_id=normalize_space(context.get("job_id") or ""),
225
+ )
226
+
227
+
228
+ def _assist_event(
229
+ event_type: str,
230
+ detection: DetectionResult,
231
+ result: FillResult,
232
+ identity: SurfaceIdentity,
233
+ *,
234
+ status: str,
235
+ blocked_reason: str = "",
236
+ ) -> AssistEvent:
237
+ return AssistEvent(
238
+ type=event_type,
239
+ surface=detection.surface,
240
+ ats=detection.ats,
241
+ status=status,
242
+ filled_count=len(result.filled),
243
+ required_empty_count=len(result.required_empty),
244
+ unknown_count=len(result.unknown_questions),
245
+ reached_submit_step=bool(result.reached_submit_step),
246
+ blocked_reason=blocked_reason,
247
+ job=dict(detection.job_context or {}),
248
+ identity=identity,
249
+ required_empty=list(result.required_empty),
250
+ unknown_questions=list(result.unknown_questions),
251
+ timestamp=utc_timestamp(),
252
+ )
253
+
254
+
255
+ def _event_payload(event: AssistEvent) -> dict[str, Any]:
256
+ job = _bounded_job_context(event.job)
257
+ identity = _identity_payload(event.identity)
258
+ return {
259
+ "type": event.type,
260
+ "action": event.type,
261
+ "timestamp": event.timestamp,
262
+ "surface": event.surface,
263
+ "ats": event.ats,
264
+ "status": event.status,
265
+ "filled_count": event.filled_count,
266
+ "required_empty_count": event.required_empty_count,
267
+ "unknown_count": event.unknown_count,
268
+ "reached_submit_step": event.reached_submit_step,
269
+ "blocked_reason": event.blocked_reason,
270
+ "job": job,
271
+ "identity": identity,
272
+ "required_empty": list(event.required_empty),
273
+ "unknown_questions": [_unknown_question_payload(item) for item in event.unknown_questions],
274
+ "feedback": compact_assist_feedback(event),
275
+ }
276
+
277
+
278
+ def _bounded_job_context(context: Mapping[str, Any] | None) -> dict[str, Any]:
279
+ ctx = dict(context or {})
280
+ url = normalize_url_for_audit(ctx.get("apply_url") or ctx.get("url") or "")
281
+ domain = domain_from_url(url or ctx.get("domain") or "")
282
+ return {
283
+ "job_id": normalize_space(ctx.get("job_id") or ""),
284
+ "company": normalize_space(ctx.get("company") or ""),
285
+ "role": normalize_space(ctx.get("role") or ctx.get("title") or ""),
286
+ "title": normalize_space(ctx.get("title") or ctx.get("role") or ""),
287
+ "url": url,
288
+ "domain": domain,
289
+ "ats": normalize_space(ctx.get("ats") or ""),
290
+ }
291
+
292
+
293
+ def _identity_payload(identity: SurfaceIdentity | None) -> dict[str, Any]:
294
+ if identity is None:
295
+ return {}
296
+ url = normalize_url_for_audit(identity.url)
297
+ return {
298
+ "url": url,
299
+ "domain": domain_from_url(url),
300
+ "title": identity.title,
301
+ "surface": identity.surface,
302
+ "ats": identity.ats,
303
+ "job_id": identity.job_id,
304
+ }
305
+
306
+
307
+ def _unknown_question_payload(item: Any) -> dict[str, Any] | str:
308
+ if not isinstance(item, Mapping):
309
+ return normalize_space(item)
310
+ url = normalize_url_for_audit(item.get("apply_url") or item.get("url") or "")
311
+ return {
312
+ "question": normalize_space(item.get("question") or ""),
313
+ "field_type": normalize_space(item.get("field_type") or "text"),
314
+ "required": bool(item.get("required")),
315
+ "ats": normalize_space(item.get("ats") or ""),
316
+ "company": normalize_space(item.get("company") or ""),
317
+ "role": normalize_space(item.get("role") or item.get("title") or ""),
318
+ "domain": domain_from_url(url or item.get("domain") or ""),
319
+ }
320
+
321
+
322
+ def compact_assist_feedback(event: AssistEvent) -> str:
323
+ """Return one compact user-facing assist feedback line."""
324
+
325
+ surface = event.surface or "none"
326
+ ats = event.ats or "unknown"
327
+ return (
328
+ f"{surface}/{ats} status={event.status or 'unknown'} "
329
+ f"filled={event.filled_count} required_empty={event.required_empty_count} "
330
+ f"unknown={event.unknown_count} submit_step={str(event.reached_submit_step).lower()}"
331
+ )
332
+
333
+
334
+ def run_assist_workflow(
335
+ request: AssistRequest,
336
+ session_factory: BrowserSessionFactory,
337
+ detector: Any,
338
+ fill_adapter: FillAdapter,
339
+ report_sink: ReportSink,
340
+ submission_policy: SubmissionPolicy | None = None,
341
+ bank: Any = None,
342
+ ) -> AssistResult:
343
+ """Run one bounded assistive fill-only session."""
344
+
345
+ session: BrowserSession | None = None
346
+ events: list[AssistEvent] = []
347
+ seen: set[str] = set()
348
+ timestamp = utc_timestamp()
349
+ try:
350
+ session = session_factory.open(request)
351
+ if request.start_url:
352
+ session.open_url(request.start_url)
353
+
354
+ cycles = clamp_assist_cycles(request.max_cycles)
355
+ for _ in range(cycles):
356
+ detection = detector.detect(session)
357
+ if detection.surface == "none":
358
+ continue
359
+ identity = surface_identity_from_detection(detection)
360
+ if detection.surface == "browser_blocked":
361
+ blocked_reason = normalize_space(
362
+ detection.job_context.get("blocked_reason")
363
+ or "Visible browser requires user action."
364
+ )
365
+ events.append(
366
+ _assist_event(
367
+ "blocked",
368
+ detection,
369
+ FillResult(surface=detection.surface),
370
+ identity,
371
+ status="blocked",
372
+ blocked_reason=blocked_reason,
373
+ )
374
+ )
375
+ continue
376
+ if identity.key() in seen:
377
+ events.append(
378
+ AssistEvent(
379
+ type="skipped",
380
+ surface=detection.surface,
381
+ ats=detection.ats,
382
+ status="duplicate",
383
+ job=dict(detection.job_context or {}),
384
+ identity=identity,
385
+ timestamp=utc_timestamp(),
386
+ )
387
+ )
388
+ continue
389
+ seen.add(identity.key())
390
+ fill_surface = getattr(fill_adapter, "fill")
391
+ fill_result = fill_surface(
392
+ detection,
393
+ dict(request.profile),
394
+ bank,
395
+ dict(request.qa_context),
396
+ dict(request.documents),
397
+ )
398
+ status = "blocked" if fill_result.required_empty else "filled"
399
+ blocked_reason = "; ".join(str(item) for item in fill_result.required_empty)
400
+ events.append(
401
+ _assist_event(
402
+ "filled",
403
+ detection,
404
+ fill_result,
405
+ identity,
406
+ status=status,
407
+ blocked_reason=blocked_reason,
408
+ )
409
+ )
410
+ finally:
411
+ # Live sessions are intentionally visible; factories can choose no-op close.
412
+ if session is not None and getattr(session, "close_on_exit", False):
413
+ session.close()
414
+
415
+ summary = {
416
+ "command": "assist",
417
+ "mode": request.mode,
418
+ "requested_cycles": request.max_cycles,
419
+ "effective_cycles": clamp_assist_cycles(request.max_cycles),
420
+ "events": len(events),
421
+ "filled": sum(1 for event in events if event.type == "filled"),
422
+ "blocked": sum(1 for event in events if event.status == "blocked"),
423
+ "duplicates": sum(1 for event in events if event.status == "duplicate"),
424
+ "required_empty": sum(event.required_empty_count for event in events),
425
+ "unknown_questions": sum(event.unknown_count for event in events),
426
+ "submitted": 0,
427
+ }
428
+ report = {
429
+ "command": "assist",
430
+ "timestamp": timestamp,
431
+ "events": [_event_payload(event) for event in events],
432
+ "summary": summary,
433
+ }
434
+ artifacts = _report_artifacts(report_sink.write("assist", report))
435
+ return AssistResult(timestamp=timestamp, events=events, summary=summary, reports=artifacts)