diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a3343ce --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cs] +indent_style = space +indent_size = 4 +csharp_new_line_before_open_brace = all +csharp_style_var_elsewhere = false:suggestion +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:suggestion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fab7228 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci +on: + push: + pull_request: +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: dotnet restore SentinelCareer.sln + - name: Build + run: dotnet build SentinelCareer.sln --no-restore -c Release + - name: Test + run: dotnet test SentinelCareer.sln --no-build -c Release + - name: Format check + run: dotnet format SentinelCareer.sln --verify-no-changes diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml new file mode 100644 index 0000000..73ba8c9 --- /dev/null +++ b/.github/workflows/package-windows.yml @@ -0,0 +1,20 @@ +name: package-windows +on: + workflow_dispatch: + push: + tags: + - 'v*' +jobs: + package: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - run: dotnet publish src/SentinelCareer.Web -c Release -r win-x64 --self-contained false -o artifacts/web + - run: dotnet publish src/SentinelCareer.Workers -c Release -r win-x64 --self-contained false -o artifacts/workers + - uses: actions/upload-artifact@v4 + with: + name: sentinelcareer-win + path: artifacts diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..1e49c5a --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,17 @@ +name: security +on: + push: + pull_request: +jobs: + dependency-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - run: dotnet list SentinelCareer.sln package --vulnerable --include-transitive + - uses: github/codeql-action/init@v3 + with: + languages: csharp + - uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..892a4d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +bin/ +obj/ +.vs/ +.idea/ +*.user +*.suo +*.db +*.sqlite +TestResults/ +artifacts/ +.env +.env.* +.python-version +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.venv/ +*.pyc +node_modules/ +playwright-report/ diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..7bfc575 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,20 @@ + + + true + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8434d27 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# SentinelCareer + +SentinelCareer is a Windows-local AI-assisted job intelligence platform for a single operator, focused on India-first senior opportunities in operations/governance/risk/transformation leadership. + +## Architecture +- `SentinelCareer.Web`: ASP.NET Core Razor dashboard. +- `SentinelCareer.Workers`: background ingestion/scoring workers (Windows service-capable). +- `SentinelCareer.Domain`: entities and deterministic scoring rules. +- `SentinelCareer.Application`: use-cases, normalization, scoring engine, dedupe, interview prep orchestration. +- `SentinelCareer.Infrastructure`: EF Core + Npgsql, adapters, repositories, notifications, source-run persistence, seeding. +- `SentinelCareer.PythonWorkers`: optional parsing/model helpers. + +## Windows-local productionization highlights +- One-command setup: `pwsh ./scripts/setup-local.ps1` +- One-command startup: `pwsh ./scripts/start-local.ps1` +- One-command workers: `pwsh ./scripts/run-workers.ps1` +- Real Windows toast attempt via PowerShell + Windows runtime API with fallback logging. +- Source-run start/end persistence for troubleshooting. +- Backup/restore scripts for local PostgreSQL (`backup-db.ps1`, `restore-db.ps1`). + +## Validation & calibration pack (current) +Seeded validation pack includes: +- 10 watchlist companies +- 5 executive search sources +- 3 public-sector/strategic portals +- 3 event/expo/industry sources +- 3 tender/EOI/procurement sources +- 2 selective job boards + +## Windows local setup +1. Install .NET SDK 8+ and PostgreSQL 15+. +2. Run `pwsh ./scripts/setup-local.ps1`. +3. Start app stack with `pwsh ./scripts/start-local.ps1`. +4. Validate host E2E + evidence capture with `pwsh ./scripts/windows-e2e-validate.ps1`. +5. Verify evidence completeness with `pwsh ./scripts/review-host-evidence.ps1`. + +## Backup and restore +- Backup: `pwsh ./scripts/backup-db.ps1` +- Restore: `pwsh ./scripts/restore-db.ps1 -Input .\backup\sentinelcareer-YYYYMMDD-HHMMSS.sql` + +## Testing +- Domain scoring threshold tests. +- Application scoring/normalization/dedupe tests. +- Infrastructure adapter tests. +- Worker notification semantics tests. +- PostgreSQL integration tests (when `SENTINELCAREER_TEST_PG` is set). +- Python parser tests. + +## Docs +- `docs/windows-setup.md` +- `docs/runbook.md` +- `docs/productionization-report.md` +- `docs/calibration-report.md` +- `docs/windows-host-validation.md` +- `docs/host-validation-report.md` +- `docs/remediation-report.md` diff --git a/SentinelCareer.sln b/SentinelCareer.sln new file mode 100644 index 0000000..b1beade --- /dev/null +++ b/SentinelCareer.sln @@ -0,0 +1,39 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Contracts", "src/SentinelCareer.Contracts/SentinelCareer.Contracts.csproj", "{10000000-0000-0000-0000-000000000001}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Domain", "src/SentinelCareer.Domain/SentinelCareer.Domain.csproj", "{10000000-0000-0000-0000-000000000002}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Application", "src/SentinelCareer.Application/SentinelCareer.Application.csproj", "{10000000-0000-0000-0000-000000000003}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Infrastructure", "src/SentinelCareer.Infrastructure/SentinelCareer.Infrastructure.csproj", "{10000000-0000-0000-0000-000000000004}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Web", "src/SentinelCareer.Web/SentinelCareer.Web.csproj", "{10000000-0000-0000-0000-000000000005}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Workers", "src/SentinelCareer.Workers/SentinelCareer.Workers.csproj", "{10000000-0000-0000-0000-000000000006}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Domain.Tests", "tests/SentinelCareer.Domain.Tests/SentinelCareer.Domain.Tests.csproj", "{10000000-0000-0000-0000-000000000007}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Application.Tests", "tests/SentinelCareer.Application.Tests/SentinelCareer.Application.Tests.csproj", "{10000000-0000-0000-0000-000000000008}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Infrastructure.Tests", "tests/SentinelCareer.Infrastructure.Tests/SentinelCareer.Infrastructure.Tests.csproj", "{10000000-0000-0000-0000-000000000009}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.IntegrationTests", "tests/SentinelCareer.IntegrationTests/SentinelCareer.IntegrationTests.csproj", "{10000000-0000-0000-0000-00000000000A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Web.SmokeTests", "tests/SentinelCareer.Web.SmokeTests/SentinelCareer.Web.SmokeTests.csproj", "{10000000-0000-0000-0000-00000000000B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SentinelCareer.Workers.Tests", "tests/SentinelCareer.Workers.Tests/SentinelCareer.Workers.Tests.csproj", "{10000000-0000-0000-0000-00000000000C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/docs/adr-001-deterministic-scoring.md b/docs/adr-001-deterministic-scoring.md new file mode 100644 index 0000000..8b7abc0 --- /dev/null +++ b/docs/adr-001-deterministic-scoring.md @@ -0,0 +1,3 @@ +# ADR-001 Deterministic-first Scoring +Decision: keep numeric scoring deterministic and auditable, with optional LLM rewrite limited to language cleanup. +Status: accepted. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..de36b37 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,3 @@ +# Architecture Summary +SentinelCareer uses a modular monorepo with .NET-first services and optional Python workers. +Data flow: Source registry -> Adapter ingestion -> Normalization -> Dedupe/merge -> Deterministic scoring -> alerts + dashboard + interview prep. diff --git a/docs/calibration-report.md b/docs/calibration-report.md new file mode 100644 index 0000000..35550e3 --- /dev/null +++ b/docs/calibration-report.md @@ -0,0 +1,28 @@ +# Calibration report + +## Issues found +- Mid-level roles with generic operations language could score too high. +- Compensation parsing had weak handling for ranges/crore/currency variants. +- Notification semantics needed explicit codification for new>=80 and +5 improvements. +- Triage flow needed operator decisioning and clearer score movement/history. +- Worker retries needed stronger timeout/jitter/per-item failure isolation. + +## Calibration changes applied +- Updated deterministic scoring rules to penalize mid-level cues and out-of-sector generic operations context. +- Strengthened exclusion penalties for engineering/analyst/sales cues. +- Improved compensation parsing/conversion logic for LPA/lakh/crore and USD/EUR/GBP. +- Added review workflow persistence (`Status`, `OperatorNotes`) with dashboard controls. +- Added score delta and score history display in triage. +- Added source pack seeding for validation coverage counts. +- Added tests for compensation edges, duplicate boundary behavior, score threshold transitions, parser edge cases, and notification semantics. + +## Known limitations +- Validation sources are configured as representative endpoints; production source-specific selectors still require site-by-site hardening. +- Windows desktop notifications are still a logger-backed stub. +- Full integration test against live PostgreSQL was not executable in this environment due missing `dotnet` runtime. + +## Recommended next phase +- Run full Windows local validation against real endpoints and tune source-specific parsers. +- Add PostgreSQL-backed integration suite for ingestion/reconciliation workflows. +- Replace notification stub with real Windows toast integration. +- Expand migration history to fully align all domain entities and future review fields. diff --git a/docs/host-validation-report.md b/docs/host-validation-report.md new file mode 100644 index 0000000..f668942 --- /dev/null +++ b/docs/host-validation-report.md @@ -0,0 +1,28 @@ +# Host validation report + +## Environment used +- Host: (fill Windows version/build) +- .NET SDK: (fill) +- PostgreSQL: (fill) +- Validation date/time: (fill) + +## Issues found +- (fill) + +## Fixes applied +- (fill) + +## Evidence captured +- restore/build logs +- migration output +- source-run summary +- API output +- web/worker logs +- dashboard screenshots +- notification evidence + +## Remaining limitations +- (fill) + +## Recommended next phase +- (fill) diff --git a/docs/productionization-report.md b/docs/productionization-report.md new file mode 100644 index 0000000..2dc2628 --- /dev/null +++ b/docs/productionization-report.md @@ -0,0 +1,28 @@ +# Productionization report + +## Issues found +- Notification implementation was a logging stub, not real Windows toast. +- Source-run lifecycle status was not persisted for troubleshooting. +- Source adapters had weak live-endpoint resilience and limited fallback parsing. +- Integration coverage for PostgreSQL persistence workflows was shallow. +- Windows-local operations lacked explicit one-command setup/start and backup/restore scripts. + +## Fixes applied +- Added Windows-host E2E validation script (`scripts/windows-e2e-validate.ps1`) and host validation documentation/report template. +- Implemented real Windows-local toast attempt using PowerShell + Windows runtime Toast API, with safe fallback logging. +- Added source-run persistence methods and wired worker to start/complete source run records. +- Improved adapter anti-brittleness patterns with HTTP fetch fallback, selector fallback extraction, and source-specific logs. +- Added PostgreSQL-backed integration tests for migration/table visibility and review workflow persistence (env-driven). +- Added one-command scripts: setup/start and backup/restore. +- Updated Windows setup and runbook docs with troubleshooting and recovery guidance. + +## Remaining limitations +- Full automated E2E execution could not be run in this environment because `dotnet` is unavailable; use the host validation script on Windows. +- Live source parsers are still generic templates; source-specific selectors need iterative tuning per endpoint. +- Toast behavior depends on Windows runtime availability and PowerShell execution policy. + +## Recommended next phase +- Run full Windows laptop validation pass and capture runtime logs/screenshots. +- Add source-specific parser contract tests with HTML fixtures per high-priority endpoint. +- Add scheduled backup rotation and restore verification job. +- Expand integration suite with source-run and score-history round-trip assertions using dedicated test DB. diff --git a/docs/remediation-report.md b/docs/remediation-report.md new file mode 100644 index 0000000..80ad0f4 --- /dev/null +++ b/docs/remediation-report.md @@ -0,0 +1,27 @@ +# Remediation report + +## Evidence reviewed +- No `artifacts/host-validation/` payload was present in this environment at review time. +- Existing host-validation scripts/docs were reviewed for failure ambiguity and evidence completeness gaps. + +## Issues found +- Missing host-validation evidence prevented concrete runtime issue attribution. +- `windows-e2e-validate.ps1` had ambiguous failure behavior (continued across failing steps) and no explicit status summary. +- Notification proof extraction was not explicit. +- Evidence completeness checks were manual and error-prone. + +## Fixes applied +- Hardened `windows-e2e-validate.ps1` with strict mode, step gating, explicit success/failure summary, and notification evidence extraction. +- Fixed notification evidence matching to use regex pattern detection for toast log lines. +- Added `review-host-evidence.ps1` to validate expected host evidence files and generate `evidence-review.txt`. +- Extended evidence review script with quick diagnostics for common web/worker/migration failure signatures. +- Updated host-validation docs to include required evidence set and review workflow. + +## Remaining limitations +- Actual Windows runtime issues cannot be conclusively diagnosed until host evidence is generated and reviewed. +- Live endpoint parser tuning remains constrained without concrete failing endpoint logs/HTML captures. + +## Next recommended phase +1. Execute `pwsh ./scripts/windows-e2e-validate.ps1` on the target Windows host. +2. Execute `pwsh ./scripts/review-host-evidence.ps1`. +3. Share `artifacts/host-validation/*` (including screenshots and notification logs) for issue-specific remediation. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..fe83d9b --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,33 @@ +# Operator runbook + +## Setup / startup +1. `pwsh ./scripts/setup-local.ps1` +2. `pwsh ./scripts/start-local.ps1` + +## Windows host E2E validation +- Run `pwsh ./scripts/windows-e2e-validate.ps1`. +- Run `pwsh ./scripts/review-host-evidence.ps1`. +- Review files in `artifacts/host-validation`. + +## Validate end-to-end local execution +- PostgreSQL running and reachable. +- Migration applied (`JobOpportunities` table exists). +- Seed loaded (validation pack sources/watchlist). +- Web UI reachable. +- Worker logs show scheduled ingestion/scoring cycles. +- Source run statuses persisted (`SourceRuns` table). +- Notification attempt logged/visible. +- Shutdown is graceful. + +## Operations +- Monitor logs for `SourceRunStart/End`, retry warnings, and source-level run status. +- Use Opportunities page to mark pursue/review/ignore and capture notes. +- Verify score history and score delta movement for key opportunities. +- Confirm alerts fire only for new >=80 and +5 score improvements. + +## Troubleshooting +- Source fetch failure: check `AdapterFetchFallback` entries and endpoint env vars (`SENTINELCAREER_COMPANY_SOURCE`, `SENTINELCAREER_EXEC_SOURCE`). +- Parser empty: check `AdapterParseEmpty` and inspect endpoint HTML drift. +- No notifications: verify Windows session notifications enabled and check worker logs for PowerShell toast errors. +- If a source repeatedly fails, disable it in `Sources` table and continue. +- If DB corruption suspected, restore from last backup using `restore-db.ps1`. diff --git a/docs/scoring-model.md b/docs/scoring-model.md new file mode 100644 index 0000000..06d282c --- /dev/null +++ b/docs/scoring-model.md @@ -0,0 +1,22 @@ +# Scoring model + +Required sub-scores: +- profile_fit +- compensation_fit +- geography_fit +- seniority_fit +- industry_fit +- source_credibility +- freshness +- opportunity_potential + +Bands: +- >=80 pursue immediately +- 65-79 review manually +- <65 track only + +Hardening notes: +- India-first and seniority alignment are positively weighted. +- Compensation uses baseline 35L and premium 50L thresholds. +- Explicit penalties are applied for excluded role families (engineering-heavy, SOC analyst, sales-only cues). +- Explanation text now includes deterministic reason statements for operator triage. diff --git a/docs/source-adapter-guide.md b/docs/source-adapter-guide.md new file mode 100644 index 0000000..9e13576 --- /dev/null +++ b/docs/source-adapter-guide.md @@ -0,0 +1,14 @@ +# Source adapter developer guide + +Implement `ISourceAdapter` and register in Infrastructure DI. + +Guidance: +- Keep parser versioned and source metadata maintained. +- Prefer resilient extraction patterns with fallback selectors. +- Use endpoint fetch + parse + fallback seed behavior to avoid total ingestion loss. +- Emit source-specific logs for fetch, parse success/fallback, and failure causes. +- Ensure cancellation support and bounded retries at worker layer. +- Normalize payload fields before persistence. +- Validate parser edge cases: missing location, duplicate cards, malformed compensation fields, and stale listings. + +- Add endpoint-specific HTML fixtures under tests and assert parser behavior to reduce selector brittleness. diff --git a/docs/windows-host-validation.md b/docs/windows-host-validation.md new file mode 100644 index 0000000..9c73bab --- /dev/null +++ b/docs/windows-host-validation.md @@ -0,0 +1,35 @@ +# Windows host validation guide + +Run end-to-end host validation and capture evidence: + +```powershell +pwsh ./scripts/windows-e2e-validate.ps1 +``` + +Then review evidence completeness + quick diagnostics: + +```powershell +pwsh ./scripts/review-host-evidence.ps1 +``` + +Evidence output directory: +- `artifacts/host-validation/validation.log` +- `artifacts/host-validation/summary.txt` +- `artifacts/host-validation/restore.log` +- `artifacts/host-validation/build.log` +- `artifacts/host-validation/migrate.log` +- `artifacts/host-validation/web.log` +- `artifacts/host-validation/worker.log` +- `artifacts/host-validation/api-opportunities.json` +- `artifacts/host-validation/source-run-summary.txt` +- `artifacts/host-validation/opportunity-count.txt` +- `artifacts/host-validation/score-history-count.txt` +- `artifacts/host-validation/notification-evidence.txt` +- `artifacts/host-validation/evidence-review.txt` (includes quick diagnostics) + +Optional screenshot capture after web starts: +- Capture `/` and `/Opportunities/Index` manually using Windows Snipping Tool and store under `artifacts/host-validation/screenshots/`. + +Notification evidence: +- Verify toast appears on Windows desktop. +- If no toast appears, inspect `worker.log` and `notification-evidence.txt`. diff --git a/docs/windows-setup.md b/docs/windows-setup.md new file mode 100644 index 0000000..91a7724 --- /dev/null +++ b/docs/windows-setup.md @@ -0,0 +1,30 @@ +# Windows setup + +## Prerequisites +1. Install PostgreSQL (`psql`, `pg_dump`, `createdb` available in PATH). +2. Install .NET SDK 8.x. +3. Ensure PowerShell (`pwsh`) available. + +## One-command local setup +```powershell +pwsh ./scripts/setup-local.ps1 +``` + +## One-command startup +```powershell +pwsh ./scripts/start-local.ps1 +``` + +## Individual commands +- Migrations only: `pwsh ./scripts/apply-migrations.ps1` +- Workers only: `pwsh ./scripts/run-workers.ps1` +- Web only: `pwsh ./scripts/run-web.ps1` + +## Graceful shutdown +- Stop web with `Ctrl+C` in the web console. +- Stop workers by closing worker console (or `Ctrl+C` if foreground). +- Hosted services are cancellation-aware and stop safely. + +## Backup and restore +- Backup: `pwsh ./scripts/backup-db.ps1` +- Restore: `pwsh ./scripts/restore-db.ps1 -Input .\backup\sentinelcareer-YYYYMMDD-HHMMSS.sql` diff --git a/global.json b/global.json new file mode 100644 index 0000000..2a70d8a --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.303", + "rollForward": "latestMinor" + } +} diff --git a/scripts/apply-migrations.ps1 b/scripts/apply-migrations.ps1 new file mode 100644 index 0000000..596e614 --- /dev/null +++ b/scripts/apply-migrations.ps1 @@ -0,0 +1,7 @@ +param( + [string]$Database = "sentinelcareer", + [string]$SqlFile = "src/SentinelCareer.Infrastructure/Migrations/001_initial.sql" +) + +Write-Host "Applying baseline schema to $Database using $SqlFile" +psql -d $Database -f $SqlFile diff --git a/scripts/backup-db.ps1 b/scripts/backup-db.ps1 new file mode 100644 index 0000000..7356ee3 --- /dev/null +++ b/scripts/backup-db.ps1 @@ -0,0 +1,8 @@ +param( + [string]$Database = "sentinelcareer", + [string]$Output = "backup/sentinelcareer-$(Get-Date -Format yyyyMMdd-HHmmss).sql" +) + +New-Item -ItemType Directory -Force -Path (Split-Path $Output) | Out-Null +pg_dump -d $Database -f $Output +Write-Host "Backup complete: $Output" diff --git a/scripts/restore-db.ps1 b/scripts/restore-db.ps1 new file mode 100644 index 0000000..6a1a7eb --- /dev/null +++ b/scripts/restore-db.ps1 @@ -0,0 +1,7 @@ +param( + [string]$Database = "sentinelcareer", + [Parameter(Mandatory = $true)][string]$Input +) + +psql -d $Database -f $Input +Write-Host "Restore complete from: $Input" diff --git a/scripts/review-host-evidence.ps1 b/scripts/review-host-evidence.ps1 new file mode 100644 index 0000000..e650b72 --- /dev/null +++ b/scripts/review-host-evidence.ps1 @@ -0,0 +1,73 @@ +param( + [string]$EvidenceDir = "artifacts/host-validation" +) + +$ErrorActionPreference = "Stop" + +if (!(Test-Path $EvidenceDir)) { + Write-Host "Evidence directory not found: $EvidenceDir" + exit 2 +} + +$required = @( + "validation.log", + "summary.txt", + "restore.log", + "build.log", + "migrate.log", + "web.log", + "worker.log", + "api-opportunities.json", + "source-run-summary.txt", + "opportunity-count.txt", + "notification-evidence.txt" +) + +$missing = @() +foreach ($f in $required) { + if (!(Test-Path (Join-Path $EvidenceDir $f))) { $missing += $f } +} + +$report = Join-Path $EvidenceDir "evidence-review.txt" +"Evidence review at $(Get-Date -Format s)" | Out-File $report +if ($missing.Count -gt 0) { + "Missing files:" | Out-File $report -Append + $missing | ForEach-Object { " - $_" | Out-File $report -Append } +} +else { + "All required evidence files present." | Out-File $report -Append +} + +if (Test-Path (Join-Path $EvidenceDir "summary.txt")) { + "`nSummary:" | Out-File $report -Append + Get-Content (Join-Path $EvidenceDir "summary.txt") | Out-File $report -Append +} + +Write-Host "Evidence review written: $report" +if ($missing.Count -gt 0) { exit 1 } + + +# diagnostic extraction (best-effort) +$diag = @() +$webLog = Join-Path $EvidenceDir "web.log" +$workerLog = Join-Path $EvidenceDir "worker.log" +$migrateLog = Join-Path $EvidenceDir "migrate.log" + +if (Test-Path $webLog) { + if (Select-String -Path $webLog -Pattern "Address already in use|Failed to bind" -Quiet) { $diag += "Web port binding conflict detected." } + if (Select-String -Path $webLog -Pattern "Npgsql|connection|password authentication failed" -Quiet) { $diag += "Web DB connection/authentication issue detected." } +} +if (Test-Path $workerLog) { + if (Select-String -Path $workerLog -Pattern "SourceRunFailed" -Quiet) { $diag += "One or more source runs failed." } + if (Select-String -Path $workerLog -Pattern "DesktopNotification powershell failed|DesktopNotification exception" -Quiet) { $diag += "Notification dispatch failure detected." } +} +if (Test-Path $migrateLog) { + if (Select-String -Path $migrateLog -Pattern "ERROR:|error:" -Quiet) { $diag += "Migration errors detected." } +} + +"`nDiagnostics:" | Out-File $report -Append +if ($diag.Count -eq 0) { + " - No known error signatures detected by quick scan." | Out-File $report -Append +} else { + $diag | Sort-Object -Unique | ForEach-Object { " - $_" | Out-File $report -Append } +} diff --git a/scripts/run-all.ps1 b/scripts/run-all.ps1 new file mode 100644 index 0000000..b9ddc96 --- /dev/null +++ b/scripts/run-all.ps1 @@ -0,0 +1,3 @@ +Start-Process powershell -ArgumentList "-NoExit", "-Command", "dotnet run --project src/SentinelCareer.Workers" +Start-Sleep -Seconds 2 +dotnet run --project src/SentinelCareer.Web diff --git a/scripts/run-web.ps1 b/scripts/run-web.ps1 new file mode 100644 index 0000000..3e390a1 --- /dev/null +++ b/scripts/run-web.ps1 @@ -0,0 +1 @@ +dotnet run --project src/SentinelCareer.Web diff --git a/scripts/run-workers.ps1 b/scripts/run-workers.ps1 new file mode 100644 index 0000000..7851b41 --- /dev/null +++ b/scripts/run-workers.ps1 @@ -0,0 +1 @@ +dotnet run --project src/SentinelCareer.Workers diff --git a/scripts/setup-local.ps1 b/scripts/setup-local.ps1 new file mode 100644 index 0000000..a44cde7 --- /dev/null +++ b/scripts/setup-local.ps1 @@ -0,0 +1,12 @@ +param( + [string]$Database = "sentinelcareer" +) + +Write-Host "[1/3] Ensuring database exists: $Database" +createdb $Database 2>$null + +Write-Host "[2/3] Applying baseline migration" +pwsh ./scripts/apply-migrations.ps1 -Database $Database + +Write-Host "[3/3] Build verification" +dotnet build SentinelCareer.sln diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 new file mode 100644 index 0000000..6e77329 --- /dev/null +++ b/scripts/start-local.ps1 @@ -0,0 +1,4 @@ +Write-Host "Starting SentinelCareer workers and web..." +Start-Process powershell -ArgumentList "-NoExit", "-Command", "dotnet run --project src/SentinelCareer.Workers" +Start-Sleep -Seconds 3 +dotnet run --project src/SentinelCareer.Web diff --git a/scripts/windows-e2e-validate.ps1 b/scripts/windows-e2e-validate.ps1 new file mode 100644 index 0000000..2d7ceba --- /dev/null +++ b/scripts/windows-e2e-validate.ps1 @@ -0,0 +1,81 @@ +param( + [string]$Database = "sentinelcareer", + [string]$EvidenceDir = "artifacts/host-validation" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null + +$log = Join-Path $EvidenceDir "validation.log" +$summary = Join-Path $EvidenceDir "summary.txt" +if (Test-Path $log) { Remove-Item $log -Force } +if (Test-Path $summary) { Remove-Item $summary -Force } + +function Log([string]$msg) { $msg | Tee-Object -FilePath $log -Append } +function Run-Step([string]$name, [scriptblock]$action) { + Log "[STEP] $name" + try { + & $action + Log "[OK] $name" + } + catch { + Log "[FAIL] $name :: $($_.Exception.Message)" + throw + } +} + +$worker = $null +$web = $null + +try { + Run-Step "dotnet restore" { dotnet restore SentinelCareer.sln 2>&1 | Tee-Object -FilePath (Join-Path $EvidenceDir "restore.log") -Append } + Run-Step "dotnet build" { dotnet build SentinelCareer.sln -c Release 2>&1 | Tee-Object -FilePath (Join-Path $EvidenceDir "build.log") -Append } + Run-Step "ensure db" { createdb $Database 2>$null } + Run-Step "migrations" { pwsh ./scripts/apply-migrations.ps1 -Database $Database 2>&1 | Tee-Object -FilePath (Join-Path $EvidenceDir "migrate.log") -Append } + + Run-Step "start workers" { + $script:worker = Start-Process powershell -PassThru -WindowStyle Hidden -ArgumentList "-NoProfile", "-Command", "dotnet run --project src/SentinelCareer.Workers *> '$EvidenceDir\\worker.log'" + Start-Sleep -Seconds 6 + } + + Run-Step "start web" { + $script:web = Start-Process powershell -PassThru -WindowStyle Hidden -ArgumentList "-NoProfile", "-Command", "dotnet run --project src/SentinelCareer.Web *> '$EvidenceDir\\web.log'" + Start-Sleep -Seconds 10 + } + + Run-Step "call API" { + Invoke-WebRequest -Uri "http://localhost:5000/api/opportunities" -UseBasicParsing -TimeoutSec 25 | + Select-Object -ExpandProperty Content | + Out-File (Join-Path $EvidenceDir "api-opportunities.json") + } + + Run-Step "capture DB evidence" { + psql -d $Database -c "select \"Status\", count(*) from \"SourceRuns\" group by \"Status\";" | Out-File (Join-Path $EvidenceDir "source-run-summary.txt") + psql -d $Database -c "select count(*) as opportunities from \"JobOpportunities\";" | Out-File (Join-Path $EvidenceDir "opportunity-count.txt") + psql -d $Database -c "select count(*) as score_history from \"OpportunityScoreHistories\";" | Out-File (Join-Path $EvidenceDir "score-history-count.txt") + } + + Run-Step "capture notification evidence" { + if (Test-Path (Join-Path $EvidenceDir "worker.log")) { + Select-String -Path (Join-Path $EvidenceDir "worker.log") -Pattern "DesktopNotification|SentinelCareer Alert" | + Out-File (Join-Path $EvidenceDir "notification-evidence.txt") + } + if (!(Test-Path (Join-Path $EvidenceDir "notification-evidence.txt"))) { + "No notification lines found in worker log." | Out-File (Join-Path $EvidenceDir "notification-evidence.txt") + } + } + + "ValidationStatus=Success`nTimestamp=$(Get-Date -Format s)" | Out-File $summary +} +catch { + "ValidationStatus=Failure`nTimestamp=$(Get-Date -Format s)`nMessage=$($_.Exception.Message)" | Out-File $summary + throw +} +finally { + Log "[STEP] graceful shutdown" + if ($web -and -not $web.HasExited) { Stop-Process -Id $web.Id } + if ($worker -and -not $worker.HasExited) { Stop-Process -Id $worker.Id } + Log "Validation complete. Evidence stored in $EvidenceDir" +} diff --git a/src/SentinelCareer.Application/Abstractions/IRepositories.cs b/src/SentinelCareer.Application/Abstractions/IRepositories.cs new file mode 100644 index 0000000..d973980 --- /dev/null +++ b/src/SentinelCareer.Application/Abstractions/IRepositories.cs @@ -0,0 +1,28 @@ +using SentinelCareer.Domain.Entities; +using SentinelCareer.Domain.Enums; + +namespace SentinelCareer.Application.Abstractions; + +public interface IOpportunityRepository +{ + Task> ListAsync(CancellationToken cancellationToken); + Task GetAsync(Guid id, CancellationToken cancellationToken); + Task FindByExternalIdAsync(string externalId, CancellationToken cancellationToken); + Task FindPotentialDuplicateAsync(string normalizedTitle, string companyName, string city, DateTimeOffset publishedAt, CancellationToken cancellationToken); + Task UpsertAsync(JobOpportunity opportunity, CancellationToken cancellationToken); + Task UpdateReviewAsync(Guid opportunityId, OpportunityStatus status, string notes, CancellationToken cancellationToken); +} + +public interface ISourceRepository +{ + Task> EnabledAsync(CancellationToken cancellationToken); + Task StartRunAsync(string adapterFamily, CancellationToken cancellationToken); + Task CompleteRunAsync(Guid runId, string status, string message, CancellationToken cancellationToken); +} + +public interface IScoreRepository +{ + Task SaveScoreAsync(OpportunityScore score, CancellationToken cancellationToken); + Task LatestForOpportunityAsync(Guid opportunityId, CancellationToken cancellationToken); + Task> GetHistoryAsync(Guid opportunityId, CancellationToken cancellationToken); +} diff --git a/src/SentinelCareer.Application/DTOs/OpportunityDto.cs b/src/SentinelCareer.Application/DTOs/OpportunityDto.cs new file mode 100644 index 0000000..c5de7a0 --- /dev/null +++ b/src/SentinelCareer.Application/DTOs/OpportunityDto.cs @@ -0,0 +1,3 @@ +namespace SentinelCareer.Application.DTOs; + +public record OpportunityDto(Guid Id, string Title, string Company, string Location, int Score, string ScoreBand, string NextAction); diff --git a/src/SentinelCareer.Application/Options/ScoringOptions.cs b/src/SentinelCareer.Application/Options/ScoringOptions.cs new file mode 100644 index 0000000..38d8349 --- /dev/null +++ b/src/SentinelCareer.Application/Options/ScoringOptions.cs @@ -0,0 +1,11 @@ +using SentinelCareer.Contracts.Scoring; + +namespace SentinelCareer.Application.Options; + +public class ScoringOptions +{ + public const string Section = "Scoring"; + public int BaselineCompInrLpa { get; set; } = 35; + public int AspirationalCompInrLpa { get; set; } = 50; + public ScoringWeights Weights { get; set; } = new(0.2m, 0.15m, 0.15m, 0.15m, 0.1m, 0.1m, 0.05m, 0.1m); +} diff --git a/src/SentinelCareer.Application/SentinelCareer.Application.csproj b/src/SentinelCareer.Application/SentinelCareer.Application.csproj new file mode 100644 index 0000000..7755936 --- /dev/null +++ b/src/SentinelCareer.Application/SentinelCareer.Application.csproj @@ -0,0 +1,14 @@ + + + net8.0 + enable + enable + + + + + + + + + diff --git a/src/SentinelCareer.Application/Services/DedupeService.cs b/src/SentinelCareer.Application/Services/DedupeService.cs new file mode 100644 index 0000000..09d49df --- /dev/null +++ b/src/SentinelCareer.Application/Services/DedupeService.cs @@ -0,0 +1,10 @@ +namespace SentinelCareer.Application.Services; + +public class DedupeService +{ + public string BuildKey(string normalizedTitle, string normalizedCompany, string city) + => $"{normalizedTitle}|{normalizedCompany}|{city.Trim().ToLowerInvariant()}"; + + public bool IsLikelyDuplicate(DateTimeOffset firstPublished, DateTimeOffset candidatePublished) + => Math.Abs((firstPublished - candidatePublished).TotalDays) <= 30; +} diff --git a/src/SentinelCareer.Application/Services/InterviewPrepService.cs b/src/SentinelCareer.Application/Services/InterviewPrepService.cs new file mode 100644 index 0000000..d635ab4 --- /dev/null +++ b/src/SentinelCareer.Application/Services/InterviewPrepService.cs @@ -0,0 +1,32 @@ +using SentinelCareer.Contracts.LLM; +using SentinelCareer.Domain.Entities; + +namespace SentinelCareer.Application.Services; + +public class InterviewPrepService +{ + private readonly ILocalModelProvider _modelProvider; + + public InterviewPrepService(ILocalModelProvider modelProvider) => _modelProvider = modelProvider; + + public async Task GenerateAsync(JobOpportunity opportunity, CancellationToken cancellationToken) + { + var company = opportunity.Company?.Name ?? "target company"; + var deterministic = new InterviewPrepPack + { + OpportunityId = opportunity.Id, + CompanyBrief = $"{company} appears to be hiring senior leadership capacity tied to execution discipline, governance rigor, and transformation outcomes.", + RoleBrief = $"{opportunity.Title} is framed as a mandate role: align strategy-to-execution, reduce delivery risk, and establish measurable operating rhythm.", + FitAnalysis = "Profile fit is strongest where the mandate combines operations leadership, risk/compliance governance, and enterprise transformation execution.", + QuestionsAndAnswers = "Q1: How do you stabilize execution quickly? A1: Establish governance cadence, critical-metric baseline, and risk heatmap in first 30 days. Q2: How do you align leaders? A2: Clarify decision rights and enforce cross-functional operating reviews.", + Entry3090Plan = "30 days: diagnose mandate, stakeholders, and risk backlog. 60 days: launch PMO/governance controls and close top execution gaps. 90 days: institutionalize KPI-led rhythm and leadership accountability model.", + StakeholderMap = "Primary: CEO/BU head (mandate sponsor), CHRO (org alignment), CFO (cost/risk trade-offs). Secondary: plant/ops heads, compliance/legal, key external regulators or customers.", + KeyRisks = "Risk of mandate ambiguity, fragmented decision rights, underpowered transformation office, and compensation-to-scope mismatch.", + NegotiationNotes = "Negotiate for explicit success metrics, authority boundaries, team shape, reporting line, and fixed-pay alignment to 35L baseline / 50L+ premium expectations." + }; + + if (!_modelProvider.IsEnabled) return deterministic; + deterministic.RoleBrief = await _modelProvider.GenerateInterviewPackAsync(deterministic.RoleBrief, cancellationToken); + return deterministic; + } +} diff --git a/src/SentinelCareer.Application/Services/NormalizationService.cs b/src/SentinelCareer.Application/Services/NormalizationService.cs new file mode 100644 index 0000000..a13cc41 --- /dev/null +++ b/src/SentinelCareer.Application/Services/NormalizationService.cs @@ -0,0 +1,42 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SentinelCareer.Application.Services; + +public class NormalizationService +{ + public string NormalizeTitle(string title) + => title.Trim().ToLowerInvariant().Replace("director-level", "director").Replace("&", "and"); + + public string NormalizeCompany(string company) + => company.Trim().ToLowerInvariant().Replace("limited", "ltd").Replace("private", "pvt"); + + public string NormalizeLocation(string city, string country) + => $"{city.Trim()}, {country.Trim()}"; + + public bool IsExcludedRole(string title, string description) + { + string blob = $"{title} {description}".ToLowerInvariant(); + string[] excluded = ["software engineer", "developer", "soc analyst", "security analyst", "inside sales", "business development", "sales manager"]; + return excluded.Any(blob.Contains); + } + + public decimal? NormalizeCompensationToInr(string? compensationText, IReadOnlyDictionary fx) + { + if (string.IsNullOrWhiteSpace(compensationText)) return null; + + var lower = compensationText.ToLowerInvariant().Replace(",", "").Trim(); + var numbers = Regex.Matches(lower, "\\d+(?:\\.\\d+)?").Select(m => decimal.Parse(m.Value, CultureInfo.InvariantCulture)).ToList(); + if (numbers.Count == 0) return null; + + decimal amount = numbers.Max(); + if (lower.Contains("cr") || lower.Contains("crore")) amount *= 10000000; + else if (lower.Contains("lpa") || lower.Contains("lakh")) amount *= 100000; + + if ((lower.Contains("usd") || lower.Contains("$")) && fx.TryGetValue("USD", out var usdRate)) return amount * usdRate; + if ((lower.Contains("eur") || lower.Contains("€")) && fx.TryGetValue("EUR", out var eurRate)) return amount * eurRate; + if ((lower.Contains("gbp") || lower.Contains("£")) && fx.TryGetValue("GBP", out var gbpRate)) return amount * gbpRate; + + return amount; + } +} diff --git a/src/SentinelCareer.Application/Services/ScoringEngine.cs b/src/SentinelCareer.Application/Services/ScoringEngine.cs new file mode 100644 index 0000000..7f3bae1 --- /dev/null +++ b/src/SentinelCareer.Application/Services/ScoringEngine.cs @@ -0,0 +1,94 @@ +using SentinelCareer.Application.Options; +using SentinelCareer.Contracts.Scoring; +using SentinelCareer.Domain.Entities; + +namespace SentinelCareer.Application.Services; + +public class ScoringEngine +{ + private readonly ScoringOptions _options; + public ScoringEngine(ScoringOptions options) => _options = options; + + public ScoredOpportunity Score(JobOpportunity opportunity, decimal? maxCompInr, decimal sourceCredibility, int freshnessDays, int opportunityPotential) + { + List reasons = []; + string blob = $"{opportunity.Title} {opportunity.Description}"; + bool hasTargetIndustry = ContainsAny(blob, "defence", "aerospace", "strategic", "manufacturing", "industrial", "infrastructure", "logistics", "consulting", "audit", "governance", "compliance", "risk", "transformation"); + bool seniorRole = ContainsAny(opportunity.Title, "director", "vp", "avp", "head", "gm", "board", "advisor", "consultant"); + bool midLevelCue = ContainsAny(opportunity.Title, "manager", "lead", "specialist", "associate") && !seniorRole; + + int profileFit = ContainsAny(blob, "operations", "governance", "risk", "compliance", "transformation", "pmo", "execution") ? 90 : 55; + int compensationFit; + if (maxCompInr is null) + compensationFit = seniorRole ? 58 : 45; + else if (maxCompInr >= _options.AspirationalCompInrLpa * 100000) + compensationFit = 95; + else if (maxCompInr >= _options.BaselineCompInrLpa * 100000) + compensationFit = 75; + else + compensationFit = 42; + + int geographyFit = opportunity.IsIndiaPriority ? 95 : 50; + int seniorityFit = seniorRole ? 92 : 38; + int industryFit = hasTargetIndustry ? 88 : 42; + + if (midLevelCue) + { + seniorityFit = Math.Max(seniorityFit - 18, 0); + profileFit = Math.Max(profileFit - 12, 0); + reasons.Add("Mid-level cues detected; seniority fit reduced."); + } + + if (!hasTargetIndustry && ContainsAny(blob, "operations")) + { + industryFit = Math.Max(industryFit - 10, 0); + reasons.Add("Generic operations context outside target sectors; industry fit reduced."); + } + + if (ContainsAny(blob, "software engineer", "developer", "soc analyst", "security analyst", "business development", "sales manager")) + { + profileFit = Math.Max(profileFit - 35, 0); + seniorityFit = Math.Max(seniorityFit - 25, 0); + industryFit = Math.Max(industryFit - 20, 0); + reasons.Add("Exclusion keywords detected (engineering/analyst/sales-heavy), score penalized."); + } + + reasons.Add(profileFit >= 80 ? "Role strongly aligns to the target leadership profile." : "Role has partial alignment to the target profile."); + reasons.Add(maxCompInr is null + ? "Compensation unavailable; confidence reduced but not disqualifying." + : maxCompInr >= _options.AspirationalCompInrLpa * 100000 ? "Compensation is in aspirational band (50L+)." + : maxCompInr >= _options.BaselineCompInrLpa * 100000 ? "Compensation meets baseline threshold (35L+)." + : "Compensation appears below baseline expectation."); + reasons.Add(opportunity.IsIndiaPriority ? "India-first geography preference is satisfied." : "Non-India role retained but deprioritized."); + reasons.Add(seniorityFit >= 80 ? "Seniority appears in the target band." : "Seniority appears below preferred band."); + reasons.Add(industryFit >= 80 ? "Industry/sector context matches focus clusters." : "Industry relevance is moderate/low for target sectors."); + + int sourceScore = (int)Math.Clamp(sourceCredibility * 100, 0, 100); + int freshness = freshnessDays <= 2 ? 95 : freshnessDays <= 7 ? 75 : freshnessDays <= 14 ? 60 : 40; + int oppPotential = Math.Clamp(opportunityPotential, 0, 100); + + var subs = new Dictionary + { + ["profile_fit"] = profileFit, + ["compensation_fit"] = compensationFit, + ["geography_fit"] = geographyFit, + ["seniority_fit"] = seniorityFit, + ["industry_fit"] = industryFit, + ["source_credibility"] = sourceScore, + ["freshness"] = freshness, + ["opportunity_potential"] = oppPotential + }; + + var w = _options.Weights; + int composite = (int)Math.Round( + profileFit * w.ProfileFit + compensationFit * w.CompensationFit + geographyFit * w.GeographyFit + seniorityFit * w.SeniorityFit + + industryFit * w.IndustryFit + sourceScore * w.SourceCredibility + freshness * w.Freshness + oppPotential * w.OpportunityPotential); + + string action = composite >= 80 ? "Pursue immediately" : composite >= 65 ? "Review manually" : "Track only"; + string explanation = string.Join(" ", reasons) + $" Final composite: {composite}."; + return new ScoredOpportunity(composite, subs, explanation, action); + } + + private static bool ContainsAny(string value, params string[] tokens) + => tokens.Any(t => value.Contains(t, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/SentinelCareer.Contracts/Ingestion/IngestionContracts.cs b/src/SentinelCareer.Contracts/Ingestion/IngestionContracts.cs new file mode 100644 index 0000000..4d8c349 --- /dev/null +++ b/src/SentinelCareer.Contracts/Ingestion/IngestionContracts.cs @@ -0,0 +1,8 @@ +namespace SentinelCareer.Contracts.Ingestion; + +public record IngestedOpportunity(string ExternalId, string Title, string Description, string Company, string Location, string Country, string? Compensation, string Url, DateTimeOffset PostedAt); +public interface ISourceAdapter +{ + string Family { get; } + Task> FetchAsync(CancellationToken cancellationToken); +} diff --git a/src/SentinelCareer.Contracts/LLM/ILocalModelProvider.cs b/src/SentinelCareer.Contracts/LLM/ILocalModelProvider.cs new file mode 100644 index 0000000..93be7af --- /dev/null +++ b/src/SentinelCareer.Contracts/LLM/ILocalModelProvider.cs @@ -0,0 +1,8 @@ +namespace SentinelCareer.Contracts.LLM; + +public interface ILocalModelProvider +{ + bool IsEnabled { get; } + Task RewriteExplanationAsync(string deterministicExplanation, CancellationToken cancellationToken); + Task GenerateInterviewPackAsync(string prompt, CancellationToken cancellationToken); +} diff --git a/src/SentinelCareer.Contracts/Notifications/INotificationDispatcher.cs b/src/SentinelCareer.Contracts/Notifications/INotificationDispatcher.cs new file mode 100644 index 0000000..f258d4a --- /dev/null +++ b/src/SentinelCareer.Contracts/Notifications/INotificationDispatcher.cs @@ -0,0 +1,6 @@ +namespace SentinelCareer.Contracts.Notifications; + +public interface INotificationDispatcher +{ + Task NotifyDesktopAsync(string title, string body, CancellationToken cancellationToken); +} diff --git a/src/SentinelCareer.Contracts/Scoring/ScoringContracts.cs b/src/SentinelCareer.Contracts/Scoring/ScoringContracts.cs new file mode 100644 index 0000000..f570a9a --- /dev/null +++ b/src/SentinelCareer.Contracts/Scoring/ScoringContracts.cs @@ -0,0 +1,5 @@ +namespace SentinelCareer.Contracts.Scoring; + +public record ScoringWeights(decimal ProfileFit, decimal CompensationFit, decimal GeographyFit, decimal SeniorityFit, decimal IndustryFit, decimal SourceCredibility, decimal Freshness, decimal OpportunityPotential); +public record ScoreReasons(IReadOnlyList Reasons); +public record ScoredOpportunity(int CompositeScore, Dictionary SubScores, string Explanation, string RecommendedNextAction); diff --git a/src/SentinelCareer.Contracts/SentinelCareer.Contracts.csproj b/src/SentinelCareer.Contracts/SentinelCareer.Contracts.csproj new file mode 100644 index 0000000..e8cd599 --- /dev/null +++ b/src/SentinelCareer.Contracts/SentinelCareer.Contracts.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/src/SentinelCareer.Domain/Entities/Entities.cs b/src/SentinelCareer.Domain/Entities/Entities.cs new file mode 100644 index 0000000..d27ed7d --- /dev/null +++ b/src/SentinelCareer.Domain/Entities/Entities.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations.Schema; +using SentinelCareer.Domain.Enums; + +namespace SentinelCareer.Domain.Entities; + +public class JobOpportunity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string ExternalId { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string NormalizedTitle { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public Guid? CompanyId { get; set; } + public Company? Company { get; set; } + public Guid? LocationId { get; set; } + public Location? Location { get; set; } + public WorkMode WorkMode { get; set; } + public OpportunityStatus Status { get; set; } = OpportunityStatus.New; + public string OperatorNotes { get; set; } = string.Empty; + public DateTimeOffset PublishedAt { get; set; } + public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; + public bool IsIndiaPriority { get; set; } + [NotMapped] public OpportunityScore? LatestScore { get; set; } + [NotMapped] public int ScoreDelta { get; set; } +} + +public class Company +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public string NormalizedName { get; set; } = string.Empty; + public string? Website { get; set; } + public Guid? IndustryId { get; set; } + public Industry? Industry { get; set; } + public ICollection Opportunities { get; set; } = new List(); +} + +public class Industry { public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = string.Empty; public string Cluster { get; set; } = string.Empty; } +public class Location { public Guid Id { get; set; } = Guid.NewGuid(); public string City { get; set; } = string.Empty; public string Region { get; set; } = string.Empty; public string Country { get; set; } = "India"; } +public class CompensationSnapshot { public Guid Id { get; set; } = Guid.NewGuid(); public Guid JobOpportunityId { get; set; } public decimal? MinAmount { get; set; } public decimal? MaxAmount { get; set; } public string Currency { get; set; } = "INR"; public decimal? MinAmountInInr { get; set; } public decimal? MaxAmountInInr { get; set; } public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; } +public class Source { public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = string.Empty; public SourceType SourceType { get; set; } public CadenceTier CadenceTier { get; set; } public CrawlMethod CrawlMethod { get; set; } public string ParserVersion { get; set; } = "v1"; public string LegalNotes { get; set; } = string.Empty; public decimal ConfidenceWeight { get; set; } = 0.8m; public int DedupePriority { get; set; } = 100; public bool Enabled { get; set; } = true; public string Endpoint { get; set; } = string.Empty; } +public class SourceRun { public Guid Id { get; set; } = Guid.NewGuid(); public Guid SourceId { get; set; } public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? CompletedAt { get; set; } public string Status { get; set; } = "Running"; public string Message { get; set; } = string.Empty; } +public class OpportunitySourceLink { public Guid Id { get; set; } = Guid.NewGuid(); public Guid OpportunityId { get; set; } public Guid SourceId { get; set; } public string SourceUrl { get; set; } = string.Empty; public DateTimeOffset FirstSeenAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; } +public class OpportunityScore { public Guid Id { get; set; } = Guid.NewGuid(); public Guid OpportunityId { get; set; } public int CompositeScore { get; set; } public int ProfileFit { get; set; } public int CompensationFit { get; set; } public int GeographyFit { get; set; } public int SeniorityFit { get; set; } public int IndustryFit { get; set; } public int SourceCredibility { get; set; } public int Freshness { get; set; } public int OpportunityPotential { get; set; } public string Explanation { get; set; } = string.Empty; public string RecommendedNextAction { get; set; } = string.Empty; public DateTimeOffset ScoredAt { get; set; } = DateTimeOffset.UtcNow; } +public class OpportunityScoreHistory { public Guid Id { get; set; } = Guid.NewGuid(); public Guid OpportunityId { get; set; } public int CompositeScore { get; set; } public DateTimeOffset RecordedAt { get; set; } = DateTimeOffset.UtcNow; } +public class OpportunitySignal { public Guid Id { get; set; } = Guid.NewGuid(); public Guid? OpportunityId { get; set; } public string SignalType { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty; public int Strength { get; set; } } +public class EventSignal { public Guid Id { get; set; } = Guid.NewGuid(); public string EventName { get; set; } = string.Empty; public string Location { get; set; } = string.Empty; public DateTimeOffset Date { get; set; } } +public class TenderSignal { public Guid Id { get; set; } = Guid.NewGuid(); public string Authority { get; set; } = string.Empty; public string TenderTitle { get; set; } = string.Empty; public DateTimeOffset CloseDate { get; set; } } +public class NetworkingTarget { public Guid Id { get; set; } = Guid.NewGuid(); public Guid OpportunityId { get; set; } public string Name { get; set; } = string.Empty; public string Role { get; set; } = string.Empty; public string WhyRelevant { get; set; } = string.Empty; public string SourceNote { get; set; } = string.Empty; } +public class InterviewPrepPack { public Guid Id { get; set; } = Guid.NewGuid(); public Guid OpportunityId { get; set; } public string CompanyBrief { get; set; } = string.Empty; public string RoleBrief { get; set; } = string.Empty; public string FitAnalysis { get; set; } = string.Empty; public string QuestionsAndAnswers { get; set; } = string.Empty; public string Entry3090Plan { get; set; } = string.Empty; public string StakeholderMap { get; set; } = string.Empty; public string KeyRisks { get; set; } = string.Empty; public string NegotiationNotes { get; set; } = string.Empty; public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow; } +public class WatchlistCompany { public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = string.Empty; public string Reason { get; set; } = string.Empty; public string PriorityTier { get; set; } = "A"; } +public class JobFamily { public Guid Id { get; set; } = Guid.NewGuid(); public string Name { get; set; } = string.Empty; } +public class RoleNormalizationMap { public Guid Id { get; set; } = Guid.NewGuid(); public string RawTitle { get; set; } = string.Empty; public string NormalizedTitle { get; set; } = string.Empty; public string SeniorityBand { get; set; } = string.Empty; } +public class CurrencyRateSnapshot { public Guid Id { get; set; } = Guid.NewGuid(); public string CurrencyCode { get; set; } = "USD"; public decimal InrRate { get; set; } public DateTimeOffset CapturedAt { get; set; } = DateTimeOffset.UtcNow; } +public class CrawlPolicy { public Guid Id { get; set; } = Guid.NewGuid(); public Guid SourceId { get; set; } public bool AllowOverlap { get; set; } public int RetryCount { get; set; } = 2; public int BackoffSeconds { get; set; } = 20; } +public class JobRunNotification { public Guid Id { get; set; } = Guid.NewGuid(); public Guid OpportunityId { get; set; } public string NotificationType { get; set; } = string.Empty; public string Message { get; set; } = string.Empty; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } diff --git a/src/SentinelCareer.Domain/Enums/Enums.cs b/src/SentinelCareer.Domain/Enums/Enums.cs new file mode 100644 index 0000000..833447a --- /dev/null +++ b/src/SentinelCareer.Domain/Enums/Enums.cs @@ -0,0 +1,8 @@ +namespace SentinelCareer.Domain.Enums; + +public enum SourceType { CompanyCareers, ExecutiveSearch, JobBoard, GovernmentPortal, EventCalendar, NewsFeed, TenderPortal, ManualImport } +public enum CadenceTier { Tier1, Tier2, Daily, Nightly } +public enum CrawlMethod { Api, Scrape, Rss, Manual } +public enum WorkMode { Onsite, Hybrid, Remote, TravelHeavy } +public enum OpportunityStatus { New, Tracked, Shortlisted, Ignored, Archived } +public enum ScoreBand { TrackOnly, ManualReview, PursueImmediately } diff --git a/src/SentinelCareer.Domain/Rules/ScoreBandClassifier.cs b/src/SentinelCareer.Domain/Rules/ScoreBandClassifier.cs new file mode 100644 index 0000000..afe59f5 --- /dev/null +++ b/src/SentinelCareer.Domain/Rules/ScoreBandClassifier.cs @@ -0,0 +1,13 @@ +using SentinelCareer.Domain.Enums; + +namespace SentinelCareer.Domain.Rules; + +public static class ScoreBandClassifier +{ + public static ScoreBand Classify(int score) => score switch + { + >= 80 => ScoreBand.PursueImmediately, + >= 65 => ScoreBand.ManualReview, + _ => ScoreBand.TrackOnly + }; +} diff --git a/src/SentinelCareer.Domain/SentinelCareer.Domain.csproj b/src/SentinelCareer.Domain/SentinelCareer.Domain.csproj new file mode 100644 index 0000000..42e6bd5 --- /dev/null +++ b/src/SentinelCareer.Domain/SentinelCareer.Domain.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + + + + + diff --git a/src/SentinelCareer.Infrastructure/Adapters/GenericAdapters.cs b/src/SentinelCareer.Infrastructure/Adapters/GenericAdapters.cs new file mode 100644 index 0000000..61523f7 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Adapters/GenericAdapters.cs @@ -0,0 +1,93 @@ +using SentinelCareer.Contracts.Ingestion; + +namespace SentinelCareer.Infrastructure.Adapters; + +public abstract class AdapterBase(ILogger logger, IHttpClientFactory httpClientFactory) +{ + protected ILogger Logger { get; } = logger; + protected IHttpClientFactory HttpClientFactory { get; } = httpClientFactory; + + protected static IngestedOpportunity Build(string externalId, string title, string description, string company, string location, string country, string? compensation, string url, DateTimeOffset postedAt) + => new(externalId.Trim(), title.Trim(), description.Trim(), company.Trim(), location.Trim(), country.Trim(), compensation, url.Trim(), postedAt); + + protected async Task TryFetchHtmlAsync(string endpoint, CancellationToken cancellationToken) + { + try + { + var client = HttpClientFactory.CreateClient("sources"); + client.Timeout = TimeSpan.FromSeconds(15); + return await client.GetStringAsync(endpoint, cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "AdapterFetchFallback | Endpoint={Endpoint}", endpoint); + return null; + } + } +} + +public class GenericCompanyCareersAdapter(ILogger logger, IHttpClientFactory httpClientFactory) : AdapterBase(logger, httpClientFactory), ISourceAdapter +{ + public string Family => "company-careers"; + + public async Task> FetchAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var endpoint = Environment.GetEnvironmentVariable("SENTINELCAREER_COMPANY_SOURCE") ?? "https://example.com/jobs/1"; + var html = await TryFetchHtmlAsync(endpoint, cancellationToken); + if (!string.IsNullOrWhiteSpace(html)) + { + var parsed = SourceHtmlParser.ParseSimpleCards(html, "Strategic Manufacturing Ltd", endpoint, m => logger.LogInformation("{Message}", m)); + if (parsed.Count > 0) + { + logger.LogInformation("AdapterParsed | Family={Family} | Count={Count}", Family, parsed.Count); + return parsed; + } + logger.LogWarning("AdapterParseEmpty | Family={Family} | Endpoint={Endpoint}", Family, endpoint); + } + + logger.LogInformation("AdapterUsingFallbackSeed | Family={Family}", Family); + return [ + Build("cmp-001", "Director, Operations Governance", "Lead transformation and governance across India plants", "Strategic Manufacturing Ltd", "Pune", "India", "INR 55 LPA", endpoint, DateTimeOffset.UtcNow.AddHours(-4)) + ]; + } +} + +public class GenericExecutiveSearchAdapter(ILogger logger, IHttpClientFactory httpClientFactory) : AdapterBase(logger, httpClientFactory), ISourceAdapter +{ + public string Family => "executive-search"; + + public async Task> FetchAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var endpoint = Environment.GetEnvironmentVariable("SENTINELCAREER_EXEC_SOURCE") ?? "https://example.com/es/1"; + var html = await TryFetchHtmlAsync(endpoint, cancellationToken); + if (!string.IsNullOrWhiteSpace(html)) + { + var parsed = SourceHtmlParser.ParseSimpleCards(html, "Apex Search Partners", endpoint, m => logger.LogInformation("{Message}", m)); + if (parsed.Count > 0) + { + logger.LogInformation("AdapterParsed | Family={Family} | Count={Count}", Family, parsed.Count); + return parsed; + } + logger.LogWarning("AdapterParseEmpty | Family={Family} | Endpoint={Endpoint}", Family, endpoint); + } + + logger.LogInformation("AdapterUsingFallbackSeed | Family={Family}", Family); + return [ + Build("es-001", "VP - Risk & Compliance", "Executive mandate for enterprise risk", "Apex Search Partners", "Mumbai", "India", "INR 65 LPA", endpoint, DateTimeOffset.UtcNow.AddHours(-12)) + ]; + } +} + +public class GenericNewsEventTenderAdapter(ILogger logger, IHttpClientFactory httpClientFactory) : AdapterBase(logger, httpClientFactory), ISourceAdapter +{ + public string Family => "signal-feed"; + + public Task> FetchAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + logger.LogInformation("AdapterSignalFeedNoop | Family={Family}", Family); + return Task.FromResult>([]); + } +} diff --git a/src/SentinelCareer.Infrastructure/Adapters/SourceHtmlParser.cs b/src/SentinelCareer.Infrastructure/Adapters/SourceHtmlParser.cs new file mode 100644 index 0000000..df63f4f --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Adapters/SourceHtmlParser.cs @@ -0,0 +1,77 @@ +using System.Text.RegularExpressions; +using SentinelCareer.Contracts.Ingestion; + +namespace SentinelCareer.Infrastructure.Adapters; + +public static class SourceHtmlParser +{ + public static IReadOnlyList ParseSimpleCards(string html, string companyHint, string endpoint, Action? log = null) + { + List results = []; + if (string.IsNullOrWhiteSpace(html)) return results; + + var cards = Regex.Matches(html, "<(?div|article)[^>]*(class|data-role)=\"[^\"]*(job|career|posting)[^\"]*\"[^>]*>(?.*?)>", RegexOptions.IgnoreCase | RegexOptions.Singleline); + foreach (Match card in cards) + { + var chunk = card.Groups["body"].Value; + var title = Extract(chunk, "title") + ?? ExtractTag(chunk, "h1|h2|h3") + ?? ExtractFallback(chunk, "director|vp|avp|head|gm|consultant|advisor"); + if (string.IsNullOrWhiteSpace(title)) + { + log?.Invoke("ParserSkipCard | reason=no-title"); + continue; + } + + var location = Extract(chunk, "location") ?? ExtractLabel(chunk, "location") ?? "India"; + var compensation = Extract(chunk, "comp|salary|ctc") ?? ExtractLabel(chunk, "salary|ctc|comp") ?? "Unknown"; + var externalId = ExtractAttribute(card.Value, "data-id") ?? $"{companyHint}-{Math.Abs((title + location).GetHashCode())}"; + + results.Add(new IngestedOpportunity( + externalId.Trim(), + Cleanup(title), + Cleanup(chunk), + companyHint.Trim(), + Cleanup(location), + location.Contains("india", StringComparison.OrdinalIgnoreCase) ? "India" : "Unknown", + Cleanup(compensation), + endpoint.Trim(), + DateTimeOffset.UtcNow)); + } + + log?.Invoke($"ParserResult | cards={cards.Count} parsed={results.Count}"); + return results; + } + + private static string Cleanup(string raw) => Regex.Replace(raw, "\\s+", " ").Trim(); + + private static string? Extract(string html, string clsPattern) + { + var m = Regex.Match(html, $"class=\"[^\"]*({clsPattern})[^\"]*\"[^>]*>([^<]+)", RegexOptions.IgnoreCase); + return m.Success ? m.Groups[2].Value : null; + } + + private static string? ExtractTag(string html, string tags) + { + var m = Regex.Match(html, $"<({tags})[^>]*>([^<]+)", RegexOptions.IgnoreCase); + return m.Success ? m.Groups[2].Value : null; + } + + private static string? ExtractLabel(string html, string label) + { + var m = Regex.Match(html, $"({label})\\s*[:|-]\\s*([^<\\n]+)", RegexOptions.IgnoreCase); + return m.Success ? m.Groups[2].Value : null; + } + + private static string? ExtractFallback(string html, string pattern) + { + var m = Regex.Match(html, pattern, RegexOptions.IgnoreCase); + return m.Success ? m.Value : null; + } + + private static string? ExtractAttribute(string html, string attribute) + { + var m = Regex.Match(html, $"{attribute}=\"([^\"]+)\"", RegexOptions.IgnoreCase); + return m.Success ? m.Groups[1].Value : null; + } +} diff --git a/src/SentinelCareer.Infrastructure/Data/SentinelCareerDbContext.cs b/src/SentinelCareer.Infrastructure/Data/SentinelCareerDbContext.cs new file mode 100644 index 0000000..b388a3b --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Data/SentinelCareerDbContext.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using SentinelCareer.Domain.Entities; + +namespace SentinelCareer.Infrastructure.Data; + +public class SentinelCareerDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet JobOpportunities => Set(); + public DbSet Companies => Set(); + public DbSet Industries => Set(); + public DbSet Locations => Set(); + public DbSet CompensationSnapshots => Set(); + public DbSet Sources => Set(); + public DbSet SourceRuns => Set(); + public DbSet OpportunityScores => Set(); + public DbSet OpportunityScoreHistories => Set(); + public DbSet InterviewPrepPacks => Set(); + public DbSet NetworkingTargets => Set(); + public DbSet WatchlistCompanies => Set(); + public DbSet JobRunNotifications => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(x => x.ExternalId); + modelBuilder.Entity().HasIndex(x => x.Status); + modelBuilder.Entity().HasIndex(x => x.NormalizedName); + modelBuilder.Entity().HasIndex(x => x.Name).IsUnique(); + } +} diff --git a/src/SentinelCareer.Infrastructure/Migrations/001_initial.sql b/src/SentinelCareer.Infrastructure/Migrations/001_initial.sql new file mode 100644 index 0000000..05a9b31 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Migrations/001_initial.sql @@ -0,0 +1,32 @@ +-- Baseline schema migration for SentinelCareer (apply with psql -f) +create table if not exists "Companies" ( + "Id" uuid primary key, + "Name" text not null, + "NormalizedName" text not null, + "Website" text null +); + +create table if not exists "Locations" ( + "Id" uuid primary key, + "City" text not null, + "Region" text not null, + "Country" text not null +); + +create table if not exists "JobOpportunities" ( + "Id" uuid primary key, + "ExternalId" text not null, + "Title" text not null, + "NormalizedTitle" text not null, + "Description" text not null, + "CompanyId" uuid references "Companies"("Id"), + "LocationId" uuid references "Locations"("Id"), + "Status" integer not null default 0, + "OperatorNotes" text not null default '', + "PublishedAt" timestamptz not null, + "LastSeenAt" timestamptz not null, + "IsIndiaPriority" boolean not null default false +); + +create index if not exists "IX_JobOpportunities_ExternalId" on "JobOpportunities"("ExternalId"); +create index if not exists "IX_JobOpportunities_Status" on "JobOpportunities"("Status"); diff --git a/src/SentinelCareer.Infrastructure/Notifications/DisabledLocalModelProvider.cs b/src/SentinelCareer.Infrastructure/Notifications/DisabledLocalModelProvider.cs new file mode 100644 index 0000000..4a04661 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Notifications/DisabledLocalModelProvider.cs @@ -0,0 +1,10 @@ +using SentinelCareer.Contracts.LLM; + +namespace SentinelCareer.Infrastructure.Notifications; + +public class DisabledLocalModelProvider : ILocalModelProvider +{ + public bool IsEnabled => false; + public Task RewriteExplanationAsync(string deterministicExplanation, CancellationToken cancellationToken) => Task.FromResult(deterministicExplanation); + public Task GenerateInterviewPackAsync(string prompt, CancellationToken cancellationToken) => Task.FromResult(prompt); +} diff --git a/src/SentinelCareer.Infrastructure/Notifications/WindowsDesktopNotificationDispatcher.cs b/src/SentinelCareer.Infrastructure/Notifications/WindowsDesktopNotificationDispatcher.cs new file mode 100644 index 0000000..4c82d6f --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Notifications/WindowsDesktopNotificationDispatcher.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using SentinelCareer.Contracts.Notifications; + +namespace SentinelCareer.Infrastructure.Notifications; + +public class WindowsDesktopNotificationDispatcher(ILogger logger) : INotificationDispatcher +{ + public async Task NotifyDesktopAsync(string title, string body, CancellationToken cancellationToken) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + logger.LogInformation("DesktopNotification(non-windows) | {Title} | {Body}", title, body); + return; + } + + try + { + var escapedTitle = title.Replace("'", "''"); + var escapedBody = body.Replace("'", "''"); + var script = "$ErrorActionPreference='Stop';" + + "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null;" + + "$template=[Windows.UI.Notifications.ToastTemplateType]::ToastText02;" + + "$xml=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template);" + + "$nodes=$xml.GetElementsByTagName('text');" + + "$nodes[0].AppendChild($xml.CreateTextNode('" + escapedTitle + "')) > $null;" + + "$nodes[1].AppendChild($xml.CreateTextNode('" + escapedBody + "')) > $null;" + + "$toast=[Windows.UI.Notifications.ToastNotification]::new($xml);" + + "$notifier=[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('SentinelCareer');" + + "$notifier.Show($toast);"; + + using var process = Process.Start(new ProcessStartInfo + { + FileName = "powershell", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{script}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true + }); + + if (process is null) + { + logger.LogWarning("DesktopNotification powershell start failed | {Title}", title); + return; + } + + await process.WaitForExitAsync(cancellationToken); + if (process.ExitCode != 0) + { + var err = await process.StandardError.ReadToEndAsync(cancellationToken); + logger.LogWarning("DesktopNotification powershell failed ({Code}) | {Error}", process.ExitCode, err); + } + else + { + logger.LogInformation("DesktopNotification sent | {Title}", title); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "DesktopNotification exception | {Title}", title); + } + } +} diff --git a/src/SentinelCareer.Infrastructure/Repositories/Repositories.cs b/src/SentinelCareer.Infrastructure/Repositories/Repositories.cs new file mode 100644 index 0000000..71e6d82 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Repositories/Repositories.cs @@ -0,0 +1,173 @@ +using Microsoft.EntityFrameworkCore; +using SentinelCareer.Application.Abstractions; +using SentinelCareer.Domain.Entities; +using SentinelCareer.Domain.Enums; +using SentinelCareer.Infrastructure.Data; + +namespace SentinelCareer.Infrastructure.Repositories; + +public class OpportunityRepository(SentinelCareerDbContext db) : IOpportunityRepository +{ + public async Task GetAsync(Guid id, CancellationToken cancellationToken) => + await db.JobOpportunities.Include(x => x.Company).Include(x => x.Location).FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + public async Task> ListAsync(CancellationToken cancellationToken) + { + var items = await db.JobOpportunities.Include(x => x.Company).Include(x => x.Location).OrderByDescending(x => x.PublishedAt).ToListAsync(cancellationToken); + var ids = items.Select(x => x.Id).ToList(); + + var scores = await db.OpportunityScores + .Where(x => ids.Contains(x.OpportunityId)) + .OrderByDescending(x => x.ScoredAt) + .ToListAsync(cancellationToken); + + var grouped = scores.GroupBy(x => x.OpportunityId).ToDictionary(g => g.Key, g => g.Take(2).ToList()); + foreach (var item in items) + { + var latestTwo = grouped.GetValueOrDefault(item.Id); + item.LatestScore = latestTwo?.FirstOrDefault(); + item.ScoreDelta = latestTwo is { Count: >= 2 } ? latestTwo[0].CompositeScore - latestTwo[1].CompositeScore : 0; + } + + return items; + } + + public async Task FindByExternalIdAsync(string externalId, CancellationToken cancellationToken) + => await db.JobOpportunities.FirstOrDefaultAsync(x => x.ExternalId == externalId, cancellationToken); + + public async Task FindPotentialDuplicateAsync(string normalizedTitle, string companyName, string city, DateTimeOffset publishedAt, CancellationToken cancellationToken) + { + var minDate = publishedAt.AddDays(-30); + var maxDate = publishedAt.AddDays(30); + return await db.JobOpportunities + .Include(x => x.Company) + .Include(x => x.Location) + .Where(x => x.NormalizedTitle == normalizedTitle && x.PublishedAt >= minDate && x.PublishedAt <= maxDate) + .FirstOrDefaultAsync(x => x.Company != null && x.Location != null && + x.Company.NormalizedName == companyName && x.Location.City.ToLower() == city, + cancellationToken); + } + + public async Task UpsertAsync(JobOpportunity opportunity, CancellationToken cancellationToken) + { + if (opportunity.Company is not null) + { + var existingCompany = await db.Companies.FirstOrDefaultAsync(x => x.NormalizedName == opportunity.Company.NormalizedName, cancellationToken); + if (existingCompany is null) + { + db.Companies.Add(opportunity.Company); + opportunity.CompanyId = opportunity.Company.Id; + } + else + { + opportunity.CompanyId = existingCompany.Id; + opportunity.Company = existingCompany; + } + } + + if (opportunity.Location is not null) + { + var city = opportunity.Location.City.ToLowerInvariant(); + var country = opportunity.Location.Country.ToLowerInvariant(); + var existingLocation = await db.Locations.FirstOrDefaultAsync(x => x.City.ToLower() == city && x.Country.ToLower() == country, cancellationToken); + if (existingLocation is null) + { + db.Locations.Add(opportunity.Location); + opportunity.LocationId = opportunity.Location.Id; + } + else + { + opportunity.LocationId = existingLocation.Id; + opportunity.Location = existingLocation; + } + } + + var existing = await db.JobOpportunities.FirstOrDefaultAsync(x => x.ExternalId == opportunity.ExternalId, cancellationToken); + if (existing is null) + { + db.JobOpportunities.Add(opportunity); + } + else + { + existing.Title = opportunity.Title; + existing.NormalizedTitle = opportunity.NormalizedTitle; + existing.Description = opportunity.Description; + existing.LastSeenAt = DateTimeOffset.UtcNow; + existing.PublishedAt = opportunity.PublishedAt; + existing.IsIndiaPriority = opportunity.IsIndiaPriority; + existing.CompanyId = opportunity.CompanyId; + existing.LocationId = opportunity.LocationId; + } + + await db.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateReviewAsync(Guid opportunityId, OpportunityStatus status, string notes, CancellationToken cancellationToken) + { + var opportunity = await db.JobOpportunities.FirstOrDefaultAsync(x => x.Id == opportunityId, cancellationToken); + if (opportunity is null) return; + opportunity.Status = status; + opportunity.OperatorNotes = notes.Trim(); + await db.SaveChangesAsync(cancellationToken); + } +} + +public class SourceRepository(SentinelCareerDbContext db) : ISourceRepository +{ + public async Task> EnabledAsync(CancellationToken cancellationToken) => + await db.Sources.Where(x => x.Enabled).ToListAsync(cancellationToken); + + public async Task StartRunAsync(string adapterFamily, CancellationToken cancellationToken) + { + var source = await ResolveSourceAsync(adapterFamily, cancellationToken); + var run = new SourceRun + { + SourceId = source?.Id ?? Guid.Empty, + StartedAt = DateTimeOffset.UtcNow, + Status = "Running", + Message = $"Start {adapterFamily}" + }; + db.SourceRuns.Add(run); + await db.SaveChangesAsync(cancellationToken); + return run; + } + + public async Task CompleteRunAsync(Guid runId, string status, string message, CancellationToken cancellationToken) + { + var run = await db.SourceRuns.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken); + if (run is null) return; + run.CompletedAt = DateTimeOffset.UtcNow; + run.Status = status; + run.Message = message; + await db.SaveChangesAsync(cancellationToken); + } + + private async Task ResolveSourceAsync(string family, CancellationToken cancellationToken) + { + family = family.ToLowerInvariant(); + if (family.Contains("executive")) + return await db.Sources.FirstOrDefaultAsync(x => x.SourceType == SourceType.ExecutiveSearch, cancellationToken); + if (family.Contains("company")) + return await db.Sources.FirstOrDefaultAsync(x => x.SourceType == SourceType.CompanyCareers, cancellationToken); + if (family.Contains("signal") || family.Contains("event")) + return await db.Sources.FirstOrDefaultAsync(x => x.SourceType == SourceType.EventCalendar || x.SourceType == SourceType.NewsFeed, cancellationToken); + + return await db.Sources.FirstOrDefaultAsync(cancellationToken); + } +} + +public class ScoreRepository(SentinelCareerDbContext db) : IScoreRepository +{ + public async Task LatestForOpportunityAsync(Guid opportunityId, CancellationToken cancellationToken) => + await db.OpportunityScores.Where(x => x.OpportunityId == opportunityId).OrderByDescending(x => x.ScoredAt).FirstOrDefaultAsync(cancellationToken); + + public async Task SaveScoreAsync(OpportunityScore score, CancellationToken cancellationToken) + { + db.OpportunityScores.Add(score); + db.OpportunityScoreHistories.Add(new OpportunityScoreHistory { OpportunityId = score.OpportunityId, CompositeScore = score.CompositeScore }); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task> GetHistoryAsync(Guid opportunityId, CancellationToken cancellationToken) + => await db.OpportunityScoreHistories.Where(x => x.OpportunityId == opportunityId).OrderByDescending(x => x.RecordedAt).Take(10).ToListAsync(cancellationToken); +} diff --git a/src/SentinelCareer.Infrastructure/Scheduling/SchedulingOptions.cs b/src/SentinelCareer.Infrastructure/Scheduling/SchedulingOptions.cs new file mode 100644 index 0000000..7d1adeb --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Scheduling/SchedulingOptions.cs @@ -0,0 +1,11 @@ +namespace SentinelCareer.Infrastructure.Scheduling; + +public class SchedulingOptions +{ + public const string Section = "Scheduling"; + public int Tier1Hours { get; set; } = 6; + public int Tier2Hours { get; set; } = 24; + public int SignalsHours { get; set; } = 24; + public int EnrichmentHours { get; set; } = 24; + public int ReconciliationHours { get; set; } = 24; +} diff --git a/src/SentinelCareer.Infrastructure/Seed/SeedData.cs b/src/SentinelCareer.Infrastructure/Seed/SeedData.cs new file mode 100644 index 0000000..3276806 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/Seed/SeedData.cs @@ -0,0 +1,80 @@ +using SentinelCareer.Domain.Entities; +using SentinelCareer.Domain.Enums; +using SentinelCareer.Infrastructure.Data; + +namespace SentinelCareer.Infrastructure.Seed; + +public static class SeedData +{ + public static async Task EnsureAsync(SentinelCareerDbContext db, CancellationToken cancellationToken) + { + if (!db.Sources.Any()) + { + db.Sources.AddRange(BuildValidationSources()); + } + + if (!db.WatchlistCompanies.Any()) + { + db.WatchlistCompanies.AddRange([ + new WatchlistCompany { Name = "HAL", Reason = "Defence aerospace strategic hiring" }, + new WatchlistCompany { Name = "Bharat Electronics", Reason = "Strategic electronics and defence programs" }, + new WatchlistCompany { Name = "BEML", Reason = "Heavy engineering and strategic manufacturing" }, + new WatchlistCompany { Name = "L&T", Reason = "Infrastructure and industrial leadership opportunities" }, + new WatchlistCompany { Name = "Tata Advanced Systems", Reason = "Aerospace and homeland security scale-up" }, + new WatchlistCompany { Name = "Boeing India", Reason = "Aerospace operations and governance" }, + new WatchlistCompany { Name = "Airbus India", Reason = "Aerospace capability expansion" }, + new WatchlistCompany { Name = "Siemens India", Reason = "Industrial digital and manufacturing transformations" }, + new WatchlistCompany { Name = "Honeywell India", Reason = "Strategic technology and operations leadership" }, + new WatchlistCompany { Name = "KPMG India", Reason = "Governance/risk/compliance consulting roles" } + ]); + } + + await db.SaveChangesAsync(cancellationToken); + } + + private static IReadOnlyList BuildValidationSources() + { + return [ + // 5 executive search sources + NewSource("Apex Executive Search", SourceType.ExecutiveSearch, CadenceTier.Tier2, CrawlMethod.Manual, "https://example.com/es/apex"), + NewSource("Crest Leadership Search", SourceType.ExecutiveSearch, CadenceTier.Tier2, CrawlMethod.Scrape, "https://example.com/es/crest"), + NewSource("Northbridge Board Search", SourceType.ExecutiveSearch, CadenceTier.Tier2, CrawlMethod.Scrape, "https://example.com/es/northbridge"), + NewSource("StratEdge Partners", SourceType.ExecutiveSearch, CadenceTier.Tier2, CrawlMethod.Manual, "https://example.com/es/stratedge"), + NewSource("Axis CXO Search", SourceType.ExecutiveSearch, CadenceTier.Tier2, CrawlMethod.Scrape, "https://example.com/es/axis"), + + // 3 public-sector / strategic portals + NewSource("PSU Careers Portal", SourceType.GovernmentPortal, CadenceTier.Daily, CrawlMethod.Scrape, "https://example.com/psu/careers"), + NewSource("Defence Procurement Public Notices", SourceType.GovernmentPortal, CadenceTier.Daily, CrawlMethod.Rss, "https://example.com/defence/notices"), + NewSource("Strategic Institutions Careers", SourceType.GovernmentPortal, CadenceTier.Daily, CrawlMethod.Scrape, "https://example.com/strategic/careers"), + + // 3 event / expo / industry sources + NewSource("Industrial Expo Calendar", SourceType.EventCalendar, CadenceTier.Daily, CrawlMethod.Rss, "https://example.com/events/industrial"), + NewSource("Defence Summit Agenda", SourceType.EventCalendar, CadenceTier.Daily, CrawlMethod.Scrape, "https://example.com/events/defence"), + NewSource("Governance Forum Events", SourceType.EventCalendar, CadenceTier.Daily, CrawlMethod.Rss, "https://example.com/events/governance"), + + // 3 tender/EOI/procurement + NewSource("National Tender Wire", SourceType.TenderPortal, CadenceTier.Daily, CrawlMethod.Scrape, "https://example.com/tenders/national"), + NewSource("Infrastructure EOI Tracker", SourceType.TenderPortal, CadenceTier.Daily, CrawlMethod.Scrape, "https://example.com/tenders/infra"), + NewSource("Strategic Procurement Board", SourceType.TenderPortal, CadenceTier.Daily, CrawlMethod.Rss, "https://example.com/tenders/strategic"), + + // 2 selective job boards + NewSource("Selective Leadership Board A", SourceType.JobBoard, CadenceTier.Tier1, CrawlMethod.Api, "https://example.com/jobs/lead-a"), + NewSource("Selective Leadership Board B", SourceType.JobBoard, CadenceTier.Tier1, CrawlMethod.Api, "https://example.com/jobs/lead-b"), + + // company watchlist careers sources (10) + NewSource("HAL Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/hal"), + NewSource("BEL Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/bel"), + NewSource("BEML Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/beml"), + NewSource("L&T Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/lt"), + NewSource("TASL Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/tasl"), + NewSource("Boeing India Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/boeing"), + NewSource("Airbus India Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/airbus"), + NewSource("Siemens India Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/siemens"), + NewSource("Honeywell India Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/honeywell"), + NewSource("KPMG India Careers", SourceType.CompanyCareers, CadenceTier.Tier1, CrawlMethod.Scrape, "https://example.com/company/kpmg") + ]; + } + + private static Source NewSource(string name, SourceType type, CadenceTier tier, CrawlMethod method, string endpoint) + => new() { Name = name, SourceType = type, CadenceTier = tier, CrawlMethod = method, Endpoint = endpoint, LegalNotes = "Validation pack source" }; +} diff --git a/src/SentinelCareer.Infrastructure/SentinelCareer.Infrastructure.csproj b/src/SentinelCareer.Infrastructure/SentinelCareer.Infrastructure.csproj new file mode 100644 index 0000000..2251047 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/SentinelCareer.Infrastructure.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + enable + + + + + + + + + all + + + + diff --git a/src/SentinelCareer.Infrastructure/ServiceCollectionExtensions.cs b/src/SentinelCareer.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..962c035 --- /dev/null +++ b/src/SentinelCareer.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SentinelCareer.Application.Abstractions; +using SentinelCareer.Application.Options; +using SentinelCareer.Application.Services; +using SentinelCareer.Contracts.Ingestion; +using SentinelCareer.Contracts.LLM; +using SentinelCareer.Contracts.Notifications; +using SentinelCareer.Infrastructure.Adapters; +using SentinelCareer.Infrastructure.Data; +using SentinelCareer.Infrastructure.Notifications; +using SentinelCareer.Infrastructure.Repositories; +using SentinelCareer.Infrastructure.Scheduling; + +namespace SentinelCareer.Infrastructure; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddSentinelCareer(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(ScoringOptions.Section)); + services.Configure(configuration.GetSection(SchedulingOptions.Section)); + + services.AddDbContext(o => + o.UseNpgsql(configuration.GetConnectionString("Postgres") ?? "Host=localhost;Database=sentinelcareer;Username=postgres;Password=postgres")); + + services.AddHttpClient("sources"); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(provider => new ScoringEngine(provider.GetRequiredService>().Value)); + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; + } +} diff --git a/src/SentinelCareer.PythonWorkers/pyproject.toml b/src/SentinelCareer.PythonWorkers/pyproject.toml new file mode 100644 index 0000000..a2e5f81 --- /dev/null +++ b/src/SentinelCareer.PythonWorkers/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "sentinelcareer-pythonworkers" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["httpx>=0.27", "pydantic>=2.8"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/SentinelCareer.PythonWorkers/sentinelcareer_pythonworkers/__init__.py b/src/SentinelCareer.PythonWorkers/sentinelcareer_pythonworkers/__init__.py new file mode 100644 index 0000000..c70b7da --- /dev/null +++ b/src/SentinelCareer.PythonWorkers/sentinelcareer_pythonworkers/__init__.py @@ -0,0 +1 @@ +from .adapters import parse_generic_jobs_html diff --git a/src/SentinelCareer.PythonWorkers/sentinelcareer_pythonworkers/adapters.py b/src/SentinelCareer.PythonWorkers/sentinelcareer_pythonworkers/adapters.py new file mode 100644 index 0000000..0537e4b --- /dev/null +++ b/src/SentinelCareer.PythonWorkers/sentinelcareer_pythonworkers/adapters.py @@ -0,0 +1,20 @@ +import re + + +def _extract(css_class: str, chunk: str) -> str: + m = re.search(rf'class="{css_class}"\s*>\s*([^<]+)', chunk, flags=re.IGNORECASE) + return m.group(1).strip() if m else "" + + +def parse_generic_jobs_html(html: str) -> list[dict]: + jobs = [] + for chunk in re.findall(r']*>(.*?)', html, flags=re.IGNORECASE | re.DOTALL): + title = _extract("title", chunk) + location = _extract("location", chunk) + if title: + jobs.append({"title": title, "location": location}) + if not jobs and 'class="job"' in html.lower(): + title = _extract("title", html) + if title: + jobs.append({"title": title, "location": _extract("location", html)}) + return jobs diff --git a/src/SentinelCareer.Web/Pages/Index.cshtml b/src/SentinelCareer.Web/Pages/Index.cshtml new file mode 100644 index 0000000..0ec5617 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Index.cshtml @@ -0,0 +1,6 @@ +@page +@model IndexModel +

