Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"preview": "vite preview",
"type-check": "tsc --noEmit",
"tauri": "tauri",
"pretauri:dev": "kill -9 $(lsof -ti:1420 -sTCP:LISTEN) 2>/dev/null || true",
"tauri:dev": "tauri dev --config src-tauri/tauri.conf.dev.json",
"tauri:build": "tauri build",
"generate-test-data": "tsx scripts/generate-test-data.ts --employees",
Expand Down
98 changes: 97 additions & 1 deletion src-tauri/src/commands/recruiting.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! Tauri command bridge for the `recruiting` module.

use crate::db::Database;
use crate::recruiting::{self, RecruitingSearch};
use crate::keyring::{self, KeyringError};
use crate::recruiting::adapters::exa::{self, ExaError, ExaSearchResponse};
use crate::recruiting::{self, RecruitingSearch, EXA_PROVIDER_ID};
use serde::Serialize;

/// Create a new recruiting search. Returns the generated row ID.
#[tauri::command]
Expand All @@ -24,3 +27,96 @@ pub(crate) async fn recruiting_list_searches(
.await
.map_err(|e| e.to_string())
}

/// Discriminated error type for `recruiting_search_exa`. The frontend
/// pattern-matches on `kind`:
/// - `MissingKey` → render the "Recruiting needs your Exa API key" banner.
/// - `InvalidKey` → same banner; suffix that Exa rejected the stored key.
/// - `RateLimit` → soft toast with the Exa-supplied message.
/// - `Network` → soft toast ("couldn't reach Exa").
/// - `ExaApi` → surface status + body inline (debugging-friendly).
/// - `Internal` → unexpected path: Keychain read failure, response
/// parse failure, etc.
#[derive(Debug, Serialize)]
#[serde(tag = "kind")]
pub enum RecruitingSearchError {
MissingKey,
InvalidKey,
RateLimit { message: String },
Network { message: String },
ExaApi { status: u16, body: String },
Internal { message: String },
}

impl From<ExaError> for RecruitingSearchError {
fn from(err: ExaError) -> Self {
match err {
ExaError::Network(e) => RecruitingSearchError::Network {
message: e.to_string(),
},
ExaError::InvalidKey => RecruitingSearchError::InvalidKey,
ExaError::RateLimit { message } => RecruitingSearchError::RateLimit { message },
ExaError::Api { status, body } => RecruitingSearchError::ExaApi { status, body },
ExaError::InvalidResponse(message) => RecruitingSearchError::Internal { message },
}
}
}

// ============================================================================
// Exa API key management — Recruiting-namespaced wrappers
// ============================================================================
//
// These deliberately bypass `commands::api_keys::*_provider_api_key` because
// those commands gate on the LLM-provider registry (`providers::get_provider`)
// — Exa is a data source, not an LLM, so it isn't (and shouldn't be) registered
// there. The gate is a real safety net for the LLM-key path (catches typos
// like "anthropic-key"), so punching a hole in it would be wrong; better to
// keep the LLM and data-source key surfaces independent.
//
// Storage is shared with the LLM keys (same Keychain service, account
// `exa_api_key` per `keychain_account_for_provider("exa")`), so a key written
// here is readable by `recruiting_search_exa` and vice-versa.

/// Check whether an Exa API key is stored in the Keychain.
#[tauri::command]
pub(crate) fn recruiting_has_exa_key() -> Result<bool, String> {
Ok(keyring::has_provider_api_key(EXA_PROVIDER_ID))
}

/// Store the Exa API key in the Keychain. Format validation lives client-side
/// (UUID regex in `ExaKeyInput.tsx`); the Rust side is intentionally permissive
/// to keep the recruiting command surface small. If Exa changes its key format,
/// nothing here needs to change.
#[tauri::command]
pub(crate) fn recruiting_store_exa_key(api_key: String) -> Result<(), String> {
keyring::store_provider_api_key(EXA_PROVIDER_ID, &api_key).map_err(|e| e.to_string())
}

/// Delete the Exa API key from the Keychain. Idempotent — returns Ok even
/// when no key was stored (matches `keyring::delete_provider_api_key`).
#[tauri::command]
pub(crate) fn recruiting_delete_exa_key() -> Result<(), String> {
keyring::delete_provider_api_key(EXA_PROVIDER_ID).map_err(|e| e.to_string())
}

