content-core 2.2.1__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {content_core-2.2.1 → content_core-2.3.0}/.claude-plugin/plugin.json +1 -1
  2. {content_core-2.2.1 → content_core-2.3.0}/.codex-plugin/plugin.json +1 -1
  3. {content_core-2.2.1 → content_core-2.3.0}/AGENTS.md +2 -0
  4. {content_core-2.2.1 → content_core-2.3.0}/CHANGELOG.md +5 -0
  5. {content_core-2.2.1 → content_core-2.3.0}/PKG-INFO +2 -1
  6. {content_core-2.2.1 → content_core-2.3.0}/README.md +1 -0
  7. {content_core-2.2.1 → content_core-2.3.0}/docs/cli.md +7 -0
  8. {content_core-2.2.1 → content_core-2.3.0}/docs/usage.md +31 -0
  9. {content_core-2.2.1 → content_core-2.3.0}/pyproject.toml +1 -1
  10. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/cli.py +7 -0
  11. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/config.py +16 -1
  12. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/docling.py +62 -1
  13. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_config_file.py +18 -0
  14. content_core-2.3.0/tests/unit/test_docling_vision_api.py +138 -0
  15. {content_core-2.2.1 → content_core-2.3.0}/uv.lock +1 -1
  16. {content_core-2.2.1 → content_core-2.3.0}/.agent-smith/index.json +0 -0
  17. {content_core-2.2.1 → content_core-2.3.0}/.agents/plugins/marketplace.json +0 -0
  18. {content_core-2.2.1 → content_core-2.3.0}/.claude-plugin/marketplace.json +0 -0
  19. {content_core-2.2.1 → content_core-2.3.0}/.gitattributes +0 -0
  20. {content_core-2.2.1 → content_core-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {content_core-2.2.1 → content_core-2.3.0}/.github/workflows/claude-code-review.yml +0 -0
  22. {content_core-2.2.1 → content_core-2.3.0}/.github/workflows/claude.yml +0 -0
  23. {content_core-2.2.1 → content_core-2.3.0}/.github/workflows/create-tag.yml +0 -0
  24. {content_core-2.2.1 → content_core-2.3.0}/.github/workflows/package.yml +0 -0
  25. {content_core-2.2.1 → content_core-2.3.0}/.github/workflows/publish.yml +0 -0
  26. {content_core-2.2.1 → content_core-2.3.0}/.github/workflows/test.yml +0 -0
  27. {content_core-2.2.1 → content_core-2.3.0}/.gitignore +0 -0
  28. {content_core-2.2.1 → content_core-2.3.0}/.maintainer/PROFILE.md +0 -0
  29. {content_core-2.2.1 → content_core-2.3.0}/.maintainer/README.md +0 -0
  30. {content_core-2.2.1 → content_core-2.3.0}/.maintainer/gotchas.md +0 -0
  31. {content_core-2.2.1 → content_core-2.3.0}/.maintainer/profile.toml +0 -0
  32. {content_core-2.2.1 → content_core-2.3.0}/.maintainer/release/runbook.md +0 -0
  33. {content_core-2.2.1 → content_core-2.3.0}/.maintainer/release/test-matrix.md +0 -0
  34. {content_core-2.2.1 → content_core-2.3.0}/.python-version +0 -0
  35. {content_core-2.2.1 → content_core-2.3.0}/ARCHITECTURE.md +0 -0
  36. {content_core-2.2.1 → content_core-2.3.0}/CLAUDE.md +0 -0
  37. {content_core-2.2.1 → content_core-2.3.0}/CONTRIBUTING.md +0 -0
  38. {content_core-2.2.1 → content_core-2.3.0}/LICENSE +0 -0
  39. {content_core-2.2.1 → content_core-2.3.0}/Makefile +0 -0
  40. {content_core-2.2.1 → content_core-2.3.0}/REFACTOR.md +0 -0
  41. {content_core-2.2.1 → content_core-2.3.0}/TEST_PLAN.md +0 -0
  42. {content_core-2.2.1 → content_core-2.3.0}/docs/mcp.md +0 -0
  43. {content_core-2.2.1 → content_core-2.3.0}/docs/processors.md +0 -0
  44. {content_core-2.2.1 → content_core-2.3.0}/examples/main.py +0 -0
  45. {content_core-2.2.1 → content_core-2.3.0}/prompts/content/cleanup.jinja +0 -0
  46. {content_core-2.2.1 → content_core-2.3.0}/prompts/content/summarize.jinja +0 -0
  47. {content_core-2.2.1 → content_core-2.3.0}/skills/content-core/SKILL.md +0 -0
  48. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/__init__.py +0 -0
  49. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/__init__.py +0 -0
  50. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/exceptions.py +0 -0
  51. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/http.py +0 -0
  52. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/messages.py +0 -0
  53. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/retry.py +0 -0
  54. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/state.py +0 -0
  55. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/common/types.py +0 -0
  56. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/content/__init__.py +0 -0
  57. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/content/extraction/__init__.py +0 -0
  58. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/content/identification/__init__.py +0 -0
  59. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/content/identification/file_detector.py +0 -0
  60. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/content/summary/__init__.py +0 -0
  61. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/content/summary/core.py +0 -0
  62. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/extraction.py +0 -0
  63. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/logging.py +0 -0
  64. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/mcp/__init__.py +0 -0
  65. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/mcp/server.py +0 -0
  66. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/models.py +0 -0
  67. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/__init__.py +0 -0
  68. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/docx.py +0 -0
  69. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/epub.py +0 -0
  70. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/odf.py +0 -0
  71. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/pdf.py +0 -0
  72. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/pptx.py +0 -0
  73. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/document/xlsx.py +0 -0
  74. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/media/__init__.py +0 -0
  75. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/media/audio.py +0 -0
  76. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/media/video.py +0 -0
  77. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/text.py +0 -0
  78. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/__init__.py +0 -0
  79. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/bs4.py +0 -0
  80. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/crawl4ai.py +0 -0
  81. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/firecrawl.py +0 -0
  82. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/jina.py +0 -0
  83. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/reddit.py +0 -0
  84. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/processors/url/youtube.py +0 -0
  85. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/py.typed +0 -0
  86. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/templated_message.py +0 -0
  87. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/tools/__init__.py +0 -0
  88. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/tools/extract.py +0 -0
  89. {content_core-2.2.1 → content_core-2.3.0}/src/content_core/tools/summarize.py +0 -0
  90. {content_core-2.2.1 → content_core-2.3.0}/tests/conftest.py +0 -0
  91. {content_core-2.2.1 → content_core-2.3.0}/tests/e2e/__init__.py +0 -0
  92. {content_core-2.2.1 → content_core-2.3.0}/tests/e2e/test_docling.py +0 -0
  93. {content_core-2.2.1 → content_core-2.3.0}/tests/e2e/test_media.py +0 -0
  94. {content_core-2.2.1 → content_core-2.3.0}/tests/e2e/test_remote.py +0 -0
  95. {content_core-2.2.1 → content_core-2.3.0}/tests/e2e/test_url_engines.py +0 -0
  96. {content_core-2.2.1 → content_core-2.3.0}/tests/e2e/test_youtube.py +0 -0
  97. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.docx +0 -0
  98. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.epub +0 -0
  99. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.md +0 -0
  100. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.mp3 +0 -0
  101. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.mp4 +0 -0
  102. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.odp +0 -0
  103. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.ods +0 -0
  104. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.odt +0 -0
  105. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.pdf +0 -0
  106. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.pptx +0 -0
  107. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.txt +0 -0
  108. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/file.xlsx +0 -0
  109. {content_core-2.2.1 → content_core-2.3.0}/tests/input_content/new_pdf.pdf +0 -0
  110. {content_core-2.2.1 → content_core-2.3.0}/tests/integration/conftest.py +0 -0
  111. {content_core-2.2.1 → content_core-2.3.0}/tests/integration/test_cli_v2.py +0 -0
  112. {content_core-2.2.1 → content_core-2.3.0}/tests/integration/test_extraction.py +0 -0
  113. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_audio_concurrency.py +0 -0
  114. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_cli.py +0 -0
  115. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_config_v2.py +0 -0
  116. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_crawl4ai_docker.py +0 -0
  117. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_docling_extraction.py +0 -0
  118. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_epub_extraction.py +0 -0
  119. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_exceptions.py +0 -0
  120. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_file_detector.py +0 -0
  121. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_file_detector_critical.py +0 -0
  122. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_file_detector_performance.py +0 -0
  123. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_guarded_http.py +0 -0
  124. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_html_file_extraction.py +0 -0
  125. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_logging.py +0 -0
  126. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_mcp_server_version.py +0 -0
  127. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_mcp_v2.py +0 -0
  128. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_media_pipeline.py +0 -0
  129. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_models_v2.py +0 -0
  130. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_office_extraction.py +0 -0
  131. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_pdf_extraction.py +0 -0
  132. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_pdf_helpers.py +0 -0
  133. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_reddit_extraction.py +0 -0
  134. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_retry.py +0 -0
  135. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_routing.py +0 -0
  136. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_text_processing.py +0 -0
  137. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_url_engine_select.py +0 -0
  138. {content_core-2.2.1 → content_core-2.3.0}/tests/unit/test_youtube_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Extract text content from URLs, documents, YouTube videos, Reddit posts, and audio/video files — via CLI (uvx) or MCP.",
5
5
  "author": {
6
6
  "name": "Luis Novo",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Extract text content from URLs, documents, YouTube videos, Reddit posts, and audio/video files — via CLI (uvx) or MCP.",
5
5
  "skills": "./skills/"
6
6
  }
@@ -134,6 +134,8 @@ Key settings: `url_engine`, `document_engine`, `audio_provider`, `audio_model`,
134
134
 
135
135
  Docling enrichment flags (`docling_ocr`, `docling_formulas`, `docling_vision`) control OCR, formula extraction, and image/chart processing when `document_engine="docling"`. These are also exposed as CLI flags (`--formulas`, `--pictures`, `--no-ocr`) and MCP parameters.
136
136
 
137
+ `docling_vision_url` (plus `docling_vision_model`, `_api_key`, `_prompt`, `_max_tokens`, `_timeout`, `_token_param`) sends Docling's image descriptions to an OpenAI-compatible endpoint instead of the inline model. It only applies when `docling_vision` is on; without the URL, nothing is sent over the network.
138
+
137
139
  Priority: constructor args > env vars (`CCORE_*`) > config file (`~/.content-core/config.toml`) > defaults
138
140
 
139
141
  ## Code Style
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.0] - 2026-11-07
11
+
12
+ ### Added
13
+ - Docling image descriptions can use any OpenAI-compatible chat completions endpoint (vLLM, Ollama, LM Studio, MLX servers...) instead of Docling's small inline model (#111, building on #104 by PhantombrainM). Set `docling_vision_url` (`CCORE_DOCLING_VISION_URL`), with optional `docling_vision_model`, `docling_vision_api_key`, `docling_vision_prompt`, `docling_vision_max_tokens` (default 400), `docling_vision_timeout` (default 20s) and `docling_vision_token_param` (`max_completion_tokens` or `max_tokens`). Applies only when `docling_vision` is on; without the URL, behavior is unchanged and nothing leaves the machine. Chart data extraction still uses Docling's own model. All settings work from the constructor, env vars and `content-core config set`.
14
+
10
15
  ## [2.2.1] - 2026-11-03
