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,57 @@
1
+ # Troubleshooting
2
+
3
+ Start with the command help:
4
+
5
+ ```powershell
6
+ linkedin-apply-assistant --help
7
+ linkedin-apply-assistant config check
8
+ linkedin-apply-assistant config check --help
9
+ linkedin-apply-assistant search --help
10
+ linkedin-apply-assistant assist --help
11
+ linkedin-apply-assistant apply --help
12
+ linkedin-apply-assistant dry-run --help
13
+ linkedin-apply-assistant report --help
14
+ ```
15
+
16
+ For normal command examples, resolved output paths, reports, browser profile location, and first-run setup, read the [command reference](commands.md).
17
+
18
+ ## Command Not Found
19
+
20
+ Install the package in editable mode:
21
+
22
+ ```powershell
23
+ python -m pip install -e ".[dev]"
24
+ ```
25
+
26
+ Or use the module fallback:
27
+
28
+ ```powershell
29
+ $env:PYTHONPATH=(Resolve-Path 'src').Path
30
+ python -m linkedin_apply_assistant.cli --help
31
+ ```
32
+
33
+ ## Browser Does Not Open
34
+
35
+ - Confirm Playwright is installed with the package dependencies.
36
+ - Install Chromium with `python -m playwright install chromium`.
37
+ - Run `linkedin-apply-assistant config check` to inspect the resolved browser profile path.
38
+ - Use `--browser-profile` with a local ignored directory.
39
+ - Avoid copying browser profile directories between machines or into public artifacts.
40
+
41
+ ## Unknown Required Questions
42
+
43
+ Update your local Q&A bank from [../configs/qa_bank.example.yml](../configs/qa_bank.example.yml). Unknown required questions should pause the workflow until you can provide a truthful answer.
44
+
45
+ ## Selector Drift
46
+
47
+ LinkedIn and ATS pages can change. If a visible-browser workflow stops detecting fields, reduce scope to `dry-run` or `report`, capture a sanitized description, and add or update tests before changing selectors.
48
+
49
+ ## Quality Gate Failure
50
+
51
+ Run the package quality gate from the package root:
52
+
53
+ ```powershell
54
+ python scripts\quality.py
55
+ ```
56
+
57
+ The gate runs compile checks, package pytest, Ruff check, Ruff format check, dependency audit, docs smoke tests, privacy scans, and release-readiness checks.
@@ -0,0 +1,25 @@
1
+ {
2
+ "jobs": [
3
+ {
4
+ "title": "Backend Platform Engineer",
5
+ "company": "Example Systems",
6
+ "url": "https://example.com/jobs/backend-platform-engineer",
7
+ "location": "Remote",
8
+ "description": "Build and maintain service APIs, data workflows, and reliability tooling for a small product team."
9
+ },
10
+ {
11
+ "title": "Frontend Application Engineer",
12
+ "company": "Sample Interface Labs",
13
+ "url": "https://example.com/jobs/frontend-application-engineer",
14
+ "location": "Example City",
15
+ "description": "Develop accessible application screens, reusable components, and browser-based workflow improvements."
16
+ },
17
+ {
18
+ "title": "Automation QA Engineer",
19
+ "company": "Demo Quality Works",
20
+ "url": "https://example.com/jobs/automation-qa-engineer",
21
+ "location": "Hybrid",
22
+ "description": "Create deterministic test fixtures, validate user-facing flows, and improve local developer feedback loops."
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "schema_version": "example-1",
3
+ "command": "apply",
4
+ "generated_at": "2026-06-08T00:00:00Z",
5
+ "status": "prepared",
6
+ "summary": {
7
+ "jobs_loaded": 1,
8
+ "jobs_prepared": 1,
9
+ "applications_submitted": 0,
10
+ "unknown_required_questions": 1
11
+ },
12
+ "jobs": [
13
+ {
14
+ "company": "Example Robotics",
15
+ "role": "Applied AI Engineer",
16
+ "decision": "needs-review",
17
+ "submit_state": "disabled",
18
+ "blockers": [
19
+ "One required question needs a truthful user answer before completion."
20
+ ]
21
+ }
22
+ ],
23
+ "privacy": {
24
+ "synthetic": true,
25
+ "contains_browser_state": false,
26
+ "contains_private_documents": false,
27
+ "contains_full_private_urls": false,
28
+ "contains_screenshots": false
29
+ }
30
+ }
31
+
@@ -0,0 +1,40 @@
1
+ {
2
+ "schema_version": "example-1",
3
+ "command": "search",
4
+ "generated_at": "2026-06-08T00:00:00Z",
5
+ "status": "completed",
6
+ "query": {
7
+ "text": "applied ai engineer",
8
+ "location": "Remote",
9
+ "limit": 2
10
+ },
11
+ "summary": {
12
+ "jobs_seen": 2,
13
+ "jobs_recorded": 2,
14
+ "applications_submitted": 0,
15
+ "blocked": 0
16
+ },
17
+ "jobs": [
18
+ {
19
+ "company": "Example Robotics",
20
+ "role": "Applied AI Engineer",
21
+ "source": "linkedin-search",
22
+ "status": "recorded",
23
+ "public_domain": "example.test"
24
+ },
25
+ {
26
+ "company": "Sample Automation Lab",
27
+ "role": "Workflow Automation Engineer",
28
+ "source": "linkedin-search",
29
+ "status": "recorded",
30
+ "public_domain": "example.test"
31
+ }
32
+ ],
33
+ "privacy": {
34
+ "synthetic": true,
35
+ "contains_browser_state": false,
36
+ "contains_private_documents": false,
37
+ "contains_full_private_urls": false
38
+ }
39
+ }
40
+
package/install.ps1 ADDED
@@ -0,0 +1,178 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [string]$InstallDir = (Join-Path $env:LOCALAPPDATA "linkedin-apply-assistant"),
4
+ [string]$Ref = "main",
5
+ [switch]$InstallBrowser,
6
+ [switch]$NoPath
7
+ )
8
+
9
+ Set-StrictMode -Version 3.0
10
+ $ErrorActionPreference = "Stop"
11
+
12
+ $RepoOwner = "MohammedGhazal09"
13
+ $RepoName = "linkedin-apply-assistant"
14
+
15
+ function Write-Step {
16
+ param([string]$Message)
17
+ Write-Host "[linkedin-apply-assistant] $Message"
18
+ }
19
+
20
+ function Get-ArchiveUrl {
21
+ param([string]$SourceRef)
22
+
23
+ if ($SourceRef -match "^refs/heads/") {
24
+ return "https://github.com/$RepoOwner/$RepoName/archive/$SourceRef.zip"
25
+ }
26
+ if ($SourceRef -match "^refs/tags/") {
27
+ return "https://github.com/$RepoOwner/$RepoName/archive/$SourceRef.zip"
28
+ }
29
+ if ($SourceRef -match "^v?\d+\.\d+\.\d+") {
30
+ return "https://github.com/$RepoOwner/$RepoName/archive/refs/tags/$SourceRef.zip"
31
+ }
32
+ return "https://github.com/$RepoOwner/$RepoName/archive/refs/heads/$SourceRef.zip"
33
+ }
34
+
35
+ function Get-Python {
36
+ $candidates = @(
37
+ @{ Command = "py"; Args = @("-3.11") },
38
+ @{ Command = "py"; Args = @("-3") },
39
+ @{ Command = "python"; Args = @() },
40
+ @{ Command = "python3"; Args = @() }
41
+ )
42
+
43
+ foreach ($candidate in $candidates) {
44
+ $command = $candidate.Command
45
+ $prefixArgs = [string[]]$candidate.Args
46
+ try {
47
+ & $command @prefixArgs -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" *> $null
48
+ if ($LASTEXITCODE -eq 0) {
49
+ return $candidate
50
+ }
51
+ }
52
+ catch {
53
+ continue
54
+ }
55
+ }
56
+
57
+ throw "Python 3.11 or newer was not found. Install Python 3.11+ and rerun this script."
58
+ }
59
+
60
+ function Invoke-Python {
61
+ param(
62
+ [hashtable]$Python,
63
+ [string[]]$Arguments
64
+ )
65
+
66
+ & $Python.Command @([string[]]$Python.Args + $Arguments)
67
+ if ($LASTEXITCODE -ne 0) {
68
+ throw "Python command failed: $($Python.Command) $($Arguments -join ' ')"
69
+ }
70
+ }
71
+
72
+ function Add-UserPathEntry {
73
+ param([string]$PathEntry)
74
+
75
+ $current = [Environment]::GetEnvironmentVariable("Path", "User")
76
+ $parts = @()
77
+ if ($current) {
78
+ $parts = $current -split [IO.Path]::PathSeparator
79
+ }
80
+ if ($parts -contains $PathEntry) {
81
+ return
82
+ }
83
+
84
+ $next = if ($current) {
85
+ "$PathEntry$([IO.Path]::PathSeparator)$current"
86
+ }
87
+ else {
88
+ $PathEntry
89
+ }
90
+ [Environment]::SetEnvironmentVariable("Path", $next, "User")
91
+
92
+ if (($env:Path -split [IO.Path]::PathSeparator) -notcontains $PathEntry) {
93
+ $env:Path = "$PathEntry$([IO.Path]::PathSeparator)$env:Path"
94
+ }
95
+ }
96
+
97
+ $python = Get-Python
98
+ $archiveUrl = Get-ArchiveUrl -SourceRef $Ref
99
+ $installRoot = [IO.Path]::GetFullPath($InstallDir)
100
+ $sourceDir = Join-Path $installRoot "source"
101
+ $venvDir = Join-Path $installRoot ".venv"
102
+ $binDir = Join-Path $installRoot "bin"
103
+ $tempDir = Join-Path ([IO.Path]::GetTempPath()) ("linkedin-apply-assistant-" + [guid]::NewGuid())
104
+ $zipPath = Join-Path $tempDir "source.zip"
105
+
106
+ try {
107
+ Write-Step "Installing from $archiveUrl"
108
+ New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
109
+ New-Item -ItemType Directory -Force -Path $installRoot | Out-Null
110
+
111
+ Invoke-WebRequest -UseBasicParsing -Uri $archiveUrl -OutFile $zipPath
112
+ Expand-Archive -LiteralPath $zipPath -DestinationPath $tempDir -Force
113
+
114
+ $expanded = Get-ChildItem -LiteralPath $tempDir -Directory |
115
+ Where-Object { $_.Name -like "$RepoName-*" } |
116
+ Select-Object -First 1
117
+ if ($null -eq $expanded) {
118
+ throw "Could not find extracted package directory in $tempDir"
119
+ }
120
+
121
+ if (Test-Path -LiteralPath $sourceDir) {
122
+ Remove-Item -LiteralPath $sourceDir -Recurse -Force
123
+ }
124
+ Move-Item -LiteralPath $expanded.FullName -Destination $sourceDir
125
+
126
+ Write-Step "Creating virtual environment"
127
+ Invoke-Python -Python $python -Arguments @("-m", "venv", $venvDir)
128
+
129
+ $venvPython = Join-Path $venvDir "Scripts\python.exe"
130
+ if (-not (Test-Path -LiteralPath $venvPython)) {
131
+ throw "Virtual environment Python was not created at $venvPython"
132
+ }
133
+
134
+ Write-Step "Installing Python package"
135
+ & $venvPython -m pip install --upgrade pip
136
+ if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed" }
137
+ & $venvPython -m pip install $sourceDir
138
+ if ($LASTEXITCODE -ne 0) { throw "Package install failed" }
139
+
140
+ if ($InstallBrowser) {
141
+ Write-Step "Installing Playwright Chromium"
142
+ & $venvPython -m playwright install chromium
143
+ if ($LASTEXITCODE -ne 0) { throw "Playwright Chromium install failed" }
144
+ }
145
+
146
+ New-Item -ItemType Directory -Force -Path $binDir | Out-Null
147
+ $psShim = Join-Path $binDir "linkedin-apply-assistant.ps1"
148
+ $cmdShim = Join-Path $binDir "linkedin-apply-assistant.cmd"
149
+
150
+ @"
151
+ param([Parameter(ValueFromRemainingArguments = `$true)][string[]]`$RemainingArgs)
152
+ & "$venvPython" -m linkedin_apply_assistant.cli @RemainingArgs
153
+ exit `$LASTEXITCODE
154
+ "@ | Set-Content -LiteralPath $psShim -Encoding UTF8
155
+
156
+ "@echo off`r`n`"$venvPython`" -m linkedin_apply_assistant.cli %*`r`n" |
157
+ Set-Content -LiteralPath $cmdShim -Encoding ASCII
158
+
159
+ if (-not $NoPath) {
160
+ Add-UserPathEntry -PathEntry $binDir
161
+ }
162
+
163
+ Write-Step "Installed to $installRoot"
164
+ Write-Host ""
165
+ Write-Host "Try it now:"
166
+ Write-Host " linkedin-apply-assistant --help"
167
+ Write-Host " linkedin-apply-assistant config check"
168
+ if (-not $InstallBrowser) {
169
+ Write-Host ""
170
+ Write-Host "For visible-browser workflows, install Chromium later with:"
171
+ Write-Host " & `"$venvPython`" -m playwright install chromium"
172
+ }
173
+ }
174
+ finally {
175
+ if (Test-Path -LiteralPath $tempDir) {
176
+ Remove-Item -LiteralPath $tempDir -Recurse -Force
177
+ }
178
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "linkedin-apply-assistant",
3
+ "version": "0.1.1",
4
+ "description": "Thin npm launcher for the Python LinkedIn apply assistant package",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/MohammedGhazal09/linkedin-apply-assistant.git"
9
+ },
10
+ "homepage": "https://github.com/MohammedGhazal09/linkedin-apply-assistant#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/MohammedGhazal09/linkedin-apply-assistant/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "linkedin-apply-assistant": "bin/linkedin-apply-assistant.mjs"
17
+ },
18
+ "files": [
19
+ "bin/linkedin-apply-assistant.mjs",
20
+ "pyproject.toml",
21
+ "src/",
22
+ "install.ps1",
23
+ "README.md",
24
+ "SAFETY.md",
25
+ "LEGAL.md",
26
+ "LICENSE",
27
+ "THIRD_PARTY_NOTICES.md",
28
+ "CHANGELOG.md",
29
+ "MIGRATION.md",
30
+ "CONTRIBUTING.md",
31
+ "SECURITY.md",
32
+ "SUPPORT.md",
33
+ "GOVERNANCE.md",
34
+ "CODE_OF_CONDUCT.md",
35
+ ".github/ISSUE_TEMPLATE/bug_report.yml",
36
+ ".github/ISSUE_TEMPLATE/feature_request.yml",
37
+ ".github/ISSUE_TEMPLATE/docs.yml",
38
+ ".github/ISSUE_TEMPLATE/safety_compliance.yml",
39
+ ".github/ISSUE_TEMPLATE/config_help.yml",
40
+ ".github/ISSUE_TEMPLATE/config.yml",
41
+ ".github/PULL_REQUEST_TEMPLATE.md",
42
+ "RELEASE_CHECKLIST.md",
43
+ "docs/install-and-configuration.md",
44
+ "docs/registry-publication-strategy.md",
45
+ "docs/commands.md",
46
+ "docs/browser-session.md",
47
+ "docs/search.md",
48
+ "docs/assist.md",
49
+ "docs/apply.md",
50
+ "docs/ci-and-release-policy.md",
51
+ "docs/reports.md",
52
+ "docs/troubleshooting.md",
53
+ "configs/config.example.yml",
54
+ "configs/qa_bank.example.yml",
55
+ "examples/dry_run_input.example.json",
56
+ "examples/reports/search-report.example.json",
57
+ "examples/reports/apply-audit.example.json"
58
+ ]
59
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "linkedin-apply-assistant"
7
+ version = "0.1.1"
8
+ description = "Local LinkedIn application assistant with user-visible browser workflows"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "playwright>=1.40",
13
+ "scrapling>=0.2.0",
14
+ "PyYAML>=6.0",
15
+ "platformdirs>=4.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ test = ["pytest>=9.0.3"]
20
+ dev = [
21
+ "build>=1.0",
22
+ "pip-audit>=2.7",
23
+ "pytest>=9.0.3",
24
+ "ruff>=0.6",
25
+ ]
26
+
27
+ [project.scripts]
28
+ linkedin-apply-assistant = "linkedin_apply_assistant.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/MohammedGhazal09/linkedin-apply-assistant#readme"
32
+ Repository = "https://github.com/MohammedGhazal09/linkedin-apply-assistant"
33
+ Issues = "https://github.com/MohammedGhazal09/linkedin-apply-assistant/issues"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ addopts = "-p no:cacheprovider"
40
+ markers = [
41
+ "live: opt-in tests that touch live services, real browser profiles, or network targets",
42
+ ]
43
+
44
+ [tool.ruff]
45
+ target-version = "py311"
46
+ line-length = 100
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ "tests/test_cli_contract.py" = ["E402"]
50
+ "tests/test_runtime_boundaries.py" = ["E402"]
51
+ "tests/test_runtime_decoupling.py" = ["E402"]
@@ -0,0 +1,8 @@
1
+ """Package identity for the standalone LinkedIn application assistant."""
2
+
3
+ __version__ = "0.1.1"
4
+
5
+ APP_DISPLAY_NAME = "LinkedIn-apply-assistant"
6
+ APP_PACKAGE_NAME = "linkedin-apply-assistant"
7
+ APP_IMPORT_NAME = "linkedin_apply_assistant"
8
+ APP_COMMAND_NAME = "linkedin-apply-assistant"
@@ -0,0 +1,229 @@
1
+ """Package-local report writers for standalone runtime paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+ from uuid import uuid4
11
+
12
+ from .contracts import ReportArtifact
13
+ from .paths import RuntimePaths
14
+ from .redaction import sanitize_markdown_value, sanitize_report_payload
15
+
16
+
17
+ def _timestamp() -> str:
18
+ return datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
19
+
20
+
21
+ def _safe_filename_prefix(filename_prefix: str) -> str:
22
+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(filename_prefix or "")).strip(".-")
23
+ return safe or "report"
24
+
25
+
26
+ def _report_filename(filename_prefix: str, extension: str, *, timestamp: str | None = None) -> str:
27
+ report_timestamp = timestamp or _timestamp()
28
+ return (
29
+ f"{_safe_filename_prefix(filename_prefix)}_{report_timestamp}_{uuid4().hex[:8]}.{extension}"
30
+ )
31
+
32
+
33
+ def _esc(value: Any) -> str:
34
+ return sanitize_markdown_value(value)
35
+
36
+
37
+ def _event_context_label(event: dict[str, Any]) -> str:
38
+ job = event.get("job")
39
+ if not isinstance(job, dict):
40
+ job = {}
41
+ company = event.get("company") or job.get("company") or ""
42
+ role = event.get("role") or event.get("title") or job.get("role") or job.get("title") or ""
43
+ return f"{_esc(company)} {_esc(role)}".strip()
44
+
45
+
46
+ def resolve_reports_dir(
47
+ paths: RuntimePaths | None = None,
48
+ reports_dir: str | Path | None = None,
49
+ ) -> Path:
50
+ """Resolve the report directory from explicit input or runtime paths."""
51
+
52
+ if reports_dir is not None:
53
+ return Path(reports_dir).expanduser()
54
+ if paths is not None:
55
+ return paths.reports_dir
56
+ raise ValueError("reports_dir or paths is required")
57
+
58
+
59
+ def write_json_report(
60
+ report: dict[str, Any],
61
+ *,
62
+ paths: RuntimePaths | None = None,
63
+ reports_dir: str | Path | None = None,
64
+ filename_prefix: str = "report",
65
+ ) -> Path:
66
+ """Write a JSON report under an explicit standalone report directory."""
67
+
68
+ target_dir = resolve_reports_dir(paths=paths, reports_dir=reports_dir)
69
+ target_dir.mkdir(parents=True, exist_ok=True)
70
+ target = target_dir / _report_filename(filename_prefix, "json")
71
+ sanitized = sanitize_report_payload(report)
72
+ target.write_text(
73
+ json.dumps(sanitized, indent=2, ensure_ascii=False, default=str),
74
+ encoding="utf-8",
75
+ )
76
+ return target
77
+
78
+
79
+ def write_markdown_report(
80
+ report: dict[str, Any],
81
+ *,
82
+ paths: RuntimePaths | None = None,
83
+ reports_dir: str | Path | None = None,
84
+ filename_prefix: str = "report",
85
+ ) -> Path:
86
+ """Write a compact Markdown report under an explicit standalone directory."""
87
+
88
+ target_dir = resolve_reports_dir(paths=paths, reports_dir=reports_dir)
89
+ target_dir.mkdir(parents=True, exist_ok=True)
90
+ report_timestamp = _timestamp()
91
+ target = target_dir / _report_filename(filename_prefix, "md", timestamp=report_timestamp)
92
+ sanitized = sanitize_report_payload(report)
93
+ summary = sanitized.get("summary", {}) if isinstance(sanitized, dict) else {}
94
+ events = sanitized.get("events", []) if isinstance(sanitized, dict) else []
95
+ lines = [
96
+ f"# LinkedIn-apply-assistant Report - {report_timestamp}",
97
+ "",
98
+ ]
99
+ if isinstance(summary, dict):
100
+ lines.append("## Summary")
101
+ lines.append("")
102
+ for key in sorted(summary):
103
+ lines.append(f"- **{_esc(key)}:** {_esc(summary[key])}")
104
+ lines.append("")
105
+ if isinstance(events, list):
106
+ lines.append("## Events")
107
+ lines.append("")
108
+ for event in events:
109
+ if isinstance(event, dict):
110
+ label = event.get("type", "event")
111
+ context_label = _event_context_label(event)
112
+ details = []
113
+ for key in (
114
+ "status",
115
+ "surface",
116
+ "ats",
117
+ "blocked_reason",
118
+ "filled_count",
119
+ "required_empty_count",
120
+ "unknown_count",
121
+ "domain",
122
+ ):
123
+ value = event.get(key)
124
+ if value not in (None, "", [], {}):
125
+ details.append(f"{_esc(key)}={_esc(value)}")
126
+ suffix = f" - {'; '.join(details)}" if details else ""
127
+ lines.append(f"- **{_esc(label)}** {context_label}{suffix}".rstrip())
128
+ lines.append("")
129
+ target.write_text("\n".join(lines), encoding="utf-8")
130
+ return target
131
+
132
+
133
+ def write_dry_run_report(jobs: list[dict[str, Any]], report_path: str | Path) -> Path:
134
+ """Write a dry-run JSON report to an explicit file path."""
135
+
136
+ target = Path(report_path).expanduser()
137
+ target.parent.mkdir(parents=True, exist_ok=True)
138
+ passed = [job for job in jobs if job.get("pass")]
139
+ payload = {
140
+ "timestamp": _timestamp(),
141
+ "total": len(jobs),
142
+ "passed": len(passed),
143
+ "results": jobs,
144
+ }
145
+ safe_payload = sanitize_report_payload(payload)
146
+ target.write_text(json.dumps(safe_payload, indent=2, ensure_ascii=False), encoding="utf-8")
147
+ return target
148
+
149
+
150
+ def write_assistive_session_report(
151
+ report: dict[str, Any],
152
+ profile: dict[str, Any] | None = None,
153
+ *,
154
+ paths: RuntimePaths | None = None,
155
+ reports_dir: str | Path | None = None,
156
+ ) -> tuple[Path, Path]:
157
+ """Write JSON and Markdown reports for a local assistive session."""
158
+
159
+ prefix = "assistive-session"
160
+ payload = dict(report)
161
+ _ = profile
162
+ json_path = write_json_report(
163
+ payload,
164
+ paths=paths,
165
+ reports_dir=reports_dir,
166
+ filename_prefix=prefix,
167
+ )
168
+ md_path = write_markdown_report(
169
+ payload,
170
+ paths=paths,
171
+ reports_dir=reports_dir,
172
+ filename_prefix=prefix,
173
+ )
174
+ return json_path, md_path
175
+
176
+
177
+ def write_search_report(
178
+ report: dict[str, Any],
179
+ *,
180
+ paths: RuntimePaths | None = None,
181
+ reports_dir: str | Path | None = None,
182
+ ) -> tuple[Path, Path]:
183
+ """Write JSON and Markdown reports for a search workflow."""
184
+
185
+ json_path = write_json_report(
186
+ report,
187
+ paths=paths,
188
+ reports_dir=reports_dir,
189
+ filename_prefix="search",
190
+ )
191
+ md_path = write_markdown_report(
192
+ report,
193
+ paths=paths,
194
+ reports_dir=reports_dir,
195
+ filename_prefix="search",
196
+ )
197
+ return json_path, md_path
198
+
199
+
200
+ class RuntimeReportSink:
201
+ """ReportSink implementation backed by explicit runtime paths."""
202
+
203
+ def __init__(
204
+ self,
205
+ *,
206
+ paths: RuntimePaths | None = None,
207
+ reports_dir: str | Path | None = None,
208
+ ) -> None:
209
+ self.paths = paths
210
+ self.reports_dir = reports_dir
211
+
212
+ def write(self, command: str, report: dict[str, Any]) -> list[ReportArtifact]:
213
+ prefix = "assistive-session" if command == "assist" else command
214
+ json_path = write_json_report(
215
+ report,
216
+ paths=self.paths,
217
+ reports_dir=self.reports_dir,
218
+ filename_prefix=prefix,
219
+ )
220
+ md_path = write_markdown_report(
221
+ report,
222
+ paths=self.paths,
223
+ reports_dir=self.reports_dir,
224
+ filename_prefix=prefix,
225
+ )
226
+ return [
227
+ ReportArtifact(kind="json", path=json_path),
228
+ ReportArtifact(kind="markdown", path=md_path),
229
+ ]