feat: initialize project structure with SQLite database, crawler framework, and LLM-based JD analysis integration

This commit is contained in:
2026-08-12 01:13:53 -07:00
commit 51b9468737
27 changed files with 3422 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
package analyzer
import (
"context"
"fmt"
"strings"
"github.com/hireme/hireme/internal/config"
)
type AnalysisOutput struct {
MatchedHighlights []string `json:"matched_highlights"`
MissingHighlights []string `json:"missing_highlights"`
TailoringSuggestions []string `json:"tailoring_suggestions"`
}
type LLMAnalyzer interface {
AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error)
}
func NewAnalyzer(cfg *config.Config) (LLMAnalyzer, error) {
provider := strings.ToLower(cfg.LLMProvider)
switch provider {
case "gemini":
if cfg.GeminiAPIKey == "" {
return nil, fmt.Errorf("GEMINI_API_KEY is required for provider 'gemini'")
}
return NewGeminiAnalyzer(cfg.GeminiAPIKey), nil
case "deepseek":
if cfg.DeepSeekAPIKey == "" {
return nil, fmt.Errorf("DEEPSEEK_API_KEY is required for provider 'deepseek'")
}
return NewDeepSeekAnalyzer(cfg.DeepSeekAPIKey, cfg.DeepSeekBaseURL), nil
case "mock":
return NewMockAnalyzer(), nil
default:
return NewMockAnalyzer(), nil
}
}
+118
View File
@@ -0,0 +1,118 @@
package analyzer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type DeepSeekAnalyzer struct {
apiKey string
baseURL string
client *http.Client
}
func NewDeepSeekAnalyzer(apiKey, baseURL string) *DeepSeekAnalyzer {
if baseURL == "" {
baseURL = "https://api.deepseek.com"
}
return &DeepSeekAnalyzer{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s".
Provide a structured JSON output with 3 fields:
- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]).
- "missing_highlights": list of 1-3 missing skills, experience gaps, or extra requirements (e.g. ["10+ yrs experience", "Kubernetes administration"]).
- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD.
Job Title: %s
Job Content:
%s`, jobTitle, jobTitle, rawContent)
reqBody := map[string]interface{}{
"model": "deepseek-chat",
"messages": []map[string]string{
{"role": "system", "content": "You are a professional hiring assistant. You MUST respond ONLY with valid JSON matching {\"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}."},
{"role": "user", "content": prompt},
},
"response_format": map[string]string{
"type": "json_object",
},
}
jsonBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal DeepSeek payload: %w", err)
}
url := fmt.Sprintf("%s/chat/completions", d.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
if err != nil {
return nil, fmt.Errorf("failed to create DeepSeek request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", d.apiKey))
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("DeepSeek API call failed: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read DeepSeek response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("DeepSeek API error (status %d): %s", resp.StatusCode, string(respBytes))
}
var dsResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBytes, &dsResp); err != nil {
return nil, fmt.Errorf("failed to decode DeepSeek envelope: %w", err)
}
if len(dsResp.Choices) == 0 {
return nil, fmt.Errorf("empty choices in DeepSeek response")
}
rawJSON := cleanJSONResponse(dsResp.Choices[0].Message.Content)
var output AnalysisOutput
if err := json.Unmarshal([]byte(rawJSON), &output); err != nil {
return nil, fmt.Errorf("failed to parse DeepSeek JSON output: %w", err)
}
return &output, nil
}
func cleanJSONResponse(raw string) string {
cleaned := strings.TrimSpace(raw)
if strings.HasPrefix(cleaned, "```json") {
cleaned = strings.TrimPrefix(cleaned, "```json")
cleaned = strings.TrimSuffix(cleaned, "```")
} else if strings.HasPrefix(cleaned, "```") {
cleaned = strings.TrimPrefix(cleaned, "```")
cleaned = strings.TrimSuffix(cleaned, "```")
}
return strings.TrimSpace(cleaned)
}
+106
View File
@@ -0,0 +1,106 @@
package analyzer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type GeminiAnalyzer struct {
apiKey string
client *http.Client
}
func NewGeminiAnalyzer(apiKey string) *GeminiAnalyzer {
return &GeminiAnalyzer{
apiKey: apiKey,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (g *GeminiAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s".
Provide a structured JSON output with 3 fields:
- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]).
- "missing_highlights": list of 1-3 missing skills, experience gaps, or extra requirements (e.g. ["10+ yrs experience", "Kubernetes administration"]).
- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD.
Job Title: %s
Job Content:
%s
Respond STRICTLY with valid JSON matching the format:
{"matched_highlights": [...], "missing_highlights": [...], "tailoring_suggestions": [...]}`, jobTitle, jobTitle, rawContent)
reqBody := map[string]interface{}{
"contents": []map[string]interface{}{
{
"parts": []map[string]string{
{"text": prompt},
},
},
},
"generationConfig": map[string]interface{}{
"response_mime_type": "application/json",
},
}
jsonBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal Gemini payload: %w", err)
}
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=%s", g.apiKey)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
if err != nil {
return nil, fmt.Errorf("failed to create Gemini HTTP request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.client.Do(req)
if err != nil {
return nil, fmt.Errorf("Gemini API call failed: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Gemini response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Gemini API error (status %d): %s", resp.StatusCode, string(respBytes))
}
var geminiResp struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.Unmarshal(respBytes, &geminiResp); err != nil {
return nil, fmt.Errorf("failed to decode Gemini envelope: %w", err)
}
if len(geminiResp.Candidates) == 0 || len(geminiResp.Candidates[0].Content.Parts) == 0 {
return nil, fmt.Errorf("empty candidates in Gemini response")
}
rawJSON := geminiResp.Candidates[0].Content.Parts[0].Text
rawJSON = cleanJSONResponse(rawJSON)
var output AnalysisOutput
if err := json.Unmarshal([]byte(rawJSON), &output); err != nil {
return nil, fmt.Errorf("failed to parse Gemini JSON output: %w", err)
}
return &output, nil
}
+20
View File
@@ -0,0 +1,20 @@
package analyzer
import (
"context"
)
type MockAnalyzer struct{}
func NewMockAnalyzer() *MockAnalyzer {
return &MockAnalyzer{}
}
// AnalyzeJD returns empty slices for mock provider so no fake mock text is injected into side panel during debug
func (m *MockAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
return &AnalysisOutput{
MatchedHighlights: []string{},
MissingHighlights: []string{},
TailoringSuggestions: []string{},
}, nil
}
+53
View File
@@ -0,0 +1,53 @@
package config
import (
"os"
"strings"
"github.com/spf13/pflag"
)
type Config struct {
Port string
DBPath string
TargetConfig string
LLMProvider string // "gemini", "deepseek", "mock"
GeminiAPIKey string
DeepSeekAPIKey string
DeepSeekBaseURL string
}
func RegisterFlags(flags *pflag.FlagSet) *Config {
cfg := &Config{}
flags.StringVarP(&cfg.Port, "port", "p", getEnvOrDefault("PORT", "8080"), "HTTP server port")
flags.StringVarP(&cfg.DBPath, "db", "d", getEnvOrDefault("DB_PATH", "data/db/hireme.db"), "Path to database file")
flags.StringVarP(&cfg.TargetConfig, "target-config", "c", getEnvOrDefault("TARGET_CONFIG", "data/config/targets.yaml"), "Path to targets YAML file")
flags.StringVarP(&cfg.LLMProvider, "llm-provider", "l", getEnvOrDefault("LLM_PROVIDER", ""), "Active LLM Provider (gemini, deepseek, mock)")
flags.StringVar(&cfg.GeminiAPIKey, "gemini-api-key", os.Getenv("GEMINI_API_KEY"), "Gemini API Key")
flags.StringVar(&cfg.DeepSeekAPIKey, "deepseek-api-key", os.Getenv("DEEPSEEK_API_KEY"), "DeepSeek API Key")
flags.StringVar(&cfg.DeepSeekBaseURL, "deepseek-base-url", getEnvOrDefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), "DeepSeek Base URL")
return cfg
}
func (c *Config) Normalize() {
if c.LLMProvider == "" {
if c.GeminiAPIKey != "" {
c.LLMProvider = "gemini"
} else if c.DeepSeekAPIKey != "" {
c.LLMProvider = "deepseek"
} else {
c.LLMProvider = "mock"
}
}
c.LLMProvider = strings.ToLower(c.LLMProvider)
}
func getEnvOrDefault(key, defaultValue string) string {
val := os.Getenv(key)
if val != "" {
return val
}
return defaultValue
}
+32
View File
@@ -0,0 +1,32 @@
package crawler
import (
"context"
"crypto/sha256"
"fmt"
)
type DiscoveredJob struct {
CanonicalID string
ApplyURL string
}
type ExtractedJob struct {
CanonicalID string
Title string
Company string
Location string
ApplyURL string
RawContent string
}
func GenerateCanonicalID(company, title, applyURL string) string {
data := fmt.Sprintf("%s|%s|%s", company, title, applyURL)
return fmt.Sprintf("%x", sha256.Sum256([]byte(data)))
}
type SiteCrawler interface {
CanHandle(platform string) bool
DiscoverJobs(ctx context.Context, targetURL string) ([]DiscoveredJob, error)
FetchJobDetail(ctx context.Context, applyURL string) (*ExtractedJob, error)
}
+140
View File
@@ -0,0 +1,140 @@
package crawler
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"golang.org/x/net/html"
)
type GenericCrawler struct {
client *http.Client
}
func NewGenericCrawler() *GenericCrawler {
return &GenericCrawler{
client: &http.Client{
Timeout: 15 * time.Second,
},
}
}
func (c *GenericCrawler) CanHandle(platform string) bool {
p := strings.ToLower(platform)
return p == "generic" || p == "greenhouse" || p == "lever" || p == "linkedin"
}
func (c *GenericCrawler) DiscoverJobs(ctx context.Context, targetURL string) ([]DiscoveredJob, error) {
cid := GenerateCanonicalID(extractCompanyFromURL(targetURL), "Generic Job", targetURL)
return []DiscoveredJob{
{
CanonicalID: cid,
ApplyURL: targetURL,
},
}, nil
}
func (c *GenericCrawler) FetchJobDetail(ctx context.Context, applyURL string) (*ExtractedJob, error) {
req, err := http.NewRequestWithContext(ctx, "GET", applyURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status error %d for URL %s", resp.StatusCode, applyURL)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
rawText := ExtractTextFromHTML(string(body))
title := ExtractTitleFromHTML(string(body))
if title == "" {
title = "Job Opportunity"
}
company := extractCompanyFromURL(applyURL)
cid := GenerateCanonicalID(company, title, applyURL)
return &ExtractedJob{
CanonicalID: cid,
Title: title,
Company: company,
Location: "Remote / Listed Location",
ApplyURL: applyURL,
RawContent: rawText,
}, nil
}
func ExtractTitleFromHTML(htmlContent string) string {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return ""
}
var title string
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
title = strings.TrimSpace(n.FirstChild.Data)
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
return title
}
func ExtractTextFromHTML(htmlContent string) string {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return htmlContent
}
var b strings.Builder
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
return
}
if n.Type == html.TextNode {
txt := strings.TrimSpace(n.Data)
if txt != "" {
b.WriteString(txt)
b.WriteString(" ")
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
f(doc)
return b.String()
}
func extractCompanyFromURL(u string) string {
if strings.Contains(u, "google") {
return "Google"
} else if strings.Contains(u, "linkedin") {
return "LinkedIn Network"
} else if strings.Contains(u, "greenhouse") {
return "Greenhouse Client"
} else if strings.Contains(u, "lever") {
return "Lever Client"
}
return "Tech Corp"
}
+86
View File
@@ -0,0 +1,86 @@
package db
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
*sql.DB
}
func InitDB(dbPath string) (*DB, error) {
if dbPath == "" {
dbPath = "data/db/hireme.db"
}
conn, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping sqlite database: %w", err)
}
database := &DB{DB: conn}
if err := database.migrate(); err != nil {
return nil, fmt.Errorf("failed to run sqlite migrations: %w", err)
}
log.Printf("[DB] SQLite database initialized successfully at %s", dbPath)
return database, nil
}
func (db *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS search_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_url TEXT NOT NULL,
platform TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
last_crawled_at DATETIME
);
CREATE TABLE IF NOT EXISTS raw_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
canonical_id TEXT UNIQUE NOT NULL,
target_id INTEGER REFERENCES search_targets(id),
title TEXT NOT NULL,
company TEXT NOT NULL,
location TEXT,
apply_url TEXT NOT NULL,
raw_content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS job_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE,
score REAL DEFAULT 0.0,
is_qualified BOOLEAN NOT NULL DEFAULT 0,
matched_highlights JSON,
missing_highlights JSON,
tailoring_suggestions JSON,
status TEXT NOT NULL DEFAULT 'PENDING',
analyzed_at DATETIME
);
CREATE TABLE IF NOT EXISTS job_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'SAVED',
notes TEXT,
applied_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
_, err := db.Exec(schema)
return err
}
+55
View File
@@ -0,0 +1,55 @@
package db
import (
"time"
)
type SearchTarget struct {
ID int64 `json:"id"`
Name string `json:"name"`
TargetURL string `json:"target_url"`
Platform string `json:"platform"` // 'linkedin', 'google_jobs', 'greenhouse', 'lever'
IsActive bool `json:"is_active"`
LastCrawledAt *time.Time `json:"last_crawled_at"`
}
type RawJob struct {
ID int64 `json:"id"`
CanonicalID string `json:"canonical_id"` // SHA256(company + title + applyURL)
TargetID *int64 `json:"target_id"`
Title string `json:"title"`
Company string `json:"company"`
Location string `json:"location"`
ApplyURL string `json:"apply_url"`
RawContent string `json:"raw_content"`
Status string `json:"status"` // 'PENDING', 'PROCESSED', 'FAILED', 'DUPLICATE'
CrawledAt time.Time `json:"crawled_at"`
}
type JobAnalysis struct {
ID int64 `json:"id"`
RawJobID int64 `json:"raw_job_id"`
Score float64 `json:"score"`
IsQualified bool `json:"is_qualified"`
MatchedHighlights string `json:"matched_highlights"` // JSON array string
MissingHighlights string `json:"missing_highlights"` // JSON array string
TailoringSuggestions string `json:"tailoring_suggestions"` // JSON array string
Status string `json:"status"` // 'PENDING', 'PARSED', 'FILTERED_OUT', 'FAILED'
AnalyzedAt *time.Time `json:"analyzed_at"`
}
type JobApplication struct {
ID int64 `json:"id"`
RawJobID int64 `json:"raw_job_id"`
Status string `json:"status"` // 'SAVED', 'APPLIED', 'INTERVIEW', 'REJECTED', 'OFFER'
Notes string `json:"notes"`
AppliedAt *time.Time `json:"applied_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// JobWithAnalysis combines RawJob and JobAnalysis for UI rendering
type JobWithAnalysis struct {
RawJob
Analysis *JobAnalysis `json:"analysis"`
App *JobApplication `json:"application"`
}
+554
View File
@@ -0,0 +1,554 @@
package db
import (
"database/sql"
"math"
"regexp"
"strings"
"time"
)
// Target Queries
func (db *DB) CreateTarget(name, targetURL, platform string) (*SearchTarget, error) {
query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, 1) RETURNING id, name, target_url, platform, is_active, last_crawled_at`
var target SearchTarget
var lastCrawled sql.NullTime
err := db.QueryRow(query, name, targetURL, platform).Scan(
&target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled,
)
if err != nil {
return nil, err
}
if lastCrawled.Valid {
target.LastCrawledAt = &lastCrawled.Time
}
return &target, nil
}
func (db *DB) GetActiveTargets() ([]SearchTarget, error) {
query := `SELECT id, name, target_url, platform, is_active, last_crawled_at FROM search_targets WHERE is_active = 1`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var targets []SearchTarget
for rows.Next() {
var t SearchTarget
var lastCrawled sql.NullTime
if err := rows.Scan(&t.ID, &t.Name, &t.TargetURL, &t.Platform, &t.IsActive, &lastCrawled); err != nil {
return nil, err
}
if lastCrawled.Valid {
t.LastCrawledAt = &lastCrawled.Time
}
targets = append(targets, t)
}
return targets, nil
}
func (db *DB) GetAllTargets() ([]SearchTarget, error) {
query := `SELECT id, name, target_url, platform, is_active, last_crawled_at FROM search_targets ORDER BY id DESC`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var targets []SearchTarget
for rows.Next() {
var t SearchTarget
var lastCrawled sql.NullTime
if err := rows.Scan(&t.ID, &t.Name, &t.TargetURL, &t.Platform, &t.IsActive, &lastCrawled); err != nil {
return nil, err
}
if lastCrawled.Valid {
t.LastCrawledAt = &lastCrawled.Time
}
targets = append(targets, t)
}
return targets, nil
}
func (db *DB) ToggleTargetStatus(id int64) error {
query := `UPDATE search_targets SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END WHERE id = ?`
_, err := db.Exec(query, id)
return err
}
func (db *DB) UpdateTargetCrawledAt(id int64) error {
query := `UPDATE search_targets SET last_crawled_at = CURRENT_TIMESTAMP WHERE id = ?`
_, err := db.Exec(query, id)
return err
}
// Raw Jobs Queries & Vector Similarity Deduplication
func (db *DB) InsertRawJob(canonicalID string, targetID *int64, title, company, location, applyURL, rawContent string) (*RawJob, bool, error) {
// Deduplication Check 1 & 2: Check canonical_id exist AND vector similarity >= 99%
existingJobs, err := db.getAllRawJobsForDeduplication()
if err == nil {
for _, existing := range existingJobs {
if existing.CanonicalID == canonicalID {
return nil, false, nil // Duplicate Canonical ID
}
// Vector Cosine Similarity check on raw_content (run only for substantial bodies >100 chars)
if len(existing.RawContent) >= 100 && len(rawContent) >= 100 {
similarity := CalculateCosineSimilarity(existing.RawContent, rawContent)
if similarity >= 0.99 {
return nil, false, nil // Content >= 99% vector match -> Deduplicate
}
}
}
}
query := `
INSERT INTO raw_jobs (canonical_id, target_id, title, company, location, apply_url, raw_content, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING')
ON CONFLICT(canonical_id) DO NOTHING
RETURNING id, canonical_id, target_id, title, company, location, apply_url, raw_content, status, crawled_at
`
var job RawJob
var tID sql.NullInt64
err = db.QueryRow(query, canonicalID, targetID, title, company, location, applyURL, rawContent).Scan(
&job.ID, &job.CanonicalID, &tID, &job.Title, &job.Company, &job.Location, &job.ApplyURL, &job.RawContent, &job.Status, &job.CrawledAt,
)
if err == sql.ErrNoRows {
return nil, false, nil // Duplicate job
}
if err != nil {
return nil, false, err
}
if tID.Valid {
job.TargetID = &tID.Int64
}
return &job, true, nil
}
func (db *DB) CanonicalIDExists(canonicalID string) (bool, error) {
query := `SELECT EXISTS(SELECT 1 FROM raw_jobs WHERE canonical_id = ?)`
var exists bool
err := db.QueryRow(query, canonicalID).Scan(&exists)
return exists, err
}
func (db *DB) getAllRawJobsForDeduplication() ([]RawJob, error) {
query := `SELECT id, canonical_id, raw_content FROM raw_jobs`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var jobs []RawJob
for rows.Next() {
var j RawJob
if err := rows.Scan(&j.ID, &j.CanonicalID, &j.RawContent); err != nil {
return nil, err
}
jobs = append(jobs, j)
}
return jobs, nil
}
// CalculateCosineSimilarity tokenizes text into term frequency vectors and returns cosine similarity in range [0.0, 1.0]
func CalculateCosineSimilarity(text1, text2 string) float64 {
vec1 := tokenizeToVector(text1)
vec2 := tokenizeToVector(text2)
if len(vec1) == 0 || len(vec2) == 0 {
return 0.0
}
var dotProduct float64
for term, freq1 := range vec1 {
if freq2, exists := vec2[term]; exists {
dotProduct += float64(freq1 * freq2)
}
}
var norm1 float64
for _, freq := range vec1 {
norm1 += float64(freq * freq)
}
norm1 = math.Sqrt(norm1)
var norm2 float64
for _, freq := range vec2 {
norm2 += float64(freq * freq)
}
norm2 = math.Sqrt(norm2)
if norm1 == 0 || norm2 == 0 {
return 0.0
}
return dotProduct / (norm1 * norm2)
}
var wordRegex = regexp.MustCompile(`[a-zA-Z0-9]+`)
var stopWords = map[string]bool{
"the": true, "and": true, "for": true, "with": true, "this": true,
"that": true, "from": true, "are": true, "was": true, "were": true,
"google": true, "position": true, "apply": true, "url": true, "location": true,
"seattle": true, "usa": true, "job": true, "http": true, "https": true,
}
func tokenizeToVector(text string) map[string]int {
words := wordRegex.FindAllString(strings.ToLower(text), -1)
freqMap := make(map[string]int)
for _, w := range words {
if len(w) > 2 && !stopWords[w] {
freqMap[w]++
}
}
return freqMap
}
func (db *DB) GetPendingRawJobs() ([]RawJob, error) {
query := `SELECT id, canonical_id, target_id, title, company, location, apply_url, raw_content, status, crawled_at FROM raw_jobs WHERE status = 'PENDING'`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var jobs []RawJob
for rows.Next() {
var j RawJob
var tID sql.NullInt64
if err := rows.Scan(&j.ID, &j.CanonicalID, &tID, &j.Title, &j.Company, &j.Location, &j.ApplyURL, &j.RawContent, &j.Status, &j.CrawledAt); err != nil {
return nil, err
}
if tID.Valid {
j.TargetID = &tID.Int64
}
jobs = append(jobs, j)
}
return jobs, nil
}
func (db *DB) UpdateRawJobStatus(id int64, status string) error {
query := `UPDATE raw_jobs SET status = ? WHERE id = ?`
_, err := db.Exec(query, status, id)
return err
}
// Job Analyses Queries
func (db *DB) CreateJobAnalysis(rawJobID int64, score float64, isQualified bool, status string) error {
query := `
INSERT INTO job_analyses (raw_job_id, score, is_qualified, status)
VALUES (?, ?, ?, ?)
ON CONFLICT(raw_job_id) DO UPDATE SET score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status
`
_, err := db.Exec(query, rawJobID, score, isQualified, status)
return err
}
func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, matchedJSON, missingJSON, suggestionsJSON string) error {
query := `
UPDATE job_analyses
SET matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP
WHERE raw_job_id = ?
`
_, err := db.Exec(query, matchedJSON, missingJSON, suggestionsJSON, rawJobID)
return err
}
func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.status, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at
FROM raw_jobs r
JOIN job_analyses a ON r.id = a.raw_job_id
WHERE a.is_qualified = 1 AND a.status = 'PENDING'
`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []JobWithAnalysis
for rows.Next() {
var item JobWithAnalysis
var tID sql.NullInt64
var aID int64
var score float64
var isQualified bool
var matched, missing, suggestions, aStatus string
var analyzedAt sql.NullTime
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.Status, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
)
if err != nil {
return nil, err
}
if tID.Valid {
item.TargetID = &tID.Int64
}
analysis := &JobAnalysis{
ID: aID,
RawJobID: item.ID,
Score: score,
IsQualified: isQualified,
MatchedHighlights: matched,
MissingHighlights: missing,
TailoringSuggestions: suggestions,
Status: aStatus,
}
if analyzedAt.Valid {
analysis.AnalyzedAt = &analyzedAt.Time
}
item.Analysis = analysis
results = append(results, item)
}
return results, nil
}
func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.status, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
LEFT JOIN job_applications app ON r.id = app.raw_job_id
ORDER BY COALESCE(a.score, 0) DESC, r.crawled_at DESC
`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []JobWithAnalysis
for rows.Next() {
var item JobWithAnalysis
var tID sql.NullInt64
var aID, appID sql.NullInt64
var score sql.NullFloat64
var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime
var appStatus, notes sql.NullString
var appliedAt sql.NullTime
var updatedAt sql.NullTime
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.Status, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
if err != nil {
return nil, err
}
if tID.Valid {
item.TargetID = &tID.Int64
}
if aID.Valid {
analysis := &JobAnalysis{
ID: aID.Int64,
RawJobID: item.ID,
Score: score.Float64,
IsQualified: isQualified.Bool,
MatchedHighlights: matched.String,
MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String,
Status: aStatus.String,
}
if analyzedAt.Valid {
analysis.AnalyzedAt = &analyzedAt.Time
}
item.Analysis = analysis
}
if appID.Valid {
app := &JobApplication{
ID: appID.Int64,
RawJobID: item.ID,
Status: appStatus.String,
Notes: notes.String,
UpdatedAt: updatedAt.Time,
}
if appliedAt.Valid {
app.AppliedAt = &appliedAt.Time
}
item.App = app
}
results = append(results, item)
}
return results, nil
}
func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.status, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
LEFT JOIN job_applications app ON r.id = app.raw_job_id
WHERE r.id = ?
`
row := db.QueryRow(query, rawJobID)
var item JobWithAnalysis
var tID sql.NullInt64
var aID, appID sql.NullInt64
var score sql.NullFloat64
var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime
var appStatus, notes sql.NullString
var appliedAt sql.NullTime
var updatedAt sql.NullTime
err := row.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.Status, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
if err != nil {
return nil, err
}
if tID.Valid {
item.TargetID = &tID.Int64
}
if aID.Valid {
analysis := &JobAnalysis{
ID: aID.Int64,
RawJobID: item.ID,
Score: score.Float64,
IsQualified: isQualified.Bool,
MatchedHighlights: matched.String,
MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String,
Status: aStatus.String,
}
if analyzedAt.Valid {
analysis.AnalyzedAt = &analyzedAt.Time
}
item.Analysis = analysis
}
if appID.Valid {
app := &JobApplication{
ID: appID.Int64,
RawJobID: item.ID,
Status: appStatus.String,
Notes: notes.String,
UpdatedAt: updatedAt.Time,
}
if appliedAt.Valid {
app.AppliedAt = &appliedAt.Time
}
item.App = app
}
return &item, nil
}
// Applications Queries
func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (*JobApplication, error) {
now := time.Now()
var appliedAt *time.Time
if status == "APPLIED" {
appliedAt = &now
}
query := `
INSERT INTO job_applications (raw_job_id, status, notes, applied_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(raw_job_id) DO UPDATE SET
status=excluded.status,
notes=COALESCE(excluded.notes, job_applications.notes),
applied_at=COALESCE(excluded.applied_at, job_applications.applied_at),
updated_at=CURRENT_TIMESTAMP
RETURNING id, raw_job_id, status, notes, applied_at, updated_at
`
var app JobApplication
var appAt sql.NullTime
err := db.QueryRow(query, rawJobID, status, notes, appliedAt).Scan(
&app.ID, &app.RawJobID, &app.Status, &app.Notes, &appAt, &app.UpdatedAt,
)
if err != nil {
return nil, err
}
if appAt.Valid {
app.AppliedAt = &appAt.Time
}
return &app, nil
}
func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
query := `
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.status, r.crawled_at,
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM job_applications app
JOIN raw_jobs r ON app.raw_job_id = r.id
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
ORDER BY app.updated_at DESC
`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []JobWithAnalysis
for rows.Next() {
var item JobWithAnalysis
var tID sql.NullInt64
var aID, appID sql.NullInt64
var score sql.NullFloat64
var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime
var appStatus, notes sql.NullString
var appliedAt sql.NullTime
var updatedAt time.Time
err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.Status, &item.CrawledAt,
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
&appID, &appStatus, &notes, &appliedAt, &updatedAt,
)
if err != nil {
return nil, err
}
if tID.Valid {
item.TargetID = &tID.Int64
}
if aID.Valid {
analysis := &JobAnalysis{
ID: aID.Int64,
RawJobID: item.ID,
Score: score.Float64,
IsQualified: isQualified.Bool,
MatchedHighlights: matched.String,
MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String,
Status: aStatus.String,
}
if analyzedAt.Valid {
analysis.AnalyzedAt = &analyzedAt.Time
}
item.Analysis = analysis
}
app := &JobApplication{
ID: appID.Int64,
RawJobID: item.ID,
Status: appStatus.String,
Notes: notes.String,
UpdatedAt: updatedAt,
}
if appliedAt.Valid {
app.AppliedAt = &appliedAt.Time
}
item.App = app
results = append(results, item)
}
return results, nil
}
+55
View File
@@ -0,0 +1,55 @@
package db
import (
"path/filepath"
"testing"
)
func TestVectorSimilarityDeduplication(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "test_dedup.db")
database, err := InitDB(dbPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer database.Close()
contentA := "Senior Software Engineer - Go Distributed Systems. Experience with microservices, gRPC, and SQLite database pipelines."
contentSimilar := "Senior Software Engineer - Go Distributed Systems. Experience with microservices, gRPC, and SQLite database pipelines."
contentDifferent := "Marketing Specialist - Social Media Campaign Manager. Experience with SEO, Google Ads, and brand strategy."
// 1. Insert job A
jobA, inserted1, err := database.InsertRawJob("canonical-1", nil, "Software Engineer", "Google", "Seattle", "https://google.com/job/1", contentA)
if err != nil || !inserted1 {
t.Fatalf("expected job A to be inserted: err=%v, inserted=%v", err, inserted1)
}
// 2. Insert job with identical/99%+ similar content -> should be deduplicated!
_, inserted2, err := database.InsertRawJob("canonical-2-different-id", nil, "Learn more about Software Engineer", "Google", "Seattle", "https://google.com/job/1-dup", contentSimilar)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted2 {
t.Errorf("expected 99%% similar content job to be deduplicated (inserted=false), got inserted=true")
}
// 3. Insert job with distinct content -> should insert normally!
_, inserted3, err := database.InsertRawJob("canonical-3", nil, "Marketing Specialist", "Google", "Seattle", "https://google.com/job/3", contentDifferent)
if err != nil || !inserted3 {
t.Fatalf("expected distinct job to insert normally: err=%v, inserted=%v", err, inserted3)
}
// Verify only 2 raw jobs exist in DB
allJobs, err := database.GetPendingRawJobs()
if err != nil {
t.Fatalf("GetPendingRawJobs failed: %v", err)
}
if len(allJobs) != 2 {
t.Errorf("expected 2 raw jobs in DB after deduplication, got %d", len(allJobs))
}
if allJobs[0].ID != jobA.ID {
t.Errorf("first job mismatch")
}
}
+34
View File
@@ -0,0 +1,34 @@
package filter
import "context"
type FilterResult struct {
Keep bool
Score float64
Reason string
}
type JobFilter interface {
Name() string
Process(ctx context.Context, title, rawContent string) (FilterResult, error)
}
type FilterPipeline struct {
filters []JobFilter
}
func NewPipeline(filters ...JobFilter) *FilterPipeline {
return &FilterPipeline{filters: filters}
}
func (p *FilterPipeline) Execute(ctx context.Context, title, rawContent string) (bool, float64) {
var totalScore float64
for _, f := range p.filters {
res, err := f.Process(ctx, title, rawContent)
if err != nil || !res.Keep {
return false, 0
}
totalScore += res.Score
}
return true, totalScore
}
+58
View File
@@ -0,0 +1,58 @@
package filter
import (
"context"
"testing"
)
func TestHardKeywordFilter(t *testing.T) {
ctx := context.Background()
fk := NewHardKeywordFilter([]string{"DevOps", "No Sponsorship"})
// Should fail due to DevOps
res1, err := fk.Process(ctx, "Senior DevOps Engineer", "We build pipelines.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res1.Keep {
t.Errorf("expected Keep=false for DevOps, got true")
}
// Should pass
res2, err := fk.Process(ctx, "Senior Backend Engineer", "We build scalable APIs in Go.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res2.Keep {
t.Errorf("expected Keep=true for Backend Engineer, got false")
}
}
func TestFilterPipeline(t *testing.T) {
ctx := context.Background()
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
scoringFilter := NewScoringFilter([]KeywordWeight{
{Keyword: "Go", Weight: 30.0},
{Keyword: "Backend", Weight: 20.0},
})
pipeline := NewPipeline(hardFilter, scoringFilter)
// Test job 1: Unpaid job -> rejected by hard filter
keep1, score1 := pipeline.Execute(ctx, "Backend Intern", "Unpaid position working with Go.")
if keep1 {
t.Errorf("expected job 1 to be filtered out, got keep=true")
}
if score1 != 0 {
t.Errorf("expected score 0 for rejected job, got %f", score1)
}
// Test job 2: Qualified job -> passes both filters and accumulates score
keep2, score2 := pipeline.Execute(ctx, "Senior Go Backend Engineer", "Building high throughput services.")
if !keep2 {
t.Errorf("expected job 2 to pass filter pipeline, got keep=false")
}
if score2 != 50.0 {
t.Errorf("expected score 50.0 (Go + Backend), got %f", score2)
}
}
+48
View File
@@ -0,0 +1,48 @@
package filter
import (
"context"
"fmt"
"strings"
)
type HardKeywordFilter struct {
Blacklist []string
}
func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter {
if len(blacklist) == 0 {
blacklist = []string{
"no sponsorship",
"us citizen only",
"unpaid",
"volunteer",
}
}
return &HardKeywordFilter{Blacklist: blacklist}
}
func (f *HardKeywordFilter) Name() string {
return "HardKeywordFilter"
}
func (f *HardKeywordFilter) Process(ctx context.Context, title, rawContent string) (FilterResult, error) {
combined := strings.ToLower(title + " " + rawContent)
for _, kw := range f.Blacklist {
term := strings.ToLower(strings.TrimSpace(kw))
if term != "" && strings.Contains(combined, term) {
return FilterResult{
Keep: false,
Score: 0,
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", kw),
}, nil
}
}
return FilterResult{
Keep: true,
Score: 0,
Reason: "Passed hard keyword check",
}, nil
}
+59
View File
@@ -0,0 +1,59 @@
package filter
import (
"context"
"fmt"
"strings"
)
type KeywordWeight struct {
Keyword string
Weight float64
}
type ScoringFilter struct {
Weights []KeywordWeight
}
func NewScoringFilter(weights []KeywordWeight) *ScoringFilter {
if len(weights) == 0 {
weights = []KeywordWeight{
{Keyword: "go", Weight: 25.0},
{Keyword: "golang", Weight: 25.0},
{Keyword: "backend", Weight: 15.0},
{Keyword: "distributed systems", Weight: 20.0},
{Keyword: "microservices", Weight: 15.0},
{Keyword: "sqlite", Weight: 10.0},
{Keyword: "postgres", Weight: 10.0},
{Keyword: "api", Weight: 10.0},
{Keyword: "full stack", Weight: 10.0},
{Keyword: "rest", Weight: 5.0},
{Keyword: "grpc", Weight: 15.0},
}
}
return &ScoringFilter{Weights: weights}
}
func (s *ScoringFilter) Name() string {
return "ScoringFilter"
}
func (s *ScoringFilter) Process(ctx context.Context, title, rawContent string) (FilterResult, error) {
combined := strings.ToLower(title + " " + rawContent)
var totalScore float64
var matched []string
for _, kw := range s.Weights {
term := strings.ToLower(strings.TrimSpace(kw.Keyword))
if term != "" && strings.Contains(combined, term) {
totalScore += kw.Weight
matched = append(matched, kw.Keyword)
}
}
return FilterResult{
Keep: true,
Score: totalScore,
Reason: fmt.Sprintf("Matched %d keywords: %v", len(matched), matched),
}, nil
}
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/hireme/hireme/internal/analyzer"
"github.com/hireme/hireme/internal/crawler"
"github.com/hireme/hireme/internal/db"
"github.com/hireme/hireme/internal/filter"
"github.com/hireme/hireme/pkg/crawler/google"
"gopkg.in/yaml.v3"
)
type TargetConfigYAML struct {
Targets []struct {
Name string `yaml:"name"`
TargetURL string `yaml:"target_url"`
Platform string `yaml:"platform"`
IsActive bool `yaml:"is_active"`
} `yaml:"targets"`
}
type PipelineService struct {
db *db.DB
crawlers []crawler.SiteCrawler
filterPipe *filter.FilterPipeline
llmAnalyzer analyzer.LLMAnalyzer
}
func NewPipelineService(database *db.DB, llmAnalyzer analyzer.LLMAnalyzer) *PipelineService {
hardFilter := filter.NewHardKeywordFilter(nil)
scoringFilter := filter.NewScoringFilter(nil)
pipeline := filter.NewPipeline(hardFilter, scoringFilter)
crawlers := []crawler.SiteCrawler{
google.NewGoogleCrawler(),
crawler.NewGenericCrawler(),
}
return &PipelineService{
db: database,
crawlers: crawlers,
filterPipe: pipeline,
llmAnalyzer: llmAnalyzer,
}
}
// LoadTargetsFromYAML loads search targets from YAML configuration file if DB targets table is empty
func (s *PipelineService) LoadTargetsFromYAML(yamlPath string) error {
targets, err := s.db.GetAllTargets()
if err != nil {
return err
}
if len(targets) > 0 {
return nil
}
data, err := os.ReadFile(yamlPath)
if err != nil {
return fmt.Errorf("could not read targets YAML config at %s: %w", yamlPath, err)
}
var configData TargetConfigYAML
if err := yaml.Unmarshal(data, &configData); err != nil {
return fmt.Errorf("failed to parse YAML targets config: %w", err)
}
for _, t := range configData.Targets {
_, err := s.db.CreateTarget(t.Name, t.TargetURL, t.Platform)
if err != nil {
log.Printf("[Pipeline] Error loading target %s: %v", t.Name, err)
}
}
log.Printf("[Pipeline] Loaded %d search targets from YAML config (%s)", len(configData.Targets), yamlPath)
return nil
}
func (s *PipelineService) getCrawlerForPlatform(platform string) crawler.SiteCrawler {
for _, c := range s.crawlers {
if c.CanHandle(platform) {
return c
}
}
return s.crawlers[len(s.crawlers)-1] // fallback to generic
}
// RunPipeline executes Stage 1 (Ingestion) and Stage 2 (Filtering & LLM Enrichment)
func (s *PipelineService) RunPipeline(ctx context.Context) (int, int, error) {
log.Println("[Pipeline] Starting ingestion & processing run...")
// 1. Fetch active targets
targets, err := s.db.GetActiveTargets()
if err != nil {
return 0, 0, fmt.Errorf("failed to get active targets: %w", err)
}
newJobsCount := 0
for _, target := range targets {
log.Printf("[Pipeline] Discovering jobs for target #%d: %s (%s)", target.ID, target.Name, target.TargetURL)
c := s.getCrawlerForPlatform(target.Platform)
discovered, err := c.DiscoverJobs(ctx, target.TargetURL)
if err != nil {
log.Printf("[Pipeline] DiscoverJobs error for target #%d: %v", target.ID, err)
continue
}
log.Printf("[Pipeline] Discovered %d potential job links for target #%d", len(discovered), target.ID)
for _, disc := range discovered {
// Check DB deduplication first
exists, err := s.db.CanonicalIDExists(disc.CanonicalID)
if err == nil && exists {
continue // Skip existing job link!
}
// Fetch & parse detail page for new job only
extracted, err := c.FetchJobDetail(ctx, disc.ApplyURL)
if err != nil {
log.Printf("[Pipeline] FetchJobDetail error for %s: %v", disc.ApplyURL, err)
continue
}
tID := target.ID
_, inserted, err := s.db.InsertRawJob(extracted.CanonicalID, &tID, extracted.Title, extracted.Company, extracted.Location, extracted.ApplyURL, extracted.RawContent)
if err != nil {
log.Printf("[Pipeline] Error inserting job %s: %v", extracted.Title, err)
continue
}
if inserted {
newJobsCount++
}
// Polite delay to prevent HTTP 429 rate-limiting from Google
time.Sleep(500 * time.Millisecond)
}
_ = s.db.UpdateTargetCrawledAt(target.ID)
}
// 2. Execute Stage 2 Filter Pipeline on pending raw_jobs
pendingJobs, err := s.db.GetPendingRawJobs()
if err != nil {
return newJobsCount, 0, fmt.Errorf("failed to fetch pending jobs: %w", err)
}
qualifiedCount := 0
for _, job := range pendingJobs {
keep, score := s.filterPipe.Execute(ctx, job.Title, job.RawContent)
if !keep {
log.Printf("[Pipeline] Job #%d (%s) FILTERED OUT by keyword pipeline", job.ID, job.Title)
_ = s.db.CreateJobAnalysis(job.ID, 0, false, "FILTERED_OUT")
_ = s.db.UpdateRawJobStatus(job.ID, "PROCESSED")
continue
}
log.Printf("[Pipeline] Job #%d (%s) PASSED filter with score %.2f", job.ID, job.Title, score)
_ = s.db.CreateJobAnalysis(job.ID, score, true, "PENDING")
_ = s.db.UpdateRawJobStatus(job.ID, "PROCESSED")
qualifiedCount++
}
// 3. Trigger LLM Analysis on qualified PENDING analyses
pendingAnalyses, err := s.db.GetPendingQualifiedAnalyses()
if err != nil {
log.Printf("[Pipeline] Error fetching pending qualified analyses: %v", err)
return newJobsCount, qualifiedCount, nil
}
for _, item := range pendingAnalyses {
log.Printf("[Pipeline] Triggering LLM analysis for Job #%d (%s @ %s)...", item.ID, item.Title, item.Company)
output, err := s.llmAnalyzer.AnalyzeJD(ctx, item.Title, item.RawContent)
if err != nil {
log.Printf("[Pipeline] LLM analysis failed for job #%d: %v", item.ID, err)
continue
}
matchedJSON, _ := json.Marshal(output.MatchedHighlights)
missingJSON, _ := json.Marshal(output.MissingHighlights)
suggestionsJSON, _ := json.Marshal(output.TailoringSuggestions)
err = s.db.UpdateJobAnalysisLLM(item.ID, string(matchedJSON), string(missingJSON), string(suggestionsJSON))
if err != nil {
log.Printf("[Pipeline] Failed to save LLM analysis for job #%d: %v", item.ID, err)
} else {
log.Printf("[Pipeline] Successfully cached LLM analysis for Job #%d", item.ID)
}
}
return newJobsCount, qualifiedCount, nil
}
// AnalyzeJobByID manually triggers LLM analysis on a single job
func (s *PipelineService) AnalyzeJobByID(ctx context.Context, jobID int64) (*db.JobWithAnalysis, error) {
jobWithAnalysis, err := s.db.GetJobByID(jobID)
if err != nil {
return nil, err
}
output, err := s.llmAnalyzer.AnalyzeJD(ctx, jobWithAnalysis.Title, jobWithAnalysis.RawContent)
if err != nil {
return nil, fmt.Errorf("LLM analysis failed: %w", err)
}
matchedJSON, _ := json.Marshal(output.MatchedHighlights)
missingJSON, _ := json.Marshal(output.MissingHighlights)
suggestionsJSON, _ := json.Marshal(output.TailoringSuggestions)
if jobWithAnalysis.Analysis == nil {
_, score := s.filterPipe.Execute(ctx, jobWithAnalysis.Title, jobWithAnalysis.RawContent)
_ = s.db.CreateJobAnalysis(jobID, score, true, "PENDING")
}
err = s.db.UpdateJobAnalysisLLM(jobID, string(matchedJSON), string(missingJSON), string(suggestionsJSON))
if err != nil {
return nil, err
}
return s.db.GetJobByID(jobID)
}
+280
View File
@@ -0,0 +1,280 @@
package ui
import (
"context"
"log"
"net/http"
"strconv"
"strings"
"github.com/hireme/hireme/internal/config"
"github.com/hireme/hireme/internal/db"
"github.com/hireme/hireme/internal/service"
)
type Server struct {
cfg *config.Config
db *db.DB
pipeline *service.PipelineService
}
func NewServer(cfg *config.Config, database *db.DB, pipeline *service.PipelineService) *Server {
return &Server{
cfg: cfg,
db: database,
pipeline: pipeline,
}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", s.handleIndex)
mux.HandleFunc("GET /targets", s.handleGetTargets)
mux.HandleFunc("POST /targets", s.handleCreateTarget)
mux.HandleFunc("POST /targets/{id}/toggle", s.handleToggleTarget)
mux.HandleFunc("GET /jobs", s.handleGetJobs)
mux.HandleFunc("GET /jobs/{id}", s.handleGetJobDetail)
mux.HandleFunc("POST /jobs/{id}/analyze", s.handleAnalyzeJob)
mux.HandleFunc("POST /applications", s.handleCreateApplication)
mux.HandleFunc("PATCH /applications/{id}/status", s.handleUpdateAppStatus)
mux.HandleFunc("POST /pipeline/run", s.handleRunPipeline)
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
targets, err := s.db.GetAllTargets()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jobs, err := s.db.GetJobsWithAnalysis()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
apps, err := s.db.GetApplications()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
qualifiedCount := 0
for _, j := range jobs {
if j.Analysis != nil && j.Analysis.IsQualified {
qualifiedCount++
}
}
data := PageData{
Targets: targets,
Jobs: jobs,
Applications: apps,
ActiveTab: "triage",
LLMProvider: s.cfg.LLMProvider,
QualifiedJobsCount: qualifiedCount,
TotalIngestedCount: len(jobs),
ActiveApplications: len(apps),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := RenderPage(w, data); err != nil {
log.Printf("[UI] RenderPage error: %v", err)
}
}
func (s *Server) handleGetTargets(w http.ResponseWriter, r *http.Request) {
targets, err := s.db.GetAllTargets()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderTargetList(w, targets)
}
func (s *Server) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := r.FormValue("name")
targetURL := r.FormValue("target_url")
platform := r.FormValue("platform")
if name == "" || targetURL == "" {
http.Error(w, "name and target_url are required", http.StatusBadRequest)
return
}
_, err := s.db.CreateTarget(name, targetURL, platform)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.handleGetTargets(w, r)
}
func (s *Server) handleToggleTarget(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid target id", http.StatusBadRequest)
return
}
if err := s.db.ToggleTargetStatus(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.handleGetTargets(w, r)
}
func (s *Server) handleGetJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := s.db.GetJobsWithAnalysis()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderJobList(w, jobs)
}
func (s *Server) handleGetJobDetail(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid job id", http.StatusBadRequest)
return
}
job, err := s.db.GetJobByID(id)
if err != nil {
http.Error(w, "job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderJobDetail(w, job)
}
func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid job id", http.StatusBadRequest)
return
}
job, err := s.pipeline.AnalyzeJobByID(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderJobDetail(w, job)
}
func (s *Server) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rawJobIDStr := r.FormValue("raw_job_id")
status := r.FormValue("status")
if status == "" {
status = "SAVED"
}
rawJobID, err := strconv.ParseInt(rawJobIDStr, 10, 64)
if err != nil {
http.Error(w, "invalid raw_job_id", http.StatusBadRequest)
return
}
_, err = s.db.CreateOrUpdateApplication(rawJobID, status, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if strings.Contains(r.Header.Get("HX-Target"), "kanban") {
apps, _ := s.db.GetApplications()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderKanbanBoard(w, apps)
return
}
s.handleGetJobs(w, r)
}
func (s *Server) handleUpdateAppStatus(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
appID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid app id", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newStatus := r.FormValue("status")
if newStatus == "" {
http.Error(w, "status required", http.StatusBadRequest)
return
}
// Fetch rawJobID for appID
apps, err := s.db.GetApplications()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var targetRawJobID int64 = -1
for _, a := range apps {
if a.App != nil && a.App.ID == appID {
targetRawJobID = a.ID
break
}
}
if targetRawJobID == -1 {
http.Error(w, "application not found", http.StatusNotFound)
return
}
_, err = s.db.CreateOrUpdateApplication(targetRawJobID, newStatus, "")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
updatedApps, _ := s.db.GetApplications()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = RenderKanbanBoard(w, updatedApps)
}
func (s *Server) handleRunPipeline(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
_, _, err := s.pipeline.RunPipeline(ctx)
if err != nil {
log.Printf("[Server] Pipeline run error: %v", err)
}
s.handleGetJobs(w, r)
}
+688
View File
@@ -0,0 +1,688 @@
package ui
import (
"encoding/json"
"html/template"
"io"
"strings"
"time"
"github.com/hireme/hireme/internal/db"
)
var funcMap = template.FuncMap{
"formatTime": func(t *time.Time) string {
if t == nil {
return "Never"
}
return t.Format("Jan 02, 2006 15:04")
},
"formatDate": func(t time.Time) string {
return t.Format("Jan 02, 2006")
},
"parseJSON": func(jsonStr string) []string {
if jsonStr == "" {
return nil
}
var list []string
_ = json.Unmarshal([]byte(jsonStr), &list)
return list
},
"scoreBadgeClass": func(score float64) string {
if score >= 50 {
return "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
} else if score >= 20 {
return "bg-amber-500/20 text-amber-400 border-amber-500/30"
}
return "bg-slate-700/50 text-slate-400 border-slate-600/30"
},
"platformIcon": func(platform string) string {
p := strings.ToLower(platform)
if strings.Contains(p, "linkedin") {
return "💼 LinkedIn"
} else if strings.Contains(p, "google") {
return "🔍 Google Jobs"
} else if strings.Contains(p, "greenhouse") {
return "🌿 Greenhouse"
} else if strings.Contains(p, "lever") {
return "⚙️ Lever"
}
return "🌐 Web Target"
},
}
type PageData struct {
Targets []db.SearchTarget
Jobs []db.JobWithAnalysis
Applications []db.JobWithAnalysis
ActiveTab string
LLMProvider string
QualifiedJobsCount int
TotalIngestedCount int
ActiveApplications int
}
var (
rootTemplate *template.Template
)
func init() {
var err error
rootTemplate = template.New("root").Funcs(funcMap)
rootTemplate, err = rootTemplate.Parse(mainLayoutHTML)
if err != nil {
panic(err)
}
rootTemplate, err = rootTemplate.Parse(targetListHTML)
if err != nil {
panic(err)
}
rootTemplate, err = rootTemplate.Parse(jobListHTML)
if err != nil {
panic(err)
}
rootTemplate, err = rootTemplate.Parse(jobDetailHTML)
if err != nil {
panic(err)
}
rootTemplate, err = rootTemplate.Parse(kanbanBoardHTML)
if err != nil {
panic(err)
}
rootTemplate, err = rootTemplate.Parse(kanbanCardHTML)
if err != nil {
panic(err)
}
}
func RenderPage(w io.Writer, data PageData) error {
return rootTemplate.ExecuteTemplate(w, "page", data)
}
func RenderTargetList(w io.Writer, targets []db.SearchTarget) error {
return rootTemplate.ExecuteTemplate(w, "targets", targets)
}
func RenderJobList(w io.Writer, jobs []db.JobWithAnalysis) error {
return rootTemplate.ExecuteTemplate(w, "jobs", jobs)
}
func RenderJobDetail(w io.Writer, job *db.JobWithAnalysis) error {
return rootTemplate.ExecuteTemplate(w, "jobDetail", job)
}
func RenderKanbanBoard(w io.Writer, apps []db.JobWithAnalysis) error {
return rootTemplate.ExecuteTemplate(w, "kanban", apps)
}
const mainLayoutHTML = `{{ define "page" }}<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hireme | Job Command Center</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/[email protected]"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
}
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(15, 23, 42, 0.6); }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(99, 102, 241, 0.3); border-radius: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(99, 102, 241, 0.6); }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white">
<!-- Top Navigation Header -->
<header class="sticky top-0 z-30 glass-panel border-b border-slate-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 flex items-center justify-center shadow-lg shadow-indigo-500/30">
<span class="text-xl font-black text-white">H</span>
</div>
<div>
<h1 class="text-lg font-bold bg-gradient-to-r from-white via-indigo-200 to-indigo-400 bg-clip-text text-transparent">Hireme</h1>
<p class="text-xs text-slate-400 font-medium">Job Application Command Center</p>
</div>
</div>
<!-- Header Quick Stats & LLM Indicator -->
<div class="flex items-center space-x-4">
<div class="hidden md:flex items-center space-x-2 px-3 py-1.5 rounded-lg bg-slate-900/60 border border-slate-800 text-xs">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-slate-400">LLM Provider:</span>
<span class="font-semibold text-indigo-300 uppercase">{{ .LLMProvider }}</span>
</div>
<button hx-post="/pipeline/run"
hx-target="#jobs-container"
hx-indicator="#run-spinner"
class="relative inline-flex items-center justify-center px-4 py-2 text-xs font-semibold text-white transition-all bg-indigo-600 rounded-lg shadow-md hover:bg-indigo-500 active:scale-95 shadow-indigo-500/25">
<svg id="run-spinner" class="htmx-indicator animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>⚡ Run Pipeline</span>
</button>
</div>
</div>
</header>
<!-- Main Workspace Container -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<!-- Metrics Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-indigo-500/10 rounded-full blur-xl group-hover:bg-indigo-500/20 transition-all"></div>
<p class="text-xs font-medium text-slate-400">Total Search Targets</p>
<h3 class="text-2xl font-bold text-white mt-1">{{ len .Targets }}</h3>
<span class="text-[10px] text-slate-500 mt-2 block">Active Crawler Pipelines</span>
</div>
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-emerald-500/10 rounded-full blur-xl group-hover:bg-emerald-500/20 transition-all"></div>
<p class="text-xs font-medium text-slate-400">Qualified Jobs Matched</p>
<h3 class="text-2xl font-bold text-emerald-400 mt-1">{{ .QualifiedJobsCount }}</h3>
<span class="text-[10px] text-emerald-500/80 mt-2 block">Passed Keyword & Score Filter</span>
</div>
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-amber-500/10 rounded-full blur-xl group-hover:bg-amber-500/20 transition-all"></div>
<p class="text-xs font-medium text-slate-400">Total Ingested Postings</p>
<h3 class="text-2xl font-bold text-amber-400 mt-1">{{ .TotalIngestedCount }}</h3>
<span class="text-[10px] text-slate-500 mt-2 block">Deduplicated Raw Crawls</span>
</div>
<div class="glass-panel p-5 rounded-2xl border border-slate-800/80 shadow-sm relative overflow-hidden group">
<div class="absolute -right-4 -bottom-4 w-20 h-20 bg-violet-500/10 rounded-full blur-xl group-hover:bg-violet-500/20 transition-all"></div>
<p class="text-xs font-medium text-slate-400">Active Applications</p>
<h3 class="text-2xl font-bold text-violet-400 mt-1">{{ .ActiveApplications }}</h3>
<span class="text-[10px] text-slate-500 mt-2 block">Tracked in Kanban Pipeline</span>
</div>
</div>
<!-- Tabbed View Container -->
<div class="glass-panel rounded-2xl border border-slate-800 overflow-hidden shadow-xl">
<!-- Tabs Bar -->
<div class="flex items-center border-b border-slate-800 px-6 pt-4 space-x-6">
<button onclick="switchTab('triage')" id="tab-btn-triage"
class="pb-3 text-sm font-semibold text-indigo-400 border-b-2 border-indigo-500 transition-colors">
🎯 Job Triage & Matches
</button>
<button onclick="switchTab('kanban')" id="tab-btn-kanban"
class="pb-3 text-sm font-semibold text-slate-400 border-b-2 border-transparent hover:text-slate-200 transition-colors">
📋 Application Kanban
</button>
<button onclick="switchTab('targets')" id="tab-btn-targets"
class="pb-3 text-sm font-semibold text-slate-400 border-b-2 border-transparent hover:text-slate-200 transition-colors">
⚙️ Crawler Targets
</button>
</div>
<!-- Tab Content 1: Job Triage List -->
<div id="tab-content-triage" class="p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-base font-bold text-white">Qualified Job Opportunities</h2>
<p class="text-xs text-slate-400">Ranked by rule score + cached LLM skill gap analysis</p>
</div>
<button hx-get="/jobs" hx-target="#jobs-container" class="text-xs text-indigo-400 hover:underline">
🔄 Refresh List
</button>
</div>
<div id="jobs-container">
{{ template "jobs" .Jobs }}
</div>
</div>
<!-- Tab Content 2: Application Kanban -->
<div id="tab-content-kanban" class="p-6 hidden">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-base font-bold text-white">Job Application Tracking</h2>
<p class="text-xs text-slate-400">Manage saved, applied, interviewing, and offer states</p>
</div>
</div>
<div id="kanban-container">
{{ template "kanban" .Applications }}
</div>
</div>
<!-- Tab Content 3: Target Configurations -->
<div id="tab-content-targets" class="p-6 hidden space-y-6">
<!-- Add Target Form -->
<div class="p-5 rounded-xl bg-slate-900/60 border border-slate-800">
<h3 class="text-sm font-bold text-white mb-3">Add Target Search URL</h3>
<form hx-post="/targets" hx-target="#targets-container" class="grid grid-cols-1 md:grid-cols-4 gap-3">
<input type="text" name="name" placeholder="Target Name (e.g. Go Backend SF)" required
class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<input type="url" name="target_url" placeholder="Target URL" required
class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<select name="platform" class="px-3 py-2 text-xs rounded-lg bg-slate-800 border border-slate-700 text-white focus:outline-none focus:border-indigo-500">
<option value="linkedin">LinkedIn</option>
<option value="google_jobs">Google Jobs</option>
<option value="greenhouse">Greenhouse</option>
<option value="lever">Lever</option>
<option value="generic">Generic Web</option>
</select>
<button type="submit" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-semibold text-white rounded-lg shadow-md transition-all">
+ Add Target
</button>
</form>
</div>
<!-- Targets Table -->
<div id="targets-container">
{{ template "targets" .Targets }}
</div>
</div>
</div>
</main>
<!-- Slide-Over Detail Modal -->
<div id="detail-modal" class="fixed inset-0 z-50 hidden bg-slate-950/80 backdrop-blur-sm flex justify-end">
<div id="detail-modal-body" class="w-full max-w-2xl bg-slate-900 border-l border-slate-800 h-full p-6 overflow-y-auto custom-scrollbar shadow-2xl">
<!-- Dynamic HTMX content loaded here -->
</div>
</div>
<!-- Tab Switching Script -->
<script>
function switchTab(tab) {
['triage', 'kanban', 'targets'].forEach(t => {
document.getElementById('tab-content-' + t).classList.add('hidden');
const btn = document.getElementById('tab-btn-' + t);
btn.classList.remove('text-indigo-400', 'border-indigo-500');
btn.classList.add('text-slate-400', 'border-transparent');
});
document.getElementById('tab-content-' + tab).classList.remove('hidden');
const activeBtn = document.getElementById('tab-btn-' + tab);
activeBtn.classList.remove('text-slate-400', 'border-transparent');
activeBtn.classList.add('text-indigo-400', 'border-indigo-500');
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.add('hidden');
}
</script>
</body>
</html>{{ end }}`
const targetListHTML = `{{ define "targets" }}
<div class="overflow-x-auto rounded-xl border border-slate-800">
<table class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
<tr>
<th class="px-4 py-3">Platform</th>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Target URL</th>
<th class="px-4 py-3">Last Crawled</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
{{ range . }}
<tr class="hover:bg-slate-800/40 transition-colors">
<td class="px-4 py-3 font-medium text-indigo-300">{{ platformIcon .Platform }}</td>
<td class="px-4 py-3 font-semibold text-white">{{ .Name }}</td>
<td class="px-4 py-3 max-w-xs truncate text-slate-400">
<a href="{{ .TargetURL }}" target="_blank" class="hover:underline text-indigo-400">{{ .TargetURL }}</a>
</td>
<td class="px-4 py-3 text-slate-400">{{ formatTime .LastCrawledAt }}</td>
<td class="px-4 py-3">
{{ if .IsActive }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">Active</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-slate-700/50 text-slate-400 border border-slate-600/30">Paused</span>
{{ end }}
</td>
<td class="px-4 py-3 text-right">
<button hx-post="/targets/{{ .ID }}/toggle"
hx-target="#targets-container"
class="px-2.5 py-1 text-[11px] rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700">
Toggle
</button>
</td>
</tr>
{{ else }}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-slate-500">No search targets configured yet.</td>
</tr>
{{ end }}
</tbody>
</table>
</div>{{ end }}`
const jobListHTML = `{{ define "jobs" }}
<div class="overflow-x-auto rounded-xl border border-slate-800">
<table class="w-full text-left text-xs text-slate-300">
<thead class="bg-slate-900/80 text-slate-400 uppercase font-semibold text-[10px]">
<tr>
<th class="px-4 py-3">Job Title & Company</th>
<th class="px-4 py-3 text-center">Original Link</th>
<th class="px-4 py-3">Analysis Status</th>
<th class="px-4 py-3">Score & Qualification</th>
<th class="px-4 py-3">Keyword Tags</th>
<th class="px-4 py-3">Crawled Date</th>
<th class="px-4 py-3 text-right">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800 bg-slate-900/40">
{{ range . }}
<tr class="hover:bg-slate-800/40 transition-colors group">
<!-- Job Title & Company -->
<td class="px-4 py-3 font-medium">
<button hx-get="/jobs/{{ .ID }}"
hx-target="#detail-modal-body"
onclick="document.getElementById('detail-modal').classList.remove('hidden')"
class="text-left font-bold text-white group-hover:text-indigo-300 hover:underline transition-colors block">
{{ .Title }}
</button>
<div class="flex items-center space-x-2 mt-0.5 text-[11px] text-slate-400">
<span class="font-semibold text-indigo-400">{{ .Company }}</span>
<span>&bull;</span>
<span>📍 {{ .Location }}</span>
</div>
</td>
<!-- Original Link -->
<td class="px-4 py-3 text-center">
<a href="{{ .ApplyURL }}" target="_blank" title="Open External Job Posting"
class="inline-flex items-center justify-center w-7 h-7 rounded bg-slate-800 hover:bg-slate-700 text-indigo-400 hover:text-indigo-300 border border-slate-700 transition-colors">
🔗
</a>
</td>
<!-- Analysis Status (JobAnalysis.Status) -->
<td class="px-4 py-3">
{{ if .Analysis }}
{{ if eq .Analysis.Status "PARSED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
PARSED
</span>
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">
FILTERED_OUT
</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">
{{ .Analysis.Status }}
</span>
{{ end }}
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-slate-800 text-slate-400 border border-slate-700">
UNANALYZED
</span>
{{ end }}
</td>
<!-- Score & Qualification -->
<td class="px-4 py-3">
{{ if .Analysis }}
{{ if .Analysis.IsQualified }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}">
★ {{ printf "%.0f" .Analysis.Score }} Qualified
</span>
{{ else }}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
Not Qualified
</span>
{{ end }}
{{ else }}
<span class="text-slate-500 text-[11px]">-</span>
{{ end }}
</td>
<!-- Keyword Tags -->
<td class="px-4 py-3 max-w-xs">
{{ if .Analysis }}
<div class="flex flex-wrap gap-1">
{{ range parseJSON .Analysis.MatchedHighlights }}
<span class="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 text-[10px] font-medium border border-emerald-500/20">
{{ . }}
</span>
{{ else }}
<span class="text-slate-500 text-[10px] italic">No tags</span>
{{ end }}
</div>
{{ else }}
<span class="text-slate-500 text-[10px] italic">No tags</span>
{{ end }}
</td>
<!-- Crawled Date -->
<td class="px-4 py-3 text-slate-400 text-[11px]">
{{ formatDate .CrawledAt }}
</td>
<!-- Action Button -->
<td class="px-4 py-3 text-right">
{{ if .App }}
<span class="px-2 py-1 rounded bg-violet-500/20 text-violet-300 text-[10px] font-bold border border-violet-500/30">
{{ .App.Status }}
</span>
{{ else }}
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "SAVED"}'
hx-target="#jobs-container"
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-[11px] font-medium transition-colors">
+ Save
</button>
{{ end }}
</td>
</tr>
{{ else }}
<tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-500">
No job postings found yet. Click <strong>⚡ Run Pipeline</strong> to ingest jobs.
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>{{ end }}`
const jobDetailHTML = `{{ define "jobDetail" }}
<div class="space-y-6">
<div class="flex items-start justify-between border-b border-slate-800 pb-4">
<div>
<span class="text-xs font-bold text-indigo-400 uppercase tracking-wide">{{ .Company }}</span>
<h2 class="text-xl font-extrabold text-white mt-1">{{ .Title }}</h2>
<p class="text-xs text-slate-400 mt-1">📍 {{ .Location }} &bull; Ingested {{ formatDate .CrawledAt }}</p>
</div>
<button onclick="closeDetailModal()" class="text-slate-400 hover:text-white text-lg p-1">✕</button>
</div>
{{ if .Analysis }}
<div class="grid grid-cols-2 gap-3">
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Match Relevance Score</span>
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} / 100</span>
</div>
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">LLM Status</span>
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
</div>
</div>
<!-- Matched & Missing Highlights -->
<div class="space-y-4">
<div>
<h4 class="text-xs font-bold text-emerald-400 uppercase tracking-wider mb-2">✓ Matched Technical Highlights</h4>
<div class="flex flex-wrap gap-2">
{{ range parseJSON .Analysis.MatchedHighlights }}
<span class="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-300 text-xs font-semibold border border-emerald-500/30">
{{ . }}
</span>
{{ end }}
</div>
</div>
<div>
<h4 class="text-xs font-bold text-rose-400 uppercase tracking-wider mb-2">! Skill & Experience Gaps</h4>
<div class="flex flex-wrap gap-2">
{{ range parseJSON .Analysis.MissingHighlights }}
<span class="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-300 text-xs font-semibold border border-rose-500/30">
{{ . }}
</span>
{{ end }}
</div>
</div>
<div>
<h4 class="text-xs font-bold text-amber-400 uppercase tracking-wider mb-2">💡 Resume Tailoring Action Points</h4>
<ul class="space-y-2">
{{ range parseJSON .Analysis.TailoringSuggestions }}
<li class="p-3 rounded-lg bg-slate-800/80 border border-slate-700/60 text-xs text-slate-200 leading-relaxed">
&bull; {{ . }}
</li>
{{ end }}
</ul>
</div>
</div>
{{ end }}
<!-- Raw Content snippet -->
<div class="border-t border-slate-800 pt-4">
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Job Description Payload</h4>
<div class="p-4 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-300 max-h-60 overflow-y-auto leading-relaxed custom-scrollbar whitespace-pre-wrap">
{{ .RawContent }}
</div>
</div>
<!-- Actions Footer -->
<div class="flex items-center justify-between border-t border-slate-800 pt-4">
<a href="{{ .ApplyURL }}" target="_blank" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-xs font-bold text-white rounded-lg shadow-md transition-all">
Apply External ↗
</a>
<div class="flex space-x-2">
<button hx-post="/jobs/{{ .ID }}/analyze"
hx-target="#detail-modal-body"
class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 rounded-lg border border-slate-700">
🔄 Re-run LLM
</button>
<button hx-post="/applications"
hx-vals='{"raw_job_id": {{ .ID }}, "status": "APPLIED"}'
hx-target="#kanban-container"
onclick="switchTab('kanban'); closeDetailModal();"
class="px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-xs font-semibold text-white rounded-lg shadow-md">
+ Mark Applied
</button>
</div>
</div>
</div>{{ end }}`
const kanbanBoardHTML = `{{ define "kanban" }}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- SAVED COLUMN -->
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
<h3 class="text-xs font-bold text-slate-300 uppercase">💾 Saved</h3>
</div>
<div class="space-y-3 flex-1">
{{ range . }}
{{ if eq .App.Status "SAVED" }}
{{ template "kanbanCard" . }}
{{ end }}
{{ end }}
</div>
</div>
<!-- APPLIED COLUMN -->
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
<h3 class="text-xs font-bold text-indigo-400 uppercase">🚀 Applied</h3>
</div>
<div class="space-y-3 flex-1">
{{ range . }}
{{ if eq .App.Status "APPLIED" }}
{{ template "kanbanCard" . }}
{{ end }}
{{ end }}
</div>
</div>
<!-- INTERVIEW COLUMN -->
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
<h3 class="text-xs font-bold text-amber-400 uppercase">💬 Interview</h3>
</div>
<div class="space-y-3 flex-1">
{{ range . }}
{{ if eq .App.Status "INTERVIEW" }}
{{ template "kanbanCard" . }}
{{ end }}
{{ end }}
</div>
</div>
<!-- OFFER / CLOSED COLUMN -->
<div class="p-4 rounded-2xl bg-slate-900/50 border border-slate-800 flex flex-col min-h-[500px]">
<div class="flex items-center justify-between mb-4 pb-2 border-b border-slate-800">
<h3 class="text-xs font-bold text-emerald-400 uppercase">🎉 Offer / Closed</h3>
</div>
<div class="space-y-3 flex-1">
{{ range . }}
{{ if or (eq .App.Status "OFFER") (eq .App.Status "REJECTED") }}
{{ template "kanbanCard" . }}
{{ end }}
{{ end }}
</div>
</div>
</div>{{ end }}`
const kanbanCardHTML = `
{{ define "kanbanCard" }}
<div class="p-4 rounded-xl bg-slate-800/80 border border-slate-700/60 shadow-md space-y-3">
<div>
<span class="text-[10px] font-bold text-indigo-300 uppercase">{{ .Company }}</span>
<h4 class="text-xs font-bold text-white mt-0.5">{{ .Title }}</h4>
<span class="text-[10px] text-slate-400 mt-1 block">📍 {{ .Location }}</span>
</div>
<div class="flex items-center justify-between pt-2 border-t border-slate-700/50 text-[10px]">
<span class="text-slate-400">Status: <strong class="text-white">{{ .App.Status }}</strong></span>
<select hx-patch="/applications/{{ .App.ID }}/status"
hx-target="#kanban-container"
class="px-1.5 py-0.5 bg-slate-900 border border-slate-700 rounded text-slate-200 text-[10px]">
<option value="SAVED" {{ if eq .App.Status "SAVED" }}selected{{ end }}>Saved</option>
<option value="APPLIED" {{ if eq .App.Status "APPLIED" }}selected{{ end }}>Applied</option>
<option value="INTERVIEW" {{ if eq .App.Status "INTERVIEW" }}selected{{ end }}>Interview</option>
<option value="OFFER" {{ if eq .App.Status "OFFER" }}selected{{ end }}>Offer</option>
<option value="REJECTED" {{ if eq .App.Status "REJECTED" }}selected{{ end }}>Rejected</option>
</select>
</div>
</div>
{{ end }}`