11
16
 
12
17
  ### Security
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: content-core
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: Extract what matters from any media source. Available as Python Library, macOS Service, CLI and MCP Server
5
5
  Author-email: LUIS NOVO <lfnovo@gmail.com>
6
6
  License-File: LICENSE
@@ -285,6 +285,7 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
285
285
  | `CCORE_YOUTUBE_LANGUAGES` | Preferred YouTube transcript languages | - |
286
286
  | `CCORE_YOUTUBE_COOKIES_FILE` | Path to a Netscape `cookies.txt` for YouTube transcripts (missing file raises `ConfigurationError`) | - |
287
287
  | `CCORE_YOUTUBE_PROXY` | Proxy URL for YouTube transcripts, e.g. `http://user:pass@host:port` | - |
288
+ | `CCORE_DOCLING_VISION_URL` | OpenAI-compatible endpoint (vLLM, Ollama, LM Studio...) for Docling image descriptions; unset keeps Docling's inline model. Related: `CCORE_DOCLING_VISION_MODEL`, `_API_KEY`, `_PROMPT`, `_MAX_TOKENS`, `_TIMEOUT`, `_TOKEN_PARAM` ([details](docs/usage.md#image-descriptions-via-an-openai-compatible-endpoint)) | - |
288
289
 
289
290
  API keys for external services are set via their standard environment variables (e.g., `OPENAI_API_KEY`, `FIRECRAWL_API_KEY`, `JINA_API_KEY`).