Home Summary

Total opportunities: @Model.Total

+

Pursue Immediately (80+)

@Model.HighPriority

New 24h 80+: @Model.NewHighPriority

+

Manual Review (65-79)

@Model.ManualReview

+

Track Only (<65)

@Model.TrackOnly

diff --git a/src/SentinelCareer.Web/Pages/Index.cshtml.cs b/src/SentinelCareer.Web/Pages/Index.cshtml.cs new file mode 100644 index 0000000..6d200cc --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Index.cshtml.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; +using SentinelCareer.Application.Abstractions; + +namespace SentinelCareer.Web.Pages; + +public class IndexModel(IOpportunityRepository repository) : PageModel +{ + public int Total { get; private set; } + public int HighPriority { get; private set; } + public int NewHighPriority { get; private set; } + public int ManualReview { get; private set; } + public int TrackOnly { get; private set; } + + public async Task OnGet(CancellationToken cancellationToken) + { + var items = await repository.ListAsync(cancellationToken); + Total = items.Count; + HighPriority = items.Count(x => (x.LatestScore?.CompositeScore ?? 0) >= 80); + NewHighPriority = items.Count(x => (x.LatestScore?.CompositeScore ?? 0) >= 80 && x.PublishedAt >= DateTimeOffset.UtcNow.AddDays(-1)); + ManualReview = items.Count(x => (x.LatestScore?.CompositeScore ?? 0) is >= 65 and < 80); + TrackOnly = items.Count(x => (x.LatestScore?.CompositeScore ?? 0) < 65); + } +} diff --git a/src/SentinelCareer.Web/Pages/InterviewPrep/Index.cshtml b/src/SentinelCareer.Web/Pages/InterviewPrep/Index.cshtml new file mode 100644 index 0000000..2ddb2c5 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/InterviewPrep/Index.cshtml @@ -0,0 +1,3 @@ +@page +

