classifyre-cli 0.4.9__tar.gz → 0.4.11__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.
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/.turbo/turbo-build.log +1 -1
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/PKG-INFO +1 -1
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/package.json +1 -1
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/pyproject.toml +14 -2
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_base.py +28 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_image_classification.py +41 -31
- classifyre_cli-0.4.11/src/detectors/custom/runners/_llm.py +295 -0
- classifyre_cli-0.4.11/src/detectors/custom/runners/_object_detection.py +121 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/models/generated_detectors.py +147 -5
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/outputs/rest.py +4 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/pipeline/detector_pipeline.py +13 -32
- classifyre_cli-0.4.11/src/sandbox/runner.py +308 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/object_storage/base.py +81 -5
- classifyre_cli-0.4.11/src/utils/embedded_images.py +222 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/utils/file_parser.py +65 -38
- classifyre_cli-0.4.11/src/utils/file_to_images.py +134 -0
- classifyre_cli-0.4.11/tests/detectors/custom/test_llm_runner.py +236 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/custom/test_transformer_runners.py +3 -3
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_outputs.py +2 -1
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_s3_compatible_storage_source.py +68 -0
- classifyre_cli-0.4.11/tests/test_sandbox_runner.py +214 -0
- classifyre_cli-0.4.11/tests/utils/test_embedded_images.py +129 -0
- classifyre_cli-0.4.11/tests/utils/test_file_to_images.py +99 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/uv.lock +481 -172
- classifyre_cli-0.4.9/src/detectors/custom/runners/_llm.py +0 -22
- classifyre_cli-0.4.9/src/detectors/custom/runners/_object_detection.py +0 -107
- classifyre_cli-0.4.9/src/sandbox/runner.py +0 -145
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/.gitignore +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/.python-version +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/README.md +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/main.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/scripts/generate_models.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/base.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/broken_links/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/broken_links/detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/config.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/extractor.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_factory.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_feature_extraction.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_gliner2.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_regex.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_text_classification.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/trainer.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/dependencies.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/pii/detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/secrets/detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/threat/code_security_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/threat/yara_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/main.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/models/generated_input.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/models/generated_single_asset_scan_results.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/outputs/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/outputs/base.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/outputs/console.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/outputs/factory.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/outputs/file.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/pipeline/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/pipeline/content_provider.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/pipeline/parsed_content_provider.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/pipeline/worker_pool.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sandbox/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/atlassian_common.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/azure_blob_storage/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/azure_blob_storage/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/base.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/confluence/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/confluence/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/databricks/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/databricks/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/dependencies.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/google_cloud_storage/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/google_cloud_storage/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/hive/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/hive/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/jira/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/jira/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/mongodb/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/mongodb/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/mssql/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/mssql/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/mysql/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/mysql/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/neo4j/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/neo4j/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/oracle/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/oracle/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/postgresql/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/postgresql/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/powerbi/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/powerbi/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/s3_compatible_storage/README.md +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/s3_compatible_storage/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/s3_compatible_storage/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/servicedesk/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/servicedesk/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/slack/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/slack/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/snowflake/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/snowflake/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/sqlite/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/sqlite/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/tableau/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/tableau/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/tabular_base.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/tabular_utils.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/wordpress/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/sources/wordpress/source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/telemetry.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/utils/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/utils/content_extraction.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/utils/hashing.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/utils/uv_sync.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/utils/validation.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/conftest.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/broken_links/test_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/conftest.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/content/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/custom/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/custom/conftest.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/custom/test_invoice_extraction.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/custom/test_pipeline_integration.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/custom/test_regex_runner.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/pii/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/pii/conftest.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/pii/sample_invoice.pdf +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/pii/test_pii_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/pii/test_pii_detector_extended.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/secrets/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/secrets/test_secrets_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/secrets/test_secrets_detector_extended.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_base_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_custom_detector_examples_runtime.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_detector_catalog_commercial.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_detector_pipeline_types.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_detector_schema_examples.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_detector_types.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_phase2_detectors.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/test_registry.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/threat/__init__.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/threat/test_code_security_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/detectors/threat/test_yara_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/integration/test_wordpress_broken_links_detector.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/integration/test_wordpress_links_assets.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/pipeline/test_detector_pipeline.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/pipeline/test_worker_pool.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_azure_blob_storage_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_base_source_attachment.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_base_source_sampling.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_confluence_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_custom_extractor.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_databricks_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_google_cloud_storage_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_hashing.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_hive_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_jira_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_mongodb_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_mssql_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_mysql_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_neo4j_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_oracle_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_postgresql_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_powerbi_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_recipe_normalizer.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_servicedesk_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_slack_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_snowflake_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_source_dependency_groups.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_sqlite_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_tableau_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_tabular_utils.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/test_wordpress_source.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/utils/test_content_extraction.py +0 -0
- {classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/tests/utils/test_file_parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "classifyre-cli"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.11"
|
|
4
4
|
description = "Classifyre CLI — scan and classify unstructured data sources"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -47,7 +47,7 @@ privacy = [
|
|
|
47
47
|
# mid-run in frozen/venv contexts. 8.x eagerly loads all data at import time,
|
|
48
48
|
# avoiding ModuleNotFoundError during Presidio phone number analysis.
|
|
49
49
|
"phonenumbers>=8.13.0,<10.0.0",
|
|
50
|
-
"numpy>=1.26.0,<
|
|
50
|
+
"numpy>=1.26.0,<3.0.0",
|
|
51
51
|
]
|
|
52
52
|
security = [
|
|
53
53
|
"detect-secrets>=1.5.0",
|
|
@@ -91,6 +91,13 @@ custom = [
|
|
|
91
91
|
regex = [
|
|
92
92
|
"google-re2>=1.1",
|
|
93
93
|
]
|
|
94
|
+
llm = [
|
|
95
|
+
"litellm>=1.86.2",
|
|
96
|
+
# Pure-wheel PDF renderer (permissive license, no system binaries) used to
|
|
97
|
+
# rasterise PDF pages to images for vision-capable LLM detectors.
|
|
98
|
+
"pypdfium2>=4.30.0",
|
|
99
|
+
"pillow>=12.2.0",
|
|
100
|
+
]
|
|
94
101
|
detectors = [
|
|
95
102
|
{ include-group = "file-processing" },
|
|
96
103
|
{ include-group = "privacy" },
|
|
@@ -101,6 +108,7 @@ detectors = [
|
|
|
101
108
|
{ include-group = "classification" },
|
|
102
109
|
{ include-group = "custom" },
|
|
103
110
|
{ include-group = "regex" },
|
|
111
|
+
{ include-group = "llm" },
|
|
104
112
|
]
|
|
105
113
|
file-processing = [
|
|
106
114
|
"filetype>=1.2.0",
|
|
@@ -264,6 +272,10 @@ module = [
|
|
|
264
272
|
"datasets",
|
|
265
273
|
"setfit.*",
|
|
266
274
|
"setfit",
|
|
275
|
+
"litellm.*",
|
|
276
|
+
"litellm",
|
|
277
|
+
"pypdfium2.*",
|
|
278
|
+
"pypdfium2",
|
|
267
279
|
"sklearn.*",
|
|
268
280
|
"sklearn",
|
|
269
281
|
"numpy",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
5
7
|
import re
|
|
6
8
|
from abc import ABC, abstractmethod
|
|
7
9
|
from datetime import UTC, datetime
|
|
@@ -38,6 +40,32 @@ _IMAGE_CONTENT_TYPES = [
|
|
|
38
40
|
"image/bmp",
|
|
39
41
|
"image/tiff",
|
|
40
42
|
]
|
|
43
|
+
# Content types HuggingFace image detectors accept. Non-image renderable files
|
|
44
|
+
# (PDFs) are rasterised page-by-page via render_to_images before classification,
|
|
45
|
+
# mirroring the vision LLM detector's input handling.
|
|
46
|
+
_IMAGE_INPUT_CONTENT_TYPES = [*_IMAGE_CONTENT_TYPES, "application/pdf"]
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_input_images(content: bytes, content_type: str, pil: Any) -> list[tuple[int, Any]]:
|
|
52
|
+
"""Return ``(page_index, PIL.Image)`` tuples for an image or renderable file.
|
|
53
|
+
|
|
54
|
+
Image MIME types open directly; PDFs (and any type ``render_to_images`` supports)
|
|
55
|
+
are rasterised to one image per page. Unsupported types return ``[]``.
|
|
56
|
+
"""
|
|
57
|
+
from ....utils.file_to_images import render_to_images, supported_mime_type
|
|
58
|
+
|
|
59
|
+
normalized = content_type.split(";", 1)[0].strip().lower()
|
|
60
|
+
try:
|
|
61
|
+
if normalized.startswith("image/"):
|
|
62
|
+
return [(0, pil.open(io.BytesIO(content)))]
|
|
63
|
+
if supported_mime_type(content_type):
|
|
64
|
+
pages = render_to_images(content, content_type)
|
|
65
|
+
return [(idx, pil.open(io.BytesIO(png))) for idx, png in enumerate(pages)]
|
|
66
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
67
|
+
logger.warning("Failed to load input images (%s): %s", normalized, exc)
|
|
68
|
+
return []
|
|
41
69
|
|
|
42
70
|
|
|
43
71
|
def _resolve_pipeline_severity(
|
{classifyre_cli-0.4.9 → classifyre_cli-0.4.11}/src/detectors/custom/runners/_image_classification.py
RENAMED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import io
|
|
6
5
|
import logging
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
@@ -11,8 +10,9 @@ from ....models.generated_single_asset_scan_results import DetectionResult
|
|
|
11
10
|
from ...dependencies import ensure_torch, require_module
|
|
12
11
|
from ._base import (
|
|
13
12
|
_DEFAULT_IMAGE_CLASSIFICATION_MODEL,
|
|
14
|
-
|
|
13
|
+
_IMAGE_INPUT_CONTENT_TYPES,
|
|
15
14
|
BaseRunner,
|
|
15
|
+
_load_input_images,
|
|
16
16
|
_resolve_pipeline_severity,
|
|
17
17
|
)
|
|
18
18
|
|
|
@@ -54,45 +54,55 @@ class ImageClassificationRunner(BaseRunner):
|
|
|
54
54
|
raise NotImplementedError("ImageClassificationRunner uses detect() directly")
|
|
55
55
|
|
|
56
56
|
def detect(self, content: str | bytes, content_type: str) -> list[DetectionResult]:
|
|
57
|
-
if not content_type.startswith("image/"):
|
|
58
|
-
return []
|
|
59
57
|
if isinstance(content, str):
|
|
60
58
|
logger.warning("image_classification: received string content, expected bytes")
|
|
61
59
|
return []
|
|
62
60
|
|
|
61
|
+
# image/* opens directly; PDFs are rasterised to one image per page.
|
|
62
|
+
images = _load_input_images(content, content_type, self._pil)
|
|
63
|
+
if not images:
|
|
64
|
+
return []
|
|
65
|
+
|
|
63
66
|
schema = self._schema
|
|
64
67
|
threshold = schema.confidence_threshold if schema.confidence_threshold is not None else 0.0
|
|
68
|
+
multi_page = len(images) > 1
|
|
65
69
|
results: list[DetectionResult] = []
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
|
|
70
|
+
for page_index, image in images:
|
|
71
|
+
try:
|
|
72
|
+
predictions: list[dict[str, Any]] = self._pipe(image) or []
|
|
73
|
+
for pred in predictions:
|
|
74
|
+
label: str = pred.get("label", "unknown")
|
|
75
|
+
score: float = float(pred.get("score", 0.0))
|
|
76
|
+
if score < threshold:
|
|
77
|
+
continue
|
|
78
|
+
severity = _resolve_pipeline_severity(label, schema.severity_map)
|
|
79
|
+
page_suffix = f" (page {page_index + 1})" if multi_page else ""
|
|
80
|
+
metadata: dict[str, Any] = {
|
|
81
|
+
"image_size": f"{image.size[0]}x{image.size[1]}",
|
|
82
|
+
"image_mode": image.mode,
|
|
83
|
+
"model": self._model_id,
|
|
84
|
+
}
|
|
85
|
+
if multi_page:
|
|
86
|
+
metadata["page"] = page_index + 1
|
|
87
|
+
results.append(
|
|
88
|
+
self._make_result(
|
|
89
|
+
finding_type=f"classification:{label}",
|
|
90
|
+
category="CONTENT",
|
|
91
|
+
severity=severity,
|
|
92
|
+
confidence=score,
|
|
93
|
+
matched_content=(
|
|
94
|
+
f"Image classified as: {label} ({score:.3f}){page_suffix}"
|
|
95
|
+
),
|
|
96
|
+
location=None,
|
|
97
|
+
metadata=metadata,
|
|
98
|
+
)
|
|
88
99
|
)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
logger.error(
|
|
102
|
+
"image_classification error (model=%s): %s", self._model_id, exc, exc_info=True
|
|
89
103
|
)
|
|
90
|
-
except Exception as exc:
|
|
91
|
-
logger.error(
|
|
92
|
-
"image_classification error (model=%s): %s", self._model_id, exc, exc_info=True
|
|
93
|
-
)
|
|
94
104
|
results.sort(key=lambda r: r.confidence, reverse=True)
|
|
95
105
|
return results
|
|
96
106
|
|
|
97
107
|
def get_supported_content_types(self) -> list[str]:
|
|
98
|
-
return list(
|
|
108
|
+
return list(_IMAGE_INPUT_CONTENT_TYPES)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""AI/LLM pipeline runner — prompt-driven classification and field extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
# Quiet litellm's import-time provider preload warnings (bedrock/sagemaker need
|
|
13
|
+
# botocore, which we don't install) before the library is ever imported.
|
|
14
|
+
os.environ.setdefault("LITELLM_LOG", "ERROR")
|
|
15
|
+
|
|
16
|
+
from ....models.generated_detectors import LLMPipelineSchema, Severity
|
|
17
|
+
from ....models.generated_single_asset_scan_results import (
|
|
18
|
+
DetectionResult,
|
|
19
|
+
DetectorType,
|
|
20
|
+
)
|
|
21
|
+
from ....utils.file_to_images import render_to_images, supported_mime_type
|
|
22
|
+
from ...dependencies import require_module
|
|
23
|
+
from ._base import _IMAGE_CONTENT_TYPES, _TEXT_CONTENT_TYPES, BaseRunner, _resolve_pipeline_severity
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Map the stored AI provider type onto the litellm model-string convention.
|
|
28
|
+
_PROVIDER_PREFIX: dict[str, str] = {
|
|
29
|
+
"CLAUDE": "anthropic",
|
|
30
|
+
"GEMINI": "gemini",
|
|
31
|
+
"OPENAI_COMPATIBLE": "openai",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Content types a vision-capable LLM detector renders to images and sends to the
|
|
35
|
+
# model directly. PDFs are rasterised page-by-page; images pass through.
|
|
36
|
+
_VISION_CONTENT_TYPES = [*_IMAGE_CONTENT_TYPES, "application/pdf"]
|
|
37
|
+
|
|
38
|
+
# Cap the number of rendered page images sent in a single completion to bound
|
|
39
|
+
# token cost and request size for multi-page PDFs.
|
|
40
|
+
_MAX_VISION_IMAGES = 20
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LLMRunner(BaseRunner):
|
|
44
|
+
"""AI detector — sends content to a configured LLM provider for classification + extraction."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, schema: LLMPipelineSchema, detector_key: str = "", detector_name: str = ""
|
|
48
|
+
) -> None:
|
|
49
|
+
self._schema = schema
|
|
50
|
+
self._detector_key = detector_key
|
|
51
|
+
self._detector_name = detector_name
|
|
52
|
+
|
|
53
|
+
runtime = schema.provider_runtime
|
|
54
|
+
if runtime is None:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"AI detector '{detector_key}' is missing provider_runtime — the API must "
|
|
57
|
+
"inject resolved provider credentials before dispatch."
|
|
58
|
+
)
|
|
59
|
+
self._runtime = runtime
|
|
60
|
+
self._litellm = require_module("litellm", "llm", ["llm"])
|
|
61
|
+
# Let litellm silently drop params an endpoint doesn't support (e.g.
|
|
62
|
+
# response_format / temperature on some OpenAI-compatible gateways)
|
|
63
|
+
# instead of raising. Keep its own logging quiet.
|
|
64
|
+
self._litellm.drop_params = True
|
|
65
|
+
self._litellm.suppress_debug_info = True
|
|
66
|
+
logging.getLogger("LiteLLM").setLevel(logging.ERROR)
|
|
67
|
+
|
|
68
|
+
def run(self, text: str) -> None: # type: ignore[override] # pragma: no cover
|
|
69
|
+
raise NotImplementedError("LLMRunner uses detect() directly")
|
|
70
|
+
|
|
71
|
+
def detect(self, content: str | bytes, content_type: str) -> list[DetectionResult]:
|
|
72
|
+
if isinstance(content, bytes):
|
|
73
|
+
return self._detect_vision(content, content_type)
|
|
74
|
+
if content_type not in _TEXT_CONTENT_TYPES:
|
|
75
|
+
return []
|
|
76
|
+
text = content.strip()
|
|
77
|
+
if not text:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
schema = self._schema
|
|
81
|
+
content_limit = schema.content_limit or 8000
|
|
82
|
+
snippet = text[:content_limit]
|
|
83
|
+
|
|
84
|
+
messages = [
|
|
85
|
+
{"role": "system", "content": self._build_system_prompt()},
|
|
86
|
+
{"role": "user", "content": snippet},
|
|
87
|
+
]
|
|
88
|
+
return self._complete_and_parse(messages, snippet)
|
|
89
|
+
|
|
90
|
+
def _detect_vision(self, content: bytes, content_type: str) -> list[DetectionResult]:
|
|
91
|
+
"""Render a binary file (image/PDF) to images and classify via the model."""
|
|
92
|
+
if not self._vision_enabled():
|
|
93
|
+
return []
|
|
94
|
+
if not supported_mime_type(content_type):
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
images = render_to_images(
|
|
98
|
+
content,
|
|
99
|
+
content_type,
|
|
100
|
+
max_pages=_MAX_VISION_IMAGES,
|
|
101
|
+
)
|
|
102
|
+
if not images:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
image_blocks = [
|
|
106
|
+
{
|
|
107
|
+
"type": "image_url",
|
|
108
|
+
"image_url": {
|
|
109
|
+
"url": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}"
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
for png in images[:_MAX_VISION_IMAGES]
|
|
113
|
+
]
|
|
114
|
+
messages = [
|
|
115
|
+
{"role": "system", "content": self._build_system_prompt()},
|
|
116
|
+
{"role": "user", "content": image_blocks},
|
|
117
|
+
]
|
|
118
|
+
# matched_content fallback descriptor — there is no text snippet for files.
|
|
119
|
+
descriptor = f"[{content_type}, {len(image_blocks)} page image(s)]"
|
|
120
|
+
return self._complete_and_parse(messages, descriptor, vision_pages=len(image_blocks))
|
|
121
|
+
|
|
122
|
+
def _complete_and_parse(
|
|
123
|
+
self,
|
|
124
|
+
messages: list[dict[str, Any]],
|
|
125
|
+
snippet: str,
|
|
126
|
+
*,
|
|
127
|
+
vision_pages: int | None = None,
|
|
128
|
+
) -> list[DetectionResult]:
|
|
129
|
+
schema = self._schema
|
|
130
|
+
try:
|
|
131
|
+
response = self._litellm.completion(
|
|
132
|
+
model=self._model_string(),
|
|
133
|
+
api_key=self._runtime.api_key,
|
|
134
|
+
api_base=self._runtime.base_url or None,
|
|
135
|
+
temperature=schema.temperature if schema.temperature is not None else 0.0,
|
|
136
|
+
max_tokens=self._max_tokens(),
|
|
137
|
+
messages=messages,
|
|
138
|
+
response_format={"type": "json_object"},
|
|
139
|
+
)
|
|
140
|
+
raw = response.choices[0].message.content or "{}"
|
|
141
|
+
parsed = self._parse_json(raw)
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
logger.error(
|
|
144
|
+
"llm detector error (detector=%s, model=%s): %s",
|
|
145
|
+
self._detector_key,
|
|
146
|
+
self._runtime.model,
|
|
147
|
+
exc,
|
|
148
|
+
exc_info=True,
|
|
149
|
+
)
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
return self._results_from_payload(snippet, parsed, vision_pages=vision_pages)
|
|
153
|
+
|
|
154
|
+
def _vision_enabled(self) -> bool:
|
|
155
|
+
return bool(getattr(self._runtime, "supports_vision", False))
|
|
156
|
+
|
|
157
|
+
def get_supported_content_types(self) -> list[str]:
|
|
158
|
+
types = list(_TEXT_CONTENT_TYPES)
|
|
159
|
+
if self._vision_enabled():
|
|
160
|
+
types.extend(_VISION_CONTENT_TYPES)
|
|
161
|
+
return types
|
|
162
|
+
|
|
163
|
+
# ── Internals ────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _max_tokens(self) -> int | None:
|
|
166
|
+
# `max_tokens` is generated as a RootModel[int] wrapper, so unwrap `.root`
|
|
167
|
+
# before handing it to litellm — passing the model object serialises to an
|
|
168
|
+
# invalid request body and fails the whole completion.
|
|
169
|
+
raw = self._schema.max_tokens
|
|
170
|
+
if raw is None:
|
|
171
|
+
return None
|
|
172
|
+
return getattr(raw, "root", raw)
|
|
173
|
+
|
|
174
|
+
def _model_string(self) -> str:
|
|
175
|
+
prefix = _PROVIDER_PREFIX.get(self._runtime.provider.value, "openai")
|
|
176
|
+
return f"{prefix}/{self._runtime.model}"
|
|
177
|
+
|
|
178
|
+
def _build_system_prompt(self) -> str:
|
|
179
|
+
schema = self._schema
|
|
180
|
+
parts: list[str] = [schema.system_prompt.strip()]
|
|
181
|
+
|
|
182
|
+
labels = schema.labels or []
|
|
183
|
+
if labels:
|
|
184
|
+
label_lines = "\n".join(
|
|
185
|
+
f"- {lbl.name}: {lbl.description}" if lbl.description else f"- {lbl.name}"
|
|
186
|
+
for lbl in labels
|
|
187
|
+
)
|
|
188
|
+
parts.append(
|
|
189
|
+
"Classify the content using these labels:\n"
|
|
190
|
+
+ label_lines
|
|
191
|
+
+ (
|
|
192
|
+
"\nMultiple labels may apply."
|
|
193
|
+
if schema.multi_label
|
|
194
|
+
else "\nChoose the single best label."
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
fields = schema.output_fields or []
|
|
199
|
+
if fields:
|
|
200
|
+
field_lines = "\n".join(
|
|
201
|
+
f"- {f.name} ({f.type.value if f.type else 'string'}): {f.description}"
|
|
202
|
+
if f.description
|
|
203
|
+
else f"- {f.name} ({f.type.value if f.type else 'string'})"
|
|
204
|
+
for f in fields
|
|
205
|
+
)
|
|
206
|
+
parts.append("Also extract these fields:\n" + field_lines)
|
|
207
|
+
|
|
208
|
+
parts.append(
|
|
209
|
+
"Respond with a JSON object of the form: "
|
|
210
|
+
'{"labels": [{"name": "<label>", "confidence": <0-1>, '
|
|
211
|
+
'"matched_content": "<relevant snippet>"}], "fields": {<field name>: <value>}}. '
|
|
212
|
+
"Use only the labels listed above. Return an empty labels array when none apply."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if schema.response_example:
|
|
216
|
+
parts.append("Example response:\n" + schema.response_example.strip())
|
|
217
|
+
|
|
218
|
+
return "\n\n".join(parts)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _parse_json(raw: str) -> dict[str, Any]:
|
|
222
|
+
try:
|
|
223
|
+
parsed = json.loads(raw)
|
|
224
|
+
except json.JSONDecodeError:
|
|
225
|
+
start = raw.find("{")
|
|
226
|
+
end = raw.rfind("}")
|
|
227
|
+
if start == -1 or end == -1 or end <= start:
|
|
228
|
+
return {}
|
|
229
|
+
try:
|
|
230
|
+
parsed = json.loads(raw[start : end + 1])
|
|
231
|
+
except json.JSONDecodeError:
|
|
232
|
+
return {}
|
|
233
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
234
|
+
|
|
235
|
+
def _results_from_payload(
|
|
236
|
+
self,
|
|
237
|
+
snippet: str,
|
|
238
|
+
payload: dict[str, Any],
|
|
239
|
+
*,
|
|
240
|
+
vision_pages: int | None = None,
|
|
241
|
+
) -> list[DetectionResult]:
|
|
242
|
+
schema = self._schema
|
|
243
|
+
threshold = schema.confidence_threshold if schema.confidence_threshold is not None else 0.5
|
|
244
|
+
default_severity = schema.severity or Severity.info
|
|
245
|
+
extracted = self._coerce_fields(payload.get("fields"))
|
|
246
|
+
|
|
247
|
+
raw_labels = payload.get("labels")
|
|
248
|
+
label_entries: list[dict[str, Any]] = (
|
|
249
|
+
[lbl for lbl in raw_labels if isinstance(lbl, dict)]
|
|
250
|
+
if isinstance(raw_labels, list)
|
|
251
|
+
else []
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
results: list[DetectionResult] = []
|
|
255
|
+
for entry in label_entries:
|
|
256
|
+
label = str(entry.get("name", "")).strip()
|
|
257
|
+
if not label:
|
|
258
|
+
continue
|
|
259
|
+
confidence = float(entry.get("confidence", 1.0) or 0.0)
|
|
260
|
+
if confidence < threshold:
|
|
261
|
+
continue
|
|
262
|
+
severity = _resolve_pipeline_severity(label, schema.severity_map, default_severity)
|
|
263
|
+
matched = str(entry.get("matched_content") or "").strip() or snippet[:320]
|
|
264
|
+
results.append(
|
|
265
|
+
DetectionResult(
|
|
266
|
+
detector_type=DetectorType.CUSTOM,
|
|
267
|
+
finding_type=label,
|
|
268
|
+
category="CLASSIFICATION",
|
|
269
|
+
severity=severity,
|
|
270
|
+
confidence=min(0.99, confidence),
|
|
271
|
+
matched_content=matched,
|
|
272
|
+
location=None,
|
|
273
|
+
custom_detector_key=self._detector_key,
|
|
274
|
+
custom_detector_name=self._detector_name,
|
|
275
|
+
detected_at=datetime.now(UTC),
|
|
276
|
+
metadata={
|
|
277
|
+
"runner": "LLM",
|
|
278
|
+
"provider": self._runtime.provider.value,
|
|
279
|
+
"model": self._runtime.model,
|
|
280
|
+
"label": label,
|
|
281
|
+
"fields": extracted,
|
|
282
|
+
"input": "vision" if vision_pages is not None else "text",
|
|
283
|
+
**({"vision_pages": vision_pages} if vision_pages is not None else {}),
|
|
284
|
+
},
|
|
285
|
+
extracted_data=extracted or None,
|
|
286
|
+
extraction_method="LLM",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
results.sort(key=lambda r: r.confidence, reverse=True)
|
|
291
|
+
return results
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _coerce_fields(raw: Any) -> dict[str, Any]:
|
|
295
|
+
return {str(k): v for k, v in raw.items()} if isinstance(raw, dict) else {}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Object detection pipeline runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ....models.generated_detectors import ObjectDetectionPipelineSchema
|
|
9
|
+
from ....models.generated_single_asset_scan_results import DetectionResult, Location
|
|
10
|
+
from ...dependencies import MissingDependencyError, ensure_torch, require_module
|
|
11
|
+
from ._base import (
|
|
12
|
+
_IMAGE_INPUT_CONTENT_TYPES,
|
|
13
|
+
BaseRunner,
|
|
14
|
+
_load_input_images,
|
|
15
|
+
_resolve_pipeline_severity,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ObjectDetectionRunner(BaseRunner):
|
|
22
|
+
"""Object detection via a single HuggingFace object-detection pipeline."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
schema: ObjectDetectionPipelineSchema,
|
|
27
|
+
detector_key: str = "",
|
|
28
|
+
detector_name: str = "",
|
|
29
|
+
) -> None:
|
|
30
|
+
self._schema = schema
|
|
31
|
+
self._detector_key = detector_key
|
|
32
|
+
self._detector_name = detector_name
|
|
33
|
+
ensure_torch("object_detection", ["custom", "detectors"])
|
|
34
|
+
transformers = require_module("transformers", "object_detection", ["custom", "detectors"])
|
|
35
|
+
self._pil = require_module("PIL.Image", "object_detection", ["custom", "detectors"])
|
|
36
|
+
pipeline_kwargs: dict[str, Any] = {
|
|
37
|
+
"model": schema.model,
|
|
38
|
+
"device": schema.device or "cpu",
|
|
39
|
+
}
|
|
40
|
+
if schema.model_revision:
|
|
41
|
+
pipeline_kwargs["revision"] = schema.model_revision
|
|
42
|
+
nms = getattr(schema.nms_threshold, "root", schema.nms_threshold)
|
|
43
|
+
if nms is not None:
|
|
44
|
+
pipeline_kwargs["threshold"] = nms
|
|
45
|
+
try:
|
|
46
|
+
self._pipe: Any = transformers.pipeline("object-detection", **pipeline_kwargs)
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise MissingDependencyError(
|
|
49
|
+
"object_detection",
|
|
50
|
+
["custom", "detectors"],
|
|
51
|
+
f"ObjectDetectionRunner requires additional dependencies: {exc}",
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
def run(self, text: str) -> None: # type: ignore[override] # pragma: no cover
|
|
55
|
+
raise NotImplementedError("ObjectDetectionRunner uses detect() directly")
|
|
56
|
+
|
|
57
|
+
def detect(self, content: str | bytes, content_type: str) -> list[DetectionResult]:
|
|
58
|
+
if isinstance(content, str):
|
|
59
|
+
logger.warning("object_detection: received string content, expected bytes")
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
# image/* opens directly; PDFs are rasterised to one image per page.
|
|
63
|
+
images = _load_input_images(content, content_type, self._pil)
|
|
64
|
+
if not images:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
schema = self._schema
|
|
68
|
+
threshold = schema.confidence_threshold if schema.confidence_threshold is not None else 0.5
|
|
69
|
+
multi_page = len(images) > 1
|
|
70
|
+
results: list[DetectionResult] = []
|
|
71
|
+
for page_index, image in images:
|
|
72
|
+
try:
|
|
73
|
+
detections: list[dict[str, Any]] = self._pipe(image) or []
|
|
74
|
+
for det in detections:
|
|
75
|
+
label: str = det.get("label", "unknown")
|
|
76
|
+
score: float = float(det.get("score", 0.0))
|
|
77
|
+
box: dict[str, int] = det.get("box", {})
|
|
78
|
+
if score < threshold:
|
|
79
|
+
continue
|
|
80
|
+
if schema.min_box_area is not None:
|
|
81
|
+
w = max(0, box.get("xmax", 0) - box.get("xmin", 0))
|
|
82
|
+
h = max(0, box.get("ymax", 0) - box.get("ymin", 0))
|
|
83
|
+
if w * h < schema.min_box_area:
|
|
84
|
+
continue
|
|
85
|
+
severity = _resolve_pipeline_severity(label, schema.severity_map)
|
|
86
|
+
page_prefix = f"page {page_index + 1} " if multi_page else ""
|
|
87
|
+
metadata: dict[str, Any] = {
|
|
88
|
+
"box": box,
|
|
89
|
+
"score": score,
|
|
90
|
+
"image_size": f"{image.size[0]}x{image.size[1]}",
|
|
91
|
+
"model": schema.model,
|
|
92
|
+
}
|
|
93
|
+
if multi_page:
|
|
94
|
+
metadata["page"] = page_index + 1
|
|
95
|
+
results.append(
|
|
96
|
+
self._make_result(
|
|
97
|
+
finding_type=label,
|
|
98
|
+
category="CONTENT",
|
|
99
|
+
severity=severity,
|
|
100
|
+
confidence=score,
|
|
101
|
+
matched_content=label,
|
|
102
|
+
location=Location(
|
|
103
|
+
description=(
|
|
104
|
+
f"{page_prefix}box xmin={box.get('xmin')} ymin={box.get('ymin')}"
|
|
105
|
+
f" xmax={box.get('xmax')} ymax={box.get('ymax')}"
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
metadata=metadata,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.error(
|
|
113
|
+
"object_detection error (model=%s): %s", schema.model, exc, exc_info=True
|
|
114
|
+
)
|
|
115
|
+
results.sort(key=lambda r: r.confidence, reverse=True)
|
|
116
|
+
if schema.top_k is not None:
|
|
117
|
+
results = results[: schema.top_k]
|
|
118
|
+
return results
|
|
119
|
+
|
|
120
|
+
def get_supported_content_types(self) -> list[str]:
|
|
121
|
+
return list(_IMAGE_INPUT_CONTENT_TYPES)
|