290
291
 
@@ -240,6 +240,7 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
240
240
  | `CCORE_YOUTUBE_LANGUAGES` | Preferred YouTube transcript languages | - |
241
241
  | `CCORE_YOUTUBE_COOKIES_FILE` | Path to a Netscape `cookies.txt` for YouTube transcripts (missing file raises `ConfigurationError`) | - |
242
242
  | `CCORE_YOUTUBE_PROXY` | Proxy URL for YouTube transcripts, e.g. `http://user:pass@host:port` | - |
243
+ | `CCORE_DOCLING_VISION_URL` | OpenAI-compatible endpoint (vLLM, Ollama, LM Studio...) for Docling image descriptions; unset keeps Docling's inline model. Related: `CCORE_DOCLING_VISION_MODEL`, `_API_KEY`, `_PROMPT`, `_MAX_TOKENS`, `_TIMEOUT`, `_TOKEN_PARAM` ([details](docs/usage.md#image-descriptions-via-an-openai-compatible-endpoint)) | - |
243
244
 
244
245
  API keys for external services are set via their standard environment variables (e.g., `OPENAI_API_KEY`, `FIRECRAWL_API_KEY`, `JINA_API_KEY`).
245
246
 
@@ -162,6 +162,13 @@ content-core config delete llm_provider
162
162
  | `docling_ocr` | Enable OCR for scanned PDFs | `true` |