Interview Preparation Packs

+

Generated packs for opportunities scoring 80+.

diff --git a/src/SentinelCareer.Web/Pages/Opportunities/Index.cshtml b/src/SentinelCareer.Web/Pages/Opportunities/Index.cshtml new file mode 100644 index 0000000..0a55414 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Opportunities/Index.cshtml @@ -0,0 +1,37 @@ +@page +@model SentinelCareer.Web.Pages.Opportunities.IndexModel +

Opportunities Triage

+
+ + + + +
+ +@foreach (var item in Model.Items) { +var isHigh = (item.LatestScore?.CompositeScore ?? 0) >= 80; +
+ @item.Title +
@item.Company?.Name - @item.Location?.City (@item.Location?.Country)
+
Provenance: @item.ExternalId | Published: @item.PublishedAt.ToString("yyyy-MM-dd") | India Priority: @item.IsIndiaPriority
+
Score: @(item.LatestScore?.CompositeScore ?? 0) | Δ @item.ScoreDelta | Band: @SentinelCareer.Web.Pages.Opportunities.IndexModel.ScoreBand(item)
+
Why this scored: @(item.LatestScore?.Explanation ?? "Not yet scored")
+
Next action: @(item.LatestScore?.RecommendedNextAction ?? "Pending scoring")
+
Operator decision: @item.Status | Notes: @item.OperatorNotes
+ +
+ + + + +
+ + @if (Model.Histories.TryGetValue(item.Id, out var history) && history.Any()) { +
Score history: @string.Join(" -> ", history.Select(h => h.CompositeScore))
+ } +
+} diff --git a/src/SentinelCareer.Web/Pages/Opportunities/Index.cshtml.cs b/src/SentinelCareer.Web/Pages/Opportunities/Index.cshtml.cs new file mode 100644 index 0000000..40499a5 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Opportunities/Index.cshtml.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using SentinelCareer.Application.Abstractions; +using SentinelCareer.Domain.Entities; +using SentinelCareer.Domain.Enums; +using SentinelCareer.Domain.Rules; + +namespace SentinelCareer.Web.Pages.Opportunities; + +public class IndexModel(IOpportunityRepository repository, IScoreRepository scoreRepository) : PageModel +{ + [BindProperty(SupportsGet = true)] public string? Location { get; set; } + [BindProperty(SupportsGet = true)] public string? Company { get; set; } + [BindProperty(SupportsGet = true)] public int? MinScore { get; set; } + + public IReadOnlyList Items { get; private set; } = []; + public Dictionary> Histories { get; private set; } = new(); + + public async Task OnGet(CancellationToken cancellationToken) + { + var items = await repository.ListAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(Location)) + items = items.Where(x => x.Location?.City.Contains(Location, StringComparison.OrdinalIgnoreCase) == true).ToList(); + + if (!string.IsNullOrWhiteSpace(Company)) + items = items.Where(x => x.Company?.Name.Contains(Company, StringComparison.OrdinalIgnoreCase) == true).ToList(); + + if (MinScore.HasValue) + items = items.Where(x => (x.LatestScore?.CompositeScore ?? 0) >= MinScore.Value).ToList(); + + Items = items; + foreach (var item in Items.Take(20)) + Histories[item.Id] = await scoreRepository.GetHistoryAsync(item.Id, cancellationToken); + } + + public async Task OnPostDecisionAsync(Guid id, string decision, string? notes, CancellationToken cancellationToken) + { + var status = decision.ToLowerInvariant() switch + { + "pursue" => OpportunityStatus.Shortlisted, + "review" => OpportunityStatus.Tracked, + "ignore" => OpportunityStatus.Ignored, + _ => OpportunityStatus.Tracked + }; + + await repository.UpdateReviewAsync(id, status, notes ?? string.Empty, cancellationToken); + return RedirectToPage(new { Location, Company, MinScore }); + } + + public static string ScoreBand(JobOpportunity item) + => ScoreBandClassifier.Classify(item.LatestScore?.CompositeScore ?? 0).ToString(); +} diff --git a/src/SentinelCareer.Web/Pages/Shared.cs b/src/SentinelCareer.Web/Pages/Shared.cs new file mode 100644 index 0000000..64a4544 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Shared.cs @@ -0,0 +1,2 @@ +namespace SentinelCareer.Web; +public class SharedMarker {} diff --git a/src/SentinelCareer.Web/Pages/Shared/_Layout.cshtml b/src/SentinelCareer.Web/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..b75d231 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Shared/_Layout.cshtml @@ -0,0 +1,20 @@ + + + + + SentinelCareer + + + +

