Skip to main content

cloudiful_docling_convert/
conversion.rs

1use std::time::Duration;
2
3use crate::api::{DoclingClient, DoclingConfig};
4use crate::document::{
5    ChunkerKind, ChunkingOptions, ConvertOptions, InputKind, PipelineKind, RemoteConvertOptions,
6    TextConvertOptions,
7};
8use crate::error::Result;
9
10#[derive(Debug, Clone)]
11pub struct DoclingRuntimeConfig {
12    pub docling_base_url: String,
13    pub openai_base_url: String,
14    pub vlm_pipeline_model: String,
15    pub picture_description_model: String,
16    pub code_formula_model: String,
17    pub api_key: Option<String>,
18    pub tenant_id: Option<String>,
19    pub openai_api_key: Option<String>,
20    pub request_timeout: Option<Duration>,
21    pub task_timeout: Option<Duration>,
22}
23
24impl DoclingRuntimeConfig {
25    pub fn without_vlm(docling_base_url: impl Into<String>) -> Self {
26        Self {
27            docling_base_url: docling_base_url.into(),
28            openai_base_url: String::new(),
29            vlm_pipeline_model: String::new(),
30            picture_description_model: String::new(),
31            code_formula_model: String::new(),
32            api_key: None,
33            tenant_id: None,
34            openai_api_key: None,
35            request_timeout: None,
36            task_timeout: None,
37        }
38    }
39
40    pub fn from_env(docling_base_url: impl Into<String>) -> Self {
41        let mut config = Self::without_vlm(docling_base_url);
42        config.api_key = std::env::var("DOCLING_API_KEY").ok();
43        config.tenant_id = std::env::var("DOCLING_TENANT_ID").ok();
44        config.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
45        config.request_timeout = std::env::var("DOCLING_HTTP_TIMEOUT_SECS")
46            .ok()
47            .and_then(|value| value.parse::<u64>().ok())
48            .map(Duration::from_secs);
49        config.task_timeout = std::env::var("DOCLING_TASK_TIMEOUT_SECS")
50            .ok()
51            .and_then(|value| value.parse::<u64>().ok())
52            .map(Duration::from_secs);
53        config
54    }
55
56    pub fn into_docling_config(self) -> DoclingConfig {
57        DoclingConfig {
58            base_url: self.docling_base_url,
59            openai_base_url: self.openai_base_url,
60            vlm_pipeline_model: self.vlm_pipeline_model,
61            picture_description_model: self.picture_description_model,
62            code_formula_model: self.code_formula_model,
63            api_key: self
64                .api_key
65                .or_else(|| std::env::var("DOCLING_API_KEY").ok()),
66            openai_api_key: self
67                .openai_api_key
68                .or_else(|| std::env::var("OPENAI_API_KEY").ok()),
69            tenant_id: self
70                .tenant_id
71                .or_else(|| std::env::var("DOCLING_TENANT_ID").ok()),
72            request_timeout: self.request_timeout,
73            task_timeout: self.task_timeout,
74        }
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ConversionBehavior {
80    pub chunker: ChunkerKind,
81    pub chunking: ChunkingOptions,
82    pub pipeline: Option<PipelineKind>,
83    /// Preset ID for picture description, forwarded to Docling Serve as
84    /// `picture_description_preset`. Mutually exclusive with the legacy
85    /// `picture_description_custom_config` emitted when a VLM bundle is
86    /// configured; leaving this `None` preserves the legacy custom VLM
87    /// behaviour.
88    pub picture_description_preset: Option<String>,
89}
90
91impl Default for ConversionBehavior {
92    fn default() -> Self {
93        Self {
94            chunker: ChunkerKind::None,
95            chunking: ChunkingOptions::hybrid_defaults(),
96            pipeline: None,
97            picture_description_preset: None,
98        }
99    }
100}
101
102pub fn build_docling_client(config: DoclingRuntimeConfig) -> Result<DoclingClient> {
103    DoclingClient::new(config.into_docling_config())
104}
105
106pub fn build_convert_options(
107    input_kind: InputKind,
108    behavior: &ConversionBehavior,
109) -> Result<ConvertOptions> {
110    let remote_options = RemoteConvertOptions {
111        chunker: behavior.chunker,
112        chunking: behavior.chunking.clone(),
113        pipeline: behavior.pipeline,
114        picture_description_preset: behavior.picture_description_preset.clone(),
115    };
116
117    if matches!(input_kind, InputKind::Pdf) {
118        Ok(ConvertOptions::Pdf(remote_options))
119    } else if input_kind.uses_generic_convert_options() {
120        Ok(ConvertOptions::Generic(remote_options))
121    } else {
122        Ok(ConvertOptions::Text(TextConvertOptions::default()))
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn default_behavior_uses_normal_conversion() {
132        assert_eq!(ConversionBehavior::default().chunker, ChunkerKind::None);
133    }
134
135    #[test]
136    fn generic_inputs_use_native_chunker_options() {
137        let options = build_convert_options(
138            InputKind::Docx,
139            &ConversionBehavior {
140                chunker: ChunkerKind::Hierarchical,
141                ..ConversionBehavior::default()
142            },
143        )
144        .unwrap();
145
146        assert!(matches!(options, ConvertOptions::Generic(_)));
147    }
148
149    #[test]
150    fn text_inputs_remain_local() {
151        let options =
152            build_convert_options(InputKind::Text, &ConversionBehavior::default()).unwrap();
153        assert!(matches!(options, ConvertOptions::Text(_)));
154    }
155
156    #[test]
157    fn runtime_config_keeps_docling_and_openai_keys_separate() {
158        let config =
159            DoclingRuntimeConfig::without_vlm("http://localhost:5001/v1").into_docling_config();
160        assert!(config.api_key.is_none());
161        assert!(config.openai_api_key.is_none());
162    }
163
164    #[test]
165    fn default_behavior_has_no_picture_description_preset() {
166        assert!(
167            ConversionBehavior::default()
168                .picture_description_preset
169                .is_none(),
170            "default ConversionBehavior must not surface a picture_description_preset so legacy custom VLM behaviour stays unchanged"
171        );
172    }
173
174    #[test]
175    fn build_convert_options_propagates_picture_description_preset() {
176        let behavior = ConversionBehavior {
177            picture_description_preset: Some("smolvlm".to_string()),
178            ..ConversionBehavior::default()
179        };
180        let options = build_convert_options(InputKind::Pdf, &behavior).unwrap();
181        match options {
182            ConvertOptions::Pdf(remote) => {
183                assert_eq!(
184                    remote.picture_description_preset.as_deref(),
185                    Some("smolvlm")
186                );
187            }
188            other => panic!("expected Pdf options, got {other:?}"),
189        }
190
191        let generic = build_convert_options(InputKind::Docx, &behavior).unwrap();
192        match generic {
193            ConvertOptions::Generic(remote) => {
194                assert_eq!(
195                    remote.picture_description_preset.as_deref(),
196                    Some("smolvlm")
197                );
198            }
199            other => panic!("expected Generic options, got {other:?}"),
200        }
201    }
202}