/// Execute an Exa search using the user's BYOK key from Keychain.
///
/// Returns `MissingKey` when no key is stored — the frontend uses this as
/// the signal to render the "add your Exa key in Settings" banner instead
/// of treating it as an error.
#[tauri::command]
pub(crate) async fn recruiting_search_exa(
query: String,
) -> Result<ExaSearchResponse, RecruitingSearchError> {
let api_key = match keyring::get_provider_api_key(EXA_PROVIDER_ID) {
Ok(key) => key,
Err(KeyringError::NotFound) => return Err(RecruitingSearchError::MissingKey),
Err(other) => {
return Err(RecruitingSearchError::Internal {
message: other.to_string(),
})
}
};

exa::search(&query, &api_key).await.map_err(Into::into)
}
1 change: 1 addition & 0 deletions src-tauri/src/keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,5 +268,6 @@ mod tests {
assert_eq!(keychain_account_for_provider("anthropic"), "anthropic_api_key");
assert_eq!(keychain_account_for_provider("openai"), "openai_api_key");
assert_eq!(keychain_account_for_provider("gemini"), "gemini_api_key");
assert_eq!(keychain_account_for_provider("exa"), "exa_api_key");
}
}
6 changes: 5 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,13 @@ pub fn run() {
commands::documents::get_document_folder,
commands::documents::rescan_documents,
commands::documents::get_document_stats,
// Recruiting (Sourcerer module) — FHR-71
// Recruiting (Sourcerer module) — FHR-71, FHR-72
commands::recruiting::recruiting_create_search,
commands::recruiting::recruiting_list_searches,
commands::recruiting::recruiting_search_exa,
commands::recruiting::recruiting_has_exa_key,
commands::recruiting::recruiting_store_exa_key,
commands::recruiting::recruiting_delete_exa_key,
])
.setup(|app| {
// Register updater plugin for auto-updates via GitHub Releases
Expand Down
216 changes: 216 additions & 0 deletions src-tauri/src/recruiting/adapters/exa.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
//! Exa search adapter for the recruiting module.
//!
//! Wraps Exa's `/search` endpoint (<https://exa.ai>). Exa is the recruiting
//! module's primary candidate-discovery source; the team-seeded `findSimilar`
//! variant (roadmap S1.1) and per-hit `getContents` (S1.1) will extend the
//! same types defined here.
//!
//! Key conventions:
//! - The API key is read from the macOS Keychain via
//! `keyring::get_provider_api_key(crate::recruiting::EXA_PROVIDER_ID)`
//! at the command boundary; this module takes the key as a `&str`
//! argument so unit tests never touch OS-level storage.
//! - Wire format matches Exa verbatim via `#[serde(rename_all = "camelCase")]`.
//! - All hit fields except `id` and `url` are `Option<T>` because Exa
//! populates them inconsistently depending on search params (highlights,
//! summary, contents are opt-in).

use reqwest::Client;
use serde::{Deserialize, Serialize};
use thiserror::Error;

const EXA_SEARCH_URL: &str = "https://api.exa.ai/search";
const DEFAULT_NUM_RESULTS: u32 = 10;
/// Exa search mode: "neural" (semantic), "keyword" (lexical), "auto" (Exa picks).
/// "auto" is the right default for v1 — it lets Exa decide and removes a
/// premature tuning knob from the UX.
const DEFAULT_SEARCH_TYPE: &str = "auto";

// ============================================================================
// Wire types — request
// ============================================================================

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ExaSearchRequest<'a> {
query: &'a str,
num_results: u32,
#[serde(rename = "type")]
search_type: &'a str,
}

// ============================================================================
// Wire types — response
// ============================================================================

/// Top-level response from Exa's `/search` endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExaSearchResponse {
pub results: Vec<ExaHit>,
/// Exa's rewrite of the user query (only present for `neural` / `auto`).
pub autoprompt_string: Option<String>,
/// Server-side request ID — quote this when filing Exa support tickets.
pub request_id: Option<String>,
}

/// One search hit. `id` and `url` are guaranteed on every result; every
/// other field is opt-in depending on Exa search params.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExaHit {
pub id: String,
pub url: String,
pub title: Option<String>,
/// Relevance score in `[0.0, 1.0]`. `f32` is enough precision — Exa
/// returns ~2 decimal places of meaningful signal.
pub score: Option<f32>,
/// Publication date as a raw string. Kept as `String` (not parsed) because
/// Exa returns mixed formats (ISO 8601, `YYYY-MM-DD`, sometimes null) and
/// the frontend just renders it.
pub published_date: Option<String>,
pub author: Option<String>,
/// Full document text — only populated when fetched via `getContents`.
pub text: Option<String>,
/// Matched snippets — only when `highlights=true` was requested.
pub highlights: Option<Vec<String>>,
/// LLM-generated summary — only when `summary=true` was requested.
pub summary: Option<String>,
}

// ============================================================================
// Errors
// ============================================================================