SentinelCareer Dashboard

+ +
@RenderBody()
+ + diff --git a/src/SentinelCareer.Web/Pages/Signals/Index.cshtml b/src/SentinelCareer.Web/Pages/Signals/Index.cshtml new file mode 100644 index 0000000..bd82dfb --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Signals/Index.cshtml @@ -0,0 +1,3 @@ +@page +

Signals

+

High-value forward signals (funding/capex/expansion/contracts/events/tenders) are prioritized here for opportunity-potential impact.

diff --git a/src/SentinelCareer.Web/Pages/Sources/Index.cshtml b/src/SentinelCareer.Web/Pages/Sources/Index.cshtml new file mode 100644 index 0000000..9bbe39e --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Sources/Index.cshtml @@ -0,0 +1,3 @@ +@page +

Sources

+

Sources view placeholder with filters and trend widgets.

diff --git a/src/SentinelCareer.Web/Pages/Watchlist/Index.cshtml b/src/SentinelCareer.Web/Pages/Watchlist/Index.cshtml new file mode 100644 index 0000000..338993d --- /dev/null +++ b/src/SentinelCareer.Web/Pages/Watchlist/Index.cshtml @@ -0,0 +1,3 @@ +@page +

Watchlist

+

Watchlist view placeholder with filters and trend widgets.