163
163
  | `docling_output_format` | Docling output format | `markdown` |
164
164
  | `docling_vision` | Enable image description + chart data extraction | `false` |
165
+ | `docling_vision_url` | OpenAI-compatible chat completions URL for image descriptions (see [usage](usage.md#image-descriptions-via-an-openai-compatible-endpoint)) | — |
166
+ | `docling_vision_model` | Model name sent to `docling_vision_url` | — |
167
+ | `docling_vision_api_key` | Bearer token for `docling_vision_url` | — |
168
+ | `docling_vision_prompt` | Prompt for image descriptions | Docling's prompt |
169
+ | `docling_vision_max_tokens` | Token limit per image description | `400` |
170
+ | `docling_vision_timeout` | Request timeout in seconds | `20` |
171
+ | `docling_vision_token_param` | Token limit field name (`max_completion_tokens`, `max_tokens`) | `max_completion_tokens` |
165
172
  | `document_engine` | Document extraction engine (`auto`, `simple`, `docling`) | `auto` |
166
173
  | `firecrawl_api_url` | Firecrawl API URL | `https://api.firecrawl.dev` |
167
174
  | `firecrawl_proxy` | Firecrawl proxy mode (`auto`, `basic`, `stealth`) | `auto` |
@@ -476,6 +476,37 @@ extract_content(file_path="paper.pdf", engine="docling", formulas=true, pictures
476
476
 
477
477
  > **Note:** Enrichment flags are only applied when `document_engine="docling"`. A warning is logged if flags are set without the Docling engine.
478
478
 
479
+ ### Image descriptions via an OpenAI-compatible endpoint
480
+
481
+ By default, `docling_vision` describes images with Docling's small inline model, which often produces vague descriptions. You can send image descriptions to any OpenAI-compatible `/v1/chat/completions` endpoint instead (vLLM, Ollama, LM Studio, MLX servers, a hosted API...). Setting `docling_vision_url` is the switch: without it, nothing leaves the machine and the other settings below are ignored. `docling_vision` must still be on.
482
+
483
+ | Setting | Env var | What it does | Default |
484
+ |---------|---------|-------------|---------|
485
+ | `docling_vision_url` | `CCORE_DOCLING_VISION_URL` | Chat completions endpoint | — (inline model) |
486
+ | `docling_vision_model` | `CCORE_DOCLING_VISION_MODEL` | Model name sent in the request | — (server default) |
487
+ | `docling_vision_api_key` | `CCORE_DOCLING_VISION_API_KEY` | Bearer token; no `Authorization` header when unset | — |
488
+ | `docling_vision_prompt` | `CCORE_DOCLING_VISION_PROMPT` | Prompt sent with each image, e.g. to describe in another language | Docling's prompt |
489
+ | `docling_vision_max_tokens` | `CCORE_DOCLING_VISION_MAX_TOKENS` | Token limit per image | `400` |
490
+ | `docling_vision_timeout` | `CCORE_DOCLING_VISION_TIMEOUT` | Request timeout in seconds | `20` |
491
+ | `docling_vision_token_param` | `CCORE_DOCLING_VISION_TOKEN_PARAM` | Name of the token limit field: `max_completion_tokens`, or `max_tokens` for servers that don't accept the newer field | `max_completion_tokens` |
492
+
493
+ ```bash
494
+ export CCORE_DOCLING_VISION_URL=http://localhost:11434/v1/chat/completions
495
+ export CCORE_DOCLING_VISION_MODEL=qwen2.5vl:7b
496
+ content-core extract --engine docling --pictures report.pdf
497
+ ```
498
+
499
+ ```python
500
+ config = ContentCoreConfig(
501
+ document_engine="docling",
502
+ docling_vision=True,
503
+ docling_vision_url="http://localhost:11434/v1/chat/completions",
504
+ docling_vision_model="qwen2.5vl:7b",
505
+ )
506
+ ```
507
+
508
+ Only image descriptions go to the endpoint. Chart data extraction keeps using Docling's own model, because it depends on special output tokens that a general-purpose vision model does not produce.
509
+
479
510
  ## Proxy Configuration
480
511
 
481
512
  Content Core uses standard HTTP proxy environment variables:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "content-core"
3
- version = "2.2.1"
3
+ version = "2.3.0"
4
4
  description = "Extract what matters from any media source. Available as Python Library, macOS Service, CLI and MCP Server"
5
5
  readme = "README.md"
6
6
  homepage = "https://github.com/lfnovo/content-core"
@@ -130,6 +130,13 @@ def config():
130
130
  docling_formulas Enable formula extraction (default: false)
131
131
  docling_ocr Enable OCR for scanned PDFs (default: true)
132
132
  docling_vision Enable image description + chart extraction (default: false)
133
+ docling_vision_url OpenAI-compatible chat completions URL for image descriptions (default: none, inline model)
134
+ docling_vision_model Model name sent to docling_vision_url
135
+ docling_vision_api_key Bearer token for docling_vision_url
136
+ docling_vision_prompt Prompt for image descriptions (default: docling's)
137
+ docling_vision_max_tokens Token limit per image description (default: 400)
138
+ docling_vision_timeout Request timeout in seconds (default: 20)
139
+ docling_vision_token_param Token limit field: max_completion_tokens or max_tokens (default: max_completion_tokens)
133
140
  """
134
141
  pass
135
142
 
@@ -94,6 +94,19 @@ class ContentCoreConfig(BaseSettings):
94
94
  docling_formulas: bool = False
95
95
  docling_vision: bool = False
96
96
 
97
+ # Docling picture description via an OpenAI-compatible endpoint (vLLM,
98
+ # LM Studio, Ollama, ...). Only used when docling_vision is on; with no
99
+ # URL, docling's inline default model describes the pictures.
100
+ docling_vision_url: Optional[str] = None
101
+ docling_vision_model: Optional[str] = None
102
+ docling_vision_api_key: Optional[str] = None
103
+ docling_vision_prompt: Optional[str] = None # None keeps docling's prompt
104
+ docling_vision_max_tokens: int = Field(default=400, ge=1)
105
+ docling_vision_timeout: float = Field(default=20.0, gt=0)
106
+ docling_vision_token_param: Literal["max_completion_tokens", "max_tokens"] = (
107
+ "max_completion_tokens"
108
+ )
109
+
97
110
  @classmethod
98
111
  def settings_customise_sources(
99
112
  cls,
@@ -155,7 +168,7 @@ def _write_config_file(data: dict) -> None:
155
168
  for key, value in sorted(data.items()):
156
169
  if isinstance(value, bool):
157
170
  lines.append(f"{key} = {str(value).lower()}")
158
- elif isinstance(value, int):
171
+ elif isinstance(value, (int, float)):
159
172
  lines.append(f"{key} = {value}")
160
173
  elif isinstance(value, list):
161
174
  items = ", ".join(f'"{_escape_toml_string(str(v))}"' for v in value)
@@ -206,6 +219,8 @@ def config_set(key: str, value: str) -> None:
206
219
  annotation = field.annotation
207
220
  if annotation is int or (hasattr(annotation, "__origin__") and annotation is int):
208
221
  data[key] = int(value)
222
+ elif annotation is float:
223
+ data[key] = float(value)
209
224
  elif annotation is bool:
210
225
  token = value.lower()
211
226
  if token not in _TRUE_TOKENS | _FALSE_TOKENS:
@@ -2,20 +2,28 @@
2
2
  Docling-based document extraction processor.
3
3
  """
4
4
 
5
+ from typing import Optional
6
+ from urllib.parse import urlsplit
7
+
5
8
  from content_core.config import ContentCoreConfig
6
9
  from content_core.common.messages import DOCLING_MISSING_MESSAGE
7
10
  from content_core.common.state import ExtractionOutput
11
+ from content_core.logging import logger
8
12
 
9
13
  DOCLING_AVAILABLE = False
10
14
  try:
11
15
  from docling.datamodel.base_models import InputFormat
12
- from docling.datamodel.pipeline_options import PdfPipelineOptions
16
+ from docling.datamodel.pipeline_options import (
17
+ PdfPipelineOptions,
18
+ PictureDescriptionApiOptions,
19
+ )
13
20
  from docling.document_converter import DocumentConverter, PdfFormatOption
14
21
 
15
22
  DOCLING_AVAILABLE = True
16
23
  except ImportError:
17
24
  InputFormat = None # type: ignore
18
25
  PdfPipelineOptions = None # type: ignore
26
+ PictureDescriptionApiOptions = None # type: ignore
19
27
  PdfFormatOption = None # type: ignore
20
28
 
21
29
  class DocumentConverter: # type: ignore[no-redef]
@@ -45,14 +53,67 @@ DOCLING_SUPPORTED = {
45
53
  }
46
54
 
47
55
 
56
+ def _picture_description_api_options(
57
+ config: ContentCoreConfig,
58
+ ) -> Optional["PictureDescriptionApiOptions"]:
59
+ """Build the options that send picture description to an OpenAI-compatible
60
+ endpoint, or return None to keep docling's inline default model.
61
+
62
+ ``docling_vision_url`` is the switch: without it, nothing leaves the
63
+ machine and the other ``docling_vision_*`` settings are ignored.
64
+ """
65
+ if not config.docling_vision_url or PictureDescriptionApiOptions is None:
66
+ return None
67
+
68
+ # The token limit field name differs across OpenAI-compatible servers:
69
+ # newer ones expect max_completion_tokens, older ones only accept
70
+ # max_tokens. docling_vision_token_param selects the name.
71
+ params: dict = {config.docling_vision_token_param: config.docling_vision_max_tokens}
72
+ if config.docling_vision_model:
73
+ params["model"] = config.docling_vision_model
74
+
75
+ # Only send an Authorization header when a key is configured; keyless
76
+ # local servers may reject an empty bearer token.
77
+ headers = {}
78
+ if config.docling_vision_api_key:
79
+ headers["Authorization"] = f"Bearer {config.docling_vision_api_key}"
80
+
81
+ kwargs = {}
82
+ if config.docling_vision_prompt:
83
+ kwargs["prompt"] = config.docling_vision_prompt
84
+
85
+ # Log only scheme and host: the URL may carry credentials in its
86
+ # userinfo or query string.
87
+ endpoint = urlsplit(config.docling_vision_url)
88
+ logger.debug(
89
+ f"Docling picture description via {endpoint.scheme}://{endpoint.hostname} "
90
+ f"(model={config.docling_vision_model or 'server default'})"
91
+ )
92
+ return PictureDescriptionApiOptions(
93
+ url=config.docling_vision_url,
94
+ headers=headers,
95
+ params=params,
96
+ timeout=config.docling_vision_timeout,
97
+ **kwargs,
98
+ )
99
+
100
+
48
101
  async def extract_docling(source: str, config: ContentCoreConfig) -> ExtractionOutput:
49
102
  """Extract content using Docling."""
50
103
  if DOCLING_AVAILABLE and PdfPipelineOptions is not None:
104
+ pipeline_kwargs = {}
105
+ api_vision_options = None
106
+ if config.docling_vision:
107
+ api_vision_options = _picture_description_api_options(config)
108
+ if api_vision_options is not None:
109
+ pipeline_kwargs["picture_description_options"] = api_vision_options
51
110
  pipeline_options = PdfPipelineOptions(
52
111
  do_ocr=config.docling_ocr,
53
112
  do_formula_enrichment=config.docling_formulas,
54
113
  do_picture_description=config.docling_vision,
55
114
  do_chart_extraction=config.docling_vision,
115
+ enable_remote_services=api_vision_options is not None,
116
+ **pipeline_kwargs,
56
117
  )
57
118
  converter = DocumentConverter(
58
119
  format_options={
@@ -54,6 +54,24 @@ class TestConfigSet:
54
54
  config_set("stt_timeout", "1800")
55
55
  assert config_list()["stt_timeout"] == 1800
56
56
 
57
+ def test_set_float_value(self, config_dir):
58
+ config_set("docling_vision_timeout", "45.5")
59
+ assert config_list()["docling_vision_timeout"] == 45.5
60
+ assert ContentCoreConfig().docling_vision_timeout == 45.5
61
+
62
+ def test_set_docling_vision_endpoint(self, config_dir):
63
+ config_set("docling_vision_url", "http://localhost:1234/v1/chat/completions")
64
+ config_set("docling_vision_max_tokens", "800")
65
+ config_set("docling_vision_token_param", "max_tokens")
66
+ config = ContentCoreConfig()
67
+ assert config.docling_vision_url == "http://localhost:1234/v1/chat/completions"
68
+ assert config.docling_vision_max_tokens == 800
69
+ assert config.docling_vision_token_param == "max_tokens"
70
+
71
+ def test_set_invalid_token_param_raises(self, config_dir):
72
+ with pytest.raises(ValueError, match="max_completion_tokens"):
73
+ config_set("docling_vision_token_param", "max_length")
74
+
57
75
  def test_set_list_value(self, config_dir):
58
76
  config_set("youtube_languages", "en,pt,fr")
59
77
  assert config_list()["youtube_languages"] == ["en", "pt", "fr"]
@@ -0,0 +1,138 @@
1
+ """Unit tests for Docling picture description via an OpenAI-compatible endpoint
2
+ (docling_vision_* settings / CCORE_DOCLING_VISION_* env vars)."""
3
+
4
+ import os
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+ from pydantic import ValidationError
9
+
10
+ from content_core.config import ContentCoreConfig
11
+ from content_core.processors.document import docling as docling_module
12
+
13
+ pytest.importorskip("docling")
14
+
15
+ URL = "http://localhost:1234/v1/chat/completions"
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def isolated_settings(monkeypatch, tmp_path):
20
+ """Keep the caller's CCORE_DOCLING_VISION_* env vars and config file out."""
21
+ for var in list(os.environ):
22
+ if var.startswith("CCORE_DOCLING_VISION_"):
23
+ monkeypatch.delenv(var)
24
+ monkeypatch.setattr("content_core.config.CONFIG_FILE", tmp_path / "config.toml")
25
+
26
+
27
+ def test_url_with_credentials_is_not_logged():
28
+ config = ContentCoreConfig(docling_vision_url="https://user:pw@vlm.example.com/v1/chat/completions?key=abc")
29
+ with patch.object(docling_module, "logger") as logger:
30
+ docling_module._picture_description_api_options(config)
31
+ logged = " ".join(str(c) for c in logger.debug.call_args_list)
32
+ assert "vlm.example.com" in logged
33
+ assert "pw" not in logged and "abc" not in logged
34
+
35
+
36
+ def test_no_url_keeps_inline_default():
37
+ # URL is the switch: a model name alone must not enable the API path
38
+ config = ContentCoreConfig(docling_vision_url=None, docling_vision_model="some-model")
39
+ assert docling_module._picture_description_api_options(config) is None
40
+
41
+
42
+ def test_url_builds_api_options():
43
+ config = ContentCoreConfig(
44
+ docling_vision_url=URL,
45
+ docling_vision_model="my-local-vlm",
46
+ docling_vision_api_key="secret",
47
+ docling_vision_max_tokens=800,
48
+ docling_vision_timeout=120,
49
+ docling_vision_prompt="Describe it.",
50
+ )
51
+
52
+ options = docling_module._picture_description_api_options(config)
53
+
54
+ assert options is not None
55
+ assert "localhost:1234" in str(options.url)
56
+ assert options.params["model"] == "my-local-vlm"
57
+ assert options.params["max_completion_tokens"] == 800
58
+ assert options.headers["Authorization"] == "Bearer secret"
59
+ assert options.timeout == 120
60
+ assert options.prompt == "Describe it."
61
+
62
+
63
+ def test_defaults_keep_docling_prompt_and_timeout():
64
+ config = ContentCoreConfig(docling_vision_url=URL, docling_vision_api_key=None)
65
+ options = docling_module._picture_description_api_options(config)
66
+ default = docling_module.PictureDescriptionApiOptions()
67
+
68
+ assert options is not None
69
+ assert "Authorization" not in options.headers
70
+ assert "model" not in options.params
71
+ assert options.params["max_completion_tokens"] == 400
72
+ assert options.prompt == default.prompt
73
+ assert options.timeout == 20
74
+
75
+
76
+ def test_token_param_name_is_selectable():
77
+ config = ContentCoreConfig(docling_vision_url=URL, docling_vision_token_param="max_tokens")
78
+ options = docling_module._picture_description_api_options(config)
79
+ assert options is not None
80
+ assert "max_tokens" in options.params
81
+ assert "max_completion_tokens" not in options.params
82
+
83
+
84
+ def test_settings_read_from_env(monkeypatch):
85
+ monkeypatch.setenv("CCORE_DOCLING_VISION_URL", URL)
86
+ monkeypatch.setenv("CCORE_DOCLING_VISION_MODEL", "env-vlm")
87
+ monkeypatch.setenv("CCORE_DOCLING_VISION_MAX_TOKENS", "256")
88
+ monkeypatch.setenv("CCORE_DOCLING_VISION_TIMEOUT", "45.5")
89
+ monkeypatch.setenv("CCORE_DOCLING_VISION_TOKEN_PARAM", "max_tokens")
90
+
91
+ options = docling_module._picture_description_api_options(ContentCoreConfig())
92
+
93
+ assert options is not None
94
+ assert options.params == {"max_tokens": 256, "model": "env-vlm"}
95
+ assert options.timeout == 45.5
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ "field,value",
100
+ [
101
+ ("docling_vision_max_tokens", "banana"),
102
+ ("docling_vision_max_tokens", 0),
103
+ ("docling_vision_timeout", "not-a-number"),
104
+ ("docling_vision_token_param", "max_length"),
105
+ ],
106
+ )
107
+ def test_invalid_settings_are_rejected(field, value):
108
+ with pytest.raises(ValidationError):
109
+ ContentCoreConfig(**{field: value})
110
+
111
+
112
+ async def _run_extract(config):
113
+ mock_result = MagicMock()
114
+ mock_result.document.export_to_markdown.return_value = "content"
115
+ pipeline_cls = MagicMock()
116
+ with patch.object(docling_module, "DocumentConverter") as converter_cls, patch.object(
117
+ docling_module, "DOCLING_AVAILABLE", True
118
+ ), patch.object(docling_module, "PdfPipelineOptions", pipeline_cls), patch.object(
119
+ docling_module, "PdfFormatOption", MagicMock()
120
+ ), patch.object(docling_module, "InputFormat", MagicMock()):
121
+ converter_cls.return_value.convert.return_value = mock_result
122
+ await docling_module.extract_docling("/fake/doc.pdf", config)
123
+ return pipeline_cls.call_args[1]
124
+
125
+
126
+ async def test_extract_wires_api_options_when_url_set():
127
+ config = ContentCoreConfig(docling_vision=True, docling_vision_url=URL)
128
+ kwargs = await _run_extract(config)
129
+ assert kwargs["enable_remote_services"] is True
130
+ assert "localhost:1234" in str(kwargs["picture_description_options"].url)
131
+
132
+
133
+ async def test_extract_stays_local_without_vision():
134
+ # The URL alone does not turn picture description on
135
+ config = ContentCoreConfig(docling_vision=False, docling_vision_url=URL)
136
+ kwargs = await _run_extract(config)
137
+ assert kwargs["enable_remote_services"] is False
138
+ assert "picture_description_options" not in kwargs
@@ -940,7 +940,7 @@ wheels = [
940
940
 
941
941
  [[package]]
942
942
  name = "content-core"
943
- version = "2.2.1"
943
+ version = "2.3.0"
944
944
  source = { editable = "." }
945
945
  dependencies = [
946
946
  { name = "ai-prompter" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes