#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::path::PathBuf;
use candle_core::Device;
#[cfg(not(target_arch = "wasm32"))]
use crate::candle::lora;
use crate::candle::{GlinerCandleError, Result, decode, encoder, heads, pipeline};
pub struct Gliner2Candle {
pub(crate) tokenizer: crate::V2Tokenizer,
pub(crate) splitter: crate::V2Splitter,
pub(crate) device: Device,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
base_model_dir: PathBuf,
pub(crate) encoder: encoder::Encoder,
pub(crate) heads: heads::AllHeads,
active_adapter: Option<String>,
model_id: String,
approx_bytes: u64,
}
impl std::fmt::Debug for Gliner2Candle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Gliner2Candle")
.field("model_id", &self.model_id)
.field("active_adapter", &self.active_adapter)
.finish()
}
}
impl Gliner2Candle {
pub fn active_adapter(&self) -> Option<&str> {
self.active_adapter.as_deref()
}
pub fn approx_bytes(&self) -> u64 {
self.approx_bytes
}
pub fn from_bytes(safetensors: &[u8], tokenizer_json: &[u8], encoder_config_json: &[u8]) -> Result<Self> {
let device = Device::Cpu;
let tokenizer = crate::V2Tokenizer::from_bytes(tokenizer_json)?;
let splitter = crate::V2Splitter::new()?;
let config: candle_transformers::models::debertav2::Config = serde_json::from_slice(encoder_config_json)
.map_err(|e| GlinerCandleError::Backend(format!("encoder config parse: {e}")))?;
#[cfg(target_arch = "wasm32")]
let dtype = candle_core::DType::F16;
#[cfg(not(target_arch = "wasm32"))]
let dtype = candle_core::DType::F32;
let encoder = encoder::Encoder::from_buffered_safetensors(safetensors, &config, &device, dtype)?;
let heads_loaded = heads::AllHeads::from_buffered_safetensors(safetensors, &device, dtype)?;
Ok(Self {
tokenizer,
splitter,
device,
base_model_dir: PathBuf::new(),
encoder,
heads: heads_loaded,
active_adapter: None,
model_id: "gliner2_candle_bytes".to_string(),
approx_bytes: safetensors.len() as u64,
})
}
pub fn extract_ner(&self, text: &str, labels: &[&str], threshold: f32) -> Result<Vec<crate::Span>> {
if labels.is_empty() {
return Ok(vec![]);
}
let owned_labels: Vec<String> = labels.iter().map(|s| s.to_string()).collect();
let (scorer_out, pred_count, encoded) = pipeline::run_pipeline(
pipeline::PipelineComponents {
tokenizer: &self.tokenizer,
splitter: &self.splitter,
device: &self.device,
encoder: &self.encoder,
heads: &self.heads,
},
text,
&owned_labels,
)?;
if pred_count == 0 {
return Ok(vec![]);
}
let output = decode::decode_span_scores(
text,
&encoded.words,
&owned_labels,
&scorer_out,
pred_count,
decode::SpanFilterOptions {
threshold,
flat_ner: true,
dup_label: false,
multi_label: false,
},
)?;
Ok(output.spans.into_iter().next().unwrap_or_default())
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Gliner2Candle {
pub fn load_adapter(&mut self, name: &str, adapter_dir: &Path) -> Result<()> {
let adapter = lora::LoraAdapter::load(adapter_dir, &self.device)?;
if let Some(adapter_base) = adapter.config.base_model_name_or_path.as_deref()
&& !self.model_id.contains(adapter_base)
&& !adapter_base.contains(&self.model_id)
{
return Err(GlinerCandleError::Backend(format!(
"load_adapter: adapter trained on '{adapter_base}', current model is \
'{}'. Refusing to merge; remove base_model_name_or_path from \
adapter_config.json to bypass.",
self.model_id
)));
}
let base_safetensors = self.base_model_dir.join("model.safetensors");
let merged = lora::merge_into_base(&base_safetensors, &adapter, &self.device)?;
let vb = candle_nn::VarBuilder::from_tensors(merged, candle_core::DType::F32, &self.device);
let new_encoder = encoder::Encoder::from_var_builder(vb.pp("encoder"), &self.encoder.config)?;
let new_heads = heads::AllHeads::from_var_builder(vb, &self.device)?;
self.encoder = new_encoder;
self.heads = new_heads;
self.active_adapter = Some(name.to_string());
Ok(())
}
pub fn unload_adapter(&mut self) -> Result<()> {
if self.active_adapter.is_none() {
return Ok(());
}
let weights_path = self.base_model_dir.join("model.safetensors");
let config_path = resolve_encoder_config_path(&self.base_model_dir);
self.encoder = encoder::Encoder::from_safetensors(&weights_path, &config_path, &self.device)?;
self.heads = heads::AllHeads::from_safetensors(&weights_path, &self.device)?;
self.active_adapter = None;
Ok(())
}
pub fn from_local(model_dir: &Path) -> Result<Self> {
Self::from_local_with_device(model_dir, &Device::Cpu)
}
pub fn from_local_with_device(model_dir: &Path, device: &Device) -> Result<Self> {
let tokenizer_path = model_dir.join("tokenizer.json");
let weights_path = model_dir.join("model.safetensors");
let config_path = resolve_encoder_config_path(model_dir);
if !weights_path.exists() {
return Err(GlinerCandleError::Backend(format!(
"model.safetensors not found in {} (PyTorch fastino/gliner2-* repo \
expected; an ONNX export is a different artifact)",
model_dir.display()
)));
}
let tokenizer = crate::V2Tokenizer::from_file(&tokenizer_path)?;
let splitter = crate::V2Splitter::new()?;
let encoder = encoder::Encoder::from_safetensors(&weights_path, &config_path, device)?;
let heads_loaded = heads::AllHeads::from_safetensors(&weights_path, device)?;
let model_id = model_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "gliner2_candle_local".to_string());
let approx_bytes = std::fs::metadata(&weights_path).map(|m| m.len()).unwrap_or(0);
Ok(Self {
tokenizer,
splitter,
device: device.clone(),
base_model_dir: model_dir.to_path_buf(),
encoder,
heads: heads_loaded,
active_adapter: None,
model_id,
approx_bytes,
})
}
}
#[cfg(not(target_arch = "wasm32"))]
fn resolve_encoder_config_path(model_dir: &Path) -> PathBuf {
let nested = model_dir.join("encoder_config").join("config.json");
if nested.exists() {
nested
} else {
model_dir.join("config.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn from_local_with_device_rejects_missing_weights() {
let dir = tempfile::tempdir().expect("tempdir");
let err = Gliner2Candle::from_local_with_device(dir.path(), &Device::Cpu).expect_err("empty dir must fail");
assert!(err.to_string().contains("model.safetensors"));
}
#[test]
fn from_bytes_rejects_empty_safetensors() {
let err = Gliner2Candle::from_bytes(&[], b"{}", b"{}").expect_err("empty weights must fail");
assert!(
err.to_string().to_lowercase().contains("safetensors") || err.to_string().to_lowercase().contains("load")
);
}
}