diff --git a/src/SentinelCareer.Web/Pages/_ViewImports.cshtml b/src/SentinelCareer.Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..5c2d370 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@using SentinelCareer.Web +@namespace SentinelCareer.Web.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/SentinelCareer.Web/Pages/_ViewStart.cshtml b/src/SentinelCareer.Web/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/src/SentinelCareer.Web/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/SentinelCareer.Web/Program.cs b/src/SentinelCareer.Web/Program.cs new file mode 100644 index 0000000..8a11440 --- /dev/null +++ b/src/SentinelCareer.Web/Program.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using SentinelCareer.Application.Abstractions; +using SentinelCareer.Infrastructure; +using SentinelCareer.Infrastructure.Data; +using SentinelCareer.Infrastructure.Seed; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSentinelCareer(builder.Configuration); +builder.Services.AddRazorPages(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + await SeedData.EnsureAsync(db, CancellationToken.None); +} + +app.UseStaticFiles(); +app.MapRazorPages(); +app.MapGet("/api/opportunities", async (IOpportunityRepository repo, CancellationToken ct) => await repo.ListAsync(ct)); + +app.Run(); + +public partial class Program { } diff --git a/src/SentinelCareer.Web/SentinelCareer.Web.csproj b/src/SentinelCareer.Web/SentinelCareer.Web.csproj new file mode 100644 index 0000000..0ef7b6d --- /dev/null +++ b/src/SentinelCareer.Web/SentinelCareer.Web.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + + diff --git a/src/SentinelCareer.Web/appsettings.Development.json b/src/SentinelCareer.Web/appsettings.Development.json new file mode 100644 index 0000000..f149b12 --- /dev/null +++ b/src/SentinelCareer.Web/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "ValidationPack": { + "Enabled": true, + "Description": "Small real-world calibration pack", + "WatchlistCompanyCount": 10, + "ExecutiveSearchSourceCount": 5, + "PublicSectorSourceCount": 3, + "EventSourceCount": 3, + "TenderSourceCount": 3, + "SelectiveJobBoardCount": 2 + } +} diff --git a/src/SentinelCareer.Web/appsettings.json b/src/SentinelCareer.Web/appsettings.json new file mode 100644 index 0000000..4b2aa72 --- /dev/null +++ b/src/SentinelCareer.Web/appsettings.json @@ -0,0 +1,35 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Database=sentinelcareer;Username=postgres;Password=postgres" + }, + "Scoring": { + "BaselineCompInrLpa": 35, + "AspirationalCompInrLpa": 50, + "Weights": { + "ProfileFit": 0.2, + "CompensationFit": 0.15, + "GeographyFit": 0.15, + "SeniorityFit": 0.15, + "IndustryFit": 0.1, + "SourceCredibility": 0.1, + "Freshness": 0.05, + "OpportunityPotential": 0.1 + } + }, + "Scheduling": { + "Tier1Hours": 6, + "Tier2Hours": 24, + "SignalsHours": 24, + "EnrichmentHours": 24, + "ReconciliationHours": 24 + }, + "ValidationPack": { + "Enabled": true, + "WatchlistCompanyCount": 10, + "ExecutiveSearchSourceCount": 5, + "PublicSectorSourceCount": 3, + "EventSourceCount": 3, + "TenderSourceCount": 3, + "SelectiveJobBoardCount": 2 + } +} \ No newline at end of file diff --git a/src/SentinelCareer.Web/wwwroot/css/site.css b/src/SentinelCareer.Web/wwwroot/css/site.css new file mode 100644 index 0000000..c5c4431 --- /dev/null +++ b/src/SentinelCareer.Web/wwwroot/css/site.css @@ -0,0 +1,4 @@ +body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; background: #f6f8fa; } +header { color: #13294b; } +nav a { margin-right: 8px; } +.card { background: white; border-radius: 8px; padding: 12px; margin-bottom: 10px; } diff --git a/src/SentinelCareer.Workers/Program.cs b/src/SentinelCareer.Workers/Program.cs new file mode 100644 index 0000000..2de8779 --- /dev/null +++ b/src/SentinelCareer.Workers/Program.cs @@ -0,0 +1,11 @@ +using SentinelCareer.Infrastructure; +using SentinelCareer.Workers.Services; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSentinelCareer(builder.Configuration); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddWindowsService(); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/SentinelCareer.Workers/SentinelCareer.Workers.csproj b/src/SentinelCareer.Workers/SentinelCareer.Workers.csproj new file mode 100644 index 0000000..d485ec4 --- /dev/null +++ b/src/SentinelCareer.Workers/SentinelCareer.Workers.csproj @@ -0,0 +1,13 @@ + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/SentinelCareer.Workers/Services/IngestionWorker.cs b/src/SentinelCareer.Workers/Services/IngestionWorker.cs new file mode 100644 index 0000000..661bc81 --- /dev/null +++ b/src/SentinelCareer.Workers/Services/IngestionWorker.cs @@ -0,0 +1,108 @@ +using SentinelCareer.Application.Abstractions; +using SentinelCareer.Application.Services; +using SentinelCareer.Contracts.Ingestion; +using SentinelCareer.Domain.Entities; + +namespace SentinelCareer.Workers.Services; + +public class IngestionWorker(IServiceProvider serviceProvider, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using var scope = serviceProvider.CreateScope(); + var adapters = scope.ServiceProvider.GetServices(); + var repo = scope.ServiceProvider.GetRequiredService(); + var sourceRepo = scope.ServiceProvider.GetRequiredService(); + var normalize = scope.ServiceProvider.GetRequiredService(); + var dedupe = scope.ServiceProvider.GetRequiredService(); + + foreach (var adapter in adapters) + { + logger.LogInformation("SourceRunStart | {Family}", adapter.Family); + var run = await sourceRepo.StartRunAsync(adapter.Family, stoppingToken); + IReadOnlyList items = []; + var runOk = false; + + for (int attempt = 1; attempt <= 3 && !runOk; attempt++) + { + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(45)); + items = await adapter.FetchAsync(timeoutCts.Token); + runOk = true; + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + logger.LogWarning("SourceRunTimeout | {Family} | Attempt={Attempt}", adapter.Family, attempt); + if (attempt < 3) + await Task.Delay(TimeSpan.FromSeconds(8 * attempt + Random.Shared.Next(1, 4)), stoppingToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "SourceRunRetry | {Family} | Attempt={Attempt}", adapter.Family, attempt); + if (attempt < 3) + await Task.Delay(TimeSpan.FromSeconds(8 * attempt + Random.Shared.Next(1, 4)), stoppingToken); + } + } + + if (!runOk) + { + logger.LogError("SourceRunFailed | {Family}", adapter.Family); + await sourceRepo.CompleteRunAsync(run.Id, "Failed", $"Adapter failed after retries: {adapter.Family}", stoppingToken); + continue; + } + + int saved = 0; + foreach (var item in items) + { + try + { + if (normalize.IsExcludedRole(item.Title, item.Description)) + { + logger.LogInformation("OpportunityExcluded | {ExternalId} | {Title}", item.ExternalId, item.Title); + continue; + } + + if (await repo.FindByExternalIdAsync(item.ExternalId, stoppingToken) is not null) + continue; + + var normalizedTitle = normalize.NormalizeTitle(item.Title); + var normalizedCompany = normalize.NormalizeCompany(item.Company); + var existing = await repo.FindPotentialDuplicateAsync(normalizedTitle, normalizedCompany, item.Location.ToLowerInvariant(), item.PostedAt, stoppingToken); + if (existing is not null && dedupe.IsLikelyDuplicate(existing.PublishedAt, item.PostedAt)) + { + logger.LogInformation("OpportunityDeduped | {ExternalId} -> {ExistingId}", item.ExternalId, existing.Id); + continue; + } + + var opp = new JobOpportunity + { + ExternalId = item.ExternalId, + Title = item.Title, + NormalizedTitle = normalizedTitle, + Description = item.Description, + PublishedAt = item.PostedAt, + IsIndiaPriority = item.Country.Equals("India", StringComparison.OrdinalIgnoreCase), + Company = new Company { Name = item.Company, NormalizedName = normalizedCompany }, + Location = new Location { City = item.Location, Region = item.Location, Country = item.Country } + }; + await repo.UpsertAsync(opp, stoppingToken); + saved++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "OpportunityIngestionFailed | Source={Family} | ExternalId={ExternalId}", adapter.Family, item.ExternalId); + } + } + + logger.LogInformation("SourceRunEnd | {Family} | Ingested={Count} | Saved={Saved}", adapter.Family, items.Count, saved); + await sourceRepo.CompleteRunAsync(run.Id, "Completed", $"Ingested={items.Count};Saved={saved}", stoppingToken); + } + + await Task.Delay(TimeSpan.FromHours(6), stoppingToken); + } + } +} diff --git a/src/SentinelCareer.Workers/Services/ScoringWorker.cs b/src/SentinelCareer.Workers/Services/ScoringWorker.cs new file mode 100644 index 0000000..3e96423 --- /dev/null +++ b/src/SentinelCareer.Workers/Services/ScoringWorker.cs @@ -0,0 +1,69 @@ +using SentinelCareer.Application.Abstractions; +using SentinelCareer.Application.Services; +using SentinelCareer.Contracts.Notifications; +using SentinelCareer.Domain.Entities; + +namespace SentinelCareer.Workers.Services; + +public class ScoringWorker(IServiceProvider serviceProvider, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + using var scope = serviceProvider.CreateScope(); + var opportunities = await scope.ServiceProvider.GetRequiredService().ListAsync(stoppingToken); + var scoring = scope.ServiceProvider.GetRequiredService(); + var scoreRepo = scope.ServiceProvider.GetRequiredService(); + var notify = scope.ServiceProvider.GetRequiredService(); + + foreach (var opp in opportunities) + { + var result = scoring.Score(opp, 5500000, 0.9m, Math.Max(1, (DateTimeOffset.UtcNow - opp.PublishedAt).Days), 80); + var previous = await scoreRepo.LatestForOpportunityAsync(opp.Id, stoppingToken); + + await scoreRepo.SaveScoreAsync(new OpportunityScore + { + OpportunityId = opp.Id, + CompositeScore = result.CompositeScore, + ProfileFit = result.SubScores["profile_fit"], + CompensationFit = result.SubScores["compensation_fit"], + GeographyFit = result.SubScores["geography_fit"], + SeniorityFit = result.SubScores["seniority_fit"], + IndustryFit = result.SubScores["industry_fit"], + SourceCredibility = result.SubScores["source_credibility"], + Freshness = result.SubScores["freshness"], + OpportunityPotential = result.SubScores["opportunity_potential"], + Explanation = result.Explanation, + RecommendedNextAction = result.RecommendedNextAction + }, stoppingToken); + + if (ShouldNotify(previous?.CompositeScore, result.CompositeScore, out var reason)) + { + await notify.NotifyDesktopAsync("SentinelCareer Alert", $"{opp.Title}: {result.CompositeScore} ({reason})", stoppingToken); + } + } + + logger.LogInformation("ScoringRunCompleted | Count={Count}", opportunities.Count); + await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + } + } + + public static bool ShouldNotify(int? previousScore, int currentScore, out string reason) + { + if (previousScore is null && currentScore >= 80) + { + reason = "new high-priority opportunity"; + return true; + } + + if (previousScore is not null && currentScore - previousScore.Value >= 5) + { + reason = $"score improved by {currentScore - previousScore.Value}"; + return true; + } + + reason = string.Empty; + return false; + } +} diff --git a/src/SentinelCareer.Workers/appsettings.json b/src/SentinelCareer.Workers/appsettings.json new file mode 100644 index 0000000..b31a5f5 --- /dev/null +++ b/src/SentinelCareer.Workers/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Database=sentinelcareer;Username=postgres;Password=postgres" + } +} diff --git a/tests/SentinelCareer.Application.Tests/DedupeServiceTests.cs b/tests/SentinelCareer.Application.Tests/DedupeServiceTests.cs new file mode 100644 index 0000000..c8da0b4 --- /dev/null +++ b/tests/SentinelCareer.Application.Tests/DedupeServiceTests.cs @@ -0,0 +1,30 @@ +using SentinelCareer.Application.Services; + +namespace SentinelCareer.Application.Tests; + +public class DedupeServiceTests +{ + [Fact] + public void BuildKey_IsStable() + { + var svc = new DedupeService(); + var k1 = svc.BuildKey("director operations", "acme ltd", "Mumbai"); + var k2 = svc.BuildKey("director operations", "acme ltd", "mumbai"); + Assert.Equal(k1, k2); + } + + [Fact] + public void DuplicateWindow_IsWithin30Days() + { + var svc = new DedupeService(); + Assert.True(svc.IsLikelyDuplicate(DateTimeOffset.UtcNow.AddDays(-10), DateTimeOffset.UtcNow)); + Assert.False(svc.IsLikelyDuplicate(DateTimeOffset.UtcNow.AddDays(-45), DateTimeOffset.UtcNow)); + } + + [Fact] + public void DuplicateWindow_Boundary30Days_Included() + { + var svc = new DedupeService(); + Assert.True(svc.IsLikelyDuplicate(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow)); + } +} diff --git a/tests/SentinelCareer.Application.Tests/NormalizationServiceTests.cs b/tests/SentinelCareer.Application.Tests/NormalizationServiceTests.cs new file mode 100644 index 0000000..0cab0bf --- /dev/null +++ b/tests/SentinelCareer.Application.Tests/NormalizationServiceTests.cs @@ -0,0 +1,37 @@ +using SentinelCareer.Application.Services; + +namespace SentinelCareer.Application.Tests; + +public class NormalizationServiceTests +{ + [Fact] + public void NormalizeCompensation_ParsesLpa() + { + var svc = new NormalizationService(); + var inr = svc.NormalizeCompensationToInr("INR 55 LPA", new Dictionary()); + Assert.Equal(5500000, inr); + } + + [Fact] + public void NormalizeCompensation_ParsesUsdRange() + { + var svc = new NormalizationService(); + var inr = svc.NormalizeCompensationToInr("USD 120000 - 140000", new Dictionary { ["USD"] = 83m }); + Assert.Equal(11620000, inr); + } + + [Fact] + public void NormalizeCompensation_ParsesCrore() + { + var svc = new NormalizationService(); + var inr = svc.NormalizeCompensationToInr("INR 1.2 crore", new Dictionary()); + Assert.Equal(12000000, inr); + } + + [Fact] + public void IsExcludedRole_DetectsSoftwareEngineering() + { + var svc = new NormalizationService(); + Assert.True(svc.IsExcludedRole("Senior Software Engineer", "backend platform")); + } +} diff --git a/tests/SentinelCareer.Application.Tests/ScoringEngineTests.cs b/tests/SentinelCareer.Application.Tests/ScoringEngineTests.cs new file mode 100644 index 0000000..9d26b05 --- /dev/null +++ b/tests/SentinelCareer.Application.Tests/ScoringEngineTests.cs @@ -0,0 +1,45 @@ +using SentinelCareer.Application.Options; +using SentinelCareer.Application.Services; +using SentinelCareer.Domain.Entities; + +namespace SentinelCareer.Application.Tests; + +public class ScoringEngineTests +{ + [Fact] + public void Scores_India_Senior_Role_Highly() + { + var engine = new ScoringEngine(new ScoringOptions()); + var opp = new JobOpportunity { Title = "Director Operations", Description = "defence manufacturing", IsIndiaPriority = true }; + var score = engine.Score(opp, 6000000, 0.9m, 1, 80); + Assert.True(score.CompositeScore >= 80); + Assert.Contains("India-first", score.Explanation); + } + + [Fact] + public void Penalizes_Excluded_Roles() + { + var engine = new ScoringEngine(new ScoringOptions()); + var opp = new JobOpportunity { Title = "Senior Software Engineer", Description = "developer role", IsIndiaPriority = true }; + var score = engine.Score(opp, 6000000, 0.9m, 1, 80); + Assert.True(score.CompositeScore < 80); + } + + [Fact] + public void MidLevel_GenericOps_ShouldNotOverscore() + { + var engine = new ScoringEngine(new ScoringOptions()); + var opp = new JobOpportunity { Title = "Operations Manager", Description = "consumer retail operations", IsIndiaPriority = true }; + var score = engine.Score(opp, 3800000, 0.8m, 2, 60); + Assert.True(score.CompositeScore < 65); + } + + [Fact] + public void ThresholdTransition_ManualReviewBoundary() + { + var engine = new ScoringEngine(new ScoringOptions()); + var opp = new JobOpportunity { Title = "AVP Governance", Description = "manufacturing governance", IsIndiaPriority = true }; + var score = engine.Score(opp, 3500000, 0.8m, 5, 65); + Assert.True(score.CompositeScore >= 65); + } +} diff --git a/tests/SentinelCareer.Application.Tests/SentinelCareer.Application.Tests.csproj b/tests/SentinelCareer.Application.Tests/SentinelCareer.Application.Tests.csproj new file mode 100644 index 0000000..d560533 --- /dev/null +++ b/tests/SentinelCareer.Application.Tests/SentinelCareer.Application.Tests.csproj @@ -0,0 +1,10 @@ + + net8.0falseenable + + + + + + + + diff --git a/tests/SentinelCareer.Domain.Tests/ScoreBandClassifierTests.cs b/tests/SentinelCareer.Domain.Tests/ScoreBandClassifierTests.cs new file mode 100644 index 0000000..f9ac700 --- /dev/null +++ b/tests/SentinelCareer.Domain.Tests/ScoreBandClassifierTests.cs @@ -0,0 +1,13 @@ +using SentinelCareer.Domain.Enums; +using SentinelCareer.Domain.Rules; + +namespace SentinelCareer.Domain.Tests; + +public class ScoreBandClassifierTests +{ + [Theory] + [InlineData(80, ScoreBand.PursueImmediately)] + [InlineData(72, ScoreBand.ManualReview)] + [InlineData(40, ScoreBand.TrackOnly)] + public void Classify_Works(int score, ScoreBand expected) => Assert.Equal(expected, ScoreBandClassifier.Classify(score)); +} diff --git a/tests/SentinelCareer.Domain.Tests/SentinelCareer.Domain.Tests.csproj b/tests/SentinelCareer.Domain.Tests/SentinelCareer.Domain.Tests.csproj new file mode 100644 index 0000000..5028474 --- /dev/null +++ b/tests/SentinelCareer.Domain.Tests/SentinelCareer.Domain.Tests.csproj @@ -0,0 +1,10 @@ + + net8.0falseenable + + + + + + + + diff --git a/tests/SentinelCareer.Infrastructure.Tests/Fixtures/company_jobs_fixture.html b/tests/SentinelCareer.Infrastructure.Tests/Fixtures/company_jobs_fixture.html new file mode 100644 index 0000000..63131a0 --- /dev/null +++ b/tests/SentinelCareer.Infrastructure.Tests/Fixtures/company_jobs_fixture.html @@ -0,0 +1,10 @@ +
+

Senior Director - Governance & Risk

+
Bengaluru, India
+
INR 60 LPA
+
+
+
VP Operations Transformation
+
INR 75 LPA
+
Mumbai, India
+
diff --git a/tests/SentinelCareer.Infrastructure.Tests/Fixtures/executive_search_fixture.html b/tests/SentinelCareer.Infrastructure.Tests/Fixtures/executive_search_fixture.html new file mode 100644 index 0000000..8d34813 --- /dev/null +++ b/tests/SentinelCareer.Infrastructure.Tests/Fixtures/executive_search_fixture.html @@ -0,0 +1,9 @@ +
+
AVP Compliance
+ Location: Delhi, India + CTC: INR 45 LPA +
+
+

Head Strategic Programs

+ Pune, India +
diff --git a/tests/SentinelCareer.Infrastructure.Tests/GenericAdapterTests.cs b/tests/SentinelCareer.Infrastructure.Tests/GenericAdapterTests.cs new file mode 100644 index 0000000..2558482 --- /dev/null +++ b/tests/SentinelCareer.Infrastructure.Tests/GenericAdapterTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Logging.Abstractions; +using SentinelCareer.Infrastructure.Adapters; + +namespace SentinelCareer.Infrastructure.Tests; + +public class GenericAdapterTests +{ + [Fact] + public async Task CompanyAdapter_ReturnsPayload() + { + var adapter = new GenericCompanyCareersAdapter(NullLogger.Instance, new TestHttpClientFactory()); + var items = await adapter.FetchAsync(CancellationToken.None); + Assert.NotEmpty(items); + } + + [Fact] + public async Task CompanyAdapter_RespectsCancellation() + { + var adapter = new GenericCompanyCareersAdapter(NullLogger.Instance, new TestHttpClientFactory()); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync(() => adapter.FetchAsync(cts.Token)); + } + + [Fact] + public void Parser_HandlesFixture_WithFallbacks() + { + var html = File.ReadAllText("Fixtures/company_jobs_fixture.html"); + var parsed = SourceHtmlParser.ParseSimpleCards(html, "Fixture Co", "https://fixture", _ => { }); + Assert.Equal(2, parsed.Count); + Assert.Contains(parsed, x => x.Title.Contains("Senior Director", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(parsed, x => x.Compensation?.Contains("75", StringComparison.OrdinalIgnoreCase) == true); + } + + [Fact] + public void Parser_HandlesLabelFallbacks() + { + var html = File.ReadAllText("Fixtures/executive_search_fixture.html"); + var parsed = SourceHtmlParser.ParseSimpleCards(html, "Exec Co", "https://fixture", _ => { }); + Assert.Equal(2, parsed.Count); + Assert.Contains(parsed, x => x.Location.Contains("Delhi", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class TestHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } +} diff --git a/tests/SentinelCareer.Infrastructure.Tests/SentinelCareer.Infrastructure.Tests.csproj b/tests/SentinelCareer.Infrastructure.Tests/SentinelCareer.Infrastructure.Tests.csproj new file mode 100644 index 0000000..64f8b8b --- /dev/null +++ b/tests/SentinelCareer.Infrastructure.Tests/SentinelCareer.Infrastructure.Tests.csproj @@ -0,0 +1,15 @@ + + net8.0falseenable + + + + + + + + + + + + + diff --git a/tests/SentinelCareer.IntegrationTests/PlaceholderIntegrationTests.cs b/tests/SentinelCareer.IntegrationTests/PlaceholderIntegrationTests.cs new file mode 100644 index 0000000..73e5de7 --- /dev/null +++ b/tests/SentinelCareer.IntegrationTests/PlaceholderIntegrationTests.cs @@ -0,0 +1,7 @@ +namespace SentinelCareer.IntegrationTests; + +public class PlaceholderIntegrationTests +{ + [Fact] + public void Placeholder() => Assert.True(true); +} diff --git a/tests/SentinelCareer.IntegrationTests/PostgresIntegrationTests.cs b/tests/SentinelCareer.IntegrationTests/PostgresIntegrationTests.cs new file mode 100644 index 0000000..228ed59 --- /dev/null +++ b/tests/SentinelCareer.IntegrationTests/PostgresIntegrationTests.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SentinelCareer.Domain.Entities; +using SentinelCareer.Domain.Enums; +using SentinelCareer.Infrastructure.Data; +using SentinelCareer.Infrastructure.Repositories; + +namespace SentinelCareer.IntegrationTests; + +public class PostgresIntegrationTests +{ + private static string? ConnectionString => Environment.GetEnvironmentVariable("SENTINELCAREER_TEST_PG"); + + [Fact] + public async Task CanConnectAndSeeCoreTables() + { + if (string.IsNullOrWhiteSpace(ConnectionString)) return; + + await using var conn = new NpgsqlConnection(ConnectionString); + await conn.OpenAsync(); + + var cmd = new NpgsqlCommand("select to_regclass('public.\"JobOpportunities\"') is not null", conn); + var exists = (bool)(await cmd.ExecuteScalarAsync() ?? false); + Assert.True(exists); + } + + [Fact] + public async Task SourceRunLifecycle_Persists() + { + if (string.IsNullOrWhiteSpace(ConnectionString)) return; + + var options = new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options; + await using var db = new SentinelCareerDbContext(options); + var sourceRepo = new SourceRepository(db); + + var run = await sourceRepo.StartRunAsync("company-careers", CancellationToken.None); + await sourceRepo.CompleteRunAsync(run.Id, "Completed", "integration complete", CancellationToken.None); + + var persisted = await db.SourceRuns.FirstOrDefaultAsync(x => x.Id == run.Id); + Assert.NotNull(persisted); + Assert.Equal("Completed", persisted!.Status); + Assert.NotNull(persisted.CompletedAt); + } + + [Fact] + public async Task ScoreHistory_And_InterviewPrep_Persist() + { + if (string.IsNullOrWhiteSpace(ConnectionString)) return; + + var options = new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options; + await using var db = new SentinelCareerDbContext(options); + + var opp = new JobOpportunity + { + ExternalId = $"it-{Guid.NewGuid():N}", + Title = "Director Governance", + NormalizedTitle = "director governance", + Description = "integration test", + PublishedAt = DateTimeOffset.UtcNow, + Company = new Company { Name = "IT Co", NormalizedName = "it co" }, + Location = new Location { City = "Mumbai", Region = "MH", Country = "India" }, + IsIndiaPriority = true + }; + + var oppRepo = new OpportunityRepository(db); + var scoreRepo = new ScoreRepository(db); + await oppRepo.UpsertAsync(opp, CancellationToken.None); + + var saved = await oppRepo.FindByExternalIdAsync(opp.ExternalId, CancellationToken.None); + Assert.NotNull(saved); + + await scoreRepo.SaveScoreAsync(new OpportunityScore + { + OpportunityId = saved!.Id, + CompositeScore = 83, + ProfileFit = 90, + CompensationFit = 70, + GeographyFit = 95, + SeniorityFit = 92, + IndustryFit = 88, + SourceCredibility = 80, + Freshness = 90, + OpportunityPotential = 75, + Explanation = "integration", + RecommendedNextAction = "Pursue immediately" + }, CancellationToken.None); + + db.InterviewPrepPacks.Add(new InterviewPrepPack { OpportunityId = saved.Id, CompanyBrief = "brief", RoleBrief = "role", FitAnalysis = "fit" }); + await db.SaveChangesAsync(); + + var hist = await scoreRepo.GetHistoryAsync(saved.Id, CancellationToken.None); + Assert.NotEmpty(hist); + var prep = await db.InterviewPrepPacks.FirstOrDefaultAsync(x => x.OpportunityId == saved.Id); + Assert.NotNull(prep); + } + + [Fact] + public async Task ReviewWorkflow_Persists_Status_And_Notes() + { + if (string.IsNullOrWhiteSpace(ConnectionString)) return; + + var options = new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options; + await using var db = new SentinelCareerDbContext(options); + var repo = new OpportunityRepository(db); + + var opp = new JobOpportunity + { + ExternalId = $"it-{Guid.NewGuid():N}", + Title = "Director Governance", + NormalizedTitle = "director governance", + Description = "integration test", + PublishedAt = DateTimeOffset.UtcNow, + Company = new Company { Name = "IT Co", NormalizedName = "it co" }, + Location = new Location { City = "Mumbai", Region = "MH", Country = "India" }, + IsIndiaPriority = true + }; + + await repo.UpsertAsync(opp, CancellationToken.None); + var saved = await repo.FindByExternalIdAsync(opp.ExternalId, CancellationToken.None); + Assert.NotNull(saved); + + await repo.UpdateReviewAsync(saved!.Id, OpportunityStatus.Ignored, "integration note", CancellationToken.None); + var updated = await repo.GetAsync(saved.Id, CancellationToken.None); + + Assert.Equal(OpportunityStatus.Ignored, updated!.Status); + Assert.Equal("integration note", updated.OperatorNotes); + } +} diff --git a/tests/SentinelCareer.IntegrationTests/SentinelCareer.IntegrationTests.csproj b/tests/SentinelCareer.IntegrationTests/SentinelCareer.IntegrationTests.csproj new file mode 100644 index 0000000..1052905 --- /dev/null +++ b/tests/SentinelCareer.IntegrationTests/SentinelCareer.IntegrationTests.csproj @@ -0,0 +1,13 @@ + + net8.0falseenable + + + + + + + + + + + diff --git a/tests/SentinelCareer.Web.SmokeTests/SentinelCareer.Web.SmokeTests.csproj b/tests/SentinelCareer.Web.SmokeTests/SentinelCareer.Web.SmokeTests.csproj new file mode 100644 index 0000000..009cec8 --- /dev/null +++ b/tests/SentinelCareer.Web.SmokeTests/SentinelCareer.Web.SmokeTests.csproj @@ -0,0 +1,12 @@ + + net8.0falseenable + + + + + + + + + + diff --git a/tests/SentinelCareer.Web.SmokeTests/SmokeTests.cs b/tests/SentinelCareer.Web.SmokeTests/SmokeTests.cs new file mode 100644 index 0000000..7f325b3 --- /dev/null +++ b/tests/SentinelCareer.Web.SmokeTests/SmokeTests.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace SentinelCareer.Web.SmokeTests; + +public class SmokeTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public SmokeTests(WebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task HomePage_Loads() + { + var client = _factory.CreateClient(); + var response = await client.GetAsync("/"); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("SentinelCareer Dashboard", content); + } +} diff --git a/tests/SentinelCareer.Workers.Tests/NotificationSemanticsTests.cs b/tests/SentinelCareer.Workers.Tests/NotificationSemanticsTests.cs new file mode 100644 index 0000000..876ad47 --- /dev/null +++ b/tests/SentinelCareer.Workers.Tests/NotificationSemanticsTests.cs @@ -0,0 +1,25 @@ +using SentinelCareer.Workers.Services; + +namespace SentinelCareer.Workers.Tests; + +public class NotificationSemanticsTests +{ + [Fact] + public void NewHighPriority_ShouldNotify() + { + Assert.True(ScoringWorker.ShouldNotify(null, 80, out _)); + } + + [Fact] + public void SmallIncrease_ShouldNotNotify() + { + Assert.False(ScoringWorker.ShouldNotify(78, 82, out _)); + } + + [Fact] + public void LargeIncrease_ShouldNotify() + { + Assert.True(ScoringWorker.ShouldNotify(70, 76, out var reason)); + Assert.Contains("improved", reason); + } +} diff --git a/tests/SentinelCareer.Workers.Tests/SentinelCareer.Workers.Tests.csproj b/tests/SentinelCareer.Workers.Tests/SentinelCareer.Workers.Tests.csproj new file mode 100644 index 0000000..8e8be3d --- /dev/null +++ b/tests/SentinelCareer.Workers.Tests/SentinelCareer.Workers.Tests.csproj @@ -0,0 +1,11 @@ + + net8.0falseenable + + + + + + + + + diff --git a/tests/python/test_adapters.py b/tests/python/test_adapters.py new file mode 100644 index 0000000..fafc75e --- /dev/null +++ b/tests/python/test_adapters.py @@ -0,0 +1,17 @@ +from sentinelcareer_pythonworkers.adapters import parse_generic_jobs_html + + +def test_parse_generic_jobs_html_single(): + html = '
VP Risk
Mumbai
' + result = parse_generic_jobs_html(html) + assert result[0]["title"] == "VP Risk" + + +def test_parse_generic_jobs_html_multiple_and_missing_location(): + html = ( + '
Director Governance
Delhi
' + '
Head Compliance
' + ) + result = parse_generic_jobs_html(html) + assert len(result) == 2 + assert result[1]["location"] == ""