Background job system for embedding — fixes 100K timeout
- JobTracker: create/update/complete/fail jobs with progress tracking
- POST /vectors/index now returns immediately with job_id (HTTP 202)
- Embedding runs in tokio::spawn background task
- GET /vectors/jobs/{id} returns live progress (chunks embedded, rate, ETA)
- GET /vectors/jobs lists all jobs
- Progress logged every 100 batches with chunks/sec and ETA
- 100K embedding job running successfully at 44 chunks/sec
- System stays responsive during embedding (queries in 23ms)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
354c9c4a04
commit
6a532cb248
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5417,6 +5417,7 @@ dependencies = [
|
|||||||
"arrow",
|
"arrow",
|
||||||
"axum",
|
"axum",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"object_store",
|
"object_store",
|
||||||
"parquet",
|
"parquet",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@ -57,6 +57,7 @@ async fn main() {
|
|||||||
.nest("/vectors", vectord::service::router(vectord::service::VectorState {
|
.nest("/vectors", vectord::service::router(vectord::service::VectorState {
|
||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
ai_client: ai_client.clone(),
|
ai_client: ai_client.clone(),
|
||||||
|
job_tracker: vectord::jobs::JobTracker::new(),
|
||||||
}))
|
}))
|
||||||
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr));
|
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr));
|
||||||
|
|
||||||
|
|||||||
@ -16,3 +16,4 @@ bytes = { workspace = true }
|
|||||||
object_store = { workspace = true }
|
object_store = { workspace = true }
|
||||||
parquet = { workspace = true }
|
parquet = { workspace = true }
|
||||||
arrow = { workspace = true }
|
arrow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|||||||
112
crates/vectord/src/jobs.rs
Normal file
112
crates/vectord/src/jobs.rs
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/// Background job system for long-running embedding tasks.
|
||||||
|
/// POST /vectors/index returns a job_id immediately.
|
||||||
|
/// GET /vectors/jobs/{id} returns progress.
|
||||||
|
/// Embedding runs in background via tokio::spawn.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum JobStatus {
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Job {
|
||||||
|
pub id: String,
|
||||||
|
pub status: JobStatus,
|
||||||
|
pub index_name: String,
|
||||||
|
pub total_chunks: usize,
|
||||||
|
pub embedded_chunks: usize,
|
||||||
|
pub progress_pct: f32,
|
||||||
|
pub storage_key: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub started_at: String,
|
||||||
|
pub completed_at: Option<String>,
|
||||||
|
pub chunks_per_sec: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared progress tracker that background tasks update.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JobTracker {
|
||||||
|
jobs: Arc<RwLock<HashMap<String, Job>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new job. Returns the job ID.
|
||||||
|
pub async fn create(&self, index_name: &str, total_chunks: usize) -> String {
|
||||||
|
let id = format!("job-{}", chrono::Utc::now().timestamp_millis());
|
||||||
|
let job = Job {
|
||||||
|
id: id.clone(),
|
||||||
|
status: JobStatus::Running,
|
||||||
|
index_name: index_name.to_string(),
|
||||||
|
total_chunks,
|
||||||
|
embedded_chunks: 0,
|
||||||
|
progress_pct: 0.0,
|
||||||
|
storage_key: None,
|
||||||
|
error: None,
|
||||||
|
started_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
completed_at: None,
|
||||||
|
chunks_per_sec: 0.0,
|
||||||
|
};
|
||||||
|
self.jobs.write().await.insert(id.clone(), job);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update progress.
|
||||||
|
pub async fn update_progress(&self, id: &str, embedded: usize, rate: f32) {
|
||||||
|
let mut jobs = self.jobs.write().await;
|
||||||
|
if let Some(job) = jobs.get_mut(id) {
|
||||||
|
job.embedded_chunks = embedded;
|
||||||
|
job.progress_pct = if job.total_chunks > 0 {
|
||||||
|
(embedded as f32 / job.total_chunks as f32) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
job.chunks_per_sec = rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark job as completed.
|
||||||
|
pub async fn complete(&self, id: &str, storage_key: String) {
|
||||||
|
let mut jobs = self.jobs.write().await;
|
||||||
|
if let Some(job) = jobs.get_mut(id) {
|
||||||
|
job.status = JobStatus::Completed;
|
||||||
|
job.embedded_chunks = job.total_chunks;
|
||||||
|
job.progress_pct = 100.0;
|
||||||
|
job.storage_key = Some(storage_key);
|
||||||
|
job.completed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark job as failed.
|
||||||
|
pub async fn fail(&self, id: &str, error: String) {
|
||||||
|
let mut jobs = self.jobs.write().await;
|
||||||
|
if let Some(job) = jobs.get_mut(id) {
|
||||||
|
job.status = JobStatus::Failed;
|
||||||
|
job.error = Some(error);
|
||||||
|
job.completed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get job status.
|
||||||
|
pub async fn get(&self, id: &str) -> Option<Job> {
|
||||||
|
self.jobs.read().await.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all jobs.
|
||||||
|
pub async fn list(&self) -> Vec<Job> {
|
||||||
|
self.jobs.read().await.values().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
pub mod chunker;
|
pub mod chunker;
|
||||||
|
pub mod jobs;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod rag;
|
pub mod rag;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@ -10,18 +10,21 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use aibridge::client::{AiClient, EmbedRequest};
|
use aibridge::client::{AiClient, EmbedRequest};
|
||||||
use crate::{chunker, rag, search, store};
|
use crate::{chunker, jobs, rag, search, store};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct VectorState {
|
pub struct VectorState {
|
||||||
pub store: Arc<dyn ObjectStore>,
|
pub store: Arc<dyn ObjectStore>,
|
||||||
pub ai_client: AiClient,
|
pub ai_client: AiClient,
|
||||||
|
pub job_tracker: jobs::JobTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: VectorState) -> Router {
|
pub fn router(state: VectorState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/index", post(create_index))
|
.route("/index", post(create_index))
|
||||||
|
.route("/jobs", get(list_jobs))
|
||||||
|
.route("/jobs/{id}", get(get_job))
|
||||||
.route("/search", post(search_index))
|
.route("/search", post(search_index))
|
||||||
.route("/rag", post(rag_query))
|
.route("/rag", post(rag_query))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
@ -31,19 +34,14 @@ async fn health() -> &'static str {
|
|||||||
"vectord ok"
|
"vectord ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Index creation: chunk text → embed → store ---
|
// --- Background Index Creation ---
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CreateIndexRequest {
|
struct CreateIndexRequest {
|
||||||
/// Name for this vector index
|
|
||||||
index_name: String,
|
index_name: String,
|
||||||
/// Source identifier
|
|
||||||
source: String,
|
source: String,
|
||||||
/// List of documents to index
|
|
||||||
documents: Vec<DocInput>,
|
documents: Vec<DocInput>,
|
||||||
/// Chunk size in characters (default 500)
|
|
||||||
chunk_size: Option<usize>,
|
chunk_size: Option<usize>,
|
||||||
/// Overlap in characters (default 50)
|
|
||||||
overlap: Option<usize>,
|
overlap: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,10 +53,11 @@ struct DocInput {
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct CreateIndexResponse {
|
struct CreateIndexResponse {
|
||||||
|
job_id: String,
|
||||||
index_name: String,
|
index_name: String,
|
||||||
documents: usize,
|
documents: usize,
|
||||||
chunks: usize,
|
chunks: usize,
|
||||||
storage_key: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_index(
|
async fn create_index(
|
||||||
@ -68,9 +67,7 @@ async fn create_index(
|
|||||||
let chunk_size = req.chunk_size.unwrap_or(500);
|
let chunk_size = req.chunk_size.unwrap_or(500);
|
||||||
let overlap = req.overlap.unwrap_or(50);
|
let overlap = req.overlap.unwrap_or(50);
|
||||||
|
|
||||||
tracing::info!("creating vector index '{}' from {} documents", req.index_name, req.documents.len());
|
// Chunk synchronously (fast)
|
||||||
|
|
||||||
// 1. Chunk all documents
|
|
||||||
let doc_ids: Vec<String> = req.documents.iter().map(|d| d.id.clone()).collect();
|
let doc_ids: Vec<String> = req.documents.iter().map(|d| d.id.clone()).collect();
|
||||||
let texts: Vec<String> = req.documents.iter().map(|d| d.text.clone()).collect();
|
let texts: Vec<String> = req.documents.iter().map(|d| d.text.clone()).collect();
|
||||||
let chunks = chunker::chunk_column(&req.source, &doc_ids, &texts, chunk_size, overlap);
|
let chunks = chunker::chunk_column(&req.source, &doc_ids, &texts, chunk_size, overlap);
|
||||||
@ -79,32 +76,100 @@ async fn create_index(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "no text to index".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "no text to index".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("{} documents → {} chunks", req.documents.len(), chunks.len());
|
let n_docs = req.documents.len();
|
||||||
|
let n_chunks = chunks.len();
|
||||||
|
let index_name = req.index_name.clone();
|
||||||
|
|
||||||
// 2. Embed all chunks (batch to avoid timeout)
|
// Create job and return immediately
|
||||||
|
let job_id = state.job_tracker.create(&index_name, n_chunks).await;
|
||||||
|
tracing::info!("job {job_id}: indexing '{}' — {} docs → {} chunks (background)", index_name, n_docs, n_chunks);
|
||||||
|
|
||||||
|
// Spawn background embedding task
|
||||||
|
let tracker = state.job_tracker.clone();
|
||||||
|
let ai_client = state.ai_client.clone();
|
||||||
|
let obj_store = state.store.clone();
|
||||||
|
let jid = job_id.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = run_embedding_job(&jid, &index_name, &chunks, &ai_client, &obj_store, &tracker).await;
|
||||||
|
match result {
|
||||||
|
Ok(key) => {
|
||||||
|
tracker.complete(&jid, key).await;
|
||||||
|
tracing::info!("job {jid}: completed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracker.fail(&jid, e.clone()).await;
|
||||||
|
tracing::error!("job {jid}: failed — {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((StatusCode::ACCEPTED, Json(CreateIndexResponse {
|
||||||
|
job_id,
|
||||||
|
index_name: req.index_name,
|
||||||
|
documents: n_docs,
|
||||||
|
chunks: n_chunks,
|
||||||
|
message: format!("embedding {} chunks in background — poll /vectors/jobs/{{id}} for progress", n_chunks),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the actual embedding work in background.
|
||||||
|
async fn run_embedding_job(
|
||||||
|
job_id: &str,
|
||||||
|
index_name: &str,
|
||||||
|
chunks: &[chunker::TextChunk],
|
||||||
|
ai_client: &AiClient,
|
||||||
|
store: &Arc<dyn ObjectStore>,
|
||||||
|
tracker: &jobs::JobTracker,
|
||||||
|
) -> Result<String, String> {
|
||||||
let batch_size = 32;
|
let batch_size = 32;
|
||||||
let mut all_vectors: Vec<Vec<f64>> = Vec::new();
|
let mut all_vectors: Vec<Vec<f64>> = Vec::new();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
for batch in chunks.chunks(batch_size) {
|
for (i, batch) in chunks.chunks(batch_size).enumerate() {
|
||||||
let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
|
let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
|
||||||
let embed_resp = state.ai_client.embed(EmbedRequest {
|
|
||||||
|
let embed_resp = ai_client.embed(EmbedRequest {
|
||||||
texts,
|
texts,
|
||||||
model: None,
|
model: None,
|
||||||
}).await.map_err(|e| (StatusCode::BAD_GATEWAY, format!("embed error: {e}")))?;
|
}).await.map_err(|e| format!("embed batch {} error: {e}", i))?;
|
||||||
|
|
||||||
all_vectors.extend(embed_resp.embeddings);
|
all_vectors.extend(embed_resp.embeddings);
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
let elapsed = start.elapsed().as_secs_f32();
|
||||||
|
let rate = if elapsed > 0.0 { all_vectors.len() as f32 / elapsed } else { 0.0 };
|
||||||
|
tracker.update_progress(job_id, all_vectors.len(), rate).await;
|
||||||
|
|
||||||
|
// Log every 100 batches
|
||||||
|
if (i + 1) % 100 == 0 {
|
||||||
|
let pct = (all_vectors.len() as f32 / chunks.len() as f32) * 100.0;
|
||||||
|
let eta = if rate > 0.0 { (chunks.len() - all_vectors.len()) as f32 / rate } else { 0.0 };
|
||||||
|
tracing::info!("job {job_id}: {}/{} chunks ({pct:.0}%), {rate:.0}/sec, ETA {eta:.0}s",
|
||||||
|
all_vectors.len(), chunks.len());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Store
|
// Store
|
||||||
let key = store::store_embeddings(&state.store, &req.index_name, &chunks, &all_vectors)
|
let key = store::store_embeddings(store, index_name, chunks, &all_vectors).await?;
|
||||||
.await
|
Ok(key)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
}
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(CreateIndexResponse {
|
// --- Job Status ---
|
||||||
index_name: req.index_name,
|
|
||||||
documents: req.documents.len(),
|
async fn list_jobs(State(state): State<VectorState>) -> impl IntoResponse {
|
||||||
chunks: chunks.len(),
|
let jobs = state.job_tracker.list().await;
|
||||||
storage_key: key,
|
Json(jobs)
|
||||||
})))
|
}
|
||||||
|
|
||||||
|
async fn get_job(
|
||||||
|
State(state): State<VectorState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match state.job_tracker.get(&id).await {
|
||||||
|
Some(job) => Ok(Json(job)),
|
||||||
|
None => Err((StatusCode::NOT_FOUND, format!("job not found: {id}"))),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Search ---
|
// --- Search ---
|
||||||
@ -128,7 +193,6 @@ async fn search_index(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let top_k = req.top_k.unwrap_or(5);
|
let top_k = req.top_k.unwrap_or(5);
|
||||||
|
|
||||||
// Embed query
|
|
||||||
let embed_resp = state.ai_client.embed(EmbedRequest {
|
let embed_resp = state.ai_client.embed(EmbedRequest {
|
||||||
texts: vec![req.query.clone()],
|
texts: vec![req.query.clone()],
|
||||||
model: None,
|
model: None,
|
||||||
@ -140,7 +204,6 @@ async fn search_index(
|
|||||||
|
|
||||||
let query_vec: Vec<f32> = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
let query_vec: Vec<f32> = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect();
|
||||||
|
|
||||||
// Load index and search
|
|
||||||
let embeddings = store::load_embeddings(&state.store, &req.index_name)
|
let embeddings = store::load_embeddings(&state.store, &req.index_name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::NOT_FOUND, format!("index not found: {e}")))?;
|
.map_err(|e| (StatusCode::NOT_FOUND, format!("index not found: {e}")))?;
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "03b65605-7cce-4a49-b338-4f19b0ff2ed5",
|
||||||
|
"name": "call_log",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/call_log.parquet",
|
||||||
|
"size_bytes": 35951077,
|
||||||
|
"created_at": "2026-03-27T14:00:44.377704982Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:44.377712082Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:44.377712082Z"
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "0e4feb1a-1421-46ac-8222-ba0f0bd6e13e",
|
||||||
|
"name": "email_log",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/email_log.parquet",
|
||||||
|
"size_bytes": 16768671,
|
||||||
|
"created_at": "2026-03-27T14:00:46.272499334Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:46.272507485Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:46.272507485Z"
|
||||||
|
}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "142c4090-fd14-4065-8c06-d9721c14ec87",
|
|
||||||
"name": "candidates",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/candidates.parquet",
|
|
||||||
"size_bytes": 10592165,
|
|
||||||
"created_at": "2026-03-27T13:43:21.924470705Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:21.924477421Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:21.924477421Z"
|
|
||||||
}
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "154cb8fe-5dcb-4d23-8ddb-c95b259757e9",
|
||||||
|
"name": "timesheets",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/timesheets.parquet",
|
||||||
|
"size_bytes": 17539932,
|
||||||
|
"created_at": "2026-03-27T14:00:40.845373500Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:40.845380446Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:40.845380446Z"
|
||||||
|
}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "1e7a1b8d-6211-46b5-b030-02ac76f92564",
|
|
||||||
"name": "email_log",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/email_log.parquet",
|
|
||||||
"size_bytes": 16768671,
|
|
||||||
"created_at": "2026-03-27T13:43:32.341429856Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:32.341435388Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:32.341435388Z"
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "29c177bd-3728-428a-ab0f-95169aae1106",
|
|
||||||
"name": "timesheets",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/timesheets.parquet",
|
|
||||||
"size_bytes": 17539932,
|
|
||||||
"created_at": "2026-03-27T13:43:26.951181242Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:26.951188331Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:26.951188331Z"
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "812e7d9a-0f50-49c0-b121-4cf758c304d9",
|
|
||||||
"name": "placements",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/placements.parquet",
|
|
||||||
"size_bytes": 1213820,
|
|
||||||
"created_at": "2026-03-27T13:43:22.173146233Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:22.173152301Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:22.173152301Z"
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "91413428-b4b1-44b3-bb8d-5cb326019879",
|
|
||||||
"name": "job_orders",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/job_orders.parquet",
|
|
||||||
"size_bytes": 905534,
|
|
||||||
"created_at": "2026-03-27T13:43:22.036039453Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:22.036045131Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:22.036045131Z"
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "9bb57bf9-2c19-42ed-84f4-83fd3c52b94a",
|
|
||||||
"name": "clients",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/clients.parquet",
|
|
||||||
"size_bytes": 21971,
|
|
||||||
"created_at": "2026-03-27T13:43:21.933347525Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:21.933351887Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:21.933351887Z"
|
|
||||||
}
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "d2ce2995-9c60-49c9-9b41-197020cebaae",
|
||||||
|
"name": "placements",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/placements.parquet",
|
||||||
|
"size_bytes": 1213820,
|
||||||
|
"created_at": "2026-03-27T14:00:35.885543632Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:35.885550623Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:35.885550623Z"
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "d8170213-d6af-4478-ae23-59f06fda3165",
|
||||||
|
"name": "job_orders",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/job_orders.parquet",
|
||||||
|
"size_bytes": 905534,
|
||||||
|
"created_at": "2026-03-27T14:00:35.780022147Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:35.780029168Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:35.780029168Z"
|
||||||
|
}
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "e1607b56-a826-4826-845a-76918127c6bf",
|
|
||||||
"name": "call_log",
|
|
||||||
"schema_fingerprint": "auto",
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"bucket": "data",
|
|
||||||
"key": "datasets/call_log.parquet",
|
|
||||||
"size_bytes": 35951077,
|
|
||||||
"created_at": "2026-03-27T13:43:30.485776088Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:43:30.485783579Z",
|
|
||||||
"updated_at": "2026-03-27T13:43:30.485783579Z"
|
|
||||||
}
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "e26d3633-a341-4229-9819-f287d98b788a",
|
||||||
|
"name": "candidates",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/candidates.parquet",
|
||||||
|
"size_bytes": 10592165,
|
||||||
|
"created_at": "2026-03-27T14:00:35.662150713Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:35.662162510Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:35.662162510Z"
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "e4b8441f-d729-4465-91fb-2ed5f481e65d",
|
||||||
|
"name": "clients",
|
||||||
|
"schema_fingerprint": "auto",
|
||||||
|
"objects": [
|
||||||
|
{
|
||||||
|
"bucket": "data",
|
||||||
|
"key": "datasets/clients.parquet",
|
||||||
|
"size_bytes": 21971,
|
||||||
|
"created_at": "2026-03-27T14:00:35.670181596Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-03-27T14:00:35.670184688Z",
|
||||||
|
"updated_at": "2026-03-27T14:00:35.670184688Z"
|
||||||
|
}
|
||||||
@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "ws-1774619041730",
|
|
||||||
"name": "Apex Corp - .NET Developers Chicago",
|
|
||||||
"description": "Fill 5 .NET developer positions for Apex Corp, downtown Chicago, $65-85/hr bill rate",
|
|
||||||
"tier": "weekly",
|
|
||||||
"owner": "Sarah",
|
|
||||||
"previous_owners": [],
|
|
||||||
"created_at": "2026-03-27T13:44:01.730143708Z",
|
|
||||||
"updated_at": "2026-03-27T13:44:08.530268827Z",
|
|
||||||
"saved_searches": [
|
|
||||||
{
|
|
||||||
"name": "Chicago .NET active candidates",
|
|
||||||
"sql": "SELECT candidate_id, first_name, last_name, phone, email, years_experience FROM candidates WHERE city = 'Chicago' AND skills LIKE '%.NET%' AND status = 'active' ORDER BY years_experience DESC",
|
|
||||||
"created_at": "2026-03-27T13:44:01.731891844Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "test",
|
|
||||||
"sql": "SELECT 1",
|
|
||||||
"created_at": "2026-03-27T13:44:08.530262069Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"shortlist": [],
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"action": "search",
|
|
||||||
"detail": "saved search: Chicago .NET active candidates",
|
|
||||||
"timestamp": "2026-03-27T13:44:01.731898474Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "search",
|
|
||||||
"detail": "saved search: test",
|
|
||||||
"timestamp": "2026-03-27T13:44:08.530268200Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ingested_datasets": [],
|
|
||||||
"delta_keys": [],
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "ws-1774619071313",
|
|
||||||
"name": "Apex Corp - .NET Developers Chicago",
|
|
||||||
"description": "Fill 5 .NET developer positions, downtown Chicago, $65-85/hr",
|
|
||||||
"tier": "weekly",
|
|
||||||
"owner": "Mike",
|
|
||||||
"previous_owners": [
|
|
||||||
{
|
|
||||||
"from_agent": "Sarah",
|
|
||||||
"to_agent": "Mike",
|
|
||||||
"reason": "Sarah on PTO, Mike covering Apex account",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.531544562Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created_at": "2026-03-27T13:44:31.313179900Z",
|
|
||||||
"updated_at": "2026-03-27T13:44:31.534554639Z",
|
|
||||||
"saved_searches": [
|
|
||||||
{
|
|
||||||
"name": "Chicago .NET active",
|
|
||||||
"sql": "SELECT candidate_id, first_name, last_name, phone, years_experience FROM candidates WHERE city = 'Chicago' AND skills LIKE '%.NET%' AND status = 'active' ORDER BY years_experience DESC",
|
|
||||||
"created_at": "2026-03-27T13:44:31.314740279Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "High-bill .NET history",
|
|
||||||
"sql": "SELECT p.candidate_id, c.first_name, c.last_name, p.bill_rate FROM placements p JOIN candidates c ON p.candidate_id = c.candidate_id JOIN job_orders j ON p.job_order_id = j.job_order_id WHERE j.title LIKE '%.NET%' AND p.bill_rate > 60 ORDER BY p.bill_rate DESC LIMIT 20",
|
|
||||||
"created_at": "2026-03-27T13:44:31.315923201Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"shortlist": [
|
|
||||||
{
|
|
||||||
"dataset": "candidates",
|
|
||||||
"record_id": "CAND-006645",
|
|
||||||
"notes": "Joseph Hill — 30yr .NET exp",
|
|
||||||
"added_at": "2026-03-27T13:44:31.524757463Z",
|
|
||||||
"added_by": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dataset": "candidates",
|
|
||||||
"record_id": "CAND-020078",
|
|
||||||
"notes": "Jessica Jones — 30yr .NET exp",
|
|
||||||
"added_at": "2026-03-27T13:44:31.525965891Z",
|
|
||||||
"added_by": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dataset": "candidates",
|
|
||||||
"record_id": "CAND-015656",
|
|
||||||
"notes": "Barbara Wright — 30yr .NET exp",
|
|
||||||
"added_at": "2026-03-27T13:44:31.527152483Z",
|
|
||||||
"added_by": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dataset": "candidates",
|
|
||||||
"record_id": "CAND-00099",
|
|
||||||
"notes": "Mike found additional candidate via LinkedIn",
|
|
||||||
"added_at": "2026-03-27T13:44:31.534551709Z",
|
|
||||||
"added_by": "Mike"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"activity": [
|
|
||||||
{
|
|
||||||
"action": "search",
|
|
||||||
"detail": "saved search: Chicago .NET active",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.314743876Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "search",
|
|
||||||
"detail": "saved search: High-bill .NET history",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.315925687Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "shortlist",
|
|
||||||
"detail": "added CAND-006645 from candidates",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.524762385Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "shortlist",
|
|
||||||
"detail": "added CAND-020078 from candidates",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.525968748Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "shortlist",
|
|
||||||
"detail": "added CAND-015656 from candidates",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.527155126Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "call",
|
|
||||||
"detail": "Called top 3 candidates, 2 interested",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.528254640Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "email",
|
|
||||||
"detail": "Sent job descriptions to shortlist",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.529452236Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "update",
|
|
||||||
"detail": "Candidate CAND-00025 confirmed for Thursday interview",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.530540919Z",
|
|
||||||
"agent": "Sarah"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "handoff",
|
|
||||||
"detail": "handed off to Mike — Sarah on PTO, Mike covering Apex account",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.531546876Z",
|
|
||||||
"agent": "Mike"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "call",
|
|
||||||
"detail": "Followed up with CAND-00025, interview confirmed",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.533529588Z",
|
|
||||||
"agent": "Mike"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "shortlist",
|
|
||||||
"detail": "added CAND-00099 from candidates",
|
|
||||||
"timestamp": "2026-03-27T13:44:31.534554347Z",
|
|
||||||
"agent": "Mike"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ingested_datasets": [],
|
|
||||||
"delta_keys": [],
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user