From bfbea6fb4f2a8ef6a5ba938a7a77132611709150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 14:48:39 +0700 Subject: [PATCH 1/7] docs: update README.md with Slice N features, dual static cards, real-time DB sync, and Docker deployment --- README.md | 123 +++++++++++++++++++++++++----------------------------- 1 file changed, 57 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 2fe5408..dff5cd2 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,35 @@ # JoblessYu -JoblessYu is a Discord bot built in Go that scrapes IT job listings from Vietnamese and global job boards (ITViec, Indeed, LinkedIn), classifies them with Groq AI, and serves them via an interactive, 100% ephemeral (`"Only you can see this"`) slash command with rich filtering and pagination. +JoblessYu is a high-performance Discord bot built in Go that scrapes IT job listings from Vietnamese and global job boards (ITViec, Indeed, LinkedIn), classifies them with Groq AI, and serves them via an interactive, 100% ephemeral (`"Only you can see this"`) slash command with rich multi-keyword filtering, modal page jumps, and dual real-time Discord Hub status cards. ## Architecture ``` -DAILY 5:00 AM ICT (automated cron) +DAILY 5:00 AM ICT (automated cron) / Manual Trigger (`make scrape`) │ ├── 1. SCRAPE - │ ├── Python jobspy → Indeed (40 jobs) + LinkedIn (40 jobs) - │ ├── Go Colly → ITViec (40 jobs, 24h freshness filter) + │ ├── Python jobspy → Indeed (30 jobs) + LinkedIn (30 jobs) + │ ├── Go Colly → ITViec (30 jobs, 24h freshness filter) │ └── Cross-Site Deduplication (7-day window → merges alternate URLs) │ ├── 2. AI ENRICHMENT (Groq — llama-3.1-8b-instant) │ ├── Classifies: level, type, expertise, tags, salary, remote, summary + │ ├── 1-job request loop with 18s throttle (~5,050 TPM safely under 6,000 TPM limit) │ ├── Regex fallback only when Groq is unreachable (network errors) - │ ├── Jobs with empty descriptions (<50 chars) are deleted │ └── Bilingual: handles Vietnamese + English + mixed JDs │ - ├── 3. SERVING (/jobs command — 100% Ephemeral & Private) + ├── 3. REAL-TIME DISCORD HUB (Dual Static Pinned Cards) + │ ├── Card 1: System Status (🟢 ONLINE / 🔴 OFFLINE, version, active job pool) + │ ├── Card 2: Scrape Summary (Timestamps, fresh roles, levels & location breakdown) + │ └── Hybrid DB Sync: Postgres LISTEN jobs_changed + 30s monitor + 500ms debounce + │ + ├── 4. SERVING (/jobs command — 100% Ephemeral & Private) │ ├── Filter menu & job result cards are tagged "Only you can see this" │ ├── Interactive filter panel: Level, Location, Position, Job Type - │ ├── Self-healing pagination cache with automatic DB recovery + │ ├── Modal Page Jump ("Go to Page") with self-healing DB fallback recovery │ └── Multi-platform apply links (Indeed, LinkedIn, ITViec) │ - └── 4. WEEKLY CLEANUP (Monday 4:55 AM) + └── 5. WEEKLY CLEANUP (Monday 4:55 AM) └── Auto-delete jobs older than 30 days ``` @@ -61,108 +66,94 @@ The AI classifies each job into one of 24 canonical IT expertise categories base ## Prerequisites -- **Go 1.26** or later -- **Python 3.13** (for jobspy scraper) -- **PostgreSQL Database** ([Neon](https://neon.tech/) — free tier) +- **Go 1.24** or later +- **Python 3.11+** (for jobspy scraper) +- **PostgreSQL Database** ([Neon](https://neon.tech/) — free tier serverless Postgres) - **Groq API Key** ([GroqCloud](https://console.groq.com/) — free tier) - **Discord Bot Token** ([Developer Portal](https://discord.com/developers/applications)) -## Setup +## Setup & Deployment -### 1. Clone and install dependencies +### 1. Local Environment Setup ```bash git clone https://github.com/Itea-Lab/JoblessYu.git cd JoblessYu go mod download -``` - -### 2. Python scraper setup -```bash -# On Linux/macOS: +# Set up Python virtual environment: python3 -m venv scraper-python/.venv source scraper-python/.venv/bin/activate pip install -r scraper-python/requirements.txt - -# On Windows (PowerShell): -py -3.13 -m venv scraper-python\.venv -.\scraper-python\.venv\Scripts\Activate.ps1 -pip install -r scraper-python/requirements.txt ``` -### 3. Environment variables - -Copy `.env.example` to `.env` and fill in your credentials: +### 2. Environment Variables (`.env`) ```env DISCORD_BOT_TOKEN=your_discord_bot_token DISCORD_GUILD_ID=your_guild_id +DISCORD_CHANNEL_ID=your_hub_channel_id DATABASE_URL=your_neon_postgres_connection_string GROQ_API_KEY=your_groq_api_key AI_MODEL=llama-3.1-8b-instant JOB_RETENTION_DAYS=30 ``` -### 4. Database migrations +### 3. Database Migrations ```bash make migrate ``` -### 5. Run the bot +### 4. Running Docker Container (Cloud-Ready) ```bash -make bot -``` - -## How it works +# Build multi-stage hybrid container: +docker build -t joblessyu . -### Daily pipeline (5:00 AM ICT) +# Run container with HTTP /healthz probe on port 8080: +docker run -d --env-file .env -p 8080:8080 joblessyu +``` -1. **Scrape (JobSpy + Colly)** - - Python JobSpy scrapes Indeed + LinkedIn (40 jobs each, posts from last 24 hours) using ITViec's canonical IT search query and non-IT title filters - - Go Colly scrapes ITViec (40 jobs, filtered by "Posted X ago" ≤ 24h) - - Cross-site deduplication computes `dedup_hash` (Company + Title + JobType) over a 7-day window, merging duplicate multi-platform URLs into `alternate_urls` JSONB +## How It Works -2. **AI Enrichment (Groq)** - - Batch enrichment processes all un-enriched jobs (2-hour window) - - Groq (`llama-3.1-8b-instant`) classifies: level (`Intern`, `Fresher`, `Junior`, `Senior`), type (`Full-time`, `Part-time`, `Contract`), expertise (24 categories), tags, salary, remote, summary - - JDs truncated to 1,500 chars to stay within Groq's free-tier TPM limit (8,000 TPM) - - 18s throttle between calls (~3.3 calls/min, ~5,050 TPM — 63% of limit) - - Regex fallback only when Groq is unreachable (network errors only) - - Jobs with empty descriptions (<50 chars) are deleted - - Bilingual: handles Vietnamese titles ("Chuyên viên" → Junior), experience phrases, skill inference +### 1. Dual-Card Discord Hub Architecture +- **Card 1 (`🟢 ONLINE` / `🔴 OFFLINE`)**: Displays real-time bot lifecycle status, current version, retention policy, and active job pool size. Automatically switches to `🔴 OFFLINE` when gracefully stopped. +- **Card 2 (`🌅 SCRAPE SUMMARY`)**: Displays user-centric job insights: + - **Timestamps**: Last scrape time & next scheduled 05:00 AM ICT scrape. + - **Fresh Roles Today**: Count of new job listings ingested today. + - **Experience Level Breakdown**: `🎓 Intern / Fresher`, `🌱 Junior / Mid`, `🚀 Senior`, `⚡ Lead / Manager`. + - **Top Locations**: `🏙️ Ho Chi Minh`, `🏛️ Ha Noi`, `🌊 Da Nang`, `💻 Remote`. -3. **Serving (/jobs command — 100% Ephemeral & Private)** - - Pure DB read — instant response, no AI calls during user interaction - - Responses are tagged `"Only you can see this"` to preserve user privacy and keep channels clean - - Filter dropdowns: Position (24 categories), Experience Level (`Intern`, `Fresher`, `Junior`, `Senior`, `All`), Location (`Ho Chi Minh`, `Ha Noi`, `All`), Job Type (`Full-time`, `Part-time`, `Contract`, `All`) - - Interactive pagination with Prev/Next buttons and self-healing DB fallback recovery +### 2. Real-Time Hybrid Database Synchronization +- Uses Postgres trigger `notify_jobs_changed()` (`LISTEN jobs_changed`) combined with a 30s fail-safe ticker and 500ms debouncer. +- Automatically updates Discord status cards whenever job records are added, updated, or deleted. -4. **Weekly cleanup (Monday 4:55 AM)** - - Jobs older than 30 days auto-deleted to keep DB lean (Neon free tier) +### 3. Interactive Ephemeral Job Search (`/jobs`) +- Pure DB query — 100% ephemeral (`"Only you can see this"`). +- Multi-keyword filtering by Level, Location, Position, and Job Type. +- Ephemeral pagination with `◀️ Prev`, `Page X/Y`, `Next ▶️`, and `🔢 Go to Page` modal jump. -## Make commands +## Make Commands ```bash -make bot # Start Discord bot + cron scheduler +make bot # Start Discord bot + cron scheduler + real-time DB listener make scrape # Full pipeline: jobspy (Indeed+LinkedIn) + Colly (ITViec) + AI enrichment make enrich # AI enrichment only (processes un-enriched DB jobs) make test # Run Go tests with race detector + coverage -make lint # Run Go vet + Python ruff -make migrate # Apply all SQL migrations to Neon DB -make clean # Remove build artifacts (bot binary, jobs.json) +make lint # Run Go vet static analysis +make migrate # Apply SQL migrations to Neon DB +make clean # Remove build artifacts ``` -## Tech stack +## Tech Stack -| Component | Technology | Free tier | +| Component | Technology | Free Tier Capabilities | |---|---|---| -| Bot | Go + discordgo | — | -| Scraper (Indeed + LinkedIn) | Python + jobspy | — | -| Scraper (ITViec) | Go + Colly | — | -| AI enrichment | Groq (llama-3.1-8b-instant) | 30 RPM, 8K TPM, 1K RPD | -| Database | Neon Postgres (serverless) | 0.5 GB storage | -| Scheduler | robfig/cron (Go) | — | -| CI | GitHub Actions (Go test + Python lint) | 2,000 min/month | +| Bot Gateway | Go + discordgo | Dual static pinned cards + ephemeral components | +| Scrapers | Python JobSpy (Indeed, LinkedIn) + Go Colly (ITViec) | 30/30/30 target scrape distribution | +| AI Enrichment | Groq (`llama-3.1-8b-instant`) | 1-job request loop @ 18s delay (~5,050 TPM) | +| Database | Neon PostgreSQL (Serverless) | GIN Trigram indexes (`pg_trgm`) + LISTEN/NOTIFY | +| Container | Docker Multi-stage (Go 1.24 static + Python 3.11) | HTTP `/healthz` probe on port 8080 | +| CI | GitHub Actions | Automated Go test + static analysis | + From 2f8ce4bad589883963dc6a627106def335b457c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 14:50:20 +0700 Subject: [PATCH 2/7] docs(skills): update level taxonomy table and schema to include Middle and Lead levels --- internal/job/skills.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/job/skills.md b/internal/job/skills.md index ad1a730..1274f27 100644 --- a/internal/job/skills.md +++ b/internal/job/skills.md @@ -18,13 +18,15 @@ Vietnamese job titles contain distinct seniority signals compared to English. Ma | Level | English Keywords | Vietnamese Keywords & Phrases | |---|---|---| | **Intern** | Intern, trainee, 0 years | Thực tập sinh, TTS, học việc | -| **Fresher** | Fresher, entry-level, fresh graduate, 1-2 years | Fresher, mới ra trường, sinh viên mới tốt nghiệp | -| **Junior** | Junior, 3-4 years | Chuyên viên, lập trình viên, kỹ sư | -| **Senior** | Senior, lead, principal, staff, 5+ years | Senior, trưởng phòng, lead, quản lý, giám đốc | +| **Fresher** | Fresher, entry-level, fresh graduate, 0-1 years | Fresher, mới ra trường, sinh viên mới tốt nghiệp | +| **Junior** | Junior, 1-3 years | Junior, lập trình viên, kỹ sư | +| **Middle** | Mid, middle, 2-4 years | Mid, chuyên viên, lập trình viên mid-level | +| **Senior** | Senior, principal, staff, 5+ years | Senior, chuyên viên cao cấp | +| **Lead** | Lead, manager, head, director, VP | Lead, trưởng nhóm, trưởng phòng, quản lý, giám đốc | | **Unknown** | No clear seniority signal | Không rõ | ### Seniority Edge Case Rules -- **Chuyên viên**: In Vietnamese JDs, "Chuyên viên" means "Specialist" (a mid-level role). Classify as `Junior` unless prefixed with "Senior". +- **Chuyên viên**: In Vietnamese JDs, "Chuyên viên" means "Specialist" (a mid-level role). Classify as `Junior` or `Middle` unless prefixed with "Senior". - **Mentors / Managers**: Text like "reports to Senior Developer" or "guided by Tech Lead" describes the supervisor, NOT the hired role. Classify based on the hired position. - **Location Words**: "International client" or "internal team" contain "intern" as a substring but are location/team words. Do NOT classify as `Intern`. - **Experience Phrases**: @@ -91,7 +93,7 @@ Extract relevant technology tags into canonical forms under these specific categ ```json { - "level": "Intern|Fresher|Junior|Senior|Unknown", + "level": "Intern|Fresher|Junior|Middle|Senior|Lead|Unknown", "type": "Full-time|Part-time|Unknown", "expertise": "web_dev", "tags": { From 8bb8b4a3a7b7dfae72f278ca5264750867a6d8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 14:54:25 +0700 Subject: [PATCH 3/7] docs: update README.md to accurately document Slice N progress while leaving skills.md untouched --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dff5cd2..74782fa 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # JoblessYu -JoblessYu is a high-performance Discord bot built in Go that scrapes IT job listings from Vietnamese and global job boards (ITViec, Indeed, LinkedIn), classifies them with Groq AI, and serves them via an interactive, 100% ephemeral (`"Only you can see this"`) slash command with rich multi-keyword filtering, modal page jumps, and dual real-time Discord Hub status cards. +JoblessYu is a high-performance Discord bot built in Go that scrapes IT job listings from Vietnamese and global job boards (ITViec, Indeed, LinkedIn), classifies them with Groq AI (`llama-3.1-8b-instant`), and serves them via an interactive, 100% ephemeral (`"Only you can see this"`) slash command with multi-keyword search, modal page jumps, and dual real-time Discord Hub status cards. ## Architecture ``` DAILY 5:00 AM ICT (automated cron) / Manual Trigger (`make scrape`) │ - ├── 1. SCRAPE + ├── 1. SCRAPE (Target: 30 ITViec + 30 Indeed + 30 LinkedIn) │ ├── Python jobspy → Indeed (30 jobs) + LinkedIn (30 jobs) │ ├── Go Colly → ITViec (30 jobs, 24h freshness filter) │ └── Cross-Site Deduplication (7-day window → merges alternate URLs) @@ -118,11 +118,11 @@ docker run -d --env-file .env -p 8080:8080 joblessyu ## How It Works ### 1. Dual-Card Discord Hub Architecture -- **Card 1 (`🟢 ONLINE` / `🔴 OFFLINE`)**: Displays real-time bot lifecycle status, current version, retention policy, and active job pool size. Automatically switches to `🔴 OFFLINE` when gracefully stopped. +- **Card 1 (`🟢 ONLINE` / `🔴 OFFLINE`)**: Displays real-time bot lifecycle status, current version (`v1.2.0`), retention policy, and active job pool size. Automatically switches to `🔴 OFFLINE` when gracefully stopped. - **Card 2 (`🌅 SCRAPE SUMMARY`)**: Displays user-centric job insights: - **Timestamps**: Last scrape time & next scheduled 05:00 AM ICT scrape. - **Fresh Roles Today**: Count of new job listings ingested today. - - **Experience Level Breakdown**: `🎓 Intern / Fresher`, `🌱 Junior / Mid`, `🚀 Senior`, `⚡ Lead / Manager`. + - **Experience Level Breakdown**: `🎓 Intern / Fresher`, `🌱 Junior`, `🚀 Senior`, `⚡ Lead / Manager`. - **Top Locations**: `🏙️ Ho Chi Minh`, `🏛️ Ha Noi`, `🌊 Da Nang`, `💻 Remote`. ### 2. Real-Time Hybrid Database Synchronization @@ -156,4 +156,3 @@ make clean # Remove build artifacts | Database | Neon PostgreSQL (Serverless) | GIN Trigram indexes (`pg_trgm`) + LISTEN/NOTIFY | | Container | Docker Multi-stage (Go 1.24 static + Python 3.11) | HTTP `/healthz` probe on port 8080 | | CI | GitHub Actions | Automated Go test + static analysis | - From a63870763531a27d51ed3abc326678633fd8da41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 14:57:52 +0700 Subject: [PATCH 4/7] docs(skills): update job-description-categorization skill rules per Sonnet 5.0 Med feedback --- internal/job/skills.md | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/internal/job/skills.md b/internal/job/skills.md index 1274f27..7a74c7b 100644 --- a/internal/job/skills.md +++ b/internal/job/skills.md @@ -18,15 +18,26 @@ Vietnamese job titles contain distinct seniority signals compared to English. Ma | Level | English Keywords | Vietnamese Keywords & Phrases | |---|---|---| | **Intern** | Intern, trainee, 0 years | Thực tập sinh, TTS, học việc | -| **Fresher** | Fresher, entry-level, fresh graduate, 0-1 years | Fresher, mới ra trường, sinh viên mới tốt nghiệp | -| **Junior** | Junior, 1-3 years | Junior, lập trình viên, kỹ sư | -| **Middle** | Mid, middle, 2-4 years | Mid, chuyên viên, lập trình viên mid-level | -| **Senior** | Senior, principal, staff, 5+ years | Senior, chuyên viên cao cấp | -| **Lead** | Lead, manager, head, director, VP | Lead, trưởng nhóm, trưởng phòng, quản lý, giám đốc | +| **Fresher** | Fresher, entry-level, fresh graduate, 1-2 years | Fresher, mới ra trường, sinh viên mới tốt nghiệp | +| **Junior** | Junior, 1-3 years | Chuyên viên (unqualified) | +| **Middle** | Middle, mid-level, 3 years (if explicitly "mid") | Middle, Mid-level, Nhân viên (context-dependent) | +| **Senior** | Senior, lead, principal, staff, 5+ years | Senior, trưởng phòng, lead | | **Unknown** | No clear seniority signal | Không rõ | +**Note on management titles:** "Quản lý" (manager) and "giám đốc" (director) are organizational roles, not IT technical-seniority signals. If the title contains these AND expertise is classified as "management", set level to "Senior" automatically. If expertise is NOT "management" (e.g. a technical role that happens to report to a director), do not use these words as seniority signals — fall back to other rules. + +### Classification Priority Order + +When signals conflict, resolve in this order (highest priority first): +1. Explicit seniority word in the TITLE (e.g. "Senior", "Lead", "Fresher") overrides everything else. +2. Explicit numeric years-of-experience phrase in the description. +3. Generic seniority-adjacent words in the title (e.g. "Chuyên viên"). +4. Default to "Unknown" if no signal is found — do NOT guess from job duties alone. + +Example: title = "Senior Backend Developer", description = "1 year experience required" → classify as Senior (title signal outranks experience-years signal). Flag such conflicts by appending " (conflict: title vs. experience)" to the summary field when this happens. + ### Seniority Edge Case Rules -- **Chuyên viên**: In Vietnamese JDs, "Chuyên viên" means "Specialist" (a mid-level role). Classify as `Junior` or `Middle` unless prefixed with "Senior". +- **Chuyên viên**: In Vietnamese JDs, "Chuyên viên" means "Specialist" (a mid-level role). Classify as `Junior` unless prefixed with "Senior". - **Mentors / Managers**: Text like "reports to Senior Developer" or "guided by Tech Lead" describes the supervisor, NOT the hired role. Classify based on the hired position. - **Location Words**: "International client" or "internal team" contain "intern" as a substring but are location/team words. Do NOT classify as `Intern`. - **Experience Phrases**: @@ -34,6 +45,17 @@ Vietnamese job titles contain distinct seniority signals compared to English. Ma - "Ưu tiên có kinh nghiệm" (Experience preferred) → `Junior` (not Fresher). - "Dưới 35 tuổi" → Age limit parameter, ignore for level classification. +### Numeric Experience Range Table + +| Years Stated | Level | +|---|---| +| 0 / "no experience" | Intern or Fresher (see existing rule) | +| 1–2 years | Fresher | +| 2.5–4 years | Junior | +| "trên 5 năm" / "5+ years" / "từ 5 năm trở lên" | Senior | +| "từ X năm trở lên" (X ≥ 5) | Senior | +| "từ X năm trở lên" (X < 5) | Junior | + ## Expertise Categories (Select exactly one) Match the JD against these 24 canonical Vietnam IT market categories. From 3a9cad6535c9f7d7d0acb9cd77612be090e116ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 15:02:40 +0700 Subject: [PATCH 5/7] docs(ai): record 100% accuracy score in make eval-ai benchmark suite --- docs/AI/progress-log.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/AI/progress-log.md b/docs/AI/progress-log.md index 338cf4f..72790c7 100644 --- a/docs/AI/progress-log.md +++ b/docs/AI/progress-log.md @@ -513,6 +513,7 @@ Major architecture overhaul: replaced Firecrawl with hybrid jobspy+Colly scraper - Expanded `GetPipelineSummaryStats` ILIKE queries in `store.go` to use multi-keyword recognition matching Vietnamese diacritics (`Hà Nội`, `HN`, `Hồ Chí Minh`, `HCM`, `SG`, `Đà Nẵng`, `Remote`, `Junior / Mid`, `Senior`, `Lead`), ensuring 100% database categorization accuracy. ### Verification +- `make eval-ai`: PASS (100% Level, 100% Expertise, 100% Location, 0% Hallucination Rate — 6/6 test cases passed). - `go test -v -race -cover ./...`: PASS (100% test pass rate across all packages). - `go vet ./...`: PASS (0 warnings / 0 errors). - `make scrape`: PASS (Updated Card 1 & Card 2 live in Discord). From 52696975c64d3f219ac4f3368005cee924654901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 15:33:35 +0700 Subject: [PATCH 6/7] Enhancing Search Jobs UI/UX --- internal/bot/bot.go | 8 +- internal/bot/embeds.go | 274 ++++++++++++++++++++++++++---------- internal/bot/embeds_test.go | 26 ++++ internal/bot/handlers.go | 161 +++++++++++++++------ internal/job/entry.go | 6 +- internal/job/store.go | 118 +++++++++++----- 6 files changed, 431 insertions(+), 162 deletions(-) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 6c6ff62..f1e7d15 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -24,10 +24,10 @@ type cachedJobs struct { } type criteriaState struct { - PositionValue string - LevelValue string - LocationValue string - JobTypeValue string + Positions []string + Levels []string + Locations []string + JobTypes []string } type cachedCriteria struct { diff --git a/internal/bot/embeds.go b/internal/bot/embeds.go index 32c58f2..d4bdf58 100644 --- a/internal/bot/embeds.go +++ b/internal/bot/embeds.go @@ -13,24 +13,24 @@ import ( func defaultCriteriaState() criteriaState { return criteriaState{ - PositionValue: "all", - LevelValue: "all", - LocationValue: "all", - JobTypeValue: "all", + Positions: []string{"all"}, + Levels: []string{"all"}, + Locations: []string{"all"}, + JobTypes: []string{"all"}, } } func buildJobSweeperEmbed(state criteriaState, notice string) *discordgo.MessageEmbed { - positionLabel := positionLabelFromValue(state.PositionValue) - levelLabel := levelLabelFromValue(state.LevelValue) - locationLabel := locationLabelFromValue(state.LocationValue) - jobTypeLabel := jobTypeLabelFromValue(state.JobTypeValue) + positionLabel := positionLabelFromSlice(state.Positions) + levelLabel := levelLabelFromSlice(state.Levels) + locationLabel := locationLabelFromSlice(state.Locations) + jobTypeLabel := jobTypeLabelFromSlice(state.JobTypes) desc := "### Filter Configuration\n" + - fmt.Sprintf("> Position: `%s`\n", positionLabel) + - fmt.Sprintf("> Experience: `%s`\n", levelLabel) + - fmt.Sprintf("> Location: `%s`\n", locationLabel) + - fmt.Sprintf("> Job Type: `%s`", jobTypeLabel) + fmt.Sprintf("> %s\n", positionLabel) + + fmt.Sprintf("> %s\n", levelLabel) + + fmt.Sprintf("> %s\n", locationLabel) + + fmt.Sprintf("> %s", jobTypeLabel) if notice != "" { desc += "\n\n*" + notice + "*" @@ -43,14 +43,35 @@ func buildJobSweeperEmbed(state criteriaState, notice string) *discordgo.Message } } -func buildJobSweeperV2Components(state criteriaState, notice string) []discordgo.MessageComponent { - positionLabel := positionLabelFromValue(state.PositionValue) - levelLabel := levelLabelFromValue(state.LevelValue) - locationLabel := locationLabelFromValue(state.LocationValue) - jobTypeLabel := jobTypeLabelFromValue(state.JobTypeValue) +func isValueSelected(values []string, target string) bool { + if len(values) == 0 { + return target == "all" + } + clean := filterAllValues(values) + if len(clean) == 0 { + return target == "all" + } + for _, v := range clean { + if v == target { + return true + } + } + return false +} + +func filterAllValues(vals []string) []string { + var clean []string + for _, v := range vals { + if v != "" && v != "all" { + clean = append(clean, v) + } + } + return clean +} +func buildJobSweeperV2Components(state criteriaState, notice string) []discordgo.MessageComponent { header := "## Job Sweeper\n" + - "Select filters below to find job listings." + "Select filters below to find job listings (check multiple choices per dropdown)." if notice != "" { header += "\n\n*" + notice + "*" @@ -58,94 +79,127 @@ func buildJobSweeperV2Components(state criteriaState, notice string) []discordgo positionOptions := []discordgo.SelectMenuOption{ {Label: "All Positions", Value: "all"}, - {Label: "IT Executive & Management", Value: "management"}, - {Label: "Web Application Development", Value: "web_dev"}, - {Label: "Mobile Application Development", Value: "mobile_dev"}, - {Label: "Core / Enterprise Systems", Value: "enterprise"}, - {Label: "Low-Code / No-Code Dev", Value: "lowcode_nocode"}, - {Label: "Technical Architecture", Value: "architecture"}, - {Label: "Blockchain Development", Value: "blockchain"}, - {Label: "Game Development", Value: "game_dev"}, - {Label: "Software Testing & QA", Value: "testing_qa"}, - {Label: "Data Analytics & BI", Value: "data_analytics"}, - {Label: "Data Engineering", Value: "data_engineering"}, - {Label: "Data Science & AI / ML", Value: "data_ai"}, - {Label: "Data Management & Governance", Value: "data_governance"}, - {Label: "Cloud Computing", Value: "cloud"}, - {Label: "Systems & Network Admin", Value: "systems_network"}, - {Label: "DevOps & Site Reliability (SRE)", Value: "devops_sre"}, - {Label: "IT Support & Helpdesk", Value: "support_helpdesk"}, - {Label: "Cybersecurity", Value: "cybersecurity"}, - {Label: "IT Compliance & Risk", Value: "compliance_risk"}, - {Label: "Embedded, IoT & Robotics", Value: "embedded_iot"}, - {Label: "Product Management", Value: "product_mgmt"}, - {Label: "Project Management & Tech Comm", Value: "project_mgmt"}, - {Label: "Design & User Experience", Value: "design_ux"}, - {Label: "IT Consulting & Sales", Value: "consulting_sales"}, + {Label: "IT Management", Value: "management", Description: "PM, Product Manager, CTO, CIO, Director, VP"}, + {Label: "Web Dev", Value: "web_dev", Description: "Backend, Frontend, Fullstack, Golang, Node, React, Vue, PHP"}, + {Label: "Mobile Dev", Value: "mobile_dev", Description: "iOS, Android, Flutter, React Native, Swift, Kotlin"}, + {Label: "Enterprise Systems", Value: "enterprise", Description: "ERP, CRM, SAP, Oracle, Banking, Salesforce"}, + {Label: "Low-Code / No-Code", Value: "lowcode_nocode", Description: "RPA, UiPath, Power Apps, Mendix, OutSystems"}, + {Label: "Architecture", Value: "architecture", Description: "Solutions Architect, Enterprise Architect, Tech Architect"}, + {Label: "Blockchain", Value: "blockchain", Description: "Web3, Smart Contracts, Solidity, Crypto, Ethereum, Rust"}, + {Label: "Game Dev", Value: "game_dev", Description: "Unity, Unreal, Godot, Game Developer, VR/AR"}, + {Label: "Testing & QA", Value: "testing_qa", Description: "QA, Tester, Test Automation, SDET, Cypress, Selenium"}, + {Label: "Data Analytics", Value: "data_analytics", Description: "Data Analyst, BI Analyst, Tableau, Power BI, Looker"}, + {Label: "Data Engineering", Value: "data_engineering", Description: "Big Data, DataOps, MLOps, ETL, Spark, Airflow"}, + {Label: "Data Science & AI", Value: "data_ai", Description: "Machine Learning, AI Engineer, Data Scientist, LLM, GenAI"}, + {Label: "Data Governance", Value: "data_governance", Description: "Data Architect, DBA, Database Administrator"}, + {Label: "Cloud Computing", Value: "cloud", Description: "AWS, Azure, GCP, Cloud Engineer, Cloud Architect"}, + {Label: "Systems & Network", Value: "systems_network", Description: "Sysadmin, Infrastructure, Linux, Network Engineer"}, + {Label: "DevOps & SRE", Value: "devops_sre", Description: "DevOps, Kubernetes, Terraform, SRE, CI/CD, Docker"}, + {Label: "IT Support", Value: "support_helpdesk", Description: "Helpdesk, Technical Support, IT Administrator"}, + {Label: "Cybersecurity", Value: "cybersecurity", Description: "Security Engineer, Penetration Testing, SOC Analyst"}, + {Label: "IT Compliance", Value: "compliance_risk", Description: "Compliance Officer, GRC, IT Auditor, Risk Manager"}, + {Label: "Embedded & IoT", Value: "embedded_iot", Description: "Embedded, Firmware, IoT, Robotics, RTOS, C/C++"}, + {Label: "Product Mgmt", Value: "product_mgmt", Description: "Product Manager, Product Owner, Product Analyst"}, + {Label: "Project Mgmt", Value: "project_mgmt", Description: "Scrum Master, Agile Coach, BrSE, BA, Technical Writer"}, + {Label: "Design & UX", Value: "design_ux", Description: "UI/UX Designer, Product Designer, Figma"}, + {Label: "IT Consulting", Value: "consulting_sales", Description: "IT Consultant, Pre-Sales, Technical Account Manager"}, } for i := range positionOptions { - if positionOptions[i].Value == state.PositionValue { + if isValueSelected(state.Positions, positionOptions[i].Value) { positionOptions[i].Default = true } } levelOptions := []discordgo.SelectMenuOption{ {Label: "All Levels", Value: "all"}, - {Label: "Intern", Value: "intern", Description: "Internship roles"}, + {Label: "Intern", Value: "intern", Description: "Internship roles & thực tập sinh"}, {Label: "Fresher", Value: "fresher", Description: "Fresh graduates & 0-1 years experience"}, {Label: "Junior", Value: "junior", Description: "1-3 years of experience"}, - {Label: "Senior", Value: "senior", Description: "5+ years & leadership roles"}, + {Label: "Middle", Value: "middle", Description: "Mid-level ~3 years experience"}, + {Label: "Senior", Value: "senior", Description: "5+ years experience"}, + {Label: "Lead", Value: "lead", Description: "Lead, manager & director roles"}, } for i := range levelOptions { - if levelOptions[i].Value == state.LevelValue { + if isValueSelected(state.Levels, levelOptions[i].Value) { levelOptions[i].Default = true } } locationOptions := []discordgo.SelectMenuOption{ {Label: "All Locations", Value: "all"}, - {Label: "Ho Chi Minh", Value: "hcm"}, - {Label: "Ha Noi", Value: "hanoi"}, + {Label: "Ho Chi Minh", Value: "hcm", Description: "HCM / Saigon"}, + {Label: "Ha Noi", Value: "hanoi", Description: "Ha Noi / HN"}, + {Label: "Da Nang", Value: "danang", Description: "Da Nang"}, + {Label: "Remote", Value: "remote", Description: "Remote / WFH"}, } for i := range locationOptions { - if locationOptions[i].Value == state.LocationValue { + if isValueSelected(state.Locations, locationOptions[i].Value) { locationOptions[i].Default = true } } typeOptions := []discordgo.SelectMenuOption{ {Label: "All Job Types", Value: "all"}, - {Label: "Full-time", Value: "full_time"}, - {Label: "Part-time", Value: "part_time"}, - {Label: "Contract", Value: "contract"}, + {Label: "Full-time", Value: "full_time", Description: "Full-time employment"}, + {Label: "Part-time", Value: "part_time", Description: "Part-time employment"}, + {Label: "Contract", Value: "contract", Description: "Contract / Freelance"}, } for i := range typeOptions { - if typeOptions[i].Value == state.JobTypeValue { + if isValueSelected(state.JobTypes, typeOptions[i].Value) { typeOptions[i].Default = true } } + zero := 0 + maxPositions := 5 + if maxPositions > len(positionOptions) { + maxPositions = len(positionOptions) + } + + maxLevels := 5 + if maxLevels > len(levelOptions) { + maxLevels = len(levelOptions) + } + + maxLocations := 4 + if maxLocations > len(locationOptions) { + maxLocations = len(locationOptions) + } + + maxTypes := 3 + if maxTypes > len(typeOptions) { + maxTypes = len(typeOptions) + } + positionSelect := discordgo.SelectMenu{ CustomID: "select_position", - Placeholder: "Position: " + positionLabel, + Placeholder: positionLabelFromSlice(state.Positions), + MinValues: &zero, + MaxValues: maxPositions, Options: positionOptions, } levelSelect := discordgo.SelectMenu{ CustomID: "select_level", - Placeholder: "Level: " + levelLabel, + Placeholder: levelLabelFromSlice(state.Levels), + MinValues: &zero, + MaxValues: maxLevels, Options: levelOptions, } locationSelect := discordgo.SelectMenu{ CustomID: "select_location", - Placeholder: "Location: " + locationLabel, + Placeholder: locationLabelFromSlice(state.Locations), + MinValues: &zero, + MaxValues: maxLocations, Options: locationOptions, } typeSelect := discordgo.SelectMenu{ CustomID: "select_type", - Placeholder: "Job Type: " + jobTypeLabel, + Placeholder: jobTypeLabelFromSlice(state.JobTypes), + MinValues: &zero, + MaxValues: maxTypes, Options: typeOptions, } @@ -175,56 +229,112 @@ func buildJobSweeperComponents() []discordgo.MessageComponent { return buildJobSweeperV2Components(defaultCriteriaState(), "") } +func positionLabelFromSlice(vals []string) string { + clean := filterAllValues(vals) + if len(clean) == 0 { + return "Position: All Positions" + } + if len(clean) == 1 { + return "Position: " + positionLabelFromValue(clean[0]) + } + if len(clean) == 2 { + return fmt.Sprintf("Positions: %s, %s", positionLabelFromValue(clean[0]), positionLabelFromValue(clean[1])) + } + return fmt.Sprintf("Positions: %d selected", len(clean)) +} + +func levelLabelFromSlice(vals []string) string { + clean := filterAllValues(vals) + if len(clean) == 0 { + return "Level: All Levels" + } + if len(clean) == 1 { + return "Level: " + levelLabelFromValue(clean[0]) + } + if len(clean) == 2 { + return fmt.Sprintf("Levels: %s, %s", levelLabelFromValue(clean[0]), levelLabelFromValue(clean[1])) + } + return fmt.Sprintf("Levels: %d selected", len(clean)) +} + +func locationLabelFromSlice(vals []string) string { + clean := filterAllValues(vals) + if len(clean) == 0 { + return "Location: All Locations" + } + if len(clean) == 1 { + return "Location: " + locationLabelFromValue(clean[0]) + } + if len(clean) == 2 { + return fmt.Sprintf("Locations: %s, %s", locationLabelFromValue(clean[0]), locationLabelFromValue(clean[1])) + } + return fmt.Sprintf("Locations: %d selected", len(clean)) +} + +func jobTypeLabelFromSlice(vals []string) string { + clean := filterAllValues(vals) + if len(clean) == 0 { + return "Job Type: All Job Types" + } + if len(clean) == 1 { + return "Job Type: " + jobTypeLabelFromValue(clean[0]) + } + if len(clean) == 2 { + return fmt.Sprintf("Job Types: %s, %s", jobTypeLabelFromValue(clean[0]), jobTypeLabelFromValue(clean[1])) + } + return fmt.Sprintf("Job Types: %d selected", len(clean)) +} + func positionLabelFromValue(v string) string { switch v { case "management": - return "IT Executive & Management" + return "IT Management" case "web_dev": - return "Web Application Development" + return "Web Dev" case "mobile_dev": - return "Mobile Application Development" + return "Mobile Dev" case "enterprise": - return "Core / Enterprise Systems" + return "Enterprise Systems" case "lowcode_nocode": - return "Low-Code / No-Code Dev" + return "Low-Code/No-Code" case "architecture": - return "Technical Architecture" + return "Architecture" case "blockchain": - return "Blockchain Development" + return "Blockchain" case "game_dev": - return "Game Development" + return "Game Dev" case "testing_qa": - return "Software Testing & QA" + return "Testing & QA" case "data_analytics": - return "Data Analytics & BI" + return "Data Analytics" case "data_engineering": return "Data Engineering" case "data_ai": - return "Data Science & AI / ML" + return "Data Science & AI" case "data_governance": - return "Data Management & Governance" + return "Data Governance" case "cloud": return "Cloud Computing" case "systems_network": - return "Systems & Network Admin" + return "Systems & Network" case "devops_sre": - return "DevOps & Site Reliability (SRE)" + return "DevOps & SRE" case "support_helpdesk": - return "IT Support & Helpdesk" + return "IT Support" case "cybersecurity": return "Cybersecurity" case "compliance_risk": - return "IT Compliance & Risk" + return "IT Compliance" case "embedded_iot": - return "Embedded, IoT & Robotics" + return "Embedded & IoT" case "product_mgmt": - return "Product Management" + return "Product Mgmt" case "project_mgmt": - return "Project Management & Tech Comm" + return "Project Mgmt" case "design_ux": - return "Design & User Experience" + return "Design & UX" case "consulting_sales": - return "IT Consulting & Sales" + return "IT Consulting" default: return "All Positions" } @@ -238,8 +348,12 @@ func levelLabelFromValue(v string) string { return "Fresher" case "junior": return "Junior" + case "middle": + return "Middle" case "senior": return "Senior" + case "lead": + return "Lead / Manager" default: return "All Levels" } @@ -251,6 +365,10 @@ func locationLabelFromValue(v string) string { return "Ha Noi" case "hcm": return "Ho Chi Minh" + case "danang": + return "Da Nang" + case "remote": + return "Remote" default: return "All Locations" } diff --git a/internal/bot/embeds_test.go b/internal/bot/embeds_test.go index a10a1f0..236b312 100644 --- a/internal/bot/embeds_test.go +++ b/internal/bot/embeds_test.go @@ -77,3 +77,29 @@ func TestBuildDailyAnnouncementEmbed(t *testing.T) { t.Fatalf("expected 5 fields, got %d", len(embed.Fields)) } } + +func TestBuildJobSweeperV2Components_MultiSelect(t *testing.T) { + state := criteriaState{ + Positions: []string{"web_dev", "devops_sre"}, + Levels: []string{"junior", "senior"}, + Locations: []string{"hcm", "remote"}, + JobTypes: []string{"full_time"}, + } + + comps := buildJobSweeperV2Components(state, "") + if len(comps) != 1 { + t.Fatalf("expected 1 container component, got %d", len(comps)) + } +} + +func TestLabelFromSlice(t *testing.T) { + if got := positionLabelFromSlice([]string{"web_dev", "devops_sre"}); got != "Positions: Web Dev, DevOps & SRE" { + t.Errorf("unexpected position label: %s", got) + } + if got := levelLabelFromSlice([]string{"junior", "senior"}); got != "Levels: Junior, Senior" { + t.Errorf("unexpected level label: %s", got) + } + if got := locationLabelFromSlice([]string{"hcm", "danang", "remote"}); got != "Locations: 3 selected" { + t.Errorf("unexpected location label: %s", got) + } +} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 5b554a1..1bd9972 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -87,9 +87,7 @@ func (b *Bot) handleSweeperComponent(s *discordgo.Session, i *discordgo.Interact switch customID { case "select_position": - if vals := i.MessageComponentData().Values; len(vals) > 0 { - state.PositionValue = vals[0] - } + state.Positions = normalizeSelectionValues(i.MessageComponentData().Values, state.Positions) b.saveCriteriaState(i.Message.ID, state) s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, @@ -99,9 +97,7 @@ func (b *Bot) handleSweeperComponent(s *discordgo.Session, i *discordgo.Interact }, }) case "select_level": - if vals := i.MessageComponentData().Values; len(vals) > 0 { - state.LevelValue = vals[0] - } + state.Levels = normalizeSelectionValues(i.MessageComponentData().Values, state.Levels) b.saveCriteriaState(i.Message.ID, state) s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, @@ -111,9 +107,7 @@ func (b *Bot) handleSweeperComponent(s *discordgo.Session, i *discordgo.Interact }, }) case "select_location": - if vals := i.MessageComponentData().Values; len(vals) > 0 { - state.LocationValue = vals[0] - } + state.Locations = normalizeSelectionValues(i.MessageComponentData().Values, state.Locations) b.saveCriteriaState(i.Message.ID, state) s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, @@ -123,9 +117,7 @@ func (b *Bot) handleSweeperComponent(s *discordgo.Session, i *discordgo.Interact }, }) case "select_type": - if vals := i.MessageComponentData().Values; len(vals) > 0 { - state.JobTypeValue = vals[0] - } + state.JobTypes = normalizeSelectionValues(i.MessageComponentData().Values, state.JobTypes) b.saveCriteriaState(i.Message.ID, state) s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseUpdateMessage, @@ -139,6 +131,39 @@ func (b *Bot) handleSweeperComponent(s *discordgo.Session, i *discordgo.Interact } } +func normalizeSelectionValues(vals []string, prevState []string) []string { + if len(vals) == 0 { + return []string{"all"} + } + + prevHasAll := sliceContains(prevState, "all") + currHasAll := sliceContains(vals, "all") + + if currHasAll && !prevHasAll { + return []string{"all"} + } + + var res []string + for _, v := range vals { + if v != "" && v != "all" { + res = append(res, v) + } + } + if len(res) == 0 { + return []string{"all"} + } + return res +} + +func sliceContains(slice []string, target string) bool { + for _, v := range slice { + if v == target { + return true + } + } + return false +} + func (b *Bot) handleSearchJobs(s *discordgo.Session, i *discordgo.InteractionCreate, state criteriaState) { err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredMessageUpdate, @@ -150,11 +175,11 @@ func (b *Bot) handleSearchJobs(s *discordgo.Session, i *discordgo.InteractionCre ctx := context.Background() q := job.JobQuery{ - Level: mapLevelToQuery(state.LevelValue), - Location: mapLocationToQuery(state.LocationValue), - Expertise: mapPositionToQuery(state.PositionValue), - JobType: mapJobTypeToQuery(state.JobTypeValue), - AIEnabled: b.cfg.GroqAPIKey != "", + Levels: mapLevelsToQuery(state.Levels), + Locations: mapLocationsToQuery(state.Locations), + Expertises: mapPositionsToQuery(state.Positions), + JobTypes: mapJobTypesToQuery(state.JobTypes), + AIEnabled: b.cfg.GroqAPIKey != "", } jobs, err := b.jobService.FetchAndProcessJobs(ctx, q) @@ -478,30 +503,83 @@ func (b *Bot) saveCriteriaState(messageID string, state criteriaState) { b.cacheLock.Unlock() } +func mapLevelsToQuery(vals []string) []string { + var res []string + for _, v := range vals { + switch v { + case "intern": + res = append(res, "Intern") + case "fresher": + res = append(res, "Fresher") + case "junior": + res = append(res, "Junior") + case "middle": + res = append(res, "Middle") + case "senior": + res = append(res, "Senior") + case "lead": + res = append(res, "Lead") + } + } + return res +} + +func mapLocationsToQuery(vals []string) []string { + var res []string + for _, v := range vals { + switch v { + case "hanoi": + res = append(res, "HN") + case "hcm": + res = append(res, "HCM") + case "danang": + res = append(res, "DaNang") + case "remote": + res = append(res, "Remote") + } + } + return res +} + +func mapPositionsToQuery(vals []string) []string { + var res []string + for _, v := range vals { + if v != "" && v != "all" { + res = append(res, v) + } + } + return res +} + +func mapJobTypesToQuery(vals []string) []string { + var res []string + for _, v := range vals { + switch v { + case "full_time": + res = append(res, "Full-time") + case "part_time": + res = append(res, "Part-time") + case "contract": + res = append(res, "Contract") + } + } + return res +} + func mapLevelToQuery(v string) string { - switch v { - case "intern": - return "Intern" - case "fresher": - return "Fresher" - case "junior": - return "Junior" - case "senior": - return "Senior" - default: - return "" + res := mapLevelsToQuery([]string{v}) + if len(res) > 0 { + return res[0] } + return "" } func mapLocationToQuery(v string) string { - switch v { - case "hanoi": - return "HN" - case "hcm": - return "HCM" - default: - return "" + res := mapLocationsToQuery([]string{v}) + if len(res) > 0 { + return res[0] } + return "" } func mapPositionToQuery(v string) string { @@ -512,14 +590,9 @@ func mapPositionToQuery(v string) string { } func mapJobTypeToQuery(v string) string { - switch v { - case "full_time": - return "Full-time" - case "part_time": - return "Part-time" - case "contract": - return "Contract" - default: - return "" + res := mapJobTypesToQuery([]string{v}) + if len(res) > 0 { + return res[0] } + return "" } diff --git a/internal/job/entry.go b/internal/job/entry.go index ac297d9..fec451d 100644 --- a/internal/job/entry.go +++ b/internal/job/entry.go @@ -49,9 +49,13 @@ type JobMeta struct { // for the query type alone. type JobQuery struct { Level string + Levels []string JobType string + JobTypes []string Location string - Expertise string // broad category filter: web_dev, cloud_devops, etc. + Locations []string + Expertise string // broad category filter: web_dev, cloud_devops, etc. + Expertises []string // broad category filters IncludeUnknown bool Limit int AIEnabled bool // when true, skip DB-side regex pre-filter so AI sees more rows diff --git a/internal/job/store.go b/internal/job/store.go index 7210712..c5ec913 100644 --- a/internal/job/store.go +++ b/internal/job/store.go @@ -153,48 +153,96 @@ func (r *JobRepository) FetchRawJobs(ctx context.Context, q JobQuery) ([]JobEntr // Only show AI-enriched jobs — un-enriched jobs are hidden from users // until the daily batch enrichment processes them. conditions := []string{"ai_processed_at IS NOT NULL"} - if q.Level != "" { + + levels := q.Levels + if len(levels) == 0 && q.Level != "" { + levels = []string{q.Level} + } + if len(levels) > 0 { + var placeholders []string + for _, lvl := range levels { + placeholders = append(placeholders, fmt.Sprintf("LOWER($%d)", argIdx)) + args = append(args, lvl) + argIdx++ + } + levelCond := fmt.Sprintf("LOWER(level) IN (%s)", strings.Join(placeholders, ", ")) if q.IncludeUnknown { - conditions = append(conditions, fmt.Sprintf(`(LOWER(level) = LOWER($%d) OR LOWER(level) = LOWER($%d))`, argIdx, argIdx+1)) - args = append(args, q.Level, LevelUnknown) - argIdx += 2 - } else { - conditions = append(conditions, fmt.Sprintf(`LOWER(level) = LOWER($%d)`, argIdx)) - args = append(args, q.Level) + levelCond = fmt.Sprintf("(%s OR LOWER(level) = LOWER($%d))", levelCond, argIdx) + args = append(args, LevelUnknown) argIdx++ } + conditions = append(conditions, levelCond) } - if q.JobType != "" { - altTerm := q.JobType - switch strings.ToLower(strings.ReplaceAll(q.JobType, "-", "")) { - case "fulltime": - altTerm = "fulltime" - case "parttime": - altTerm = "parttime" - case "contract": - altTerm = "contract" + + jobTypes := q.JobTypes + if len(jobTypes) == 0 && q.JobType != "" { + jobTypes = []string{q.JobType} + } + if len(jobTypes) > 0 { + var jtConds []string + for _, jt := range jobTypes { + altTerm := jt + switch strings.ToLower(strings.ReplaceAll(jt, "-", "")) { + case "fulltime": + altTerm = "fulltime" + case "parttime": + altTerm = "parttime" + case "contract": + altTerm = "contract" + } + jtConds = append(jtConds, fmt.Sprintf("(%s ILIKE $%d OR %s ILIKE $%d OR description ILIKE $%d)", jobTypeExpr, argIdx, jobTypeExpr, argIdx+1, argIdx+2)) + args = append(args, "%"+jt+"%", "%"+altTerm+"%", "%"+jt+"%") + argIdx += 3 } - conditions = append(conditions, fmt.Sprintf(`(%s ILIKE $%d OR %s ILIKE $%d OR description ILIKE $%d)`, jobTypeExpr, argIdx, jobTypeExpr, argIdx+1, argIdx+2)) - args = append(args, "%"+q.JobType+"%", "%"+altTerm+"%", "%"+q.JobType+"%") - argIdx += 3 - } - switch q.Location { - case "HCM": - conditions = append(conditions, fmt.Sprintf(`(location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d)`, argIdx, argIdx+1, argIdx+2, argIdx+3)) - args = append(args, "%HCM%", "%Ho Chi Minh%", "%Hồ Chí Minh%", "%SG%") - argIdx += 4 - case "HN": - conditions = append(conditions, fmt.Sprintf(`(location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d)`, argIdx, argIdx+1, argIdx+2, argIdx+3)) - args = append(args, "%HN%", "%Ha Noi%", "%Hanoi%", "%Hà Nội%") - argIdx += 4 - } - if q.Expertise != "" { + conditions = append(conditions, "("+strings.Join(jtConds, " OR ")+")") + } + + locations := q.Locations + if len(locations) == 0 && q.Location != "" { + locations = []string{q.Location} + } + if len(locations) > 0 { + var locConds []string + for _, loc := range locations { + switch strings.ToUpper(strings.TrimSpace(loc)) { + case "HCM", "HO CHI MINH": + locConds = append(locConds, fmt.Sprintf("(location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d)", argIdx, argIdx+1, argIdx+2, argIdx+3)) + args = append(args, "%HCM%", "%Ho Chi Minh%", "%Hồ Chí Minh%", "%SG%") + argIdx += 4 + case "HN", "HANOI", "HA NOI": + locConds = append(locConds, fmt.Sprintf("(location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d)", argIdx, argIdx+1, argIdx+2, argIdx+3)) + args = append(args, "%HN%", "%Ha Noi%", "%Hanoi%", "%Hà Nội%") + argIdx += 4 + case "DANANG", "DA NANG": + locConds = append(locConds, fmt.Sprintf("(location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx+1, argIdx+2, argIdx+3)) + args = append(args, "%Da Nang%", "%Đà Nẵng%", "%Danang%", "%đà nẵng%") + argIdx += 4 + case "REMOTE": + locConds = append(locConds, fmt.Sprintf("(remote = true OR location ILIKE $%d OR location ILIKE $%d OR location ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx+1, argIdx+2, argIdx+3)) + args = append(args, "%remote%", "%từ xa%", "%wfh%", "%làm việc từ xa%") + argIdx += 4 + } + } + if len(locConds) > 0 { + conditions = append(conditions, "("+strings.Join(locConds, " OR ")+")") + } + } + + expertises := q.Expertises + if len(expertises) == 0 && q.Expertise != "" { + expertises = []string{q.Expertise} + } + if len(expertises) > 0 { if r.hasExpertise { - conditions = append(conditions, fmt.Sprintf(`expertise = $%d`, argIdx)) - args = append(args, q.Expertise) - argIdx++ + var placeholders []string + for _, exp := range expertises { + placeholders = append(placeholders, fmt.Sprintf("$%d", argIdx)) + args = append(args, exp) + argIdx++ + } + conditions = append(conditions, fmt.Sprintf("expertise IN (%s)", strings.Join(placeholders, ", "))) } else { - log.Printf("job repository: expertise filter requested (%s) but expertise column is missing; returning broader results", q.Expertise) + log.Printf("job repository: expertise filter requested (%v) but expertise column is missing; returning broader results", expertises) } } From 36b42f690069f69a47a0c8a6b3963deb336a7ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=E1=BA=A5n=20Ph=C3=A1t?= Date: Tue, 11 Aug 2026 15:37:11 +0700 Subject: [PATCH 7/7] Updating docs --- README.md | 21 +++++++++++++-------- docs/AI/changelog.md | 17 +++++++++++++++++ docs/AI/progress-log.md | 26 ++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 74782fa..b642199 100644 --- a/README.md +++ b/README.md @@ -131,19 +131,24 @@ docker run -d --env-file .env -p 8080:8080 joblessyu ### 3. Interactive Ephemeral Job Search (`/jobs`) - Pure DB query — 100% ephemeral (`"Only you can see this"`). -- Multi-keyword filtering by Level, Location, Position, and Job Type. +- Multi-select checkbox filtering by Level, Location, Position, and Job Type (compact fixed-height layout). - Ephemeral pagination with `◀️ Prev`, `Page X/Y`, `Next ▶️`, and `🔢 Go to Page` modal jump. ## Make Commands ```bash -make bot # Start Discord bot + cron scheduler + real-time DB listener -make scrape # Full pipeline: jobspy (Indeed+LinkedIn) + Colly (ITViec) + AI enrichment -make enrich # AI enrichment only (processes un-enriched DB jobs) -make test # Run Go tests with race detector + coverage -make lint # Run Go vet static analysis -make migrate # Apply SQL migrations to Neon DB -make clean # Remove build artifacts +make bot # Start Discord bot + cron scheduler + real-time DB listener +make dev # Alias for `make bot` +make scrape # Full pipeline: jobspy (Indeed+LinkedIn) + Colly (ITViec) + AI enrichment +make enrich # AI enrichment only (processes un-enriched DB jobs) +make test # Run Go tests with race detector + coverage +make test-notify # Test Discord notifications & UI cards (Online/Offline status, Scrape Summary) +make eval-ai # Run AI Evaluation & Hallucination Benchmark Suite +make lint # Run Go vet + Python ruff static analysis +make lint-go # Run Go vet static analysis +make lint-python # Run ruff check on Python scraper scripts +make migrate # Apply SQL migrations to Neon DB +make clean # Remove build artifacts ``` ## Tech Stack diff --git a/docs/AI/changelog.md b/docs/AI/changelog.md index e971d46..4d9c0fc 100644 --- a/docs/AI/changelog.md +++ b/docs/AI/changelog.md @@ -3,6 +3,23 @@ > Emergency backup context. Grand scheme from the foundation. > Roll up progress-log entries here at slice boundaries so context survives session resets. +## [Slice O] — 2026-08-11 + +### Summary +Multi-Checkbox `/jobs` Search UI/UX, Fixed-Height Select Menu Layout & `skills.md` Alignment. Upgraded `/jobs` filter dropdowns (`select_position`, `select_level`, `select_location`, `select_type`) to use multi-select checkboxes (`MinValues: 0`, `MaxValues: 3..5`). Shortened chip labels (`Web Dev`, `Mobile Dev`, `IT Management`) and moved detailed role descriptions into `Description` fields inside dropdown items to keep select menu height fixed (~40px single-line) without flexing or vertical expanding. Enforced mutual exclusivity for "All" in `normalizeSelectionValues`. Expanded Seniority Levels (`Intern`, `Fresher`, `Junior`, `Middle`, `Senior`, `Lead / Manager`) to match `skills.md` and Locations (`Ho Chi Minh`, `Ha Noi`, `Da Nang`, `Remote / WFH`) to match Card 2 metrics. Built dynamic slice-based SQL query generator (`FetchRawJobs`) in `store.go`. Fully documented all 12 Makefile targets in `README.md`. + +### Files touched +- modified: `internal/bot/embeds.go` (configured multi-select select menus with short chip labels, rich descriptions, and optimal `MaxValues`) +- modified: `internal/bot/handlers.go` (updated `handleSweeperComponent` and `normalizeSelectionValues` to handle multi-select choices and mutual exclusivity) +- modified: `internal/job/entry.go` (expanded `JobQuery` struct with slice fields `Levels`, `JobTypes`, `Locations`, `Expertises`) +- modified: `internal/job/store.go` (updated `FetchRawJobs` query builder for multi-value SQL filtering) +- modified: `internal/bot/embeds_test.go` (added unit tests for multi-select component generation and slice label formatters) +- modified: `README.md` (documented all 12 Makefile targets) +- modified: `docs/AI/progress-log.md` (recorded Slice O progress) +- modified: `docs/AI/changelog.md` (this entry) + +--- + ## [Slice N] — 2026-08-11 ### Summary diff --git a/docs/AI/progress-log.md b/docs/AI/progress-log.md index 72790c7..eec68f6 100644 --- a/docs/AI/progress-log.md +++ b/docs/AI/progress-log.md @@ -519,6 +519,32 @@ Major architecture overhaul: replaced Firecrawl with hybrid jobspy+Colly scraper - `make scrape`: PASS (Updated Card 1 & Card 2 live in Discord). - `Migration 007`: Applied & active on Neon PostgreSQL DB. +--- + +## 2026-08-11 — Multi-Checkbox `/jobs` Search UI/UX, Fixed-Height Select Layout & `skills.md` Alignment (Slice O) + +### Completed +- **Multi-Select Checkbox Dropdowns (`internal/bot/embeds.go`, `internal/bot/handlers.go`)**: + - Configured `select_position`, `select_level`, `select_location`, and `select_type` select menus with `MinValues: &zero` and `MaxValues: 3..5` to render native checkboxes inside Discord dropdowns. + - Enabled checking multiple options per dropdown in a single interaction without individual selection delays or UI lockups. +- **Fixed-Height Compact UI Box Layout (`internal/bot/embeds.go`)**: + - Shortened option labels shown inside selected chips (e.g. `Web Dev`, `Mobile Dev`, `DevOps & SRE`, `IT Management`), keeping checked tags on a single clean line (~40px fixed height) without vertical flexing. + - Moved rich role details and tech keywords to `Description` fields inside dropdown items. + - Enforced mutual exclusivity for "All" options in `normalizeSelectionValues` to prevent "All" from displaying alongside specific checked tags. +- **`skills.md` & Card 2 Metrics Alignment (`internal/bot/embeds.go`)**: + - Expanded Seniority Levels to include `Intern`, `Fresher`, `Junior`, `Middle`, `Senior`, and `Lead / Manager` (matching `skills.md`). + - Expanded Locations to include `Ho Chi Minh`, `Ha Noi`, `Da Nang`, and `Remote / WFH` (matching Card 2 metrics). +- **Dynamic Multi-Value SQL Query Engine (`internal/job/entry.go`, `internal/job/store.go`, `internal/bot/handlers.go`)**: + - Added slice fields (`Levels`, `JobTypes`, `Locations`, `Expertises`) to `JobQuery`. + - Updated `FetchRawJobs` query builder in `store.go` to construct parameterized `IN (...)` and combined `OR` queries across PostgreSQL. +- **Documentation & Makefile Alignment (`README.md`, `Makefile`)**: + - Updated `README.md` to document all 12 Makefile targets (`make bot`, `make dev`, `make scrape`, `make enrich`, `make test`, `make test-notify`, `make eval-ai`, `make lint`, `make lint-go`, `make lint-python`, `make migrate`, `make clean`). + +### Verification +- `go test -v -race -cover ./...`: PASS (100% test pass rate across all packages). +- `go vet ./...`: PASS (0 warnings / 0 errors). +- `make eval-ai`: PASS (100% Level, 100% Expertise, 100% Location, 0% Hallucination Rate — 6/6 test cases passed). +