#[derive(Debug, Error)]
pub enum ExaError {
#[error("network error: {0}")]
Network(#[from] reqwest::Error),
/// HTTP 401 — Exa rejected the key. Distinct from `MissingKey` at the
/// command layer, which means "no key was stored at all."
#[error("exa rejected the api key")]
InvalidKey,
/// HTTP 429 — quota exhausted or rate-limit window hit.
#[error("rate limited: {message}")]
RateLimit { message: String },
/// Any other non-2xx response.
#[error("exa api returned {status}: {body}")]
Api { status: u16, body: String },
/// Body was 2xx but didn't deserialize into `ExaSearchResponse`.
#[error("failed to parse exa response: {0}")]
InvalidResponse(String),
}

// ============================================================================
// Search
// ============================================================================

/// Execute a search against Exa's `/search` endpoint.
///
/// The caller fetches `api_key` from the Keychain (typically via
/// `keyring::get_provider_api_key(EXA_PROVIDER_ID)`) and is responsible for
/// translating `KeyringError::NotFound` into a higher-level "missing key"
/// condition — this function only sees the key as an opaque `&str`.
pub async fn search(query: &str, api_key: &str) -> Result<ExaSearchResponse, ExaError> {
let body = ExaSearchRequest {
query,
num_results: DEFAULT_NUM_RESULTS,
search_type: DEFAULT_SEARCH_TYPE,
};

let response = Client::new()
.post(EXA_SEARCH_URL)
.header("x-api-key", api_key)
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
Comment on lines +121 to +127

let status = response.status();
if status.is_success() {
let text = response.text().await?;
return serde_json::from_str(&text)
.map_err(|e| ExaError::InvalidResponse(e.to_string()));
}

let status_code = status.as_u16();
let body = response.text().await.unwrap_or_default();
Err(match status_code {
401 => ExaError::InvalidKey,
429 => ExaError::RateLimit { message: body },
_ => ExaError::Api {
status: status_code,
body,
},
})
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
use super::*;

/// Realistic Exa `/search` response — trimmed from a live call, covers
/// the common-case hit (id, url, title, score, publishedDate, author).
const FIXTURE_FULL: &str = r#"{
"results": [
{
"id": "https://example.com/jane-doe",
"url": "https://example.com/jane-doe",
"title": "Senior Rust Engineer at Acme",
"score": 0.87,
"publishedDate": "2024-03-15",
"author": "Jane Doe"
}
],
"autopromptString": "rust engineers berlin",
"requestId": "req_abc123"
}"#;

/// Sparse fixture — only the fields Exa guarantees on every hit. Locks
/// down `Option<T>` handling for the long tail of partial responses.
const FIXTURE_SPARSE: &str = r#"{
"results": [
{"id": "abc", "url": "https://example.org/post"}
]
}"#;

#[test]
fn deserializes_full_response() {
let parsed: ExaSearchResponse =
serde_json::from_str(FIXTURE_FULL).expect("parse full fixture");
assert_eq!(parsed.results.len(), 1);
let hit = &parsed.results[0];
assert_eq!(hit.url, "https://example.com/jane-doe");
assert_eq!(hit.title.as_deref(), Some("Senior Rust Engineer at Acme"));
assert_eq!(hit.score, Some(0.87));
assert_eq!(hit.published_date.as_deref(), Some("2024-03-15"));
assert_eq!(hit.author.as_deref(), Some("Jane Doe"));
assert!(hit.text.is_none());
assert!(hit.highlights.is_none());
assert!(hit.summary.is_none());
assert_eq!(
parsed.autoprompt_string.as_deref(),
Some("rust engineers berlin")
);
assert_eq!(parsed.request_id.as_deref(), Some("req_abc123"));
}

#[test]
fn deserializes_sparse_response_with_only_required_fields() {
let parsed: ExaSearchResponse =
serde_json::from_str(FIXTURE_SPARSE).expect("parse sparse fixture");
assert_eq!(parsed.results.len(), 1);
let hit = &parsed.results[0];
assert_eq!(hit.id, "abc");
assert_eq!(hit.url, "https://example.org/post");
assert!(hit.title.is_none(), "title is Option");
assert!(hit.score.is_none(), "score is Option");
assert!(hit.published_date.is_none(), "publishedDate is Option");
assert!(parsed.autoprompt_string.is_none(), "autoprompt is Option");
assert!(parsed.request_id.is_none(), "requestId is Option");
}
}
10 changes: 10 additions & 0 deletions src-tauri/src/recruiting/adapters/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! Data-source adapters for the recruiting module.
//!
//! Each adapter wraps one external source (Exa, and later Hunter / GitHub /
//! X — see roadmap phases S1–S3) with a consistent shape: take a query +
//! API key, return a typed response or a typed error. Adapters never read
//! the Keychain themselves; the command layer fetches the key and passes
//! it in. That separation keeps adapters unit-testable without OS-level
//! Keychain access.

pub mod exa;
7 changes: 7 additions & 0 deletions src-tauri/src/recruiting/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@
//! `chat`, `employees`, `documents` are organized).
//! - The migration that backs this module is `013_recruiting.sql`.

pub mod adapters;

use crate::db::DbPool;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Keyring provider ID for the Exa search API key. Used by the recruiting
/// search command to look up the user's BYOK Exa key:
/// `keyring::get_provider_api_key(EXA_PROVIDER_ID)`.
pub const EXA_PROVIDER_ID: &str = "exa";

#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct RecruitingSearch {
pub id: String,
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ function MainAppContent() {
>
{showRecruiting ? (
<Suspense fallback={null}>
<RecruitingView />
<RecruitingView onOpenSettings={() => setIsSettingsOpen(true)} />
</Suspense>
) : (
<ChatArea chatInputRef={chatInputRef} />
Expand Down
Loading