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.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +72 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/config_help.yml +49 -0
- package/.github/ISSUE_TEMPLATE/docs.yml +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +45 -0
- package/.github/ISSUE_TEMPLATE/safety_compliance.yml +48 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
- package/CHANGELOG.md +47 -0
- package/CODE_OF_CONDUCT.md +47 -0
- package/CONTRIBUTING.md +64 -0
- package/GOVERNANCE.md +41 -0
- package/LEGAL.md +38 -0
- package/LICENSE +22 -0
- package/MIGRATION.md +50 -0
- package/README.md +167 -0
- package/RELEASE_CHECKLIST.md +454 -0
- package/SAFETY.md +33 -0
- package/SECURITY.md +37 -0
- package/SUPPORT.md +44 -0
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/bin/linkedin-apply-assistant.mjs +95 -0
- package/configs/config.example.yml +24 -0
- package/configs/qa_bank.example.yml +35 -0
- package/docs/apply.md +40 -0
- package/docs/assist.md +35 -0
- package/docs/browser-session.md +45 -0
- package/docs/ci-and-release-policy.md +105 -0
- package/docs/commands.md +176 -0
- package/docs/install-and-configuration.md +265 -0
- package/docs/registry-publication-strategy.md +169 -0
- package/docs/reports.md +35 -0
- package/docs/search.md +39 -0
- package/docs/troubleshooting.md +57 -0
- package/examples/dry_run_input.example.json +25 -0
- package/examples/reports/apply-audit.example.json +31 -0
- package/examples/reports/search-report.example.json +40 -0
- package/install.ps1 +178 -0
- package/package.json +59 -0
- package/pyproject.toml +51 -0
- package/src/linkedin_apply_assistant/__init__.py +8 -0
- package/src/linkedin_apply_assistant/apply_reports.py +229 -0
- package/src/linkedin_apply_assistant/ats_handlers.py +217 -0
- package/src/linkedin_apply_assistant/browser_sessions.py +155 -0
- package/src/linkedin_apply_assistant/cli.py +570 -0
- package/src/linkedin_apply_assistant/config.py +109 -0
- package/src/linkedin_apply_assistant/contracts.py +255 -0
- package/src/linkedin_apply_assistant/form_engine.py +180 -0
- package/src/linkedin_apply_assistant/linkedin_layer.py +436 -0
- package/src/linkedin_apply_assistant/page_actions.py +110 -0
- package/src/linkedin_apply_assistant/page_selectors.py +88 -0
- package/src/linkedin_apply_assistant/paths.py +135 -0
- package/src/linkedin_apply_assistant/qa_bank.py +352 -0
- package/src/linkedin_apply_assistant/redaction.py +119 -0
- package/src/linkedin_apply_assistant/safety.py +230 -0
- package/src/linkedin_apply_assistant/workflows.py +435 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Command-line boundary for the standalone assistant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .apply_reports import RuntimeReportSink
|
|
12
|
+
from .ats_handlers import DisabledSubmissionPolicy
|
|
13
|
+
from .browser_sessions import PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND, VisibleBrowserSessionFactory
|
|
14
|
+
from .config import AssistantConfig, load_config
|
|
15
|
+
from .contracts import AssistRequest, SearchRequest
|
|
16
|
+
from .linkedin_layer import (
|
|
17
|
+
BrowserLinkedInDiscovery,
|
|
18
|
+
CurrentSurfaceDetector,
|
|
19
|
+
CurrentSurfaceFillAdapter,
|
|
20
|
+
StaticLinkedInDiscovery,
|
|
21
|
+
)
|
|
22
|
+
from .paths import resolve_runtime_paths
|
|
23
|
+
from .qa_bank import QABank
|
|
24
|
+
from .safety import BROWSER_PROFILE_WARNING, disabled_submit_audit_payload
|
|
25
|
+
from .workflows import compact_assist_feedback, run_assist_workflow, run_search_workflow
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
REQUIRED_JOB_FIELDS = ("title", "company", "url", "location", "description")
|
|
29
|
+
CONFIG_CHECK_COMMAND = "linkedin-apply-assistant config check"
|
|
30
|
+
CONFIG_EXAMPLE_PATH = "configs/config.example.yml"
|
|
31
|
+
QA_BANK_EXAMPLE_PATH = "configs/qa_bank.example.yml"
|
|
32
|
+
NO_SUBMIT_HELP = (
|
|
33
|
+
"Safety: public workflows are no-submit by default; assist is fill-only and "
|
|
34
|
+
"browser submission remains disabled in apply."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CliError(Exception):
|
|
39
|
+
"""User-facing CLI error."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _common_default(suppress_defaults: bool) -> str | None:
|
|
43
|
+
return argparse.SUPPRESS if suppress_defaults else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _add_common_options(
|
|
47
|
+
parser: argparse.ArgumentParser, *, suppress_defaults: bool = False
|
|
48
|
+
) -> None:
|
|
49
|
+
default = _common_default(suppress_defaults)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--workspace",
|
|
52
|
+
default=default,
|
|
53
|
+
help="Use a local workspace for config, data, browser profile, output, and reports.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--config",
|
|
57
|
+
default=default,
|
|
58
|
+
help="Path to an assistant YAML config file.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--qa-bank",
|
|
62
|
+
default=default,
|
|
63
|
+
help="Path to a Q&A bank YAML file.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--browser-profile",
|
|
67
|
+
default=default,
|
|
68
|
+
help="Path to the visible-browser profile directory.",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--output-dir",
|
|
72
|
+
default=default,
|
|
73
|
+
help="Path for local command outputs.",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--verbose",
|
|
77
|
+
action="store_true",
|
|
78
|
+
default=argparse.SUPPRESS if suppress_defaults else False,
|
|
79
|
+
help="Print additional command-boundary details.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
84
|
+
root_common = argparse.ArgumentParser(add_help=False)
|
|
85
|
+
_add_common_options(root_common)
|
|
86
|
+
subcommand_common = argparse.ArgumentParser(add_help=False)
|
|
87
|
+
_add_common_options(subcommand_common, suppress_defaults=True)
|
|
88
|
+
formatter = argparse.RawDescriptionHelpFormatter
|
|
89
|
+
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
prog="linkedin-apply-assistant",
|
|
92
|
+
parents=[root_common],
|
|
93
|
+
formatter_class=formatter,
|
|
94
|
+
description=(
|
|
95
|
+
"Local, user-visible LinkedIn application assistant for search-only, "
|
|
96
|
+
"assistive fill-only, approval-gated apply, dry-run, and report generation."
|
|
97
|
+
),
|
|
98
|
+
epilog=f"""First run:
|
|
99
|
+
{CONFIG_CHECK_COMMAND}
|
|
100
|
+
python -m playwright install chromium
|
|
101
|
+
|
|
102
|
+
Common workflows:
|
|
103
|
+
linkedin-apply-assistant search --query "python" --limit 5
|
|
104
|
+
linkedin-apply-assistant assist --mode on-demand
|
|
105
|
+
linkedin-apply-assistant dry-run --input examples/dry_run_input.example.json
|
|
106
|
+
|
|
107
|
+
Outputs use the resolved output directory and reports are written under its reports folder.
|
|
108
|
+
{NO_SUBMIT_HELP}
|
|
109
|
+
""",
|
|
110
|
+
)
|
|
111
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
112
|
+
|
|
113
|
+
config = subparsers.add_parser(
|
|
114
|
+
"config",
|
|
115
|
+
parents=[subcommand_common],
|
|
116
|
+
formatter_class=formatter,
|
|
117
|
+
help="Inspect first-run paths and setup gaps without writing files.",
|
|
118
|
+
description="Read-only configuration diagnostics for first-run setup.",
|
|
119
|
+
epilog=f"""Examples:
|
|
120
|
+
{CONFIG_CHECK_COMMAND}
|
|
121
|
+
linkedin-apply-assistant --workspace .assistant-workspace config check
|
|
122
|
+
|
|
123
|
+
The diagnostic resolves config, Q&A bank, browser profile, output, reports, data, and cache paths.
|
|
124
|
+
It creates no files and no directories.
|
|
125
|
+
""",
|
|
126
|
+
)
|
|
127
|
+
config_subparsers = config.add_subparsers(dest="config_command", required=True)
|
|
128
|
+
config_check = config_subparsers.add_parser(
|
|
129
|
+
"check",
|
|
130
|
+
parents=[subcommand_common],
|
|
131
|
+
formatter_class=formatter,
|
|
132
|
+
help="Report resolved paths and setup guidance without writing files.",
|
|
133
|
+
description="Check first-run paths and setup gaps without creating or overwriting files.",
|
|
134
|
+
epilog=f"""Examples:
|
|
135
|
+
{CONFIG_CHECK_COMMAND}
|
|
136
|
+
linkedin-apply-assistant config check --workspace .assistant-workspace
|
|
137
|
+
|
|
138
|
+
Status labels: ok, missing, warning.
|
|
139
|
+
No files or directories are created by this command.
|
|
140
|
+
""",
|
|
141
|
+
)
|
|
142
|
+
config_check.set_defaults(handler=_handle_config_check)
|
|
143
|
+
|
|
144
|
+
search = subparsers.add_parser(
|
|
145
|
+
"search",
|
|
146
|
+
parents=[subcommand_common],
|
|
147
|
+
formatter_class=formatter,
|
|
148
|
+
help="Search-only boundary for collecting candidate jobs.",
|
|
149
|
+
description="Search LinkedIn jobs and write local search reports without submitting applications.",
|
|
150
|
+
epilog=f"""Examples:
|
|
151
|
+
linkedin-apply-assistant search --query "python" --location "Remote" --limit 5
|
|
152
|
+
linkedin-apply-assistant search --workspace .assistant-workspace --search-url "https://www.linkedin.com/jobs/search/" --limit 10 --verbose
|
|
153
|
+
|
|
154
|
+
Reports are written under the resolved reports directory.
|
|
155
|
+
Run `{CONFIG_CHECK_COMMAND}` to inspect paths before browser workflows.
|
|
156
|
+
{NO_SUBMIT_HELP}
|
|
157
|
+
""",
|
|
158
|
+
)
|
|
159
|
+
search.add_argument("--query", default=None, help="Search query text.")
|
|
160
|
+
search.add_argument("--location", default=None, help="Search location text.")
|
|
161
|
+
search.add_argument("--limit", type=int, default=10, help="Maximum jobs to inspect.")
|
|
162
|
+
search.add_argument("--search-url", default=None, help="Existing LinkedIn jobs search URL.")
|
|
163
|
+
search.set_defaults(handler=_handle_search)
|
|
164
|
+
|
|
165
|
+
assist = subparsers.add_parser(
|
|
166
|
+
"assist",
|
|
167
|
+
parents=[subcommand_common],
|
|
168
|
+
formatter_class=formatter,
|
|
169
|
+
help="Assistive fill-only boundary where the user drives the visible browser.",
|
|
170
|
+
description="Open a visible browser session and fill detected application forms without submitting.",
|
|
171
|
+
epilog=f"""Examples:
|
|
172
|
+
linkedin-apply-assistant assist --mode on-demand
|
|
173
|
+
linkedin-apply-assistant assist --workspace .assistant-workspace --browser-profile .browser-profile --verbose
|
|
174
|
+
|
|
175
|
+
Install browser support with: {PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND}
|
|
176
|
+
Reports are written under the resolved reports directory.
|
|
177
|
+
{NO_SUBMIT_HELP}
|
|
178
|
+
""",
|
|
179
|
+
)
|
|
180
|
+
assist.add_argument("--start-url", default=None, help="Optional first page to open.")
|
|
181
|
+
assist.add_argument(
|
|
182
|
+
"--mode",
|
|
183
|
+
choices=("auto-watch", "on-demand"),
|
|
184
|
+
default="auto-watch",
|
|
185
|
+
help="Assist mode for visible-browser filling.",
|
|
186
|
+
)
|
|
187
|
+
assist.add_argument(
|
|
188
|
+
"--max-cycles", type=int, default=1, help="Maximum detected surfaces to inspect."
|
|
189
|
+
)
|
|
190
|
+
assist.set_defaults(handler=_handle_assist)
|
|
191
|
+
|
|
192
|
+
apply_cmd = subparsers.add_parser(
|
|
193
|
+
"apply",
|
|
194
|
+
parents=[subcommand_common],
|
|
195
|
+
formatter_class=formatter,
|
|
196
|
+
help="Apply-with-approval boundary; submissions require explicit confirmation.",
|
|
197
|
+
description="Prepare local apply reports while browser submission remains disabled.",
|
|
198
|
+
epilog=f"""Examples:
|
|
199
|
+
linkedin-apply-assistant apply --input candidates.json --limit 3
|
|
200
|
+
linkedin-apply-assistant apply --workspace .assistant-workspace --input candidates.json --verbose
|
|
201
|
+
|
|
202
|
+
Current package behavior is prepare-only. Browser submission remains disabled.
|
|
203
|
+
Reports are written under the resolved reports directory.
|
|
204
|
+
{NO_SUBMIT_HELP}
|
|
205
|
+
""",
|
|
206
|
+
)
|
|
207
|
+
apply_cmd.add_argument("--input", default=None, help="Path to candidate jobs JSON.")
|
|
208
|
+
apply_cmd.add_argument("--limit", type=int, default=10, help="Maximum jobs to prepare.")
|
|
209
|
+
apply_cmd.add_argument(
|
|
210
|
+
"--confirm-submit",
|
|
211
|
+
action="store_true",
|
|
212
|
+
help=(
|
|
213
|
+
"Guarded future option: every submission still requires explicit "
|
|
214
|
+
"per-submission confirmation and Phase 16 safety guardrails."
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
apply_cmd.set_defaults(handler=_handle_apply)
|
|
218
|
+
|
|
219
|
+
dry_run = subparsers.add_parser(
|
|
220
|
+
"dry-run",
|
|
221
|
+
parents=[subcommand_common],
|
|
222
|
+
formatter_class=formatter,
|
|
223
|
+
help="Validate local job input without browser submission.",
|
|
224
|
+
description="Validate local JSON job input without config, Q&A bank, Playwright, or a browser profile.",
|
|
225
|
+
epilog="""Example:
|
|
226
|
+
linkedin-apply-assistant dry-run --input examples/dry_run_input.example.json
|
|
227
|
+
|
|
228
|
+
This command is browser-free and does not require config or Q&A setup.
|
|
229
|
+
""",
|
|
230
|
+
)
|
|
231
|
+
dry_run.add_argument("--input", required=True, help="Path to dry-run JSON input.")
|
|
232
|
+
dry_run.set_defaults(handler=_handle_dry_run)
|
|
233
|
+
|
|
234
|
+
report = subparsers.add_parser(
|
|
235
|
+
"report",
|
|
236
|
+
parents=[subcommand_common],
|
|
237
|
+
formatter_class=formatter,
|
|
238
|
+
help="Read a local report JSON file and print a simple summary.",
|
|
239
|
+
description="Summarize a local report JSON file without config, Q&A bank, Playwright, or a browser profile.",
|
|
240
|
+
epilog="""Example:
|
|
241
|
+
linkedin-apply-assistant report output/reports/search_example.json
|
|
242
|
+
|
|
243
|
+
This command is browser-free and reads an existing local report file.
|
|
244
|
+
""",
|
|
245
|
+
)
|
|
246
|
+
report.add_argument("report_json", help="Path to the report JSON file.")
|
|
247
|
+
report.set_defaults(handler=_handle_report)
|
|
248
|
+
|
|
249
|
+
return parser
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _runtime_from_args(args: argparse.Namespace):
|
|
253
|
+
return resolve_runtime_paths(
|
|
254
|
+
workspace=args.workspace,
|
|
255
|
+
config=args.config,
|
|
256
|
+
qa_bank=args.qa_bank,
|
|
257
|
+
browser_profile=args.browser_profile,
|
|
258
|
+
output_dir=args.output_dir,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _with_config_check_hint(message: str) -> str:
|
|
263
|
+
return f"{message}\nTry: {CONFIG_CHECK_COMMAND}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _print_error(message: str, *hints: str) -> None:
|
|
267
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
268
|
+
for hint in hints:
|
|
269
|
+
print(hint, file=sys.stderr)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _path_status(path: Path, *, expected: str) -> str:
|
|
273
|
+
if not path.exists():
|
|
274
|
+
return "missing" if expected == "file" else "warning"
|
|
275
|
+
if expected == "file" and not path.is_file():
|
|
276
|
+
return "warning"
|
|
277
|
+
if expected == "directory" and not path.is_dir():
|
|
278
|
+
return "warning"
|
|
279
|
+
return "ok"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _diagnostic_rows(paths: Any) -> list[tuple[str, str, Path, str]]:
|
|
283
|
+
return [
|
|
284
|
+
(
|
|
285
|
+
"config file",
|
|
286
|
+
_path_status(paths.config_file, expected="file"),
|
|
287
|
+
paths.config_file,
|
|
288
|
+
f"Copy {CONFIG_EXAMPLE_PATH} and edit it before browser workflows if needed.",
|
|
289
|
+
),
|
|
290
|
+
(
|
|
291
|
+
"Q&A bank",
|
|
292
|
+
_path_status(paths.qa_bank_file, expected="file"),
|
|
293
|
+
paths.qa_bank_file,
|
|
294
|
+
(
|
|
295
|
+
f"Copy {QA_BANK_EXAMPLE_PATH} and answer truthfully; missing answers are "
|
|
296
|
+
"captured as pending questions during assist workflows."
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
(
|
|
300
|
+
"browser profile",
|
|
301
|
+
_path_status(paths.browser_profile_dir, expected="directory"),
|
|
302
|
+
paths.browser_profile_dir,
|
|
303
|
+
"Created only by visible-browser workflows; override with --browser-profile <path>.",
|
|
304
|
+
),
|
|
305
|
+
(
|
|
306
|
+
"output directory",
|
|
307
|
+
_path_status(paths.output_dir, expected="directory"),
|
|
308
|
+
paths.output_dir,
|
|
309
|
+
"Created when commands write local outputs.",
|
|
310
|
+
),
|
|
311
|
+
(
|
|
312
|
+
"reports directory",
|
|
313
|
+
_path_status(paths.reports_dir, expected="directory"),
|
|
314
|
+
paths.reports_dir,
|
|
315
|
+
"Created when commands write report JSON files.",
|
|
316
|
+
),
|
|
317
|
+
(
|
|
318
|
+
"data directory",
|
|
319
|
+
_path_status(paths.data_dir, expected="directory"),
|
|
320
|
+
paths.data_dir,
|
|
321
|
+
"Used for local assistant data such as pending questions.",
|
|
322
|
+
),
|
|
323
|
+
(
|
|
324
|
+
"cache directory",
|
|
325
|
+
_path_status(paths.cache_dir, expected="directory"),
|
|
326
|
+
paths.cache_dir,
|
|
327
|
+
"Used for local cache data when workflows need it.",
|
|
328
|
+
),
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _handle_config_check(args: argparse.Namespace) -> int:
|
|
333
|
+
paths = _runtime_from_args(args)
|
|
334
|
+
print("Config diagnostics")
|
|
335
|
+
print("No files or directories were created.")
|
|
336
|
+
print()
|
|
337
|
+
print(f"{'status':<8} {'item':<18} path")
|
|
338
|
+
print(f"{'-' * 8} {'-' * 18} {'-' * 4}")
|
|
339
|
+
for label, status, path, detail in _diagnostic_rows(paths):
|
|
340
|
+
print(f"{status:<8} {label:<18} {path}")
|
|
341
|
+
print(f"{'':<8} {'':<18} {detail}")
|
|
342
|
+
print()
|
|
343
|
+
print(f"Try: {PLAYWRIGHT_CHROMIUM_INSTALL_COMMAND} before visible-browser workflows.")
|
|
344
|
+
print(f"Try: {CONFIG_CHECK_COMMAND} after changing --workspace or path flags.")
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _qa_bank_setup_warning(paths: Any, bank: QABank) -> str | None:
|
|
349
|
+
pairs = bank.data.get("qa_pairs")
|
|
350
|
+
if not paths.qa_bank_file.exists():
|
|
351
|
+
return (
|
|
352
|
+
f"Warning: Q&A bank is missing: {paths.qa_bank_file}\n"
|
|
353
|
+
f"Copy {QA_BANK_EXAMPLE_PATH} and answer truthfully before filling forms."
|
|
354
|
+
)
|
|
355
|
+
if not isinstance(pairs, list) or not pairs:
|
|
356
|
+
return (
|
|
357
|
+
f"Warning: Q&A bank has no qa_pairs: {paths.qa_bank_file}\n"
|
|
358
|
+
f"Use {QA_BANK_EXAMPLE_PATH} as the format and answer truthfully."
|
|
359
|
+
)
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _load_config_if_requested(args: argparse.Namespace) -> AssistantConfig:
|
|
364
|
+
if args.config:
|
|
365
|
+
try:
|
|
366
|
+
return load_config(args.config, workspace=args.workspace)
|
|
367
|
+
except FileNotFoundError as exc:
|
|
368
|
+
raise CliError(_with_config_check_hint(f"Config file not found: {exc}")) from exc
|
|
369
|
+
except ValueError as exc:
|
|
370
|
+
raise CliError(_with_config_check_hint(f"Invalid config: {exc}")) from exc
|
|
371
|
+
return AssistantConfig()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _handle_search(args: argparse.Namespace) -> int:
|
|
375
|
+
config = _load_config_if_requested(args)
|
|
376
|
+
paths = _runtime_from_args(args)
|
|
377
|
+
should_discover = args.limit > 0 and bool(args.search_url or args.query or args.location)
|
|
378
|
+
discovery = (
|
|
379
|
+
BrowserLinkedInDiscovery(VisibleBrowserSessionFactory(paths, close_on_exit=True))
|
|
380
|
+
if should_discover
|
|
381
|
+
else StaticLinkedInDiscovery([])
|
|
382
|
+
)
|
|
383
|
+
try:
|
|
384
|
+
result = run_search_workflow(
|
|
385
|
+
SearchRequest(
|
|
386
|
+
limit=args.limit,
|
|
387
|
+
search_url=args.search_url,
|
|
388
|
+
query=args.query,
|
|
389
|
+
location=args.location,
|
|
390
|
+
profile=dict(config.profile),
|
|
391
|
+
paths=paths,
|
|
392
|
+
),
|
|
393
|
+
discovery,
|
|
394
|
+
RuntimeReportSink(paths=paths),
|
|
395
|
+
DisabledSubmissionPolicy(),
|
|
396
|
+
)
|
|
397
|
+
except RuntimeError as exc:
|
|
398
|
+
raise CliError(str(exc)) from exc
|
|
399
|
+
|
|
400
|
+
print("Search complete.")
|
|
401
|
+
print(f"Requested limit: {args.limit}")
|
|
402
|
+
print(f"Effective limit: {result.summary.get('effective_limit', args.limit)}")
|
|
403
|
+
print(f"Jobs recorded: {len(result.jobs)}")
|
|
404
|
+
print(f"Search URL: {result.search_url}")
|
|
405
|
+
for artifact in result.reports:
|
|
406
|
+
print(f"{artifact.kind} report: {artifact.path}")
|
|
407
|
+
if args.verbose:
|
|
408
|
+
print(f"Output directory: {paths.output_dir}")
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _handle_assist(args: argparse.Namespace) -> int:
|
|
413
|
+
config = _load_config_if_requested(args)
|
|
414
|
+
paths = _runtime_from_args(args)
|
|
415
|
+
try:
|
|
416
|
+
bank = QABank.from_runtime_paths(paths, profile=dict(config.profile))
|
|
417
|
+
except ValueError as exc:
|
|
418
|
+
raise CliError(_with_config_check_hint(f"Invalid Q&A bank: {exc}")) from exc
|
|
419
|
+
qa_warning = _qa_bank_setup_warning(paths, bank)
|
|
420
|
+
if qa_warning:
|
|
421
|
+
print(qa_warning)
|
|
422
|
+
print(BROWSER_PROFILE_WARNING)
|
|
423
|
+
try:
|
|
424
|
+
result = run_assist_workflow(
|
|
425
|
+
AssistRequest(
|
|
426
|
+
start_url=args.start_url,
|
|
427
|
+
mode=args.mode,
|
|
428
|
+
max_cycles=args.max_cycles,
|
|
429
|
+
profile=dict(config.profile),
|
|
430
|
+
documents=dict(config.documents),
|
|
431
|
+
paths=paths,
|
|
432
|
+
),
|
|
433
|
+
VisibleBrowserSessionFactory(paths),
|
|
434
|
+
CurrentSurfaceDetector(profile=dict(config.profile), bank=bank),
|
|
435
|
+
CurrentSurfaceFillAdapter(),
|
|
436
|
+
RuntimeReportSink(paths=paths),
|
|
437
|
+
DisabledSubmissionPolicy(),
|
|
438
|
+
bank,
|
|
439
|
+
)
|
|
440
|
+
except RuntimeError as exc:
|
|
441
|
+
raise CliError(str(exc)) from exc
|
|
442
|
+
print("Assist complete.")
|
|
443
|
+
print(f"Mode: {result.summary.get('mode', args.mode)}")
|
|
444
|
+
print(f"Events: {result.summary.get('events', len(result.events))}")
|
|
445
|
+
print(f"Filled: {result.summary.get('filled', 0)}")
|
|
446
|
+
print(f"Blocked: {result.summary.get('blocked', 0)}")
|
|
447
|
+
print(f"Submitted: {result.summary.get('submitted', 0)}")
|
|
448
|
+
for event in result.events:
|
|
449
|
+
print(compact_assist_feedback(event))
|
|
450
|
+
for artifact in result.reports:
|
|
451
|
+
print(f"{artifact.kind} report: {artifact.path}")
|
|
452
|
+
if args.verbose:
|
|
453
|
+
print(f"Browser profile directory: {paths.browser_profile_dir}")
|
|
454
|
+
return 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _handle_apply(args: argparse.Namespace) -> int:
|
|
458
|
+
_load_config_if_requested(args)
|
|
459
|
+
paths = _runtime_from_args(args)
|
|
460
|
+
sink = RuntimeReportSink(paths=paths)
|
|
461
|
+
print("Apply boundary ready. Submissions require explicit approval and confirmation.")
|
|
462
|
+
print("Browser submission is disabled in this package boundary.")
|
|
463
|
+
if args.input:
|
|
464
|
+
print(f"Input: {args.input}")
|
|
465
|
+
print(f"Limit: {args.limit}")
|
|
466
|
+
if args.confirm_submit:
|
|
467
|
+
print("Confirmation flag noted; browser submission remains disabled in this boundary.")
|
|
468
|
+
if args.confirm_submit or args.input:
|
|
469
|
+
confirmation_state = "flagged_but_disabled" if args.confirm_submit else "input_boundary"
|
|
470
|
+
report = disabled_submit_audit_payload(
|
|
471
|
+
command="apply",
|
|
472
|
+
action="submit",
|
|
473
|
+
context={},
|
|
474
|
+
confirmation_state=confirmation_state,
|
|
475
|
+
)
|
|
476
|
+
report["summary"]["requested_limit"] = args.limit
|
|
477
|
+
report["summary"]["input_provided"] = bool(args.input)
|
|
478
|
+
artifacts = sink.write("apply", report)
|
|
479
|
+
for artifact in artifacts:
|
|
480
|
+
print(f"{artifact.kind} report: {artifact.path}")
|
|
481
|
+
if args.verbose:
|
|
482
|
+
print(f"Reports directory: {paths.reports_dir}")
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _load_json(path: str | Path) -> Any:
|
|
487
|
+
json_path = Path(path).expanduser()
|
|
488
|
+
if not json_path.exists():
|
|
489
|
+
raise ValueError(f"JSON file not found: {json_path}")
|
|
490
|
+
try:
|
|
491
|
+
return json.loads(json_path.read_text(encoding="utf-8"))
|
|
492
|
+
except json.JSONDecodeError as exc:
|
|
493
|
+
raise ValueError(f"Invalid JSON in {json_path}: {exc.msg}") from exc
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _validate_dry_run_jobs(payload: Any) -> list[dict[str, Any]]:
|
|
497
|
+
if isinstance(payload, list):
|
|
498
|
+
jobs = payload
|
|
499
|
+
elif isinstance(payload, dict) and isinstance(payload.get("jobs"), list):
|
|
500
|
+
jobs = payload["jobs"]
|
|
501
|
+
else:
|
|
502
|
+
raise ValueError("Dry-run input must be a job list or an object with a jobs list")
|
|
503
|
+
|
|
504
|
+
validated: list[dict[str, Any]] = []
|
|
505
|
+
for index, job in enumerate(jobs, start=1):
|
|
506
|
+
if not isinstance(job, dict):
|
|
507
|
+
raise ValueError(f"Job {index} must be an object")
|
|
508
|
+
missing = [field for field in REQUIRED_JOB_FIELDS if not job.get(field)]
|
|
509
|
+
if missing:
|
|
510
|
+
raise ValueError(f"Job {index} missing required field(s): {', '.join(missing)}")
|
|
511
|
+
validated.append(job)
|
|
512
|
+
return validated
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _handle_dry_run(args: argparse.Namespace) -> int:
|
|
516
|
+
try:
|
|
517
|
+
payload = _load_json(args.input)
|
|
518
|
+
jobs = _validate_dry_run_jobs(payload)
|
|
519
|
+
except ValueError as exc:
|
|
520
|
+
_print_error(str(exc), "Try: check the --input path and JSON format.")
|
|
521
|
+
return 2
|
|
522
|
+
print(f"Dry run input valid: {len(jobs)} job(s)")
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _handle_report(args: argparse.Namespace) -> int:
|
|
527
|
+
try:
|
|
528
|
+
payload = _load_json(args.report_json)
|
|
529
|
+
except ValueError as exc:
|
|
530
|
+
_print_error(str(exc), "Try: pass a local report JSON path from the reports directory.")
|
|
531
|
+
return 2
|
|
532
|
+
|
|
533
|
+
if isinstance(payload, dict):
|
|
534
|
+
summary = payload.get("summary")
|
|
535
|
+
jobs = payload.get("jobs")
|
|
536
|
+
events = payload.get("events")
|
|
537
|
+
print("Report summary:")
|
|
538
|
+
if isinstance(summary, dict):
|
|
539
|
+
for key in sorted(summary):
|
|
540
|
+
print(f"{key}: {summary[key]}")
|
|
541
|
+
if isinstance(jobs, list):
|
|
542
|
+
print(f"jobs: {len(jobs)}")
|
|
543
|
+
if isinstance(events, list):
|
|
544
|
+
print(f"events: {len(events)}")
|
|
545
|
+
if (
|
|
546
|
+
not isinstance(summary, dict)
|
|
547
|
+
and not isinstance(jobs, list)
|
|
548
|
+
and not isinstance(events, list)
|
|
549
|
+
):
|
|
550
|
+
print(f"object keys: {len(payload)}")
|
|
551
|
+
elif isinstance(payload, list):
|
|
552
|
+
print(f"Report summary: list items={len(payload)}")
|
|
553
|
+
else:
|
|
554
|
+
print(f"Report summary: {type(payload).__name__}")
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def main(argv: list[str] | None = None) -> int:
|
|
559
|
+
parser = build_parser()
|
|
560
|
+
args = parser.parse_args(argv)
|
|
561
|
+
handler = getattr(args, "handler")
|
|
562
|
+
try:
|
|
563
|
+
return int(handler(args))
|
|
564
|
+
except CliError as exc:
|
|
565
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
566
|
+
return 2
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
sys.exit(main())
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Minimal config loading boundary for the standalone assistant."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .paths import RuntimePaths, resolve_runtime_paths
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class AssistantConfig:
|
|
16
|
+
"""Parsed assistant configuration with resolved runtime paths."""
|
|
17
|
+
|
|
18
|
+
profile: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
defaults: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
documents: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
runtime: RuntimePaths | None = None
|
|
22
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def document_paths(self) -> dict[str, Any]:
|
|
26
|
+
"""Compatibility alias for callers that name the document section explicitly."""
|
|
27
|
+
|
|
28
|
+
return self.documents
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _as_dict(value: Any, section: str) -> dict[str, Any]:
|
|
32
|
+
if value is None:
|
|
33
|
+
return {}
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise ValueError(f"{section} must be a mapping")
|
|
36
|
+
return dict(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_document_value(value: Any, base_dir: Path | None) -> Any:
|
|
40
|
+
if value is None:
|
|
41
|
+
return None
|
|
42
|
+
if not isinstance(value, (str, Path)):
|
|
43
|
+
return value
|
|
44
|
+
text = str(value).strip()
|
|
45
|
+
if not text:
|
|
46
|
+
return None
|
|
47
|
+
if text.lower().startswith(("http://", "https://")):
|
|
48
|
+
return text
|
|
49
|
+
path = Path(value).expanduser()
|
|
50
|
+
if base_dir is not None and not path.is_absolute():
|
|
51
|
+
return base_dir / path
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_documents(value: Any, base_dir: Path | None) -> dict[str, Any]:
|
|
56
|
+
documents = _as_dict(value, "documents")
|
|
57
|
+
return {
|
|
58
|
+
str(key): _resolve_document_value(document_value, base_dir)
|
|
59
|
+
for key, document_value in documents.items()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_config(
|
|
64
|
+
path: str | Path | None = None,
|
|
65
|
+
workspace: str | Path | None = None,
|
|
66
|
+
) -> AssistantConfig:
|
|
67
|
+
"""Load a minimal YAML config and resolve paths without hidden defaults."""
|
|
68
|
+
|
|
69
|
+
raw: dict[str, Any] = {}
|
|
70
|
+
requested_config_path = Path(path).expanduser() if path is not None else None
|
|
71
|
+
config_path = (
|
|
72
|
+
resolve_runtime_paths(workspace=workspace, config=requested_config_path).config_file
|
|
73
|
+
if requested_config_path is not None
|
|
74
|
+
else None
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if config_path is not None:
|
|
78
|
+
if not config_path.exists():
|
|
79
|
+
raise FileNotFoundError(config_path)
|
|
80
|
+
parsed = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
81
|
+
if parsed is None:
|
|
82
|
+
parsed = {}
|
|
83
|
+
if not isinstance(parsed, dict):
|
|
84
|
+
raise ValueError("config root must be a mapping")
|
|
85
|
+
raw = dict(parsed)
|
|
86
|
+
|
|
87
|
+
profile = _as_dict(raw.get("profile"), "profile")
|
|
88
|
+
defaults = _as_dict(raw.get("defaults"), "defaults")
|
|
89
|
+
path_values = _as_dict(raw.get("paths"), "paths")
|
|
90
|
+
|
|
91
|
+
runtime = resolve_runtime_paths(
|
|
92
|
+
workspace=workspace,
|
|
93
|
+
config=config_path,
|
|
94
|
+
qa_bank=path_values.get("qa_bank"),
|
|
95
|
+
browser_profile=path_values.get("browser_profile"),
|
|
96
|
+
output_dir=path_values.get("output_dir"),
|
|
97
|
+
)
|
|
98
|
+
document_base = runtime.workspace
|
|
99
|
+
if document_base is None and config_path is not None:
|
|
100
|
+
document_base = config_path.parent
|
|
101
|
+
documents = _resolve_documents(raw.get("documents"), document_base)
|
|
102
|
+
|
|
103
|
+
return AssistantConfig(
|
|
104
|
+
profile=profile,
|
|
105
|
+
defaults=defaults,
|
|
106
|
+
documents=documents,
|
|
107
|
+
runtime=runtime,
|
|
108
|
+
raw=raw,
|
|
109
|
+
)
|