contextsynapse 1.0.0__py3-none-any.whl
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.
- contextsynapse/__init__.py +107 -0
- contextsynapse/__main__.py +9 -0
- contextsynapse/a2a/__init__.py +28 -0
- contextsynapse/a2a/client.py +120 -0
- contextsynapse/a2a/discovery.py +128 -0
- contextsynapse/a2a/handlers.py +164 -0
- contextsynapse/a2a/models.py +246 -0
- contextsynapse/a2a/streaming.py +92 -0
- contextsynapse/a2a/task_manager.py +275 -0
- contextsynapse/adapters/__init__.py +98 -0
- contextsynapse/adapters/_base.py +298 -0
- contextsynapse/adapters/autogen/__init__.py +4 -0
- contextsynapse/adapters/autogen/tools.py +34 -0
- contextsynapse/adapters/crewai/__init__.py +4 -0
- contextsynapse/adapters/crewai/tools.py +63 -0
- contextsynapse/adapters/langchain/__init__.py +19 -0
- contextsynapse/adapters/langchain/message_history.py +17 -0
- contextsynapse/adapters/langchain/retriever.py +158 -0
- contextsynapse/adapters/langchain/tools.py +43 -0
- contextsynapse/adapters/langgraph/__init__.py +21 -0
- contextsynapse/adapters/langgraph/checkpoint.py +223 -0
- contextsynapse/adapters/langgraph/context_tools.py +341 -0
- contextsynapse/adapters/langgraph/message_history.py +138 -0
- contextsynapse/adapters/langgraph/tools.py +43 -0
- contextsynapse/adapters/llamaindex/__init__.py +4 -0
- contextsynapse/adapters/llamaindex/tools.py +64 -0
- contextsynapse/adapters/openai/__init__.py +24 -0
- contextsynapse/adapters/openai/tools.py +206 -0
- contextsynapse/adapters/pydantic_ai/__init__.py +4 -0
- contextsynapse/adapters/pydantic_ai/tools.py +36 -0
- contextsynapse/adapters/swarm/__init__.py +4 -0
- contextsynapse/adapters/swarm/tools.py +44 -0
- contextsynapse/agents/__init__.py +6 -0
- contextsynapse/agents/auto_capture.py +80 -0
- contextsynapse/agents/dispatcher.py +299 -0
- contextsynapse/agents/notifications.py +112 -0
- contextsynapse/aiql/__init__.py +71 -0
- contextsynapse/aiql/cache.py +341 -0
- contextsynapse/aiql/compiler/__init__.py +62 -0
- contextsynapse/aiql/compiler/optimizer.py +126 -0
- contextsynapse/aiql/compiler/planner.py +366 -0
- contextsynapse/aiql/compiler/statistics.py +235 -0
- contextsynapse/aiql/compiler/validator.py +425 -0
- contextsynapse/aiql/engine/__init__.py +11 -0
- contextsynapse/aiql/engine/chunk_storage.py +2584 -0
- contextsynapse/aiql/engine/executor.py +7737 -0
- contextsynapse/aiql/engine/extraction_integration.py +442 -0
- contextsynapse/aiql/engine/graph_builder.py +405 -0
- contextsynapse/aiql/engine/node_creation_from_normalized.py +691 -0
- contextsynapse/aiql/engine/performance_monitor.py +289 -0
- contextsynapse/aiql/engine/postprocessing.py +365 -0
- contextsynapse/aiql/engine/ray_stage_executors.py +918 -0
- contextsynapse/aiql/engine/redis_cache.py +106 -0
- contextsynapse/aiql/engine/streaming_json.py +366 -0
- contextsynapse/aiql/engine/streaming_parquet.py +568 -0
- contextsynapse/aiql/engine/streaming_ray_chunking.py +419 -0
- contextsynapse/aiql/grammar/__init__.py +14 -0
- contextsynapse/aiql/grammar/aiql_grammar.py +1822 -0
- contextsynapse/aiql/grammar/base.py +71 -0
- contextsynapse/aiql/parser/__init__.py +46 -0
- contextsynapse/aiql/parser/aiql_parser.py +11330 -0
- contextsynapse/aiql/parser/base_parser.py +47 -0
- contextsynapse/api/__init__.py +5 -0
- contextsynapse/api/a2a_router.py +233 -0
- contextsynapse/api/admin_router.py +140 -0
- contextsynapse/api/agent_worker_router.py +978 -0
- contextsynapse/api/algorithms_router.py +317 -0
- contextsynapse/api/api.py +4144 -0
- contextsynapse/api/audit.py +204 -0
- contextsynapse/api/auth.py +441 -0
- contextsynapse/api/auth_router.py +537 -0
- contextsynapse/api/billing.py +261 -0
- contextsynapse/api/billing_router.py +275 -0
- contextsynapse/api/boundary_router.py +452 -0
- contextsynapse/api/cognition_router.py +116 -0
- contextsynapse/api/connector_router.py +237 -0
- contextsynapse/api/context_router.py +1619 -0
- contextsynapse/api/dashboard_router.py +11367 -0
- contextsynapse/api/errors.py +183 -0
- contextsynapse/api/events.py +88 -0
- contextsynapse/api/experiment_bootstrap.py +321 -0
- contextsynapse/api/experiment_orchestrator.py +1078 -0
- contextsynapse/api/experiment_router.py +2089 -0
- contextsynapse/api/feature_gate.py +89 -0
- contextsynapse/api/federation_router.py +108 -0
- contextsynapse/api/integrations.py +567 -0
- contextsynapse/api/integrations_router.py +461 -0
- contextsynapse/api/intelligence_router.py +1028 -0
- contextsynapse/api/metering.py +155 -0
- contextsynapse/api/metrics.py +112 -0
- contextsynapse/api/models.py +38 -0
- contextsynapse/api/monitoring_router.py +174 -0
- contextsynapse/api/pipeline_router.py +1437 -0
- contextsynapse/api/playground_conn.py +116 -0
- contextsynapse/api/projects_router.py +131 -0
- contextsynapse/api/queries_router.py +282 -0
- contextsynapse/api/quota.py +95 -0
- contextsynapse/api/rate_limit.py +64 -0
- contextsynapse/api/search_router.py +261 -0
- contextsynapse/api/shield_router.py +447 -0
- contextsynapse/api/tenant_guard.py +103 -0
- contextsynapse/api/tenant_quotas.py +231 -0
- contextsynapse/api/tenants.py +305 -0
- contextsynapse/api/users.py +646 -0
- contextsynapse/api/v1.py +167 -0
- contextsynapse/api/vertical_builder_router.py +387 -0
- contextsynapse/benchmark/__init__.py +15 -0
- contextsynapse/benchmark/__main__.py +64 -0
- contextsynapse/benchmark/agents.py +338 -0
- contextsynapse/benchmark/live_ab.py +338 -0
- contextsynapse/benchmark/reporter.py +149 -0
- contextsynapse/benchmark/runner.py +264 -0
- contextsynapse/benchmark/savings.py +266 -0
- contextsynapse/benchmark/token_counter.py +212 -0
- contextsynapse/benchmark/workloads.py +289 -0
- contextsynapse/buffer/__init__.py +154 -0
- contextsynapse/buffer/base.py +257 -0
- contextsynapse/buffer/duckdb_buffer.py +246 -0
- contextsynapse/buffer/file_buffer.py +229 -0
- contextsynapse/buffer/lmdb_buffer.py +286 -0
- contextsynapse/buffer/local_buffer.py +154 -0
- contextsynapse/buffer/redis_buffer.py +211 -0
- contextsynapse/buffer/sqlite_buffer.py +266 -0
- contextsynapse/cache.py +2 -0
- contextsynapse/cli/__init__.py +40 -0
- contextsynapse/cli/cli.py +475 -0
- contextsynapse/cli/config_cli.py +177 -0
- contextsynapse/cli/scaffold.py +465 -0
- contextsynapse/cognition/__init__.py +31 -0
- contextsynapse/cognition/derivation.py +55 -0
- contextsynapse/cognition/events.py +135 -0
- contextsynapse/cognition/feedback.py +52 -0
- contextsynapse/cognition/hallucination.py +90 -0
- contextsynapse/cognition/invalidation.py +70 -0
- contextsynapse/cognition/read_tracker.py +50 -0
- contextsynapse/collaboration.py +2 -0
- contextsynapse/comparison.py +2 -0
- contextsynapse/config/__init__.py +10 -0
- contextsynapse/config/connectors/amfi_nav.yaml +18 -0
- contextsynapse/config/connectors/auto_sector.yaml +20 -0
- contextsynapse/config/connectors/banking_sector.yaml +20 -0
- contextsynapse/config/connectors/energy_sector.yaml +20 -0
- contextsynapse/config/connectors/fmcg_sector.yaml +20 -0
- contextsynapse/config/connectors/nsdl_fpi.yaml +9 -0
- contextsynapse/config/connectors/nse_price.yaml +61 -0
- contextsynapse/config/connectors/pharma_sector.yaml +20 -0
- contextsynapse/config/connectors/rbi_dbie.yaml +12 -0
- contextsynapse/config/context_presets/ai_research.json +25 -0
- contextsynapse/config/context_presets/chatgpt_conversations.json +19 -0
- contextsynapse/config/context_presets/claude_conversations.json +19 -0
- contextsynapse/config/context_presets/legal_compliance.json +21 -0
- contextsynapse/config/context_presets/meeting_notes.json +21 -0
- contextsynapse/config/context_presets/slack_engineering.json +21 -0
- contextsynapse/config/context_presets/tech_climate.json +24 -0
- contextsynapse/config/context_presets/toi_politics.json +25 -0
- contextsynapse/config/database_config.py +453 -0
- contextsynapse/config/deployment.py +221 -0
- contextsynapse/config/gpt_action_schema.json +164 -0
- contextsynapse/config/indicators/india_macro.yaml +213 -0
- contextsynapse/config/logging_config.py +88 -0
- contextsynapse/config/mode_config.py +223 -0
- contextsynapse/config/schemas/api_spec.yaml +48 -0
- contextsynapse/config/schemas/architecture_doc.yaml +51 -0
- contextsynapse/config/schemas/chat_history.yaml +90 -0
- contextsynapse/config/schemas/chatgpt_conversation.yaml +101 -0
- contextsynapse/config/schemas/claude_conversation.yaml +148 -0
- contextsynapse/config/schemas/contract.yaml +48 -0
- contextsynapse/config/schemas/conversation.yaml +167 -0
- contextsynapse/config/schemas/database.yaml +74 -0
- contextsynapse/config/schemas/decision.yaml +72 -0
- contextsynapse/config/schemas/ecommerce.yaml +56 -0
- contextsynapse/config/schemas/education.yaml +42 -0
- contextsynapse/config/schemas/healthcare.yaml +133 -0
- contextsynapse/config/schemas/hr_document.yaml +48 -0
- contextsynapse/config/schemas/invoice.yaml +40 -0
- contextsynapse/config/schemas/knowledge_base.yaml +75 -0
- contextsynapse/config/schemas/legal.yaml +42 -0
- contextsynapse/config/schemas/marketing_content.yaml +48 -0
- contextsynapse/config/schemas/meeting_notes.yaml +51 -0
- contextsynapse/config/schemas/news_article.yaml +61 -0
- contextsynapse/config/schemas/real_estate.yaml +49 -0
- contextsynapse/config/schemas/requirements_doc.yaml +48 -0
- contextsynapse/config/schemas/research_paper.yaml +56 -0
- contextsynapse/config/schemas/resume.yaml +49 -0
- contextsynapse/config/schemas/retail.yaml +42 -0
- contextsynapse/config/schemas/rule_meta.yaml +173 -0
- contextsynapse/config/schemas/rules.yaml +72 -0
- contextsynapse/config/schemas/screener_meta.yaml +236 -0
- contextsynapse/config/schemas/sdlc.yaml +103 -0
- contextsynapse/config/schemas/session_graph.yaml +172 -0
- contextsynapse/config/schemas/support_ticket.yaml +48 -0
- contextsynapse/config/schemas/web.yaml +64 -0
- contextsynapse/config/sensors/earnings_calendar.yaml +52 -0
- contextsynapse/config/sensors/global_risk.yaml +53 -0
- contextsynapse/config/sensors/government_policy.yaml +54 -0
- contextsynapse/config/sensors/supply_chain.yaml +50 -0
- contextsynapse/config/sensors/templates/behavioral.yaml +56 -0
- contextsynapse/config/sensors/templates/equity_sentiment.yaml +48 -0
- contextsynapse/config/sensors/templates/global_macro.yaml +47 -0
- contextsynapse/config/sensors/templates/market_data.yaml +118 -0
- contextsynapse/config/sensors/templates/sector_technical.yaml +47 -0
- contextsynapse/config/sensors/weather.yaml +52 -0
- contextsynapse/config/storage_strategy.py +202 -0
- contextsynapse/config/templates/competitive_intel.yaml +39 -0
- contextsynapse/config/templates/geopolitical_risk.yaml +46 -0
- contextsynapse/config/templates/market_analysis.yaml +58 -0
- contextsynapse/connectors/__init__.py +15 -0
- contextsynapse/connectors/api_router.py +121 -0
- contextsynapse/connectors/base.py +357 -0
- contextsynapse/connectors/financial/audio.py +170 -0
- contextsynapse/connectors/financial/youtube.py +272 -0
- contextsynapse/connectors/github.py +164 -0
- contextsynapse/connectors/jira.py +120 -0
- contextsynapse/connectors/kafka_connector.py +124 -0
- contextsynapse/connectors/pipeline_connector.py +747 -0
- contextsynapse/connectors/registry.py +116 -0
- contextsynapse/connectors/scheduler.py +87 -0
- contextsynapse/connectors/universal.py +360 -0
- contextsynapse/context/__init__.py +96 -0
- contextsynapse/context/__main__.py +4 -0
- contextsynapse/context/acl.py +125 -0
- contextsynapse/context/agent_card.py +201 -0
- contextsynapse/context/agent_memory.py +814 -0
- contextsynapse/context/agents.py +472 -0
- contextsynapse/context/agents_redis.py +391 -0
- contextsynapse/context/assembled.py +204 -0
- contextsynapse/context/attribution.py +113 -0
- contextsynapse/context/blob.py +184 -0
- contextsynapse/context/boundaries.py +187 -0
- contextsynapse/context/boundary.py +507 -0
- contextsynapse/context/bundle.py +155 -0
- contextsynapse/context/cli.py +519 -0
- contextsynapse/context/collaboration.py +165 -0
- contextsynapse/context/compiler.py +604 -0
- contextsynapse/context/composite.py +536 -0
- contextsynapse/context/compression.py +147 -0
- contextsynapse/context/context_manager.py +888 -0
- contextsynapse/context/context_schema.py +90 -0
- contextsynapse/context/context_schema.yaml +105 -0
- contextsynapse/context/context_units.py +891 -0
- contextsynapse/context/conversation.py +488 -0
- contextsynapse/context/dedup.py +207 -0
- contextsynapse/context/document_processor.py +698 -0
- contextsynapse/context/embedding_hooks.py +338 -0
- contextsynapse/context/execution_schema.py +274 -0
- contextsynapse/context/fan_out.py +138 -0
- contextsynapse/context/frozen.py +80 -0
- contextsynapse/context/gravity.py +227 -0
- contextsynapse/context/hub.py +1401 -0
- contextsynapse/context/ingest.py +391 -0
- contextsynapse/context/injection.py +226 -0
- contextsynapse/context/interaction_graphifier.py +562 -0
- contextsynapse/context/layers.py +574 -0
- contextsynapse/context/projection.py +1195 -0
- contextsynapse/context/promotion.py +634 -0
- contextsynapse/context/proof.py +131 -0
- contextsynapse/context/propagation.py +577 -0
- contextsynapse/context/pubsub.py +135 -0
- contextsynapse/context/quality.py +1054 -0
- contextsynapse/context/quality_tracker.py +270 -0
- contextsynapse/context/scoping.py +370 -0
- contextsynapse/context/seed.py +992 -0
- contextsynapse/context/session.py +1142 -0
- contextsynapse/context/session_graph.py +593 -0
- contextsynapse/context/session_memory.py +309 -0
- contextsynapse/context/session_resolver.py +111 -0
- contextsynapse/context/skill_learning.py +237 -0
- contextsynapse/context/smart_budget.py +129 -0
- contextsynapse/context/source_policy.py +242 -0
- contextsynapse/context/store_factory.py +53 -0
- contextsynapse/context/sync.py +648 -0
- contextsynapse/context/token.py +236 -0
- contextsynapse/context/vector_integration.py +297 -0
- contextsynapse/context/webhooks.py +194 -0
- contextsynapse/context/working_memory.py +224 -0
- contextsynapse/core/__init__.py +18 -0
- contextsynapse/core/archiver.py +237 -0
- contextsynapse/core/change_stream.py +282 -0
- contextsynapse/core/checkpoint.py +310 -0
- contextsynapse/core/cloud_storage.py +387 -0
- contextsynapse/core/context_state.py +651 -0
- contextsynapse/core/cron.py +324 -0
- contextsynapse/core/datasource.py +643 -0
- contextsynapse/core/db.py +289 -0
- contextsynapse/core/federation.py +283 -0
- contextsynapse/core/graph_coordinator.py +201 -0
- contextsynapse/core/graph_intelligence.py +388 -0
- contextsynapse/core/graph_structures.py +106 -0
- contextsynapse/core/graph_sync.py +157 -0
- contextsynapse/core/hybrid_graph_storage.py +2998 -0
- contextsynapse/core/lifecycle.py +691 -0
- contextsynapse/core/multiworker.py +378 -0
- contextsynapse/core/pruning.py +312 -0
- contextsynapse/core/redis_registry.py +524 -0
- contextsynapse/core/registry.py +1043 -0
- contextsynapse/core/registry_factory.py +51 -0
- contextsynapse/core/registry_metadata.py +294 -0
- contextsynapse/core/replay.py +354 -0
- contextsynapse/core/replication.py +339 -0
- contextsynapse/core/time_travel.py +260 -0
- contextsynapse/core/write_behind.py +152 -0
- contextsynapse/core/write_context.py +39 -0
- contextsynapse/db/__init__.py +1 -0
- contextsynapse/db/postgres.py +267 -0
- contextsynapse/db/profile.py +236 -0
- contextsynapse/db/rules.py +328 -0
- contextsynapse/demo/__init__.py +0 -0
- contextsynapse/demo/report.py +147 -0
- contextsynapse/demo/runner.py +520 -0
- contextsynapse/demo/scenario.py +165 -0
- contextsynapse/demo/session_demo.py +280 -0
- contextsynapse/demo/session_report.py +143 -0
- contextsynapse/demo/universal_runner.py +257 -0
- contextsynapse/engine/__init__.py +15 -0
- contextsynapse/engine/core.py +555 -0
- contextsynapse/extraction/__init__.py +69 -0
- contextsynapse/extraction/cli.py +274 -0
- contextsynapse/extraction/config.py +107 -0
- contextsynapse/extraction/examples/example_usage.py +118 -0
- contextsynapse/extraction/examples/sample_manifest.json +53 -0
- contextsynapse/extraction/examples/sample_normalized.json +185 -0
- contextsynapse/extraction/extract_engine.py +440 -0
- contextsynapse/extraction/extractors/__init__.py +37 -0
- contextsynapse/extraction/extractors/chat_export_extractor.py +189 -0
- contextsynapse/extraction/extractors/csv_extractor.py +83 -0
- contextsynapse/extraction/extractors/docx_extractor.py +129 -0
- contextsynapse/extraction/extractors/excel_extractor.py +152 -0
- contextsynapse/extraction/extractors/html_extractor.py +146 -0
- contextsynapse/extraction/extractors/pdf_extractor.py +635 -0
- contextsynapse/extraction/extractors/pdf_extractor_layoutparser.py +408 -0
- contextsynapse/extraction/extractors/register_extractors.py +63 -0
- contextsynapse/extraction/extractors/text_extractor.py +65 -0
- contextsynapse/extraction/extractors/txt_extractor.py +120 -0
- contextsynapse/extraction/extractors/website_extractor.py +246 -0
- contextsynapse/extraction/fact_extractor.py +236 -0
- contextsynapse/extraction/file_manager.py +353 -0
- contextsynapse/extraction/hierarchy.py +239 -0
- contextsynapse/extraction/id_generator.py +450 -0
- contextsynapse/extraction/layout_detector.py +409 -0
- contextsynapse/extraction/llm_entity_extractor.py +301 -0
- contextsynapse/extraction/normalized_store.py +480 -0
- contextsynapse/extraction/normalizer.py +594 -0
- contextsynapse/extraction/parquet_writer.py +598 -0
- contextsynapse/extraction/post_processors/__init__.py +40 -0
- contextsynapse/extraction/post_processors/column_reorganizer.py +507 -0
- contextsynapse/extraction/ray_normalization.py +233 -0
- contextsynapse/extraction/ray_runner.py +207 -0
- contextsynapse/extraction/redis_schema_registry.py +222 -0
- contextsynapse/extraction/registry.py +204 -0
- contextsynapse/extraction/schema_loader.py +663 -0
- contextsynapse/extraction/schema_registry.py +230 -0
- contextsynapse/extraction/semantic_normalizer.py +245 -0
- contextsynapse/extraction/version_manager.py +314 -0
- contextsynapse/gateway/__init__.py +24 -0
- contextsynapse/gateway/action_emitter.py +212 -0
- contextsynapse/gateway/cost_tracker.py +508 -0
- contextsynapse/gateway/gateway.py +377 -0
- contextsynapse/gateway/policy.py +303 -0
- contextsynapse/governance/__init__.py +13 -0
- contextsynapse/governance/layer.py +513 -0
- contextsynapse/ingestion/__init__.py +15 -0
- contextsynapse/ingestion/amplifier.py +288 -0
- contextsynapse/ingestion/async_ingest.py +463 -0
- contextsynapse/ingestion/chunker.py +303 -0
- contextsynapse/ingestion/chunking.py +58 -0
- contextsynapse/ingestion/cleaner.py +190 -0
- contextsynapse/ingestion/cleanse.py +252 -0
- contextsynapse/ingestion/connectors/__init__.py +38 -0
- contextsynapse/ingestion/connectors/base.py +300 -0
- contextsynapse/ingestion/connectors/chatgpt_memory.py +247 -0
- contextsynapse/ingestion/connectors/claude_memory.py +274 -0
- contextsynapse/ingestion/connectors/sap_ingestor.py +208 -0
- contextsynapse/ingestion/content_detector.py +80 -0
- contextsynapse/ingestion/content_monitor.py +502 -0
- contextsynapse/ingestion/dedup.py +106 -0
- contextsynapse/ingestion/feed.py +640 -0
- contextsynapse/ingestion/filters.py +461 -0
- contextsynapse/ingestion/graph_builder.py +705 -0
- contextsynapse/ingestion/input_classifier.py +307 -0
- contextsynapse/ingestion/job_manager.py +571 -0
- contextsynapse/ingestion/llm_extractor.py +610 -0
- contextsynapse/ingestion/orchestrator.py +116 -0
- contextsynapse/ingestion/parsers/__init__.py +19 -0
- contextsynapse/ingestion/parsers/graph_parser_registry.py +103 -0
- contextsynapse/ingestion/parsers/graphml_parser.py +141 -0
- contextsynapse/ingestion/parsers/jsonld_parser.py +171 -0
- contextsynapse/ingestion/parsers/rdf_parser.py +150 -0
- contextsynapse/ingestion/pipeline_context.py +228 -0
- contextsynapse/ingestion/pipeline_store.py +1195 -0
- contextsynapse/ingestion/queue.py +133 -0
- contextsynapse/ingestion/reprocess.py +179 -0
- contextsynapse/ingestion/scenario_router.py +224 -0
- contextsynapse/ingestion/schema_extractor.py +759 -0
- contextsynapse/ingestion/schema_validator.py +274 -0
- contextsynapse/ingestion/sdlc_ingest.py +87 -0
- contextsynapse/ingestion/smart_ingest.py +2451 -0
- contextsynapse/ingestion/source_connector.py +171 -0
- contextsynapse/ingestion/stage_executor.py +2095 -0
- contextsynapse/ingestion/stages/__init__.py +9 -0
- contextsynapse/ingestion/stages/tabular_to_graph.py +289 -0
- contextsynapse/ingestion/strategies.py +386 -0
- contextsynapse/ingestion/universal/__init__.py +4 -0
- contextsynapse/ingestion/universal/_operator_registry.py +59 -0
- contextsynapse/ingestion/universal/ingest_content.py +32 -0
- contextsynapse/ingestion/universal/operators/__init__.py +1 -0
- contextsynapse/ingestion/universal/operators/base.py +19 -0
- contextsynapse/ingestion/universal/operators/build_edges.py +75 -0
- contextsynapse/ingestion/universal/operators/cluster_topics.py +397 -0
- contextsynapse/ingestion/universal/operators/deduplicate.py +45 -0
- contextsynapse/ingestion/universal/operators/detect_signals.py +90 -0
- contextsynapse/ingestion/universal/operators/embed.py +80 -0
- contextsynapse/ingestion/universal/operators/extract_entities.py +253 -0
- contextsynapse/ingestion/universal/operators/extract_preferences.py +117 -0
- contextsynapse/ingestion/universal/operators/filter_content.py +69 -0
- contextsynapse/ingestion/universal/operators/index_bm25.py +52 -0
- contextsynapse/ingestion/universal/operators/infer_domains.py +69 -0
- contextsynapse/ingestion/universal/operators/link_cross_reference.py +210 -0
- contextsynapse/ingestion/universal/operators/parse_records.py +66 -0
- contextsynapse/ingestion/universal/operators/resolve_entities.py +261 -0
- contextsynapse/ingestion/universal/operators/scanners/__init__.py +16 -0
- contextsynapse/ingestion/universal/operators/scanners/edges.py +173 -0
- contextsynapse/ingestion/universal/operators/scanners/git.py +187 -0
- contextsynapse/ingestion/universal/operators/scanners/github.py +135 -0
- contextsynapse/ingestion/universal/operators/scanners/github_api.py +509 -0
- contextsynapse/ingestion/universal/operators/scanners/helpers.py +67 -0
- contextsynapse/ingestion/universal/operators/scanners/jira.py +65 -0
- contextsynapse/ingestion/universal/operators/scanners/llm_enrichment.py +82 -0
- contextsynapse/ingestion/universal/operators/scanners/repo.py +149 -0
- contextsynapse/ingestion/universal/operators/sdlc_scan.py +213 -0
- contextsynapse/ingestion/universal/operators/spec_scanner.py +149 -0
- contextsynapse/ingestion/universal/operators/store_documents.py +72 -0
- contextsynapse/ingestion/universal/operators/synthesize_cu.py +180 -0
- contextsynapse/ingestion/universal/operators/validate_gate.py +65 -0
- contextsynapse/ingestion/universal/parsers/__init__.py +5 -0
- contextsynapse/ingestion/universal/parsers/record_parser.py +203 -0
- contextsynapse/ingestion/universal/parsers/turn_parser.py +329 -0
- contextsynapse/ingestion/universal/shared_signals.py +161 -0
- contextsynapse/ingestion/universal/stage_executor.py +219 -0
- contextsynapse/ingestion/web_crawler.py +265 -0
- contextsynapse/intelligence/__init__.py +53 -0
- contextsynapse/intelligence/alerts.py +198 -0
- contextsynapse/intelligence/collector.py +655 -0
- contextsynapse/intelligence/comparison.py +198 -0
- contextsynapse/intelligence/config.py +56 -0
- contextsynapse/intelligence/conflict_detector.py +182 -0
- contextsynapse/intelligence/context_gaps.py +192 -0
- contextsynapse/intelligence/context_radar.py +135 -0
- contextsynapse/intelligence/correlation_engine.py +416 -0
- contextsynapse/intelligence/correlation_template.py +120 -0
- contextsynapse/intelligence/dedup.py +296 -0
- contextsynapse/intelligence/event_bus.py +91 -0
- contextsynapse/intelligence/feedback_loop.py +101 -0
- contextsynapse/intelligence/freshness.py +307 -0
- contextsynapse/intelligence/fundamental_feed.py +551 -0
- contextsynapse/intelligence/geo_data.py +170 -0
- contextsynapse/intelligence/geo_reference.py +112 -0
- contextsynapse/intelligence/graph_fusion.py +465 -0
- contextsynapse/intelligence/heuristics.py +176 -0
- contextsynapse/intelligence/impact_tracker.py +336 -0
- contextsynapse/intelligence/macro_indicators.py +319 -0
- contextsynapse/intelligence/ocr.py +104 -0
- contextsynapse/intelligence/persistence.py +267 -0
- contextsynapse/intelligence/pipeline_health.py +130 -0
- contextsynapse/intelligence/price_feed.py +221 -0
- contextsynapse/intelligence/rate_limiter.py +130 -0
- contextsynapse/intelligence/reactive.py +411 -0
- contextsynapse/intelligence/runtime_context.py +390 -0
- contextsynapse/intelligence/sentiment_decay.py +426 -0
- contextsynapse/intelligence/session.py +277 -0
- contextsynapse/intelligence/signal_hierarchy.py +438 -0
- contextsynapse/intelligence/signals.py +185 -0
- contextsynapse/intelligence/source_watcher.py +132 -0
- contextsynapse/intelligence/table_enricher.py +256 -0
- contextsynapse/intelligence/tagger.py +319 -0
- contextsynapse/intelligence/timeseries.py +209 -0
- contextsynapse/intelligence/tracker.py +318 -0
- contextsynapse/intelligence/watchdog.py +392 -0
- contextsynapse/intelligence/watchdog_manager.py +263 -0
- contextsynapse/intelligence/webhook_receiver.py +165 -0
- contextsynapse/intelligence/weight_learner.py +234 -0
- contextsynapse/llm/__init__.py +8 -0
- contextsynapse/llm/client.py +348 -0
- contextsynapse/llm/rate_limiter.py +166 -0
- contextsynapse/logging_config.py +2 -0
- contextsynapse/marketplace/__init__.py +7 -0
- contextsynapse/marketplace/registry.py +173 -0
- contextsynapse/mcp/__init__.py +11 -0
- contextsynapse/mcp/__main__.py +4 -0
- contextsynapse/mcp/auth_middleware.py +212 -0
- contextsynapse/mcp/connection_pool.py +128 -0
- contextsynapse/mcp/server.py +986 -0
- contextsynapse/mcp/session_context.py +67 -0
- contextsynapse/memory/__init__.py +34 -0
- contextsynapse/memory/engine.py +475 -0
- contextsynapse/memory/temporal.py +327 -0
- contextsynapse/metadata/__init__.py +12 -0
- contextsynapse/metadata/metadata_db.py +272 -0
- contextsynapse/metadata/metadata_query_tool.py +300 -0
- contextsynapse/metadata/metadata_tracker.py +425 -0
- contextsynapse/models/__init__.py +27 -0
- contextsynapse/models/embedding_service.py +360 -0
- contextsynapse/models/model_registry.py +228 -0
- contextsynapse/multiworker.py +2 -0
- contextsynapse/pipelines/__init__.py +1 -0
- contextsynapse/pipelines/executor.py +851 -0
- contextsynapse/pipelines/filters.py +189 -0
- contextsynapse/pipelines/models.py +176 -0
- contextsynapse/pipelines/scheduler.py +268 -0
- contextsynapse/playground/__init__.py +1 -0
- contextsynapse/playground/templates.py +85 -0
- contextsynapse/plugins/__init__.py +43 -0
- contextsynapse/plugins/api.py +66 -0
- contextsynapse/plugins/base.py +350 -0
- contextsynapse/plugins/domain_interface.py +508 -0
- contextsynapse/plugins/domain_loader.py +241 -0
- contextsynapse/plugins/loader.py +148 -0
- contextsynapse/plugins/registry.py +265 -0
- contextsynapse/project/__init__.py +11 -0
- contextsynapse/project/code_context.py +807 -0
- contextsynapse/project/connectors/__init__.py +4 -0
- contextsynapse/project/connectors/github_connector.py +113 -0
- contextsynapse/project/connectors/jira_connector.py +160 -0
- contextsynapse/project/graph.py +1023 -0
- contextsynapse/project/project_context.py +545 -0
- contextsynapse/project/scanner_registry.py +61 -0
- contextsynapse/project/schema_manager.py +322 -0
- contextsynapse/project/schemas/knowledge.yaml +59 -0
- contextsynapse/project/schemas/sdlc.yaml +369 -0
- contextsynapse/project/sdlc_schema.py +172 -0
- contextsynapse/project/spec_parser.py +85 -0
- contextsynapse/project/task_lock.py +181 -0
- contextsynapse/project/task_stream.py +203 -0
- contextsynapse/project/team_tools.py +537 -0
- contextsynapse/py.typed +0 -0
- contextsynapse/realtime/__init__.py +6 -0
- contextsynapse/realtime/collaboration.py +147 -0
- contextsynapse/rules/__init__.py +10 -0
- contextsynapse/rules/engine.py +116 -0
- contextsynapse/rules/evaluators/__init__.py +6 -0
- contextsynapse/rules/evaluators/aggregate.py +205 -0
- contextsynapse/rules/evaluators/custom.py +170 -0
- contextsynapse/rules/evaluators/pre_action.py +125 -0
- contextsynapse/rules/evaluators/temporal.py +129 -0
- contextsynapse/rules/loader.py +52 -0
- contextsynapse/rules/models.py +124 -0
- contextsynapse/rules/universal.py +393 -0
- contextsynapse/scheduler.py +229 -0
- contextsynapse/schema/__init__.py +42 -0
- contextsynapse/schema/compiler.py +252 -0
- contextsynapse/schema/composer.py +158 -0
- contextsynapse/schema/dedup.py +73 -0
- contextsynapse/schema/derivation.py +172 -0
- contextsynapse/schema/sdl.py +326 -0
- contextsynapse/schema/validation_gate.py +124 -0
- contextsynapse/sdk/__init__.py +63 -0
- contextsynapse/sdk/agent.py +214 -0
- contextsynapse/sdk/client.py +269 -0
- contextsynapse/sdk/exceptions.py +23 -0
- contextsynapse/sdk/models.py +78 -0
- contextsynapse/sdk/session.py +408 -0
- contextsynapse/sdk/tracked.py +218 -0
- contextsynapse/sdk/worker.py +266 -0
- contextsynapse/sdk/workspace_client.py +98 -0
- contextsynapse/sdk/ws.py +41 -0
- contextsynapse/search/__init__.py +20 -0
- contextsynapse/search/embedding_cache.py +140 -0
- contextsynapse/search/enhanced_search.py +152 -0
- contextsynapse/search/fulltext.py +130 -0
- contextsynapse/search/graph_search.py +634 -0
- contextsynapse/search/lmdb_index.py +1218 -0
- contextsynapse/search/nl_to_aiql.py +199 -0
- contextsynapse/search/rag.py +1346 -0
- contextsynapse/search/rag_cache.py +167 -0
- contextsynapse/search/reasoning_chain.py +231 -0
- contextsynapse/search/redis_search.py +207 -0
- contextsynapse/search/retrieval_quality.py +314 -0
- contextsynapse/search/semantic_query.py +455 -0
- contextsynapse/search/text_resolver.py +159 -0
- contextsynapse/search/whoosh_search.py +373 -0
- contextsynapse/security/__init__.py +17 -0
- contextsynapse/security/audit.py +297 -0
- contextsynapse/security/audit_logger.py +97 -0
- contextsynapse/security/audit_trail.py +270 -0
- contextsynapse/security/auth_unified.py +284 -0
- contextsynapse/security/auto_tagger.py +230 -0
- contextsynapse/security/data_security.py +218 -0
- contextsynapse/security/encryption.py +207 -0
- contextsynapse/security/identity.py +447 -0
- contextsynapse/security/jwt_identity.py +59 -0
- contextsynapse/security/middleware.py +336 -0
- contextsynapse/security/pii.py +233 -0
- contextsynapse/security/rbac.py +96 -0
- contextsynapse/security/rls.py +205 -0
- contextsynapse/security/sanitize.py +233 -0
- contextsynapse/security/scoped_encryption.py +131 -0
- contextsynapse/security/tenant.py +401 -0
- contextsynapse/shield/__init__.py +21 -0
- contextsynapse/shield/anomaly.py +56 -0
- contextsynapse/shield/permissions.py +99 -0
- contextsynapse/shield/profile.py +103 -0
- contextsynapse/shield/shield.py +108 -0
- contextsynapse/shield/trust_engine.py +69 -0
- contextsynapse/skills/__init__.py +14 -0
- contextsynapse/skills/engine.py +233 -0
- contextsynapse/skills/loader.py +59 -0
- contextsynapse/skills/models.py +139 -0
- contextsynapse/storage/__init__.py +7 -0
- contextsynapse/storage/cache.py +208 -0
- contextsynapse/storage/columnar_store_v2.py +178 -0
- contextsynapse/storage/csr_graph_storage.py +587 -0
- contextsynapse/storage/csr_store.py +388 -0
- contextsynapse/storage/document_store.py +491 -0
- contextsynapse/storage/hnsw_index.py +294 -0
- contextsynapse/storage/hybrid_store_v2.py +278 -0
- contextsynapse/storage/lmdb_graph_storage.py +366 -0
- contextsynapse/storage/migration.py +279 -0
- contextsynapse/storage/namespace_store.py +346 -0
- contextsynapse/storage/pure_graph_storage.py +185 -0
- contextsynapse/storage/redis_graph_adapter.py +186 -0
- contextsynapse/storage/redis_graph_storage.py +375 -0
- contextsynapse/storage/router/__init__.py +29 -0
- contextsynapse/storage/router/duckdb_store.py +398 -0
- contextsynapse/storage/router/factory.py +83 -0
- contextsynapse/storage/router/interface.py +125 -0
- contextsynapse/storage/router/postgres_store.py +339 -0
- contextsynapse/storage/router/resolver.py +192 -0
- contextsynapse/storage/router/router.py +300 -0
- contextsynapse/storage/single_file_storage.py +391 -0
- contextsynapse/storage/storage_manager.py +214 -0
- contextsynapse/storage/storage_query_optimizer.py +314 -0
- contextsynapse/storage/wal.py +359 -0
- contextsynapse/temporal/__init__.py +0 -0
- contextsynapse/temporal/storage.py +106 -0
- contextsynapse/tests/__init__.py +11 -0
- contextsynapse/tests/regression/__init__.py +1 -0
- contextsynapse/tests/regression/add_metadata.py +395 -0
- contextsynapse/tests/regression/add_skip_flags.py +85 -0
- contextsynapse/tests/regression/cases/test_basic_create_node.yaml +20 -0
- contextsynapse/tests/regression/cases/test_basic_select.yaml +21 -0
- contextsynapse/tests/regression/cases/test_blockchain_audit_trail.yaml +37 -0
- contextsynapse/tests/regression/cases/test_blockchain_audit_trail_filtered.yaml +39 -0
- contextsynapse/tests/regression/cases/test_blockchain_blocks_range.yaml +39 -0
- contextsynapse/tests/regression/cases/test_blockchain_get_block.yaml +35 -0
- contextsynapse/tests/regression/cases/test_blockchain_latest_block.yaml +35 -0
- contextsynapse/tests/regression/cases/test_blockchain_length.yaml +37 -0
- contextsynapse/tests/regression/cases/test_blockchain_merkle_root.yaml +37 -0
- contextsynapse/tests/regression/cases/test_blockchain_verify.yaml +35 -0
- contextsynapse/tests/regression/cases/test_blockchain_verify_block.yaml +35 -0
- contextsynapse/tests/regression/cases/test_create_edge.yaml +28 -0
- contextsynapse/tests/regression/cases/test_delete_edge_basic.yaml +29 -0
- contextsynapse/tests/regression/cases/test_delete_node_basic.yaml +29 -0
- contextsynapse/tests/regression/cases/test_evaluation_mrr.yaml +40 -0
- contextsynapse/tests/regression/cases/test_evaluation_ndcg.yaml +40 -0
- contextsynapse/tests/regression/cases/test_evaluation_precision.yaml +40 -0
- contextsynapse/tests/regression/cases/test_evaluation_recall.yaml +40 -0
- contextsynapse/tests/regression/cases/test_group_by_basic.yaml +56 -0
- contextsynapse/tests/regression/cases/test_match_node_basic.yaml +29 -0
- contextsynapse/tests/regression/cases/test_match_node_with_order_limit.yaml +31 -0
- contextsynapse/tests/regression/cases/test_namespace_switch.yaml +18 -0
- contextsynapse/tests/regression/cases/test_pipeline_cascaded.yaml +61 -0
- contextsynapse/tests/regression/cases/test_pipeline_fixed_size_chunking.yaml +48 -0
- contextsynapse/tests/regression/cases/test_pipeline_gpt4_chunking.yaml +54 -0
- contextsynapse/tests/regression/cases/test_pipeline_hybrid_indexing.yaml +57 -0
- contextsynapse/tests/regression/cases/test_pipeline_large_embedding.yaml +52 -0
- contextsynapse/tests/regression/cases/test_pipeline_paragraph_chunking.yaml +49 -0
- contextsynapse/tests/regression/cases/test_pipeline_recursive_chunking.yaml +47 -0
- contextsynapse/tests/regression/cases/test_pipeline_run_pipeline.yaml +56 -0
- contextsynapse/tests/regression/cases/test_pipeline_section_based_chunking.yaml +53 -0
- contextsynapse/tests/regression/cases/test_pipeline_semantic_chunking.yaml +51 -0
- contextsynapse/tests/regression/cases/test_pipeline_with_entity_extraction.yaml +57 -0
- contextsynapse/tests/regression/cases/test_pipeline_with_relationships.yaml +62 -0
- contextsynapse/tests/regression/cases/test_rag_evaluation.yaml +38 -0
- contextsynapse/tests/regression/cases/test_rag_graph_bfs_with_eval.yaml +48 -0
- contextsynapse/tests/regression/cases/test_rag_graph_centrality.yaml +40 -0
- contextsynapse/tests/regression/cases/test_rag_graph_centrality_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_community.yaml +40 -0
- contextsynapse/tests/regression/cases/test_rag_graph_community_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_dfs_with_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_multi_hop_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_path.yaml +40 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_basic.yaml +38 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_bfs.yaml +52 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_dfs.yaml +41 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_multi_hop.yaml +43 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_shortest_path.yaml +41 -0
- contextsynapse/tests/regression/cases/test_rag_graph_search_weighted.yaml +43 -0
- contextsynapse/tests/regression/cases/test_rag_graph_shortest_path_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_graph_traversal.yaml +30 -0
- contextsynapse/tests/regression/cases/test_rag_graph_weighted_eval.yaml +35 -0
- contextsynapse/tests/regression/cases/test_rag_hybrid_search.yaml +33 -0
- contextsynapse/tests/regression/cases/test_rag_hybrid_with_prompt_embedding.yaml +44 -0
- contextsynapse/tests/regression/cases/test_rag_keyword_search.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_multi_hop_graph.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_path_based.yaml +34 -0
- contextsynapse/tests/regression/cases/test_rag_profile_based.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_vector_search.yaml +32 -0
- contextsynapse/tests/regression/cases/test_rag_weighted_graph.yaml +34 -0
- contextsynapse/tests/regression/cases/test_rag_weighted_hybrid.yaml +31 -0
- contextsynapse/tests/regression/cases/test_semantic_boundary_chunking.yaml +57 -0
- contextsynapse/tests/regression/cases/test_semantic_boundary_detection.yaml +33 -0
- contextsynapse/tests/regression/cases/test_semantic_hash_function.yaml +30 -0
- contextsynapse/tests/regression/cases/test_semantic_hash_with_model.yaml +31 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_fixed_size.yaml +44 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_recursive.yaml +46 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_section.yaml +46 -0
- contextsynapse/tests/regression/cases/test_tcs_ingestion_semantic.yaml +44 -0
- contextsynapse/tests/regression/cases/test_time_travel_as_of.yaml +35 -0
- contextsynapse/tests/regression/cases/test_time_travel_at_commit.yaml +33 -0
- contextsynapse/tests/regression/cases/test_time_travel_at_timestamp.yaml +33 -0
- contextsynapse/tests/regression/cases/test_time_travel_for_system_time_all.yaml +37 -0
- contextsynapse/tests/regression/cases/test_time_travel_versions_between.yaml +39 -0
- contextsynapse/tests/regression/cases/test_traverse_basic.yaml +31 -0
- contextsynapse/tests/regression/cases/test_traverse_max_depth.yaml +34 -0
- contextsynapse/tests/regression/cases/test_update_edge_basic.yaml +29 -0
- contextsynapse/tests/regression/evaluation_metrics.py +227 -0
- contextsynapse/tests/regression/regression_runner.py +1081 -0
- contextsynapse/tests/regression/setup_regression_data.py +212 -0
- contextsynapse/tests/regression/test_basic_only.py +84 -0
- contextsynapse/tests/regression/test_namespace_creation.py +29 -0
- contextsynapse/tests/regression/test_single_fix.py +61 -0
- contextsynapse/tests/regression/test_single_query.py +20 -0
- contextsynapse/tests/regression/validate_test_syntax.py +179 -0
- contextsynapse/tests/test_advanced_features.py +347 -0
- contextsynapse/tests/test_aiql_summary.py +283 -0
- contextsynapse/tests/test_all_aiql_queries.py +948 -0
- contextsynapse/tests/test_all_create_queries.py +213 -0
- contextsynapse/tests/test_api_vs_native.py +280 -0
- contextsynapse/tests/test_chunking_dedup.py +199 -0
- contextsynapse/tests/test_connectors.py +171 -0
- contextsynapse/tests/test_edges_and_match.py +202 -0
- contextsynapse/tests/test_extraction_pipeline_integration.py +581 -0
- contextsynapse/tests/test_name_property.py +224 -0
- contextsynapse/tests/test_node_edge_metadata.py +250 -0
- contextsynapse/tests/test_playground_conn.py +114 -0
- contextsynapse/tests/test_query_cache.py +213 -0
- contextsynapse/tests/test_source_policy_auth.py +348 -0
- contextsynapse/tests/test_temporal_and_domain.py +224 -0
- contextsynapse/tests/test_time_travel_e2e.py +571 -0
- contextsynapse/tests/test_traversal_and_aggregation.py +275 -0
- contextsynapse/tools/__init__.py +36 -0
- contextsynapse/tools/a2a_tools.py +106 -0
- contextsynapse/tools/cognition_tools.py +118 -0
- contextsynapse/tools/context.py +905 -0
- contextsynapse/tools/formatting.py +92 -0
- contextsynapse/tools/gateway_tools.py +186 -0
- contextsynapse/tools/graph.py +1700 -0
- contextsynapse/tools/intelligence.py +235 -0
- contextsynapse/tools/memory.py +118 -0
- contextsynapse/tools/registry.py +373 -0
- contextsynapse/tools/shield_tools.py +159 -0
- contextsynapse/tools/tasks.py +341 -0
- contextsynapse/tools/workspace_tools.py +124 -0
- contextsynapse/utils/__init__.py +15 -0
- contextsynapse/utils/config_loader.py +198 -0
- contextsynapse/utils/env_loader.py +156 -0
- contextsynapse/utils/file_utils.py +288 -0
- contextsynapse/utils/logging_optimizer.py +119 -0
- contextsynapse/vector/__init__.py +30 -0
- contextsynapse/vector/chroma_vector_store.py +152 -0
- contextsynapse/vector/custom_vector_store.py +537 -0
- contextsynapse/vector/faiss_vector_store.py +296 -0
- contextsynapse/vector/qdrant_vector_store.py +163 -0
- contextsynapse/vector/vector_db.py +953 -0
- contextsynapse/vector/vector_db_manager.py +197 -0
- contextsynapse/workspace/__init__.py +27 -0
- contextsynapse/workspace/base.py +94 -0
- contextsynapse/workspace/git.py +228 -0
- contextsynapse/workspace/github.py +78 -0
- contextsynapse/workspace/local.py +73 -0
- contextsynapse/workspace/tools.py +222 -0
- contextsynapse-1.0.0.dist-info/METADATA +847 -0
- contextsynapse-1.0.0.dist-info/RECORD +776 -0
- contextsynapse-1.0.0.dist-info/WHEEL +5 -0
- contextsynapse-1.0.0.dist-info/entry_points.txt +4 -0
- contextsynapse-1.0.0.dist-info/licenses/LICENSE +200 -0
- contextsynapse-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2584 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chunk Storage with Pointers to Normalized JSON
|
|
3
|
+
|
|
4
|
+
Smart chunk storage that references normalized JSON instead of duplicating data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, List, Optional, Any
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _log_chunking_operation(level: str, message: str, context: Optional[Dict[str, Any]] = None, exc_info: bool = False):
|
|
18
|
+
"""
|
|
19
|
+
Structured logging helper for chunking operations.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
level: Log level ('debug', 'info', 'warning', 'error')
|
|
23
|
+
message: Log message
|
|
24
|
+
context: Additional context dictionary (document_id, page_no, strategy, etc.)
|
|
25
|
+
exc_info: Whether to include exception info
|
|
26
|
+
"""
|
|
27
|
+
context_str = ""
|
|
28
|
+
if context:
|
|
29
|
+
context_parts = [f"{k}={v}" for k, v in context.items() if v is not None]
|
|
30
|
+
if context_parts:
|
|
31
|
+
context_str = f" | {' | '.join(context_parts)}"
|
|
32
|
+
|
|
33
|
+
log_message = f"[CHUNKING]{context_str} | {message}"
|
|
34
|
+
|
|
35
|
+
if level == 'debug':
|
|
36
|
+
logger.debug(log_message, exc_info=exc_info)
|
|
37
|
+
elif level == 'info':
|
|
38
|
+
logger.info(log_message, exc_info=exc_info)
|
|
39
|
+
elif level == 'warning':
|
|
40
|
+
logger.warning(log_message, exc_info=exc_info)
|
|
41
|
+
elif level == 'error':
|
|
42
|
+
logger.error(log_message, exc_info=exc_info)
|
|
43
|
+
else:
|
|
44
|
+
logger.info(log_message, exc_info=exc_info)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChunkStorage:
|
|
48
|
+
"""
|
|
49
|
+
Manages chunk storage with pointers to normalized JSON.
|
|
50
|
+
|
|
51
|
+
Strategy:
|
|
52
|
+
- Chunk content stored in graph nodes (for fast retrieval)
|
|
53
|
+
- Chunk metadata includes pointer to normalized JSON
|
|
54
|
+
- Large structures (tables, images) referenced, not duplicated
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
# Class-level cache for chunking config (avoids re-reading YAML on every call)
|
|
58
|
+
_chunking_config_cache: Optional[Dict[str, Any]] = None
|
|
59
|
+
_chunking_config_mtime: float = 0.0
|
|
60
|
+
|
|
61
|
+
def __init__(self, namespace: str, base_dir: str = "contextcore_data"):
|
|
62
|
+
"""
|
|
63
|
+
Initialize chunk storage.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
namespace: Namespace name
|
|
67
|
+
base_dir: Base directory for storage
|
|
68
|
+
"""
|
|
69
|
+
self.namespace = namespace
|
|
70
|
+
self.base_dir = Path(base_dir)
|
|
71
|
+
self.documents_dir = self.base_dir / "namespaces" / namespace / "documents"
|
|
72
|
+
# Cache for normalized documents to avoid repeated loads
|
|
73
|
+
self._normalized_doc_cache = {}
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def load_chunking_config(cls) -> Dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Load chunking strategies config from YAML.
|
|
79
|
+
Returns dict of strategy_name -> {parameters: {...}, ...}.
|
|
80
|
+
Caches the result and re-reads only if the file changed.
|
|
81
|
+
"""
|
|
82
|
+
config_path = Path(__file__).resolve().parent.parent.parent.parent / "config" / "strategies" / "chunking.yaml"
|
|
83
|
+
try:
|
|
84
|
+
if not config_path.exists():
|
|
85
|
+
return {}
|
|
86
|
+
mtime = config_path.stat().st_mtime
|
|
87
|
+
if cls._chunking_config_cache is not None and mtime == cls._chunking_config_mtime:
|
|
88
|
+
return cls._chunking_config_cache
|
|
89
|
+
import yaml
|
|
90
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
91
|
+
data = yaml.safe_load(f) or {}
|
|
92
|
+
cls._chunking_config_cache = data.get("strategies", {})
|
|
93
|
+
cls._chunking_config_mtime = mtime
|
|
94
|
+
return cls._chunking_config_cache
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"Failed to load chunking config: {e}")
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def get_strategy_params(cls, strategy_name: str) -> Dict[str, Any]:
|
|
101
|
+
"""
|
|
102
|
+
Get configured parameters for a chunking strategy.
|
|
103
|
+
Returns the parameters dict from the YAML config, or empty dict if not found.
|
|
104
|
+
"""
|
|
105
|
+
config = cls.load_chunking_config()
|
|
106
|
+
strategy = config.get(strategy_name, {})
|
|
107
|
+
return dict(strategy.get("parameters", {}))
|
|
108
|
+
|
|
109
|
+
def create_chunk_with_pointer(
|
|
110
|
+
self,
|
|
111
|
+
chunk_id: str,
|
|
112
|
+
content: str,
|
|
113
|
+
document_id: str,
|
|
114
|
+
normalized_version: str,
|
|
115
|
+
page_no: Optional[int] = None,
|
|
116
|
+
start_char: Optional[int] = None,
|
|
117
|
+
end_char: Optional[int] = None,
|
|
118
|
+
chunk_index: int = 0,
|
|
119
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
120
|
+
chunking_strategy: Optional[str] = None,
|
|
121
|
+
strategy_version: Optional[str] = None,
|
|
122
|
+
chunking_params: Optional[Dict[str, Any]] = None,
|
|
123
|
+
document_node_id: Optional[str] = None
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""
|
|
126
|
+
Create chunk node with pointer to normalized JSON.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
chunk_id: Chunk identifier
|
|
130
|
+
content: Chunk text content (for fast retrieval)
|
|
131
|
+
document_id: Source document ID
|
|
132
|
+
normalized_version: Version of normalized JSON
|
|
133
|
+
page_no: Page number in normalized JSON
|
|
134
|
+
start_char: Start character position in page
|
|
135
|
+
end_char: End character position in page
|
|
136
|
+
chunk_index: Chunk index within document
|
|
137
|
+
metadata: Additional chunk metadata
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Chunk node properties dictionary
|
|
141
|
+
"""
|
|
142
|
+
# Build source pointer
|
|
143
|
+
source_pointer = {
|
|
144
|
+
"document_id": document_id,
|
|
145
|
+
"normalized_version": normalized_version,
|
|
146
|
+
"normalized_json_path": f"documents/{document_id}/normalized/normalized_{normalized_version}.json",
|
|
147
|
+
"hdf5_path": f"{document_id}/{normalized_version}", # For hybrid storage
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Add chunking strategy metadata (for multiple strategies support)
|
|
151
|
+
if chunking_strategy:
|
|
152
|
+
source_pointer["chunking_strategy"] = chunking_strategy
|
|
153
|
+
if strategy_version:
|
|
154
|
+
source_pointer["strategy_version"] = strategy_version
|
|
155
|
+
if chunking_params:
|
|
156
|
+
source_pointer["chunking_params"] = chunking_params
|
|
157
|
+
|
|
158
|
+
# Link to Document node (if created in CONNECT stage)
|
|
159
|
+
if document_node_id:
|
|
160
|
+
source_pointer["document_node_id"] = document_node_id
|
|
161
|
+
|
|
162
|
+
if page_no is not None:
|
|
163
|
+
source_pointer["page_no"] = page_no
|
|
164
|
+
|
|
165
|
+
if start_char is not None:
|
|
166
|
+
source_pointer["start_char"] = start_char
|
|
167
|
+
|
|
168
|
+
if end_char is not None:
|
|
169
|
+
source_pointer["end_char"] = end_char
|
|
170
|
+
|
|
171
|
+
# CRITICAL: Ensure content doesn't contain image bytes
|
|
172
|
+
# Images are reference-only and should never be loaded into chunk content
|
|
173
|
+
if content:
|
|
174
|
+
# Check for base64 image data patterns (common indicators of image bytes in text)
|
|
175
|
+
if "data:image" in content and "base64," in content:
|
|
176
|
+
logger.warning(f"[CHUNK] Chunk {chunk_id[:50]}... contains base64 image data - removing image data (images are reference-only)")
|
|
177
|
+
# Remove base64 image data from content
|
|
178
|
+
import re
|
|
179
|
+
content = re.sub(r'data:image/[^;]+;base64,[A-Za-z0-9+/=]+', '[IMAGE_DATA_REMOVED]', content)
|
|
180
|
+
|
|
181
|
+
# Check for suspiciously large content that might contain binary data
|
|
182
|
+
# Normal text chunks should be reasonable size (e.g., < 1MB)
|
|
183
|
+
if len(content) > 1024 * 1024: # 1MB threshold
|
|
184
|
+
logger.warning(f"[CHUNK] Chunk {chunk_id[:50]}... has unusually large content ({len(content):,} bytes) - may contain binary data")
|
|
185
|
+
# Log first 200 chars for inspection
|
|
186
|
+
preview = content[:200].replace('\n', ' ').replace('\r', ' ')
|
|
187
|
+
logger.warning(f"[CHUNK] Content preview: {preview}...")
|
|
188
|
+
|
|
189
|
+
# Build chunk properties
|
|
190
|
+
chunk_properties = {
|
|
191
|
+
"chunk_id": chunk_id,
|
|
192
|
+
"content": content, # Store content for fast retrieval (images are reference-only)
|
|
193
|
+
"modality": "text",
|
|
194
|
+
"order": chunk_index,
|
|
195
|
+
"metadata": metadata or {},
|
|
196
|
+
"source": source_pointer, # Pointer to normalized JSON
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return chunk_properties
|
|
200
|
+
|
|
201
|
+
def get_source_from_normalized_json(
|
|
202
|
+
self,
|
|
203
|
+
document_id: str,
|
|
204
|
+
normalized_version: str,
|
|
205
|
+
page_no: Optional[int] = None,
|
|
206
|
+
start_char: Optional[int] = None,
|
|
207
|
+
end_char: Optional[int] = None
|
|
208
|
+
) -> Optional[Dict[str, Any]]:
|
|
209
|
+
"""
|
|
210
|
+
Get source content from normalized JSON using pointer.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
document_id: Document identifier
|
|
214
|
+
normalized_version: Version of normalized JSON
|
|
215
|
+
page_no: Page number (optional)
|
|
216
|
+
start_char: Start character position (optional)
|
|
217
|
+
end_char: End character position (optional)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Source content dictionary or None
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
from contextsynapse.extraction.normalized_store import NormalizedDocumentStore
|
|
224
|
+
from pathlib import Path
|
|
225
|
+
import json
|
|
226
|
+
|
|
227
|
+
# First try NormalizedDocumentStore (namespace-aware paths)
|
|
228
|
+
store = NormalizedDocumentStore(namespace=self.namespace, base_dir=str(self.base_dir))
|
|
229
|
+
normalized_doc = store.get(document_id, normalized_version, load_full=True)
|
|
230
|
+
|
|
231
|
+
# If not found, try legacy path format (where extract actually stores files)
|
|
232
|
+
if not normalized_doc:
|
|
233
|
+
legacy_path = Path(self.base_dir) / document_id / "normalized" / f"normalized_{normalized_version}.json"
|
|
234
|
+
if legacy_path.exists():
|
|
235
|
+
try:
|
|
236
|
+
with open(legacy_path, 'r', encoding='utf-8') as f:
|
|
237
|
+
normalized_doc = json.load(f)
|
|
238
|
+
logger.debug(f"Loaded normalized document from legacy path: {legacy_path}")
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.warning(f"Failed to load from legacy path {legacy_path}: {e}")
|
|
241
|
+
else:
|
|
242
|
+
# Try to find any normalized file in legacy path
|
|
243
|
+
legacy_dir = Path(self.base_dir) / document_id / "normalized"
|
|
244
|
+
if legacy_dir.exists():
|
|
245
|
+
normalized_files = list(legacy_dir.glob("normalized_*.json"))
|
|
246
|
+
if normalized_files:
|
|
247
|
+
normalized_files.sort(reverse=True)
|
|
248
|
+
latest_file = normalized_files[0]
|
|
249
|
+
try:
|
|
250
|
+
with open(latest_file, 'r', encoding='utf-8') as f:
|
|
251
|
+
normalized_doc = json.load(f)
|
|
252
|
+
logger.debug(f"Loaded normalized document from legacy path (latest): {latest_file}")
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.warning(f"Failed to load from legacy path {latest_file}: {e}")
|
|
255
|
+
|
|
256
|
+
if not normalized_doc:
|
|
257
|
+
logger.warning(f"Normalized document not found: {document_id}/{normalized_version}")
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
# If page_no specified, get specific page
|
|
261
|
+
if page_no is not None:
|
|
262
|
+
pages = normalized_doc.get('content', {}).get('pages', [])
|
|
263
|
+
for page in pages:
|
|
264
|
+
if page.get('page_no') == page_no:
|
|
265
|
+
page_content = page.get('flat_text', '')
|
|
266
|
+
|
|
267
|
+
# If character range specified, extract substring
|
|
268
|
+
if start_char is not None and end_char is not None:
|
|
269
|
+
page_content = page_content[start_char:end_char]
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"page_no": page_no,
|
|
273
|
+
"content": page_content,
|
|
274
|
+
"normalized_blocks": page.get('normalized_blocks', []),
|
|
275
|
+
"structure": page.get('hierarchy', {})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Return full document if no page specified
|
|
279
|
+
return {
|
|
280
|
+
"document_id": document_id,
|
|
281
|
+
"version": normalized_version,
|
|
282
|
+
"content": normalized_doc.get('content', {}),
|
|
283
|
+
"metadata": normalized_doc.get('metadata', {})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.error(f"Failed to load source from normalized JSON: {e}")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def create_chunks_from_normalized_json(
|
|
291
|
+
self,
|
|
292
|
+
document_id: str,
|
|
293
|
+
normalized_version: str,
|
|
294
|
+
chunking_strategy: str = "semantic",
|
|
295
|
+
strategy_version: str = "v1",
|
|
296
|
+
chunk_size: int = 1000,
|
|
297
|
+
overlap: int = 200,
|
|
298
|
+
document_node_id: Optional[str] = None,
|
|
299
|
+
normalized_doc: Optional[Dict[str, Any]] = None,
|
|
300
|
+
chunk_callback: Optional[callable] = None,
|
|
301
|
+
**kwargs
|
|
302
|
+
) -> List[Dict[str, Any]]:
|
|
303
|
+
"""
|
|
304
|
+
Create chunks from normalized JSON.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
document_id: Document identifier
|
|
308
|
+
normalized_version: Version of normalized JSON
|
|
309
|
+
chunking_strategy: Chunking strategy ("semantic", "fixed", "paragraph", "hierarchical", "recursive", "parent_child")
|
|
310
|
+
chunk_size: Chunk size in tokens/characters
|
|
311
|
+
overlap: Overlap between chunks
|
|
312
|
+
**kwargs: Additional chunking parameters
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
List of chunk properties dictionaries
|
|
316
|
+
"""
|
|
317
|
+
# Merge YAML config defaults (YAML < caller kwargs < explicit params)
|
|
318
|
+
yaml_params = self.get_strategy_params(chunking_strategy)
|
|
319
|
+
if yaml_params:
|
|
320
|
+
# YAML defaults for chunk_size/overlap only apply if caller used the function defaults
|
|
321
|
+
if 'chunk_size' in yaml_params and chunk_size == 1000:
|
|
322
|
+
chunk_size = yaml_params.pop('chunk_size')
|
|
323
|
+
else:
|
|
324
|
+
yaml_params.pop('chunk_size', None)
|
|
325
|
+
if 'overlap' in yaml_params and overlap == 200:
|
|
326
|
+
overlap = yaml_params.pop('overlap')
|
|
327
|
+
else:
|
|
328
|
+
yaml_params.pop('overlap', None)
|
|
329
|
+
# Remaining YAML params become kwargs defaults (caller kwargs take precedence)
|
|
330
|
+
merged_kwargs = {**yaml_params, **kwargs}
|
|
331
|
+
kwargs = merged_kwargs
|
|
332
|
+
|
|
333
|
+
operation_start = time.time()
|
|
334
|
+
context = {
|
|
335
|
+
'document_id': document_id,
|
|
336
|
+
'normalized_version': normalized_version,
|
|
337
|
+
'strategy': chunking_strategy,
|
|
338
|
+
'strategy_version': strategy_version,
|
|
339
|
+
'chunk_size': chunk_size,
|
|
340
|
+
'overlap': overlap,
|
|
341
|
+
'namespace': self.namespace
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_log_chunking_operation('info', f"Starting chunk creation", context)
|
|
345
|
+
_log_chunking_operation('debug', f"Additional kwargs: {list(kwargs.keys())}", context)
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
# If normalized_doc is provided, use it directly (already loaded)
|
|
349
|
+
if normalized_doc is not None:
|
|
350
|
+
_log_chunking_operation('debug', f"Using provided normalized document", context)
|
|
351
|
+
# Cache it for future use
|
|
352
|
+
cache_key = f"{document_id}_{normalized_version}"
|
|
353
|
+
if len(self._normalized_doc_cache) > 10:
|
|
354
|
+
oldest_key = next(iter(self._normalized_doc_cache))
|
|
355
|
+
del self._normalized_doc_cache[oldest_key]
|
|
356
|
+
self._normalized_doc_cache[cache_key] = normalized_doc
|
|
357
|
+
else:
|
|
358
|
+
# Check cache first
|
|
359
|
+
cache_key = f"{document_id}_{normalized_version}"
|
|
360
|
+
load_start = time.time()
|
|
361
|
+
if cache_key in self._normalized_doc_cache:
|
|
362
|
+
normalized_doc = self._normalized_doc_cache[cache_key]
|
|
363
|
+
_log_chunking_operation('debug', f"Using cached normalized document", context)
|
|
364
|
+
else:
|
|
365
|
+
_log_chunking_operation('debug', f"Loading normalized document from store", context)
|
|
366
|
+
from contextsynapse.extraction.normalized_store import NormalizedDocumentStore
|
|
367
|
+
|
|
368
|
+
store = NormalizedDocumentStore(namespace=self.namespace, base_dir=str(self.base_dir))
|
|
369
|
+
normalized_doc = store.get(document_id, normalized_version, load_full=True)
|
|
370
|
+
|
|
371
|
+
if normalized_doc:
|
|
372
|
+
# Cache the document (limit cache size to avoid memory issues)
|
|
373
|
+
if len(self._normalized_doc_cache) > 10:
|
|
374
|
+
# Remove oldest entry (simple FIFO)
|
|
375
|
+
oldest_key = next(iter(self._normalized_doc_cache))
|
|
376
|
+
del self._normalized_doc_cache[oldest_key]
|
|
377
|
+
_log_chunking_operation('debug', f"Cache full, removed oldest entry: {oldest_key}", context)
|
|
378
|
+
self._normalized_doc_cache[cache_key] = normalized_doc
|
|
379
|
+
load_time = time.time() - load_start
|
|
380
|
+
_log_chunking_operation('info', f"Loaded and cached normalized document in {load_time:.2f}s", context)
|
|
381
|
+
|
|
382
|
+
if not normalized_doc:
|
|
383
|
+
_log_chunking_operation('error', f"Normalized document not found", context)
|
|
384
|
+
return []
|
|
385
|
+
|
|
386
|
+
# Log document structure info with detailed breakdown
|
|
387
|
+
pages = normalized_doc.get('content', {}).get('pages', [])
|
|
388
|
+
total_pages = len(pages)
|
|
389
|
+
pages_with_text = sum(1 for p in pages if p.get('flat_text', '').strip())
|
|
390
|
+
total_text_length = sum(len(p.get('flat_text', '')) for p in pages)
|
|
391
|
+
total_text_mb = total_text_length / (1024 * 1024)
|
|
392
|
+
|
|
393
|
+
# Log first few pages details
|
|
394
|
+
print(f"[CHUNK_STORAGE] Document structure analysis:", flush=True)
|
|
395
|
+
print(f"[CHUNK_STORAGE] Total pages: {total_pages}", flush=True)
|
|
396
|
+
print(f"[CHUNK_STORAGE] Pages with text: {pages_with_text}", flush=True)
|
|
397
|
+
print(f"[CHUNK_STORAGE] Total text: {total_text_length:,} chars ({total_text_mb:.2f} MB)", flush=True)
|
|
398
|
+
|
|
399
|
+
# Log first 3 pages details
|
|
400
|
+
for i, page in enumerate(pages[:3]):
|
|
401
|
+
page_no = page.get('page_no', i + 1)
|
|
402
|
+
page_text = page.get('flat_text', '')
|
|
403
|
+
page_text_len = len(page_text) if isinstance(page_text, str) else 0
|
|
404
|
+
print(f"[CHUNK_STORAGE] Page {page_no}: {page_text_len:,} chars", flush=True)
|
|
405
|
+
if page_text_len > 0:
|
|
406
|
+
preview = page_text[:150].replace('\n', ' ').strip()
|
|
407
|
+
print(f"[CHUNK_STORAGE] Preview: {preview}...", flush=True)
|
|
408
|
+
|
|
409
|
+
_log_chunking_operation('info',
|
|
410
|
+
f"Document structure: {total_pages} pages, {pages_with_text} with text, {total_text_length:,} total chars ({total_text_mb:.2f} MB)",
|
|
411
|
+
context)
|
|
412
|
+
|
|
413
|
+
# Handle strategies that work on full document structure
|
|
414
|
+
if chunking_strategy == "hierarchical":
|
|
415
|
+
_log_chunking_operation('info', f"Using hierarchical chunking strategy", context)
|
|
416
|
+
hierarchical_start = time.time()
|
|
417
|
+
hierarchical_chunks = self._chunk_hierarchical(normalized_doc, chunk_size, overlap)
|
|
418
|
+
hierarchical_time = time.time() - hierarchical_start
|
|
419
|
+
_log_chunking_operation('info', f"Hierarchical chunking completed: {len(hierarchical_chunks)} chunks in {hierarchical_time:.2f}s", context)
|
|
420
|
+
# Convert to chunk properties format
|
|
421
|
+
chunks = []
|
|
422
|
+
for chunk_data in hierarchical_chunks:
|
|
423
|
+
from contextsynapse.extraction.id_generator import IDGenerator
|
|
424
|
+
chunk_id = IDGenerator.generate_chunk_id(
|
|
425
|
+
document_id=document_id,
|
|
426
|
+
page_no=chunk_data["page_no"],
|
|
427
|
+
chunk_index=chunk_data["chunk_index"],
|
|
428
|
+
chunking_strategy=chunking_strategy,
|
|
429
|
+
strategy_version=strategy_version,
|
|
430
|
+
namespace=None
|
|
431
|
+
)
|
|
432
|
+
chunk_props = self.create_chunk_with_pointer(
|
|
433
|
+
chunk_id=chunk_id,
|
|
434
|
+
content=chunk_data["content"],
|
|
435
|
+
document_id=document_id,
|
|
436
|
+
normalized_version=normalized_version,
|
|
437
|
+
page_no=chunk_data["page_no"],
|
|
438
|
+
start_char=chunk_data.get("start_char"),
|
|
439
|
+
end_char=chunk_data.get("end_char"),
|
|
440
|
+
chunk_index=chunk_data["chunk_index"],
|
|
441
|
+
chunking_strategy=chunking_strategy,
|
|
442
|
+
strategy_version=strategy_version,
|
|
443
|
+
chunking_params={"chunk_size": chunk_size, "overlap": overlap},
|
|
444
|
+
document_node_id=document_node_id,
|
|
445
|
+
metadata={
|
|
446
|
+
"chunk_size": len(chunk_data["content"]),
|
|
447
|
+
"level": chunk_data.get("level", "chunk"),
|
|
448
|
+
"parent": chunk_data.get("parent"),
|
|
449
|
+
"section": chunk_data.get("section"),
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
# Write immediately if callback provided, otherwise accumulate
|
|
453
|
+
if chunk_callback:
|
|
454
|
+
chunk_callback(chunk_props)
|
|
455
|
+
else:
|
|
456
|
+
chunks.append(chunk_props)
|
|
457
|
+
return chunks if not chunk_callback else []
|
|
458
|
+
|
|
459
|
+
if chunking_strategy == "parent_child":
|
|
460
|
+
_log_chunking_operation('info', f"Using parent_child chunking strategy", context)
|
|
461
|
+
parent_child_start = time.time()
|
|
462
|
+
parent_child_chunks = self._chunk_parent_child(normalized_doc, chunk_size, overlap)
|
|
463
|
+
parent_child_time = time.time() - parent_child_start
|
|
464
|
+
_log_chunking_operation('info', f"Parent-child chunking completed: {len(parent_child_chunks)} chunks in {parent_child_time:.2f}s", context)
|
|
465
|
+
# Convert to chunk properties format
|
|
466
|
+
chunks = []
|
|
467
|
+
for chunk_data in parent_child_chunks:
|
|
468
|
+
from contextsynapse.extraction.id_generator import IDGenerator
|
|
469
|
+
chunk_id = IDGenerator.generate_chunk_id(
|
|
470
|
+
document_id=document_id,
|
|
471
|
+
page_no=chunk_data["page_no"],
|
|
472
|
+
chunk_index=chunk_data["chunk_index"],
|
|
473
|
+
chunking_strategy=chunking_strategy,
|
|
474
|
+
strategy_version=strategy_version,
|
|
475
|
+
namespace=None
|
|
476
|
+
)
|
|
477
|
+
chunk_props = self.create_chunk_with_pointer(
|
|
478
|
+
chunk_id=chunk_id,
|
|
479
|
+
content=chunk_data["content"],
|
|
480
|
+
document_id=document_id,
|
|
481
|
+
normalized_version=normalized_version,
|
|
482
|
+
page_no=chunk_data["page_no"],
|
|
483
|
+
start_char=chunk_data.get("start_char"),
|
|
484
|
+
end_char=chunk_data.get("end_char"),
|
|
485
|
+
chunk_index=chunk_data["chunk_index"],
|
|
486
|
+
chunking_strategy=chunking_strategy,
|
|
487
|
+
strategy_version=strategy_version,
|
|
488
|
+
chunking_params={"chunk_size": chunk_size, "overlap": overlap},
|
|
489
|
+
document_node_id=document_node_id,
|
|
490
|
+
metadata={
|
|
491
|
+
"chunk_size": len(chunk_data["content"]),
|
|
492
|
+
"level": chunk_data.get("level", "chunk"),
|
|
493
|
+
"parent": chunk_data.get("parent"),
|
|
494
|
+
"parent_chunk_index": chunk_data.get("parent_chunk_index"),
|
|
495
|
+
"children": chunk_data.get("children", []),
|
|
496
|
+
}
|
|
497
|
+
)
|
|
498
|
+
# Write immediately if callback provided, otherwise accumulate
|
|
499
|
+
if chunk_callback:
|
|
500
|
+
chunk_callback(chunk_props)
|
|
501
|
+
else:
|
|
502
|
+
chunks.append(chunk_props)
|
|
503
|
+
return chunks if not chunk_callback else []
|
|
504
|
+
|
|
505
|
+
# Page-by-page chunking strategies
|
|
506
|
+
chunks = []
|
|
507
|
+
pages = normalized_doc.get('content', {}).get('pages', [])
|
|
508
|
+
|
|
509
|
+
_log_chunking_operation('info', f"Processing {len(pages)} pages with {chunking_strategy} strategy", context)
|
|
510
|
+
|
|
511
|
+
# Special handling for cross_page strategy (needs all pages)
|
|
512
|
+
if chunking_strategy == "cross_page":
|
|
513
|
+
_log_chunking_operation('info', f"Using cross_page chunking strategy", context)
|
|
514
|
+
context_pages = kwargs.get('context_pages', 1)
|
|
515
|
+
context_size = kwargs.get('context_size', 200)
|
|
516
|
+
for page_idx, page in enumerate(pages):
|
|
517
|
+
page_chunks = self._chunk_cross_page(pages, page_idx, chunk_size, overlap, context_pages, context_size)
|
|
518
|
+
page_no = page.get('page_no')
|
|
519
|
+
|
|
520
|
+
# Create chunk nodes
|
|
521
|
+
for i, (chunk_text, start_char, end_char) in enumerate(page_chunks):
|
|
522
|
+
from contextsynapse.extraction.id_generator import IDGenerator
|
|
523
|
+
chunk_id = IDGenerator.generate_chunk_id(
|
|
524
|
+
document_id=document_id,
|
|
525
|
+
page_no=page_no,
|
|
526
|
+
chunk_index=i,
|
|
527
|
+
chunking_strategy=chunking_strategy,
|
|
528
|
+
strategy_version=strategy_version,
|
|
529
|
+
namespace=None
|
|
530
|
+
)
|
|
531
|
+
chunk_props = self.create_chunk_with_pointer(
|
|
532
|
+
chunk_id=chunk_id,
|
|
533
|
+
content=chunk_text,
|
|
534
|
+
document_id=document_id,
|
|
535
|
+
normalized_version=normalized_version,
|
|
536
|
+
page_no=page_no,
|
|
537
|
+
start_char=start_char,
|
|
538
|
+
end_char=end_char,
|
|
539
|
+
chunk_index=i,
|
|
540
|
+
chunking_strategy=chunking_strategy,
|
|
541
|
+
strategy_version=strategy_version,
|
|
542
|
+
chunking_params={"chunk_size": chunk_size, "overlap": overlap, **kwargs},
|
|
543
|
+
document_node_id=document_node_id,
|
|
544
|
+
metadata={"chunk_size": len(chunk_text), "cross_page_context": True}
|
|
545
|
+
)
|
|
546
|
+
# Write immediately if callback provided, otherwise accumulate
|
|
547
|
+
if chunk_callback:
|
|
548
|
+
chunk_callback(chunk_props)
|
|
549
|
+
else:
|
|
550
|
+
chunks.append(chunk_props)
|
|
551
|
+
return chunks if not chunk_callback else []
|
|
552
|
+
|
|
553
|
+
page_chunking_times = []
|
|
554
|
+
for page_idx, page in enumerate(pages):
|
|
555
|
+
page_no = page.get('page_no', page_idx + 1)
|
|
556
|
+
page_text = page.get('flat_text', '')
|
|
557
|
+
page_context = {**context, 'page_no': page_no, 'page_idx': page_idx}
|
|
558
|
+
|
|
559
|
+
if not page_text:
|
|
560
|
+
_log_chunking_operation('debug', f"Page has no text, skipping", page_context)
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
page_text_length = len(page_text)
|
|
564
|
+
_log_chunking_operation('debug', f"Chunking page with {page_text_length:,} characters", page_context)
|
|
565
|
+
|
|
566
|
+
# Page monitoring hook - before processing (if available from executor)
|
|
567
|
+
page_start_metrics = None
|
|
568
|
+
page_monitor = getattr(self, 'page_monitor', None) or kwargs.get('page_monitor')
|
|
569
|
+
if page_monitor:
|
|
570
|
+
try:
|
|
571
|
+
page_start_metrics = page_monitor.monitor_page_start(page_no)
|
|
572
|
+
except Exception:
|
|
573
|
+
pass # Silently fail if monitor not available
|
|
574
|
+
|
|
575
|
+
page_chunk_start = time.time()
|
|
576
|
+
try:
|
|
577
|
+
# Chunk the page text
|
|
578
|
+
# Use LangChain internally if available for better chunking quality
|
|
579
|
+
if chunking_strategy == "semantic":
|
|
580
|
+
page_chunks = self._chunk_semantic_with_langchain(page_text, chunk_size, overlap, **kwargs)
|
|
581
|
+
elif chunking_strategy == "paragraph":
|
|
582
|
+
page_chunks = self._chunk_paragraph(page_text, overlap)
|
|
583
|
+
elif chunking_strategy == "recursive":
|
|
584
|
+
page_chunks = self._chunk_recursive_with_langchain(page_text, chunk_size, overlap, **kwargs)
|
|
585
|
+
elif chunking_strategy == "sliding_window":
|
|
586
|
+
page_chunks = self._chunk_sliding_window(page_text, chunk_size, overlap, **kwargs)
|
|
587
|
+
elif chunking_strategy == "section_based":
|
|
588
|
+
page_chunks = self._chunk_section_based(page, chunk_size, overlap, **kwargs)
|
|
589
|
+
elif chunking_strategy == "table_aware":
|
|
590
|
+
page_chunks = self._chunk_table_aware(page, normalized_doc, chunk_size, overlap, **kwargs)
|
|
591
|
+
elif chunking_strategy == "smart":
|
|
592
|
+
page_chunks = self._chunk_smart(page, normalized_doc, chunk_size, overlap, **kwargs)
|
|
593
|
+
elif chunking_strategy == "token_based":
|
|
594
|
+
max_tokens = kwargs.get('max_tokens', 512)
|
|
595
|
+
overlap_tokens = kwargs.get('overlap_tokens', 50)
|
|
596
|
+
tokenizer = kwargs.get('tokenizer', 'tiktoken')
|
|
597
|
+
model = kwargs.get('model', 'gpt-3.5-turbo')
|
|
598
|
+
page_chunks = self._chunk_token_based(page_text, max_tokens, overlap_tokens, tokenizer, model)
|
|
599
|
+
elif chunking_strategy == "semantic_similarity":
|
|
600
|
+
similarity_threshold = kwargs.get('similarity_threshold', 0.7)
|
|
601
|
+
max_chunk_size = kwargs.get('max_chunk_size', chunk_size)
|
|
602
|
+
min_chunk_size = kwargs.get('min_chunk_size', 200)
|
|
603
|
+
embedding_model = kwargs.get('embedding_model', 'sentence-transformers')
|
|
604
|
+
page_chunks = self._chunk_semantic_similarity(page_text, similarity_threshold, max_chunk_size, min_chunk_size, embedding_model)
|
|
605
|
+
elif chunking_strategy == "topic_aware":
|
|
606
|
+
num_topics = kwargs.get('num_topics', 10)
|
|
607
|
+
min_chunk_size = kwargs.get('min_chunk_size', 300)
|
|
608
|
+
topic_model = kwargs.get('topic_model', 'simple')
|
|
609
|
+
page_chunks = self._chunk_topic_aware(page_text, num_topics, min_chunk_size, topic_model)
|
|
610
|
+
elif chunking_strategy == "qa_aware":
|
|
611
|
+
question_length = kwargs.get('question_length', 50)
|
|
612
|
+
context_size = kwargs.get('context_size', 500)
|
|
613
|
+
answer_markers = kwargs.get('answer_markers', None)
|
|
614
|
+
page_chunks = self._chunk_qa_aware(page_text, question_length, context_size, answer_markers)
|
|
615
|
+
elif chunking_strategy == "code_aware":
|
|
616
|
+
preserve_code_blocks = kwargs.get('preserve_code_blocks', True)
|
|
617
|
+
code_context = kwargs.get('code_context', 200)
|
|
618
|
+
min_code_block_size = kwargs.get('min_code_block_size', 50)
|
|
619
|
+
page_chunks = self._chunk_code_aware(page, chunk_size, overlap, preserve_code_blocks, code_context, min_code_block_size)
|
|
620
|
+
elif chunking_strategy == "image_aware":
|
|
621
|
+
include_captions = kwargs.get('include_captions', True)
|
|
622
|
+
caption_context = kwargs.get('caption_context', 300)
|
|
623
|
+
image_chunk_size = kwargs.get('image_chunk_size', 200)
|
|
624
|
+
page_chunks = self._chunk_image_aware(page, normalized_doc, chunk_size, overlap, include_captions, caption_context, image_chunk_size)
|
|
625
|
+
elif chunking_strategy == "adaptive":
|
|
626
|
+
base_chunk_size = kwargs.get('base_chunk_size', chunk_size)
|
|
627
|
+
complexity_threshold = kwargs.get('complexity_threshold', 0.5)
|
|
628
|
+
density_factor = kwargs.get('density_factor', 1.2)
|
|
629
|
+
page_chunks = self._chunk_adaptive(page_text, base_chunk_size, overlap, complexity_threshold, density_factor)
|
|
630
|
+
elif chunking_strategy == "entity_aware":
|
|
631
|
+
entity_types = kwargs.get('entity_types', None)
|
|
632
|
+
page_chunks = self._chunk_entity_aware(page_text, chunk_size, overlap, entity_types)
|
|
633
|
+
elif chunking_strategy == "multilang":
|
|
634
|
+
per_language_chunking = kwargs.get('per_language_chunking', True)
|
|
635
|
+
page_chunks = self._chunk_multilang(page_text, chunk_size, overlap, per_language_chunking)
|
|
636
|
+
elif chunking_strategy == "citation_aware":
|
|
637
|
+
citation_patterns = kwargs.get('citation_patterns', None)
|
|
638
|
+
citation_context = kwargs.get('citation_context', 300)
|
|
639
|
+
page_chunks = self._chunk_citation_aware(page_text, chunk_size, overlap, citation_patterns, citation_context)
|
|
640
|
+
elif chunking_strategy == "dialogue_aware":
|
|
641
|
+
speaker_detection = kwargs.get('speaker_detection', True)
|
|
642
|
+
preserve_turns = kwargs.get('preserve_turns', True)
|
|
643
|
+
page_chunks = self._chunk_dialogue_aware(page_text, chunk_size, overlap, speaker_detection, preserve_turns)
|
|
644
|
+
elif chunking_strategy == "formula_aware":
|
|
645
|
+
formula_patterns = kwargs.get('formula_patterns', None)
|
|
646
|
+
formula_context = kwargs.get('formula_context', 200)
|
|
647
|
+
page_chunks = self._chunk_formula_aware(page_text, chunk_size, overlap, formula_patterns, formula_context)
|
|
648
|
+
elif chunking_strategy == "list_aware":
|
|
649
|
+
preserve_lists = kwargs.get('preserve_lists', True)
|
|
650
|
+
list_context = kwargs.get('list_context', 200)
|
|
651
|
+
page_chunks = self._chunk_list_aware(page, chunk_size, overlap, preserve_lists, list_context)
|
|
652
|
+
elif chunking_strategy == "cross_page":
|
|
653
|
+
# cross_page is handled before the loop (needs all pages)
|
|
654
|
+
# Skip this page in the loop
|
|
655
|
+
page_chunks = []
|
|
656
|
+
_log_chunking_operation('debug',
|
|
657
|
+
f"Cross-page strategy: skipping page (handled before loop)",
|
|
658
|
+
page_context)
|
|
659
|
+
else: # fixed
|
|
660
|
+
page_chunks = self._chunk_fixed(page_text, chunk_size, overlap)
|
|
661
|
+
|
|
662
|
+
page_chunk_time = time.time() - page_chunk_start
|
|
663
|
+
if page_chunks: # Only log if we actually created chunks
|
|
664
|
+
page_chunking_times.append(page_chunk_time)
|
|
665
|
+
_log_chunking_operation('debug',
|
|
666
|
+
f"Page chunking completed: {len(page_chunks)} chunks in {page_chunk_time:.3f}s",
|
|
667
|
+
page_context)
|
|
668
|
+
except Exception as page_err:
|
|
669
|
+
page_chunk_time = time.time() - page_chunk_start
|
|
670
|
+
_log_chunking_operation('error',
|
|
671
|
+
f"Error chunking page: {str(page_err)}",
|
|
672
|
+
page_context, exc_info=True)
|
|
673
|
+
logger.error(f"Full traceback for page {page_no}: {traceback.format_exc()}")
|
|
674
|
+
page_chunks = [] # Set to empty list on error
|
|
675
|
+
|
|
676
|
+
# Create chunk nodes with pointers
|
|
677
|
+
# Validate chunks don't exceed page boundaries
|
|
678
|
+
# Skip if no chunks were created (cross_page strategy or error)
|
|
679
|
+
if not page_chunks:
|
|
680
|
+
continue
|
|
681
|
+
|
|
682
|
+
page_text_length = len(page_text)
|
|
683
|
+
|
|
684
|
+
chunks_created_for_page = 0
|
|
685
|
+
chunks_skipped_for_page = 0
|
|
686
|
+
for i, (chunk_text, start_char, end_char) in enumerate(page_chunks):
|
|
687
|
+
chunk_context = {**page_context, 'chunk_index': i}
|
|
688
|
+
|
|
689
|
+
# Validate chunk boundaries are within page
|
|
690
|
+
if start_char < 0:
|
|
691
|
+
_log_chunking_operation('warning',
|
|
692
|
+
f"Chunk has negative start_char ({start_char}), clamping to 0",
|
|
693
|
+
chunk_context)
|
|
694
|
+
start_char = 0
|
|
695
|
+
|
|
696
|
+
if end_char > page_text_length:
|
|
697
|
+
_log_chunking_operation('warning',
|
|
698
|
+
f"Chunk exceeds page boundary (end_char={end_char}, page_length={page_text_length}), clamping",
|
|
699
|
+
chunk_context)
|
|
700
|
+
end_char = page_text_length
|
|
701
|
+
# Truncate chunk text to match
|
|
702
|
+
chunk_text = chunk_text[:end_char - start_char]
|
|
703
|
+
|
|
704
|
+
if start_char >= end_char:
|
|
705
|
+
_log_chunking_operation('warning',
|
|
706
|
+
f"Chunk has invalid boundaries (start={start_char}, end={end_char}), skipping",
|
|
707
|
+
chunk_context)
|
|
708
|
+
chunks_skipped_for_page += 1
|
|
709
|
+
continue
|
|
710
|
+
|
|
711
|
+
# Verify chunk text matches the page text at these positions
|
|
712
|
+
expected_text = page_text[start_char:end_char]
|
|
713
|
+
if chunk_text != expected_text and len(chunk_text) > 0:
|
|
714
|
+
# Allow small differences (whitespace normalization), but log significant mismatches
|
|
715
|
+
if abs(len(chunk_text) - len(expected_text)) > 10:
|
|
716
|
+
_log_chunking_operation('warning',
|
|
717
|
+
f"Text mismatch: chunk_len={len(chunk_text)}, expected_len={len(expected_text)}",
|
|
718
|
+
chunk_context)
|
|
719
|
+
|
|
720
|
+
# Use IDGenerator for consistent chunk ID generation
|
|
721
|
+
from contextsynapse.extraction.id_generator import IDGenerator
|
|
722
|
+
|
|
723
|
+
chunk_id = IDGenerator.generate_chunk_id(
|
|
724
|
+
document_id=document_id,
|
|
725
|
+
page_no=page_no,
|
|
726
|
+
chunk_index=i,
|
|
727
|
+
chunking_strategy=chunking_strategy,
|
|
728
|
+
strategy_version=strategy_version,
|
|
729
|
+
namespace=None # Will be set by executor
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
chunk_props = self.create_chunk_with_pointer(
|
|
733
|
+
chunk_id=chunk_id,
|
|
734
|
+
content=chunk_text,
|
|
735
|
+
document_id=document_id,
|
|
736
|
+
normalized_version=normalized_version,
|
|
737
|
+
page_no=page_no,
|
|
738
|
+
start_char=start_char,
|
|
739
|
+
end_char=end_char,
|
|
740
|
+
chunk_index=i,
|
|
741
|
+
chunking_strategy=chunking_strategy,
|
|
742
|
+
strategy_version=strategy_version,
|
|
743
|
+
chunking_params={
|
|
744
|
+
"chunk_size": chunk_size,
|
|
745
|
+
"overlap": overlap
|
|
746
|
+
},
|
|
747
|
+
document_node_id=document_node_id,
|
|
748
|
+
metadata={
|
|
749
|
+
"chunk_size": len(chunk_text),
|
|
750
|
+
"token_count": len(chunk_text.split()),
|
|
751
|
+
"page_boundary_validated": True, # Flag to indicate validation
|
|
752
|
+
}
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# Write immediately if callback provided, otherwise accumulate
|
|
756
|
+
if chunk_callback:
|
|
757
|
+
chunk_callback(chunk_props)
|
|
758
|
+
else:
|
|
759
|
+
chunks.append(chunk_props)
|
|
760
|
+
chunks_created_for_page += 1
|
|
761
|
+
|
|
762
|
+
if chunks_created_for_page > 0:
|
|
763
|
+
_log_chunking_operation('debug',
|
|
764
|
+
f"Page processing complete: {chunks_created_for_page} chunks created, {chunks_skipped_for_page} skipped",
|
|
765
|
+
page_context)
|
|
766
|
+
|
|
767
|
+
# Page monitoring hook - after processing (if available)
|
|
768
|
+
page_monitor = getattr(self, 'page_monitor', None) or kwargs.get('page_monitor')
|
|
769
|
+
if page_monitor and page_start_metrics:
|
|
770
|
+
try:
|
|
771
|
+
page_time = time.time() - page_chunk_start
|
|
772
|
+
page_monitor.monitor_page_end(page_no, page_start_metrics,
|
|
773
|
+
chunks_created=chunks_created_for_page,
|
|
774
|
+
processing_time=page_time)
|
|
775
|
+
except Exception:
|
|
776
|
+
pass # Silently fail if monitor not available
|
|
777
|
+
|
|
778
|
+
operation_time = time.time() - operation_start
|
|
779
|
+
avg_page_time = sum(page_chunking_times) / len(page_chunking_times) if page_chunking_times else 0
|
|
780
|
+
# If using callback, chunks list is empty, so we can't calculate stats from it
|
|
781
|
+
# In that case, the callback should track stats
|
|
782
|
+
total_chunks = len(chunks) if not chunk_callback else 0
|
|
783
|
+
avg_chunk_size = sum(len(c.get('content', '')) for c in chunks) / total_chunks if total_chunks > 0 else 0
|
|
784
|
+
|
|
785
|
+
_log_chunking_operation('info',
|
|
786
|
+
f"Chunk creation complete: {total_chunks if not chunk_callback else 'streamed'} chunks in {operation_time:.2f}s "
|
|
787
|
+
f"(avg {avg_page_time:.3f}s/page, avg chunk size: {avg_chunk_size:.0f} chars)",
|
|
788
|
+
context)
|
|
789
|
+
|
|
790
|
+
return chunks if not chunk_callback else []
|
|
791
|
+
|
|
792
|
+
except Exception as e:
|
|
793
|
+
operation_time = time.time() - operation_start
|
|
794
|
+
_log_chunking_operation('error',
|
|
795
|
+
f"Failed to create chunks: {str(e)} (after {operation_time:.2f}s)",
|
|
796
|
+
context, exc_info=True)
|
|
797
|
+
logger.error(f"Full traceback:\n{traceback.format_exc()}")
|
|
798
|
+
return []
|
|
799
|
+
|
|
800
|
+
def _chunk_semantic_with_langchain(self, text: str, chunk_size: int, overlap: int, **kwargs) -> List[tuple]:
|
|
801
|
+
"""
|
|
802
|
+
Chunk text using semantic boundaries with LangChain if available.
|
|
803
|
+
Falls back to built-in semantic chunking if LangChain is not available.
|
|
804
|
+
"""
|
|
805
|
+
# Try to use LangChain RecursiveCharacterTextSplitter if available
|
|
806
|
+
try:
|
|
807
|
+
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
|
808
|
+
|
|
809
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
810
|
+
chunk_size=chunk_size,
|
|
811
|
+
chunk_overlap=overlap,
|
|
812
|
+
separators=["\n\n", "\n", ". ", " ", ""], # Sentence-aware separators
|
|
813
|
+
length_function=len,
|
|
814
|
+
is_separator_regex=False,
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
langchain_chunks = splitter.split_text(text)
|
|
818
|
+
|
|
819
|
+
# Convert LangChain chunks to our format (chunk_text, start_char, end_char)
|
|
820
|
+
chunks = []
|
|
821
|
+
current_pos = 0
|
|
822
|
+
for chunk_text in langchain_chunks:
|
|
823
|
+
if not chunk_text.strip():
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
# Find the chunk in the original text
|
|
827
|
+
start_char = text.find(chunk_text, current_pos)
|
|
828
|
+
if start_char == -1:
|
|
829
|
+
start_char = current_pos
|
|
830
|
+
|
|
831
|
+
end_char = start_char + len(chunk_text)
|
|
832
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
833
|
+
current_pos = end_char - overlap # Account for overlap
|
|
834
|
+
|
|
835
|
+
logger.debug(f"LangChain recursive chunking created {len(chunks)} chunks")
|
|
836
|
+
return chunks
|
|
837
|
+
|
|
838
|
+
except ImportError:
|
|
839
|
+
# Fall back to built-in semantic chunking
|
|
840
|
+
logger.debug("LangChain not available, using built-in semantic chunking")
|
|
841
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
842
|
+
except Exception as e:
|
|
843
|
+
logger.warning(f"LangChain chunking failed: {e}, falling back to built-in semantic chunking")
|
|
844
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
845
|
+
|
|
846
|
+
def _chunk_recursive_with_langchain(self, text: str, chunk_size: int, overlap: int, **kwargs) -> List[tuple]:
|
|
847
|
+
"""
|
|
848
|
+
Chunk text using recursive splitting with LangChain if available.
|
|
849
|
+
Falls back to built-in recursive chunking if LangChain is not available.
|
|
850
|
+
"""
|
|
851
|
+
# Try to use LangChain RecursiveCharacterTextSplitter if available
|
|
852
|
+
try:
|
|
853
|
+
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
|
854
|
+
|
|
855
|
+
separators = kwargs.get('separators', ["\n\n", "\n", ". ", " ", ""])
|
|
856
|
+
|
|
857
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
858
|
+
chunk_size=chunk_size,
|
|
859
|
+
chunk_overlap=overlap,
|
|
860
|
+
separators=separators,
|
|
861
|
+
length_function=len,
|
|
862
|
+
is_separator_regex=False,
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
langchain_chunks = splitter.split_text(text)
|
|
866
|
+
|
|
867
|
+
# Convert LangChain chunks to our format
|
|
868
|
+
chunks = []
|
|
869
|
+
current_pos = 0
|
|
870
|
+
for chunk_text in langchain_chunks:
|
|
871
|
+
if not chunk_text.strip():
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
start_char = text.find(chunk_text, current_pos)
|
|
875
|
+
if start_char == -1:
|
|
876
|
+
start_char = current_pos
|
|
877
|
+
|
|
878
|
+
end_char = start_char + len(chunk_text)
|
|
879
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
880
|
+
current_pos = end_char - overlap
|
|
881
|
+
|
|
882
|
+
logger.debug(f"LangChain recursive chunking created {len(chunks)} chunks")
|
|
883
|
+
return chunks
|
|
884
|
+
|
|
885
|
+
except ImportError:
|
|
886
|
+
# Fall back to built-in recursive chunking
|
|
887
|
+
logger.debug("LangChain not available, using built-in recursive chunking")
|
|
888
|
+
return self._chunk_recursive(text, chunk_size, overlap)
|
|
889
|
+
except Exception as e:
|
|
890
|
+
logger.warning(f"LangChain chunking failed: {e}, falling back to built-in recursive chunking")
|
|
891
|
+
return self._chunk_recursive(text, chunk_size, overlap)
|
|
892
|
+
|
|
893
|
+
def _chunk_semantic(self, text: str, chunk_size: int, overlap: int) -> List[tuple]:
|
|
894
|
+
"""
|
|
895
|
+
Chunk text using semantic boundaries (sentence-aware).
|
|
896
|
+
|
|
897
|
+
Logs detailed information about chunking process for debugging.
|
|
898
|
+
"""
|
|
899
|
+
"""
|
|
900
|
+
Semantic chunking (sentence-aware).
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
904
|
+
"""
|
|
905
|
+
import re
|
|
906
|
+
|
|
907
|
+
# Log input details
|
|
908
|
+
text_length = len(text) if text else 0
|
|
909
|
+
logger.debug(f"[_chunk_semantic] Starting: text_length={text_length:,}, chunk_size={chunk_size}, overlap={overlap}")
|
|
910
|
+
|
|
911
|
+
if not text or not text.strip():
|
|
912
|
+
logger.warning(f"[_chunk_semantic] Empty text provided, returning empty chunks")
|
|
913
|
+
return []
|
|
914
|
+
|
|
915
|
+
# Split into sentences
|
|
916
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
917
|
+
logger.debug(f"[_chunk_semantic] Split text into {len(sentences)} sentences")
|
|
918
|
+
|
|
919
|
+
if len(sentences) == 0:
|
|
920
|
+
logger.warning(f"[_chunk_semantic] No sentences found, returning single chunk")
|
|
921
|
+
return [(text, 0, text_length)]
|
|
922
|
+
|
|
923
|
+
chunks = []
|
|
924
|
+
current_chunk = []
|
|
925
|
+
current_size = 0
|
|
926
|
+
start_char = 0
|
|
927
|
+
chunk_count = 0
|
|
928
|
+
|
|
929
|
+
for sentence_idx, sentence in enumerate(sentences):
|
|
930
|
+
sentence_size = len(sentence)
|
|
931
|
+
|
|
932
|
+
if current_size + sentence_size > chunk_size and current_chunk:
|
|
933
|
+
# Save current chunk
|
|
934
|
+
chunk_text = ' '.join(current_chunk)
|
|
935
|
+
end_char = start_char + len(chunk_text)
|
|
936
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
937
|
+
chunk_count += 1
|
|
938
|
+
|
|
939
|
+
# Log chunk creation (first 3 and every 10th)
|
|
940
|
+
if chunk_count <= 3 or chunk_count % 10 == 0:
|
|
941
|
+
chunk_preview = chunk_text[:100].replace('\n', ' ').strip()
|
|
942
|
+
logger.debug(f"[_chunk_semantic] Created chunk {chunk_count}: size={len(chunk_text):,} chars, start={start_char}, end={end_char}, preview={chunk_preview}...")
|
|
943
|
+
|
|
944
|
+
# Start new chunk with overlap
|
|
945
|
+
overlap_text = ' '.join(current_chunk[-overlap//50:]) if overlap > 0 else ''
|
|
946
|
+
current_chunk = [overlap_text, sentence] if overlap_text else [sentence]
|
|
947
|
+
current_size = len(overlap_text) + sentence_size if overlap_text else sentence_size
|
|
948
|
+
start_char = end_char - len(overlap_text) if overlap_text else end_char
|
|
949
|
+
else:
|
|
950
|
+
current_chunk.append(sentence)
|
|
951
|
+
current_size += sentence_size
|
|
952
|
+
|
|
953
|
+
# Add final chunk
|
|
954
|
+
if current_chunk:
|
|
955
|
+
chunk_text = ' '.join(current_chunk)
|
|
956
|
+
end_char = start_char + len(chunk_text)
|
|
957
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
958
|
+
chunk_count += 1
|
|
959
|
+
chunk_preview = chunk_text[:100].replace('\n', ' ').strip()
|
|
960
|
+
logger.debug(f"[_chunk_semantic] Created final chunk {chunk_count}: size={len(chunk_text):,} chars, start={start_char}, end={end_char}, preview={chunk_preview}...")
|
|
961
|
+
|
|
962
|
+
logger.info(f"[_chunk_semantic] Completed: {len(chunks)} chunks created from {text_length:,} chars")
|
|
963
|
+
return chunks
|
|
964
|
+
|
|
965
|
+
def _chunk_fixed(self, text: str, chunk_size: int, overlap: int) -> List[tuple]:
|
|
966
|
+
"""
|
|
967
|
+
Fixed-size chunking with memory-efficient handling for large texts.
|
|
968
|
+
|
|
969
|
+
Returns:
|
|
970
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
971
|
+
"""
|
|
972
|
+
chunks = []
|
|
973
|
+
start_char = 0
|
|
974
|
+
text_length = len(text)
|
|
975
|
+
|
|
976
|
+
logger.debug(f"[_chunk_fixed] Starting fixed chunking (text_length={text_length}, chunk_size={chunk_size}, overlap={overlap})")
|
|
977
|
+
|
|
978
|
+
# For very large texts (>10MB), process in batches to avoid memory issues
|
|
979
|
+
# This prevents accumulating all chunks in memory at once
|
|
980
|
+
max_batch_size = 1000 # Process up to 1000 chunks at a time for very large texts
|
|
981
|
+
is_very_large = text_length > 10_000_000 # >10MB
|
|
982
|
+
|
|
983
|
+
if is_very_large:
|
|
984
|
+
logger.warning(f"[_chunk_fixed] Very large text detected ({text_length:,} chars), using memory-efficient batch processing")
|
|
985
|
+
|
|
986
|
+
chunk_count = 0
|
|
987
|
+
while start_char < text_length:
|
|
988
|
+
end_char = min(start_char + chunk_size, text_length)
|
|
989
|
+
chunk_text = text[start_char:end_char]
|
|
990
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
991
|
+
chunk_count += 1
|
|
992
|
+
start_char = end_char - overlap if overlap > 0 else end_char
|
|
993
|
+
|
|
994
|
+
# For very large texts, yield chunks in batches to avoid memory buildup
|
|
995
|
+
# Note: This is a safeguard - in practice, chunks should be written immediately
|
|
996
|
+
# by the caller, so this shouldn't be needed, but it provides extra safety
|
|
997
|
+
if is_very_large and chunk_count % max_batch_size == 0:
|
|
998
|
+
# Force garbage collection periodically for very large texts
|
|
999
|
+
import gc
|
|
1000
|
+
gc.collect()
|
|
1001
|
+
logger.debug(f"[_chunk_fixed] Processed {chunk_count} chunks so far (memory safeguard)")
|
|
1002
|
+
|
|
1003
|
+
logger.debug(f"[_chunk_fixed] Created {len(chunks)} chunks")
|
|
1004
|
+
return chunks
|
|
1005
|
+
|
|
1006
|
+
def _chunk_paragraph(self, text: str, overlap: int) -> List[tuple]:
|
|
1007
|
+
"""
|
|
1008
|
+
Paragraph-based chunking.
|
|
1009
|
+
|
|
1010
|
+
Returns:
|
|
1011
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1012
|
+
"""
|
|
1013
|
+
paragraphs = text.split('\n\n')
|
|
1014
|
+
chunks = []
|
|
1015
|
+
start_char = 0
|
|
1016
|
+
|
|
1017
|
+
for para in paragraphs:
|
|
1018
|
+
if not para.strip():
|
|
1019
|
+
continue
|
|
1020
|
+
|
|
1021
|
+
chunk_text = para.strip()
|
|
1022
|
+
end_char = start_char + len(chunk_text)
|
|
1023
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1024
|
+
start_char = end_char + 2 # +2 for \n\n
|
|
1025
|
+
|
|
1026
|
+
return chunks
|
|
1027
|
+
|
|
1028
|
+
def _chunk_hierarchical(self, normalized_doc: Dict[str, Any], chunk_size: int, overlap: int) -> List[Dict[str, Any]]:
|
|
1029
|
+
"""
|
|
1030
|
+
Hierarchical chunking that preserves document structure.
|
|
1031
|
+
|
|
1032
|
+
Uses normalized blocks to create chunks that respect document hierarchy:
|
|
1033
|
+
- Document -> Page -> Section/Heading -> Paragraph -> Chunk
|
|
1034
|
+
|
|
1035
|
+
Returns:
|
|
1036
|
+
List of chunk dictionaries with hierarchical metadata
|
|
1037
|
+
"""
|
|
1038
|
+
chunks = []
|
|
1039
|
+
document_id = normalized_doc.get("document_id", "unknown")
|
|
1040
|
+
pages = normalized_doc.get("content", {}).get("pages", [])
|
|
1041
|
+
|
|
1042
|
+
logger.debug(f"[_chunk_hierarchical] Processing {len(pages)} pages for document {document_id}")
|
|
1043
|
+
|
|
1044
|
+
for page in pages:
|
|
1045
|
+
page_no = page.get("page_no", 0)
|
|
1046
|
+
normalized_blocks = page.get("normalized_blocks", [])
|
|
1047
|
+
flat_text = page.get("flat_text", "")
|
|
1048
|
+
|
|
1049
|
+
if not normalized_blocks:
|
|
1050
|
+
# Fallback to fixed chunking if no blocks
|
|
1051
|
+
logger.debug(f"[_chunk_hierarchical] Page {page_no} has no normalized_blocks, using fixed chunking fallback")
|
|
1052
|
+
page_chunks = self._chunk_fixed(flat_text, chunk_size, overlap)
|
|
1053
|
+
for i, (chunk_text, start, end) in enumerate(page_chunks):
|
|
1054
|
+
chunks.append({
|
|
1055
|
+
"content": chunk_text,
|
|
1056
|
+
"page_no": page_no,
|
|
1057
|
+
"chunk_index": len(chunks),
|
|
1058
|
+
"level": "chunk",
|
|
1059
|
+
"parent": f"page_{page_no}",
|
|
1060
|
+
"start_char": start,
|
|
1061
|
+
"end_char": end,
|
|
1062
|
+
})
|
|
1063
|
+
continue
|
|
1064
|
+
|
|
1065
|
+
# Use hierarchical structure from blocks
|
|
1066
|
+
current_section = None
|
|
1067
|
+
current_paragraph_group = []
|
|
1068
|
+
current_start_char = 0
|
|
1069
|
+
|
|
1070
|
+
for block_idx, block in enumerate(normalized_blocks):
|
|
1071
|
+
block_type = block.get("type", "paragraph")
|
|
1072
|
+
block_text = block.get("text", "")
|
|
1073
|
+
block_id = block.get("id", f"block_{page_no}_{block_idx}")
|
|
1074
|
+
|
|
1075
|
+
if block_type == "heading":
|
|
1076
|
+
# Save previous paragraph group as chunk
|
|
1077
|
+
if current_paragraph_group:
|
|
1078
|
+
chunk_text = "\n\n".join(current_paragraph_group)
|
|
1079
|
+
if len(chunk_text) > 50: # Minimum chunk size
|
|
1080
|
+
chunks.append({
|
|
1081
|
+
"content": chunk_text,
|
|
1082
|
+
"page_no": page_no,
|
|
1083
|
+
"chunk_index": len(chunks),
|
|
1084
|
+
"level": "paragraph_group",
|
|
1085
|
+
"parent": current_section or f"page_{page_no}",
|
|
1086
|
+
"section": current_section,
|
|
1087
|
+
"start_char": current_start_char,
|
|
1088
|
+
"end_char": current_start_char + len(chunk_text),
|
|
1089
|
+
})
|
|
1090
|
+
current_paragraph_group = []
|
|
1091
|
+
|
|
1092
|
+
# Start new section
|
|
1093
|
+
level = block.get("level", 1)
|
|
1094
|
+
current_section = f"section_{page_no}_{block_idx}"
|
|
1095
|
+
|
|
1096
|
+
# Heading itself can be a chunk if substantial
|
|
1097
|
+
if len(block_text) > 50:
|
|
1098
|
+
chunks.append({
|
|
1099
|
+
"content": block_text,
|
|
1100
|
+
"page_no": page_no,
|
|
1101
|
+
"chunk_index": len(chunks),
|
|
1102
|
+
"level": f"heading_{level}",
|
|
1103
|
+
"parent": f"page_{page_no}",
|
|
1104
|
+
"section": current_section,
|
|
1105
|
+
"start_char": current_start_char,
|
|
1106
|
+
"end_char": current_start_char + len(block_text),
|
|
1107
|
+
})
|
|
1108
|
+
current_start_char += len(block_text) + 2
|
|
1109
|
+
|
|
1110
|
+
elif block_type == "paragraph":
|
|
1111
|
+
# Add to current paragraph group
|
|
1112
|
+
if block_text.strip():
|
|
1113
|
+
current_paragraph_group.append(block_text)
|
|
1114
|
+
|
|
1115
|
+
# If group is large enough, create chunk
|
|
1116
|
+
group_text = "\n\n".join(current_paragraph_group)
|
|
1117
|
+
if len(group_text) >= chunk_size:
|
|
1118
|
+
chunks.append({
|
|
1119
|
+
"content": group_text,
|
|
1120
|
+
"page_no": page_no,
|
|
1121
|
+
"chunk_index": len(chunks),
|
|
1122
|
+
"level": "paragraph_group",
|
|
1123
|
+
"parent": current_section or f"page_{page_no}",
|
|
1124
|
+
"section": current_section,
|
|
1125
|
+
"start_char": current_start_char,
|
|
1126
|
+
"end_char": current_start_char + len(group_text),
|
|
1127
|
+
})
|
|
1128
|
+
current_start_char += len(group_text) + 2
|
|
1129
|
+
current_paragraph_group = []
|
|
1130
|
+
|
|
1131
|
+
# Save remaining paragraph group
|
|
1132
|
+
if current_paragraph_group:
|
|
1133
|
+
chunk_text = "\n\n".join(current_paragraph_group)
|
|
1134
|
+
if len(chunk_text) > 50:
|
|
1135
|
+
chunks.append({
|
|
1136
|
+
"content": chunk_text,
|
|
1137
|
+
"page_no": page_no,
|
|
1138
|
+
"chunk_index": len(chunks),
|
|
1139
|
+
"level": "paragraph_group",
|
|
1140
|
+
"parent": current_section or f"page_{page_no}",
|
|
1141
|
+
"section": current_section,
|
|
1142
|
+
"start_char": current_start_char,
|
|
1143
|
+
"end_char": current_start_char + len(chunk_text),
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
return chunks
|
|
1147
|
+
|
|
1148
|
+
def _chunk_recursive(self, text: str, chunk_size: int, overlap: int, max_depth: int = 3) -> List[tuple]:
|
|
1149
|
+
"""
|
|
1150
|
+
Recursive chunking - recursively splits text into smaller chunks.
|
|
1151
|
+
|
|
1152
|
+
Strategy:
|
|
1153
|
+
1. Try to split at paragraph boundaries
|
|
1154
|
+
2. If still too large, split at sentence boundaries
|
|
1155
|
+
3. If still too large, split at word boundaries
|
|
1156
|
+
4. If still too large, use fixed-size chunking
|
|
1157
|
+
|
|
1158
|
+
Optimized to avoid deep recursion and improve performance.
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1162
|
+
"""
|
|
1163
|
+
# Pre-compile regex for better performance
|
|
1164
|
+
import re
|
|
1165
|
+
sentence_pattern = re.compile(r'(?<=[.!?])\s+')
|
|
1166
|
+
|
|
1167
|
+
def recursive_split(text_segment: str, start_pos: int, depth: int = 0) -> List[tuple]:
|
|
1168
|
+
# Base case: segment is small enough or max depth reached
|
|
1169
|
+
if depth >= max_depth or len(text_segment) <= chunk_size:
|
|
1170
|
+
return [(text_segment, start_pos, start_pos + len(text_segment))]
|
|
1171
|
+
|
|
1172
|
+
# Try paragraph split first (most efficient)
|
|
1173
|
+
if '\n\n' in text_segment:
|
|
1174
|
+
paragraphs = text_segment.split('\n\n')
|
|
1175
|
+
# Filter out empty paragraphs early
|
|
1176
|
+
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
|
1177
|
+
if len(paragraphs) > 1:
|
|
1178
|
+
result = []
|
|
1179
|
+
current_pos = start_pos
|
|
1180
|
+
for para in paragraphs:
|
|
1181
|
+
if len(para) <= chunk_size:
|
|
1182
|
+
# Paragraph fits in one chunk
|
|
1183
|
+
result.append((para, current_pos, current_pos + len(para)))
|
|
1184
|
+
current_pos += len(para) + 2 # +2 for \n\n
|
|
1185
|
+
else:
|
|
1186
|
+
# Recursively split large paragraph
|
|
1187
|
+
para_chunks = recursive_split(para, current_pos, depth + 1)
|
|
1188
|
+
result.extend(para_chunks)
|
|
1189
|
+
if para_chunks:
|
|
1190
|
+
current_pos = para_chunks[-1][2] + 2 # Update position
|
|
1191
|
+
return result
|
|
1192
|
+
|
|
1193
|
+
# Try sentence split (more granular)
|
|
1194
|
+
sentences = sentence_pattern.split(text_segment)
|
|
1195
|
+
if len(sentences) > 1:
|
|
1196
|
+
result = []
|
|
1197
|
+
current_pos = start_pos
|
|
1198
|
+
current_chunk = []
|
|
1199
|
+
current_size = 0
|
|
1200
|
+
|
|
1201
|
+
for sentence in sentences:
|
|
1202
|
+
if not sentence.strip():
|
|
1203
|
+
continue
|
|
1204
|
+
sentence_size = len(sentence)
|
|
1205
|
+
|
|
1206
|
+
# If adding this sentence would exceed chunk_size, save current chunk
|
|
1207
|
+
if current_size + sentence_size > chunk_size and current_chunk:
|
|
1208
|
+
chunk_text = ' '.join(current_chunk)
|
|
1209
|
+
result.append((chunk_text, current_pos, current_pos + len(chunk_text)))
|
|
1210
|
+
current_pos = current_pos + len(chunk_text) + 1
|
|
1211
|
+
current_chunk = [sentence]
|
|
1212
|
+
current_size = sentence_size
|
|
1213
|
+
else:
|
|
1214
|
+
current_chunk.append(sentence)
|
|
1215
|
+
current_size += sentence_size + 1
|
|
1216
|
+
|
|
1217
|
+
# Add final chunk
|
|
1218
|
+
if current_chunk:
|
|
1219
|
+
chunk_text = ' '.join(current_chunk)
|
|
1220
|
+
result.append((chunk_text, current_pos, current_pos + len(chunk_text)))
|
|
1221
|
+
|
|
1222
|
+
return result
|
|
1223
|
+
|
|
1224
|
+
# Fallback to fixed-size chunking (fastest for very large segments)
|
|
1225
|
+
return self._chunk_fixed(text_segment, chunk_size, overlap)
|
|
1226
|
+
|
|
1227
|
+
chunks = recursive_split(text, 0, 0)
|
|
1228
|
+
return chunks
|
|
1229
|
+
|
|
1230
|
+
def _chunk_parent_child(self, normalized_doc: Dict[str, Any], chunk_size: int, overlap: int) -> List[Dict[str, Any]]:
|
|
1231
|
+
"""
|
|
1232
|
+
Parent-child chunking that creates parent chunks and child chunks.
|
|
1233
|
+
|
|
1234
|
+
Strategy:
|
|
1235
|
+
- Creates parent chunks at section/heading level
|
|
1236
|
+
- Creates child chunks at paragraph level
|
|
1237
|
+
- Maintains parent-child relationships
|
|
1238
|
+
|
|
1239
|
+
Returns:
|
|
1240
|
+
List of chunk dictionaries with parent-child metadata
|
|
1241
|
+
"""
|
|
1242
|
+
chunks = []
|
|
1243
|
+
document_id = normalized_doc.get("document_id", "unknown")
|
|
1244
|
+
pages = normalized_doc.get("content", {}).get("pages", [])
|
|
1245
|
+
|
|
1246
|
+
for page in pages:
|
|
1247
|
+
page_no = page.get("page_no", 0)
|
|
1248
|
+
normalized_blocks = page.get("normalized_blocks", [])
|
|
1249
|
+
|
|
1250
|
+
if not normalized_blocks:
|
|
1251
|
+
continue
|
|
1252
|
+
|
|
1253
|
+
current_parent = None
|
|
1254
|
+
current_parent_content = []
|
|
1255
|
+
child_chunks = []
|
|
1256
|
+
|
|
1257
|
+
for block_idx, block in enumerate(normalized_blocks):
|
|
1258
|
+
block_type = block.get("type", "paragraph")
|
|
1259
|
+
block_text = block.get("text", "")
|
|
1260
|
+
|
|
1261
|
+
if block_type == "heading":
|
|
1262
|
+
# Save previous parent chunk with its children
|
|
1263
|
+
if current_parent and current_parent_content:
|
|
1264
|
+
parent_content = "\n\n".join(current_parent_content)
|
|
1265
|
+
if len(parent_content) > 50:
|
|
1266
|
+
parent_chunk = {
|
|
1267
|
+
"content": parent_content,
|
|
1268
|
+
"page_no": page_no,
|
|
1269
|
+
"chunk_index": len(chunks),
|
|
1270
|
+
"level": "parent",
|
|
1271
|
+
"parent": f"page_{page_no}",
|
|
1272
|
+
"children": [c["chunk_index"] for c in child_chunks],
|
|
1273
|
+
"start_char": 0, # Will be calculated
|
|
1274
|
+
"end_char": len(parent_content),
|
|
1275
|
+
}
|
|
1276
|
+
chunks.append(parent_chunk)
|
|
1277
|
+
|
|
1278
|
+
# Add child chunks
|
|
1279
|
+
for child in child_chunks:
|
|
1280
|
+
child["parent_chunk_index"] = parent_chunk["chunk_index"]
|
|
1281
|
+
chunks.append(child)
|
|
1282
|
+
|
|
1283
|
+
child_chunks = []
|
|
1284
|
+
current_parent_content = []
|
|
1285
|
+
|
|
1286
|
+
# Start new parent
|
|
1287
|
+
level = block.get("level", 1)
|
|
1288
|
+
current_parent = {
|
|
1289
|
+
"id": f"parent_{page_no}_{block_idx}",
|
|
1290
|
+
"level": level,
|
|
1291
|
+
"heading": block_text,
|
|
1292
|
+
}
|
|
1293
|
+
current_parent_content.append(block_text)
|
|
1294
|
+
|
|
1295
|
+
elif block_type == "paragraph" and current_parent:
|
|
1296
|
+
# Add to parent content
|
|
1297
|
+
current_parent_content.append(block_text)
|
|
1298
|
+
|
|
1299
|
+
# Create child chunk if paragraph is substantial
|
|
1300
|
+
if len(block_text) > 100:
|
|
1301
|
+
child_chunk = {
|
|
1302
|
+
"content": block_text,
|
|
1303
|
+
"page_no": page_no,
|
|
1304
|
+
"chunk_index": len(chunks) + len(child_chunks),
|
|
1305
|
+
"level": "child",
|
|
1306
|
+
"parent": current_parent["id"],
|
|
1307
|
+
"start_char": 0, # Will be calculated
|
|
1308
|
+
"end_char": len(block_text),
|
|
1309
|
+
}
|
|
1310
|
+
child_chunks.append(child_chunk)
|
|
1311
|
+
|
|
1312
|
+
# Save final parent chunk
|
|
1313
|
+
if current_parent and current_parent_content:
|
|
1314
|
+
parent_content = "\n\n".join(current_parent_content)
|
|
1315
|
+
if len(parent_content) > 50:
|
|
1316
|
+
parent_chunk = {
|
|
1317
|
+
"content": parent_content,
|
|
1318
|
+
"page_no": page_no,
|
|
1319
|
+
"chunk_index": len(chunks),
|
|
1320
|
+
"level": "parent",
|
|
1321
|
+
"parent": f"page_{page_no}",
|
|
1322
|
+
"children": [c["chunk_index"] for c in child_chunks],
|
|
1323
|
+
"start_char": 0,
|
|
1324
|
+
"end_char": len(parent_content),
|
|
1325
|
+
}
|
|
1326
|
+
chunks.append(parent_chunk)
|
|
1327
|
+
|
|
1328
|
+
# Add child chunks
|
|
1329
|
+
for child in child_chunks:
|
|
1330
|
+
child["parent_chunk_index"] = parent_chunk["chunk_index"]
|
|
1331
|
+
chunks.append(child)
|
|
1332
|
+
|
|
1333
|
+
return chunks
|
|
1334
|
+
|
|
1335
|
+
def _chunk_sliding_window(self, text: str, window_size: int, overlap: int, step_size: Optional[int] = None, **kwargs) -> List[tuple]:
|
|
1336
|
+
"""
|
|
1337
|
+
Sliding window chunking with configurable step size.
|
|
1338
|
+
|
|
1339
|
+
Args:
|
|
1340
|
+
text: Text to chunk
|
|
1341
|
+
window_size: Size of the sliding window
|
|
1342
|
+
overlap: Overlap between chunks (used if step_size not provided)
|
|
1343
|
+
step_size: Step size for sliding window (optional)
|
|
1344
|
+
|
|
1345
|
+
Returns:
|
|
1346
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1347
|
+
"""
|
|
1348
|
+
if step_size is None:
|
|
1349
|
+
step_size = window_size - overlap
|
|
1350
|
+
|
|
1351
|
+
chunks = []
|
|
1352
|
+
start_char = 0
|
|
1353
|
+
|
|
1354
|
+
while start_char < len(text):
|
|
1355
|
+
end_char = min(start_char + window_size, len(text))
|
|
1356
|
+
chunk_text = text[start_char:end_char]
|
|
1357
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1358
|
+
start_char += step_size
|
|
1359
|
+
|
|
1360
|
+
return chunks
|
|
1361
|
+
|
|
1362
|
+
def _chunk_section_based(self, page: Dict[str, Any], chunk_size: int, overlap: int,
|
|
1363
|
+
section_markers: Optional[List[str]] = None, preserve_hierarchy: bool = True, **kwargs) -> List[tuple]:
|
|
1364
|
+
"""
|
|
1365
|
+
Section-based chunking that splits at section boundaries.
|
|
1366
|
+
|
|
1367
|
+
Args:
|
|
1368
|
+
page: Page dictionary with normalized_blocks
|
|
1369
|
+
chunk_size: Maximum chunk size
|
|
1370
|
+
overlap: Overlap between chunks
|
|
1371
|
+
section_markers: List of section markers (e.g., ["##", "###", "Chapter"])
|
|
1372
|
+
preserve_hierarchy: Whether to preserve hierarchy in chunks
|
|
1373
|
+
|
|
1374
|
+
Returns:
|
|
1375
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1376
|
+
"""
|
|
1377
|
+
if section_markers is None:
|
|
1378
|
+
section_markers = ["##", "###", "Chapter", "Section", "Part", "#"]
|
|
1379
|
+
|
|
1380
|
+
normalized_blocks = page.get("normalized_blocks", [])
|
|
1381
|
+
flat_text = page.get("flat_text", "")
|
|
1382
|
+
|
|
1383
|
+
if not normalized_blocks:
|
|
1384
|
+
# Fallback to fixed chunking
|
|
1385
|
+
return self._chunk_fixed(flat_text, chunk_size, overlap)
|
|
1386
|
+
|
|
1387
|
+
chunks = []
|
|
1388
|
+
current_section = []
|
|
1389
|
+
current_size = 0
|
|
1390
|
+
start_char = 0
|
|
1391
|
+
|
|
1392
|
+
for block in normalized_blocks:
|
|
1393
|
+
block_type = block.get("type", "paragraph")
|
|
1394
|
+
block_text = block.get("text", "")
|
|
1395
|
+
|
|
1396
|
+
# Check if this is a section marker
|
|
1397
|
+
is_section = block_type == "heading" or any(
|
|
1398
|
+
block_text.strip().startswith(marker) for marker in section_markers
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
if is_section and current_section:
|
|
1402
|
+
# Save current section as chunk
|
|
1403
|
+
section_text = "\n\n".join(current_section)
|
|
1404
|
+
if len(section_text) > 50:
|
|
1405
|
+
end_char = start_char + len(section_text)
|
|
1406
|
+
chunks.append((section_text, start_char, end_char))
|
|
1407
|
+
start_char = end_char - overlap if overlap > 0 else end_char
|
|
1408
|
+
|
|
1409
|
+
current_section = [block_text]
|
|
1410
|
+
current_size = len(block_text)
|
|
1411
|
+
else:
|
|
1412
|
+
current_section.append(block_text)
|
|
1413
|
+
current_size += len(block_text) + 2 # +2 for \n\n
|
|
1414
|
+
|
|
1415
|
+
# If section exceeds chunk_size, split it
|
|
1416
|
+
if current_size > chunk_size:
|
|
1417
|
+
section_text = "\n\n".join(current_section)
|
|
1418
|
+
# Split large section using semantic chunking
|
|
1419
|
+
section_chunks = self._chunk_semantic(section_text, chunk_size, overlap)
|
|
1420
|
+
for chunk_text, chunk_start, chunk_end in section_chunks:
|
|
1421
|
+
chunks.append((chunk_text, start_char + chunk_start, start_char + chunk_end))
|
|
1422
|
+
start_char += len(section_text)
|
|
1423
|
+
current_section = []
|
|
1424
|
+
current_size = 0
|
|
1425
|
+
|
|
1426
|
+
# Save remaining section
|
|
1427
|
+
if current_section:
|
|
1428
|
+
section_text = "\n\n".join(current_section)
|
|
1429
|
+
if len(section_text) > 50:
|
|
1430
|
+
end_char = start_char + len(section_text)
|
|
1431
|
+
chunks.append((section_text, start_char, end_char))
|
|
1432
|
+
|
|
1433
|
+
return chunks
|
|
1434
|
+
|
|
1435
|
+
def _chunk_table_aware(self, page: Dict[str, Any], normalized_doc: Dict[str, Any],
|
|
1436
|
+
chunk_size: int, overlap: int,
|
|
1437
|
+
extract_tables: bool = True, preserve_table_context: bool = True,
|
|
1438
|
+
table_chunk_size: int = 300, context_before: int = 200, context_after: int = 200, **kwargs) -> List[tuple]:
|
|
1439
|
+
"""
|
|
1440
|
+
Table-aware chunking that preserves table context.
|
|
1441
|
+
|
|
1442
|
+
Args:
|
|
1443
|
+
page: Page dictionary
|
|
1444
|
+
normalized_doc: Full normalized document
|
|
1445
|
+
chunk_size: Chunk size for text
|
|
1446
|
+
overlap: Overlap between chunks
|
|
1447
|
+
extract_tables: Whether to extract tables as separate chunks
|
|
1448
|
+
preserve_table_context: Whether to include context around tables
|
|
1449
|
+
table_chunk_size: Size for table chunks
|
|
1450
|
+
context_before: Context before table
|
|
1451
|
+
context_after: Context after table
|
|
1452
|
+
|
|
1453
|
+
Returns:
|
|
1454
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1455
|
+
"""
|
|
1456
|
+
chunks = []
|
|
1457
|
+
flat_text = page.get("flat_text", "")
|
|
1458
|
+
page_no = page.get("page_no", 0)
|
|
1459
|
+
|
|
1460
|
+
# Get tables for this page
|
|
1461
|
+
tables = normalized_doc.get("content", {}).get("tables", [])
|
|
1462
|
+
page_tables = [t for t in tables if t.get("from_page", 0) == page_no]
|
|
1463
|
+
|
|
1464
|
+
if not page_tables or not extract_tables:
|
|
1465
|
+
# No tables or table extraction disabled, use regular chunking
|
|
1466
|
+
return self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
1467
|
+
|
|
1468
|
+
# Build table positions
|
|
1469
|
+
table_positions = []
|
|
1470
|
+
for table in page_tables:
|
|
1471
|
+
# Try to find table position in text
|
|
1472
|
+
table_markdown = table.get("markdown", "")
|
|
1473
|
+
if table_markdown:
|
|
1474
|
+
pos = flat_text.find(table_markdown)
|
|
1475
|
+
if pos >= 0:
|
|
1476
|
+
table_positions.append((pos, pos + len(table_markdown), table))
|
|
1477
|
+
|
|
1478
|
+
# Sort by position
|
|
1479
|
+
table_positions.sort(key=lambda x: x[0])
|
|
1480
|
+
|
|
1481
|
+
# Chunk text with table awareness
|
|
1482
|
+
current_pos = 0
|
|
1483
|
+
for table_start, table_end, table in table_positions:
|
|
1484
|
+
# Chunk text before table
|
|
1485
|
+
if table_start > current_pos:
|
|
1486
|
+
before_text = flat_text[current_pos:table_start]
|
|
1487
|
+
if preserve_table_context and len(before_text) > context_before:
|
|
1488
|
+
# Include context
|
|
1489
|
+
context_start = max(0, table_start - context_before)
|
|
1490
|
+
before_text = flat_text[context_start:table_start]
|
|
1491
|
+
before_chunks = self._chunk_semantic(before_text, chunk_size, overlap)
|
|
1492
|
+
for chunk_text, chunk_start, chunk_end in before_chunks:
|
|
1493
|
+
chunks.append((chunk_text, context_start + chunk_start, context_start + chunk_end))
|
|
1494
|
+
elif before_text.strip():
|
|
1495
|
+
before_chunks = self._chunk_semantic(before_text, chunk_size, overlap)
|
|
1496
|
+
for chunk_text, chunk_start, chunk_end in before_chunks:
|
|
1497
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
1498
|
+
|
|
1499
|
+
# Add table as chunk
|
|
1500
|
+
if extract_tables:
|
|
1501
|
+
table_text = f"[TABLE]\n{table_markdown}\n[/TABLE]"
|
|
1502
|
+
chunks.append((table_text, table_start, table_end))
|
|
1503
|
+
|
|
1504
|
+
current_pos = table_end
|
|
1505
|
+
|
|
1506
|
+
# Chunk remaining text
|
|
1507
|
+
if current_pos < len(flat_text):
|
|
1508
|
+
remaining_text = flat_text[current_pos:]
|
|
1509
|
+
if preserve_table_context and len(remaining_text) > context_after:
|
|
1510
|
+
context_end = min(len(flat_text), current_pos + context_after)
|
|
1511
|
+
remaining_text = flat_text[current_pos:context_end]
|
|
1512
|
+
|
|
1513
|
+
if remaining_text.strip():
|
|
1514
|
+
remaining_chunks = self._chunk_semantic(remaining_text, chunk_size, overlap)
|
|
1515
|
+
for chunk_text, chunk_start, chunk_end in remaining_chunks:
|
|
1516
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
1517
|
+
|
|
1518
|
+
return chunks
|
|
1519
|
+
|
|
1520
|
+
def _chunk_smart(self, page: Dict[str, Any], normalized_doc: Dict[str, Any],
|
|
1521
|
+
chunk_size: int, overlap: int,
|
|
1522
|
+
document_type_detection: bool = True, fallback_strategy: str = "fixed", **kwargs) -> List[tuple]:
|
|
1523
|
+
"""
|
|
1524
|
+
Smart chunking that auto-detects document type and selects best strategy.
|
|
1525
|
+
|
|
1526
|
+
Args:
|
|
1527
|
+
page: Page dictionary
|
|
1528
|
+
normalized_doc: Full normalized document
|
|
1529
|
+
chunk_size: Chunk size
|
|
1530
|
+
overlap: Overlap between chunks
|
|
1531
|
+
document_type_detection: Whether to detect document type
|
|
1532
|
+
fallback_strategy: Fallback strategy if detection fails
|
|
1533
|
+
|
|
1534
|
+
Returns:
|
|
1535
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1536
|
+
"""
|
|
1537
|
+
if not document_type_detection:
|
|
1538
|
+
# Use fallback strategy
|
|
1539
|
+
page_text = page.get("flat_text", "")
|
|
1540
|
+
if fallback_strategy == "semantic":
|
|
1541
|
+
return self._chunk_semantic(page_text, chunk_size, overlap)
|
|
1542
|
+
elif fallback_strategy == "paragraph":
|
|
1543
|
+
return self._chunk_paragraph(page_text, overlap)
|
|
1544
|
+
elif fallback_strategy == "recursive":
|
|
1545
|
+
return self._chunk_recursive(page_text, chunk_size, overlap)
|
|
1546
|
+
else:
|
|
1547
|
+
return self._chunk_fixed(page_text, chunk_size, overlap)
|
|
1548
|
+
|
|
1549
|
+
# Detect document type
|
|
1550
|
+
page_text = page.get("flat_text", "")
|
|
1551
|
+
normalized_blocks = page.get("normalized_blocks", [])
|
|
1552
|
+
|
|
1553
|
+
# Count different elements
|
|
1554
|
+
heading_count = sum(1 for b in normalized_blocks if b.get("type") == "heading")
|
|
1555
|
+
table_count = len([t for t in normalized_doc.get("content", {}).get("tables", [])
|
|
1556
|
+
if t.get("from_page", 0) == page.get("page_no", 0)])
|
|
1557
|
+
paragraph_count = sum(1 for b in normalized_blocks if b.get("type") == "paragraph")
|
|
1558
|
+
|
|
1559
|
+
# Decision logic
|
|
1560
|
+
if table_count > 0:
|
|
1561
|
+
# Document has tables, use table-aware chunking
|
|
1562
|
+
return self._chunk_table_aware(page, normalized_doc, chunk_size, overlap, **kwargs)
|
|
1563
|
+
elif heading_count > 3:
|
|
1564
|
+
# Many headings, use section-based chunking
|
|
1565
|
+
return self._chunk_section_based(page, chunk_size, overlap, **kwargs)
|
|
1566
|
+
elif paragraph_count > 10:
|
|
1567
|
+
# Many paragraphs, use paragraph-based chunking
|
|
1568
|
+
return self._chunk_paragraph(page_text, overlap)
|
|
1569
|
+
else:
|
|
1570
|
+
# Default to semantic chunking
|
|
1571
|
+
return self._chunk_semantic(page_text, chunk_size, overlap)
|
|
1572
|
+
|
|
1573
|
+
def _chunk_token_based(self, text: str, max_tokens: int, overlap_tokens: int = 50,
|
|
1574
|
+
tokenizer: str = "tiktoken", model: str = "gpt-3.5-turbo", **kwargs) -> List[tuple]:
|
|
1575
|
+
"""
|
|
1576
|
+
Token-based chunking for LLM compatibility.
|
|
1577
|
+
|
|
1578
|
+
Args:
|
|
1579
|
+
text: Text to chunk
|
|
1580
|
+
max_tokens: Maximum tokens per chunk
|
|
1581
|
+
overlap_tokens: Overlap in tokens
|
|
1582
|
+
tokenizer: Tokenizer to use ("tiktoken" or "transformers")
|
|
1583
|
+
model: Model name for tokenizer (default: "gpt-3.5-turbo")
|
|
1584
|
+
|
|
1585
|
+
Returns:
|
|
1586
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1587
|
+
"""
|
|
1588
|
+
try:
|
|
1589
|
+
if tokenizer == "tiktoken":
|
|
1590
|
+
try:
|
|
1591
|
+
import tiktoken
|
|
1592
|
+
enc = tiktoken.encoding_for_model(model)
|
|
1593
|
+
tokens = enc.encode(text)
|
|
1594
|
+
except ImportError:
|
|
1595
|
+
logger.warning("tiktoken not available, falling back to character-based estimation")
|
|
1596
|
+
# Fallback: estimate tokens as ~4 chars per token
|
|
1597
|
+
return self._chunk_fixed(text, max_tokens * 4, overlap_tokens * 4)
|
|
1598
|
+
except Exception as e:
|
|
1599
|
+
logger.warning(f"tiktoken encoding failed: {e}, falling back to character-based")
|
|
1600
|
+
return self._chunk_fixed(text, max_tokens * 4, overlap_tokens * 4)
|
|
1601
|
+
else:
|
|
1602
|
+
# Fallback to character-based estimation
|
|
1603
|
+
return self._chunk_fixed(text, max_tokens * 4, overlap_tokens * 4)
|
|
1604
|
+
|
|
1605
|
+
chunks = []
|
|
1606
|
+
start_token = 0
|
|
1607
|
+
|
|
1608
|
+
while start_token < len(tokens):
|
|
1609
|
+
end_token = min(start_token + max_tokens, len(tokens))
|
|
1610
|
+
chunk_tokens = tokens[start_token:end_token]
|
|
1611
|
+
|
|
1612
|
+
# Decode tokens back to text
|
|
1613
|
+
chunk_text = enc.decode(chunk_tokens)
|
|
1614
|
+
|
|
1615
|
+
# Find position in original text
|
|
1616
|
+
# Approximate: find the text that corresponds to these tokens
|
|
1617
|
+
if start_token == 0:
|
|
1618
|
+
start_char = 0
|
|
1619
|
+
else:
|
|
1620
|
+
# Estimate start position
|
|
1621
|
+
start_char = int((start_token / len(tokens)) * len(text))
|
|
1622
|
+
|
|
1623
|
+
if end_token >= len(tokens):
|
|
1624
|
+
end_char = len(text)
|
|
1625
|
+
else:
|
|
1626
|
+
# Estimate end position
|
|
1627
|
+
end_char = int((end_token / len(tokens)) * len(text))
|
|
1628
|
+
|
|
1629
|
+
# Refine positions by finding actual text
|
|
1630
|
+
if chunk_text in text:
|
|
1631
|
+
actual_start = text.find(chunk_text, start_char)
|
|
1632
|
+
if actual_start >= 0:
|
|
1633
|
+
start_char = actual_start
|
|
1634
|
+
end_char = actual_start + len(chunk_text)
|
|
1635
|
+
|
|
1636
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1637
|
+
start_token = end_token - overlap_tokens if overlap_tokens > 0 else end_token
|
|
1638
|
+
|
|
1639
|
+
return chunks
|
|
1640
|
+
except Exception as e:
|
|
1641
|
+
logger.error(f"Token-based chunking failed: {e}, falling back to fixed chunking")
|
|
1642
|
+
return self._chunk_fixed(text, max_tokens * 4, overlap_tokens * 4)
|
|
1643
|
+
|
|
1644
|
+
def _chunk_semantic_similarity(self, text: str, similarity_threshold: float = 0.7,
|
|
1645
|
+
max_chunk_size: int = 1000, min_chunk_size: int = 200,
|
|
1646
|
+
embedding_model: str = "sentence-transformers", **kwargs) -> List[tuple]:
|
|
1647
|
+
"""
|
|
1648
|
+
Semantic similarity chunking - groups similar sentences together.
|
|
1649
|
+
|
|
1650
|
+
Args:
|
|
1651
|
+
text: Text to chunk
|
|
1652
|
+
similarity_threshold: Minimum similarity to group (default: 0.7)
|
|
1653
|
+
max_chunk_size: Maximum chunk size (default: 1000)
|
|
1654
|
+
min_chunk_size: Minimum chunk size (default: 200)
|
|
1655
|
+
embedding_model: Model for embeddings (default: "sentence-transformers")
|
|
1656
|
+
|
|
1657
|
+
Returns:
|
|
1658
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1659
|
+
"""
|
|
1660
|
+
try:
|
|
1661
|
+
# Try to use sentence transformers
|
|
1662
|
+
try:
|
|
1663
|
+
from sentence_transformers import SentenceTransformer
|
|
1664
|
+
model = SentenceTransformer('all-MiniLM-L6-v2') # Lightweight model
|
|
1665
|
+
|
|
1666
|
+
# Split into sentences
|
|
1667
|
+
import re
|
|
1668
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
1669
|
+
sentences = [s.strip() for s in sentences if s.strip()]
|
|
1670
|
+
|
|
1671
|
+
if len(sentences) < 2:
|
|
1672
|
+
# Not enough sentences, use semantic chunking
|
|
1673
|
+
return self._chunk_semantic(text, max_chunk_size, 0)
|
|
1674
|
+
|
|
1675
|
+
# Generate embeddings
|
|
1676
|
+
embeddings = model.encode(sentences)
|
|
1677
|
+
|
|
1678
|
+
# Group similar sentences
|
|
1679
|
+
chunks = []
|
|
1680
|
+
current_chunk = [sentences[0]]
|
|
1681
|
+
current_size = len(sentences[0])
|
|
1682
|
+
current_start = 0
|
|
1683
|
+
current_embedding = embeddings[0]
|
|
1684
|
+
|
|
1685
|
+
for i in range(1, len(sentences)):
|
|
1686
|
+
sentence = sentences[i]
|
|
1687
|
+
sentence_embedding = embeddings[i]
|
|
1688
|
+
|
|
1689
|
+
# Calculate similarity (cosine similarity)
|
|
1690
|
+
try:
|
|
1691
|
+
import numpy as np
|
|
1692
|
+
similarity = np.dot(current_embedding, sentence_embedding) / (
|
|
1693
|
+
np.linalg.norm(current_embedding) * np.linalg.norm(sentence_embedding)
|
|
1694
|
+
)
|
|
1695
|
+
except ImportError:
|
|
1696
|
+
# Fallback: simple dot product if numpy not available
|
|
1697
|
+
similarity = sum(a * b for a, b in zip(current_embedding, sentence_embedding)) / (
|
|
1698
|
+
(sum(a*a for a in current_embedding) ** 0.5) * (sum(b*b for b in sentence_embedding) ** 0.5)
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
# Check if we should add to current chunk
|
|
1702
|
+
if (similarity >= similarity_threshold and
|
|
1703
|
+
current_size + len(sentence) <= max_chunk_size):
|
|
1704
|
+
current_chunk.append(sentence)
|
|
1705
|
+
current_size += len(sentence) + 1
|
|
1706
|
+
# Update embedding (average)
|
|
1707
|
+
current_embedding = (current_embedding + sentence_embedding) / 2
|
|
1708
|
+
else:
|
|
1709
|
+
# Save current chunk
|
|
1710
|
+
if current_size >= min_chunk_size:
|
|
1711
|
+
chunk_text = ' '.join(current_chunk)
|
|
1712
|
+
end_char = current_start + len(chunk_text)
|
|
1713
|
+
chunks.append((chunk_text, current_start, end_char))
|
|
1714
|
+
current_start = end_char
|
|
1715
|
+
|
|
1716
|
+
# Start new chunk
|
|
1717
|
+
current_chunk = [sentence]
|
|
1718
|
+
current_size = len(sentence)
|
|
1719
|
+
current_embedding = sentence_embedding
|
|
1720
|
+
|
|
1721
|
+
# Add final chunk
|
|
1722
|
+
if current_chunk and current_size >= min_chunk_size:
|
|
1723
|
+
chunk_text = ' '.join(current_chunk)
|
|
1724
|
+
end_char = current_start + len(chunk_text)
|
|
1725
|
+
chunks.append((chunk_text, current_start, end_char))
|
|
1726
|
+
|
|
1727
|
+
return chunks if chunks else self._chunk_semantic(text, max_chunk_size, 0)
|
|
1728
|
+
|
|
1729
|
+
except ImportError:
|
|
1730
|
+
logger.warning("sentence-transformers not available, falling back to semantic chunking")
|
|
1731
|
+
return self._chunk_semantic(text, max_chunk_size, 0)
|
|
1732
|
+
except Exception as e:
|
|
1733
|
+
logger.error(f"Semantic similarity chunking failed: {e}, falling back to semantic chunking")
|
|
1734
|
+
return self._chunk_semantic(text, max_chunk_size, 0)
|
|
1735
|
+
|
|
1736
|
+
def _chunk_topic_aware(self, text: str, num_topics: int = 10, min_chunk_size: int = 300,
|
|
1737
|
+
topic_model: str = "simple", **kwargs) -> List[tuple]:
|
|
1738
|
+
"""
|
|
1739
|
+
Topic-aware chunking - splits at topic boundaries.
|
|
1740
|
+
|
|
1741
|
+
Args:
|
|
1742
|
+
text: Text to chunk
|
|
1743
|
+
num_topics: Number of topics to detect (default: 10)
|
|
1744
|
+
min_chunk_size: Minimum chunk size (default: 300)
|
|
1745
|
+
topic_model: Topic modeling algorithm ("simple", "lda", "bertopic")
|
|
1746
|
+
|
|
1747
|
+
Returns:
|
|
1748
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1749
|
+
"""
|
|
1750
|
+
# Simple topic detection based on keyword frequency
|
|
1751
|
+
# For full LDA/BERTopic, would need additional dependencies
|
|
1752
|
+
import re
|
|
1753
|
+
|
|
1754
|
+
# Split into sentences
|
|
1755
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
1756
|
+
sentences = [s.strip() for s in sentences if s.strip()]
|
|
1757
|
+
|
|
1758
|
+
if len(sentences) < num_topics:
|
|
1759
|
+
# Not enough sentences, use semantic chunking
|
|
1760
|
+
return self._chunk_semantic(text, 1000, 200)
|
|
1761
|
+
|
|
1762
|
+
# Simple topic detection: group sentences by common keywords
|
|
1763
|
+
chunks = []
|
|
1764
|
+
current_chunk = []
|
|
1765
|
+
current_size = 0
|
|
1766
|
+
start_char = 0
|
|
1767
|
+
|
|
1768
|
+
# Extract keywords from sentences (simple approach)
|
|
1769
|
+
def extract_keywords(sentence, top_n=3):
|
|
1770
|
+
words = re.findall(r'\b\w{4,}\b', sentence.lower())
|
|
1771
|
+
# Count frequency
|
|
1772
|
+
from collections import Counter
|
|
1773
|
+
return [w for w, _ in Counter(words).most_common(top_n)]
|
|
1774
|
+
|
|
1775
|
+
prev_keywords = set()
|
|
1776
|
+
|
|
1777
|
+
for sentence in sentences:
|
|
1778
|
+
keywords = set(extract_keywords(sentence))
|
|
1779
|
+
|
|
1780
|
+
# Check if sentence shares keywords with previous chunk
|
|
1781
|
+
if prev_keywords and keywords.intersection(prev_keywords):
|
|
1782
|
+
# Same topic, add to current chunk
|
|
1783
|
+
current_chunk.append(sentence)
|
|
1784
|
+
current_size += len(sentence) + 1
|
|
1785
|
+
prev_keywords.update(keywords)
|
|
1786
|
+
else:
|
|
1787
|
+
# New topic, save current chunk
|
|
1788
|
+
if current_chunk and current_size >= min_chunk_size:
|
|
1789
|
+
chunk_text = ' '.join(current_chunk)
|
|
1790
|
+
end_char = start_char + len(chunk_text)
|
|
1791
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1792
|
+
start_char = end_char
|
|
1793
|
+
|
|
1794
|
+
# Start new chunk
|
|
1795
|
+
current_chunk = [sentence]
|
|
1796
|
+
current_size = len(sentence)
|
|
1797
|
+
prev_keywords = keywords
|
|
1798
|
+
|
|
1799
|
+
# Add final chunk
|
|
1800
|
+
if current_chunk and current_size >= min_chunk_size:
|
|
1801
|
+
chunk_text = ' '.join(current_chunk)
|
|
1802
|
+
end_char = start_char + len(chunk_text)
|
|
1803
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1804
|
+
|
|
1805
|
+
return chunks if chunks else self._chunk_semantic(text, 1000, 200)
|
|
1806
|
+
|
|
1807
|
+
def _chunk_qa_aware(self, text: str, question_length: int = 50, context_size: int = 500,
|
|
1808
|
+
answer_markers: Optional[List[str]] = None, **kwargs) -> List[tuple]:
|
|
1809
|
+
"""
|
|
1810
|
+
Q&A-aware chunking - pairs questions with answers.
|
|
1811
|
+
|
|
1812
|
+
Args:
|
|
1813
|
+
text: Text to chunk
|
|
1814
|
+
question_length: Expected question length (default: 50)
|
|
1815
|
+
context_size: Context around answers (default: 500)
|
|
1816
|
+
answer_markers: Markers for answers (default: ["Answer:", "A:", "Solution:"])
|
|
1817
|
+
|
|
1818
|
+
Returns:
|
|
1819
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1820
|
+
"""
|
|
1821
|
+
if answer_markers is None:
|
|
1822
|
+
answer_markers = ["Answer:", "A:", "Solution:", "Answer", "Solution"]
|
|
1823
|
+
|
|
1824
|
+
import re
|
|
1825
|
+
|
|
1826
|
+
chunks = []
|
|
1827
|
+
lines = text.split('\n')
|
|
1828
|
+
current_chunk = []
|
|
1829
|
+
current_size = 0
|
|
1830
|
+
start_char = 0
|
|
1831
|
+
in_qa_pair = False
|
|
1832
|
+
|
|
1833
|
+
for line in lines:
|
|
1834
|
+
line_stripped = line.strip()
|
|
1835
|
+
|
|
1836
|
+
# Check if line is a question (ends with ?)
|
|
1837
|
+
is_question = line_stripped.endswith('?')
|
|
1838
|
+
|
|
1839
|
+
# Check if line is an answer (starts with answer marker)
|
|
1840
|
+
is_answer = any(line_stripped.startswith(marker) for marker in answer_markers)
|
|
1841
|
+
|
|
1842
|
+
if is_question:
|
|
1843
|
+
# Save previous chunk if exists
|
|
1844
|
+
if current_chunk and current_size >= context_size:
|
|
1845
|
+
chunk_text = '\n'.join(current_chunk)
|
|
1846
|
+
end_char = start_char + len(chunk_text)
|
|
1847
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1848
|
+
start_char = end_char
|
|
1849
|
+
current_chunk = []
|
|
1850
|
+
current_size = 0
|
|
1851
|
+
|
|
1852
|
+
# Start new Q&A chunk
|
|
1853
|
+
current_chunk.append(line)
|
|
1854
|
+
current_size = len(line)
|
|
1855
|
+
in_qa_pair = True
|
|
1856
|
+
elif is_answer or in_qa_pair:
|
|
1857
|
+
# Add answer/context to current chunk
|
|
1858
|
+
current_chunk.append(line)
|
|
1859
|
+
current_size += len(line) + 1
|
|
1860
|
+
|
|
1861
|
+
# If we've collected enough context, save chunk
|
|
1862
|
+
if current_size >= context_size:
|
|
1863
|
+
chunk_text = '\n'.join(current_chunk)
|
|
1864
|
+
end_char = start_char + len(chunk_text)
|
|
1865
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1866
|
+
start_char = end_char
|
|
1867
|
+
current_chunk = []
|
|
1868
|
+
current_size = 0
|
|
1869
|
+
in_qa_pair = False
|
|
1870
|
+
else:
|
|
1871
|
+
# Regular text
|
|
1872
|
+
if in_qa_pair:
|
|
1873
|
+
# Add to current Q&A chunk
|
|
1874
|
+
current_chunk.append(line)
|
|
1875
|
+
current_size += len(line) + 1
|
|
1876
|
+
else:
|
|
1877
|
+
# Start new chunk
|
|
1878
|
+
if current_chunk:
|
|
1879
|
+
chunk_text = '\n'.join(current_chunk)
|
|
1880
|
+
end_char = start_char + len(chunk_text)
|
|
1881
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1882
|
+
start_char = end_char
|
|
1883
|
+
current_chunk = [line]
|
|
1884
|
+
current_size = len(line)
|
|
1885
|
+
|
|
1886
|
+
# Add final chunk
|
|
1887
|
+
if current_chunk:
|
|
1888
|
+
chunk_text = '\n'.join(current_chunk)
|
|
1889
|
+
end_char = start_char + len(chunk_text)
|
|
1890
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1891
|
+
|
|
1892
|
+
return chunks if chunks else self._chunk_semantic(text, 1000, 200)
|
|
1893
|
+
|
|
1894
|
+
def _chunk_code_aware(self, page: Dict[str, Any], chunk_size: int, overlap: int,
|
|
1895
|
+
preserve_code_blocks: bool = True, code_context: int = 200,
|
|
1896
|
+
min_code_block_size: int = 50, **kwargs) -> List[tuple]:
|
|
1897
|
+
"""
|
|
1898
|
+
Code-aware chunking - preserves code blocks.
|
|
1899
|
+
|
|
1900
|
+
Args:
|
|
1901
|
+
page: Page dictionary with normalized blocks
|
|
1902
|
+
chunk_size: Chunk size (default: 1000)
|
|
1903
|
+
overlap: Overlap (default: 200)
|
|
1904
|
+
preserve_code_blocks: Keep code blocks intact (default: True)
|
|
1905
|
+
code_context: Context around code (default: 200)
|
|
1906
|
+
min_code_block_size: Minimum code block size (default: 50)
|
|
1907
|
+
|
|
1908
|
+
Returns:
|
|
1909
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
1910
|
+
"""
|
|
1911
|
+
normalized_blocks = page.get("normalized_blocks", [])
|
|
1912
|
+
flat_text = page.get("flat_text", "")
|
|
1913
|
+
|
|
1914
|
+
if not normalized_blocks:
|
|
1915
|
+
return self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
1916
|
+
|
|
1917
|
+
chunks = []
|
|
1918
|
+
current_chunk = []
|
|
1919
|
+
current_size = 0
|
|
1920
|
+
start_char = 0
|
|
1921
|
+
|
|
1922
|
+
for block in normalized_blocks:
|
|
1923
|
+
block_type = block.get("type", "paragraph")
|
|
1924
|
+
block_text = block.get("text", "")
|
|
1925
|
+
|
|
1926
|
+
# Detect code blocks (simple heuristic: lines starting with common code patterns)
|
|
1927
|
+
is_code = False
|
|
1928
|
+
if block_type in ["code", "pre"]:
|
|
1929
|
+
is_code = True
|
|
1930
|
+
elif block_text:
|
|
1931
|
+
# Check for code indicators
|
|
1932
|
+
code_indicators = ["def ", "class ", "import ", "function ", "{", "}", "=", "()", "[]"]
|
|
1933
|
+
code_line_count = sum(1 for line in block_text.split('\n')
|
|
1934
|
+
if any(indicator in line for indicator in code_indicators))
|
|
1935
|
+
is_code = code_line_count > len(block_text.split('\n')) * 0.3
|
|
1936
|
+
|
|
1937
|
+
if is_code and preserve_code_blocks:
|
|
1938
|
+
# Save previous chunk
|
|
1939
|
+
if current_chunk and current_size > 0:
|
|
1940
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
1941
|
+
end_char = start_char + len(chunk_text)
|
|
1942
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1943
|
+
start_char = end_char
|
|
1944
|
+
current_chunk = []
|
|
1945
|
+
current_size = 0
|
|
1946
|
+
|
|
1947
|
+
# Add code block as separate chunk with context
|
|
1948
|
+
if len(block_text) >= min_code_block_size:
|
|
1949
|
+
# Add context before code
|
|
1950
|
+
context_before = flat_text[max(0, start_char - code_context):start_char]
|
|
1951
|
+
if context_before:
|
|
1952
|
+
current_chunk.append(context_before)
|
|
1953
|
+
|
|
1954
|
+
# Add code block
|
|
1955
|
+
current_chunk.append(block_text)
|
|
1956
|
+
current_size = len(block_text) + len(context_before)
|
|
1957
|
+
|
|
1958
|
+
# Save code chunk
|
|
1959
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
1960
|
+
end_char = start_char + len(chunk_text)
|
|
1961
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1962
|
+
start_char = end_char
|
|
1963
|
+
current_chunk = []
|
|
1964
|
+
current_size = 0
|
|
1965
|
+
else:
|
|
1966
|
+
# Regular text block
|
|
1967
|
+
current_chunk.append(block_text)
|
|
1968
|
+
current_size += len(block_text) + 2
|
|
1969
|
+
|
|
1970
|
+
# If chunk is large enough, save it
|
|
1971
|
+
if current_size >= chunk_size:
|
|
1972
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
1973
|
+
end_char = start_char + len(chunk_text)
|
|
1974
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1975
|
+
start_char = end_char
|
|
1976
|
+
current_chunk = []
|
|
1977
|
+
current_size = 0
|
|
1978
|
+
|
|
1979
|
+
# Add final chunk
|
|
1980
|
+
if current_chunk:
|
|
1981
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
1982
|
+
end_char = start_char + len(chunk_text)
|
|
1983
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
1984
|
+
|
|
1985
|
+
return chunks if chunks else self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
1986
|
+
|
|
1987
|
+
def _chunk_image_aware(self, page: Dict[str, Any], normalized_doc: Dict[str, Any],
|
|
1988
|
+
chunk_size: int, overlap: int, include_captions: bool = True,
|
|
1989
|
+
caption_context: int = 300, image_chunk_size: int = 200, **kwargs) -> List[tuple]:
|
|
1990
|
+
"""
|
|
1991
|
+
Image-aware chunking - includes image context and captions.
|
|
1992
|
+
|
|
1993
|
+
Args:
|
|
1994
|
+
page: Page dictionary
|
|
1995
|
+
normalized_doc: Full normalized document
|
|
1996
|
+
chunk_size: Chunk size (default: 1000)
|
|
1997
|
+
overlap: Overlap (default: 200)
|
|
1998
|
+
include_captions: Include image captions (default: True)
|
|
1999
|
+
caption_context: Context around images (default: 300)
|
|
2000
|
+
image_chunk_size: Size for image chunks (default: 200)
|
|
2001
|
+
|
|
2002
|
+
Returns:
|
|
2003
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2004
|
+
"""
|
|
2005
|
+
flat_text = page.get("flat_text", "")
|
|
2006
|
+
page_no = page.get("page_no", 0)
|
|
2007
|
+
|
|
2008
|
+
# Get images for this page
|
|
2009
|
+
# NOTE: We only use image metadata (caption, id, path) - NOT image bytes
|
|
2010
|
+
# This prevents loading large image data into memory during chunking
|
|
2011
|
+
images = normalized_doc.get("content", {}).get("images", [])
|
|
2012
|
+
page_images = [img for img in images if img.get("from_page", 0) == page_no]
|
|
2013
|
+
|
|
2014
|
+
if not page_images:
|
|
2015
|
+
return self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
2016
|
+
|
|
2017
|
+
chunks = []
|
|
2018
|
+
current_pos = 0
|
|
2019
|
+
|
|
2020
|
+
for image in page_images:
|
|
2021
|
+
# Get image metadata only (caption, id, path) - do NOT load image bytes
|
|
2022
|
+
# Image bytes are stored separately and should not be loaded into memory here
|
|
2023
|
+
# Images are for reference only - chunks contain only image placeholders and metadata
|
|
2024
|
+
caption = image.get("caption", "") or image.get("description", "")
|
|
2025
|
+
image_id = image.get("id", f"image_{page_no}_{len(chunks)}")
|
|
2026
|
+
image_path = image.get("path", "")
|
|
2027
|
+
|
|
2028
|
+
# CRITICAL: Ensure we're not accidentally including image bytes
|
|
2029
|
+
# (image_bytes_base64 should not be present, but check to be safe)
|
|
2030
|
+
# If image bytes are found, exclude them and log a warning
|
|
2031
|
+
if "image_bytes" in image:
|
|
2032
|
+
logger.warning(f"[CHUNK] Image {image_id} contains 'image_bytes' - removing from chunking data (images are reference-only)")
|
|
2033
|
+
# Remove image bytes to prevent loading into memory
|
|
2034
|
+
image = {k: v for k, v in image.items() if k != "image_bytes"}
|
|
2035
|
+
if "image_bytes_base64" in image:
|
|
2036
|
+
logger.warning(f"[CHUNK] Image {image_id} contains 'image_bytes_base64' - removing from chunking data (images are reference-only)")
|
|
2037
|
+
# Remove base64 image data to prevent loading into memory
|
|
2038
|
+
image = {k: v for k, v in image.items() if k != "image_bytes_base64"}
|
|
2039
|
+
|
|
2040
|
+
# Create image reference placeholder - images are NOT loaded into chunks
|
|
2041
|
+
# Only include image ID and caption (if available) as text reference
|
|
2042
|
+
image_text = f"[IMAGE: {image_id}]"
|
|
2043
|
+
if image_path:
|
|
2044
|
+
image_text += f" (path: {image_path})"
|
|
2045
|
+
if caption:
|
|
2046
|
+
image_text += f"\nCaption: {caption}"
|
|
2047
|
+
|
|
2048
|
+
# Chunk text before image
|
|
2049
|
+
if current_pos < len(flat_text):
|
|
2050
|
+
before_text = flat_text[current_pos:]
|
|
2051
|
+
if include_captions:
|
|
2052
|
+
# Include context before image
|
|
2053
|
+
context_start = max(0, current_pos - caption_context)
|
|
2054
|
+
before_text = flat_text[context_start:current_pos]
|
|
2055
|
+
|
|
2056
|
+
if before_text.strip():
|
|
2057
|
+
before_chunks = self._chunk_semantic(before_text, chunk_size, overlap)
|
|
2058
|
+
for chunk_text, chunk_start, chunk_end in before_chunks:
|
|
2059
|
+
chunks.append((chunk_text, context_start + chunk_start if include_captions else current_pos + chunk_start,
|
|
2060
|
+
context_start + chunk_end if include_captions else current_pos + chunk_end))
|
|
2061
|
+
if before_chunks:
|
|
2062
|
+
current_pos = context_start + before_chunks[-1][2] if include_captions else current_pos + before_chunks[-1][2]
|
|
2063
|
+
|
|
2064
|
+
# Add image chunk
|
|
2065
|
+
if include_captions and caption:
|
|
2066
|
+
chunks.append((image_text, current_pos, current_pos + len(image_text)))
|
|
2067
|
+
current_pos += len(image_text)
|
|
2068
|
+
|
|
2069
|
+
# Chunk remaining text
|
|
2070
|
+
if current_pos < len(flat_text):
|
|
2071
|
+
remaining_text = flat_text[current_pos:]
|
|
2072
|
+
if remaining_text.strip():
|
|
2073
|
+
remaining_chunks = self._chunk_semantic(remaining_text, chunk_size, overlap)
|
|
2074
|
+
for chunk_text, chunk_start, chunk_end in remaining_chunks:
|
|
2075
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
2076
|
+
|
|
2077
|
+
return chunks if chunks else self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
2078
|
+
|
|
2079
|
+
def _chunk_adaptive(self, text: str, base_chunk_size: int = 1000, overlap: int = 200,
|
|
2080
|
+
complexity_threshold: float = 0.5, density_factor: float = 1.2, **kwargs) -> List[tuple]:
|
|
2081
|
+
"""
|
|
2082
|
+
Adaptive chunking - adjusts chunk size based on content complexity.
|
|
2083
|
+
|
|
2084
|
+
Args:
|
|
2085
|
+
text: Text to chunk
|
|
2086
|
+
base_chunk_size: Base chunk size (default: 1000)
|
|
2087
|
+
overlap: Overlap (default: 200)
|
|
2088
|
+
complexity_threshold: Complexity threshold (default: 0.5)
|
|
2089
|
+
density_factor: Density adjustment factor (default: 1.2)
|
|
2090
|
+
|
|
2091
|
+
Returns:
|
|
2092
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2093
|
+
"""
|
|
2094
|
+
import re
|
|
2095
|
+
|
|
2096
|
+
# Calculate complexity metrics
|
|
2097
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
2098
|
+
avg_sentence_length = sum(len(s) for s in sentences) / len(sentences) if sentences else 0
|
|
2099
|
+
|
|
2100
|
+
# Count complex words (long words, technical terms)
|
|
2101
|
+
words = re.findall(r'\b\w+\b', text)
|
|
2102
|
+
complex_words = sum(1 for w in words if len(w) > 6)
|
|
2103
|
+
complexity_ratio = complex_words / len(words) if words else 0
|
|
2104
|
+
|
|
2105
|
+
# Adjust chunk size based on complexity
|
|
2106
|
+
if complexity_ratio > complexity_threshold or avg_sentence_length > 150:
|
|
2107
|
+
# High complexity - use smaller chunks
|
|
2108
|
+
adjusted_chunk_size = int(base_chunk_size / density_factor)
|
|
2109
|
+
elif complexity_ratio < 0.2 and avg_sentence_length < 50:
|
|
2110
|
+
# Low complexity - use larger chunks
|
|
2111
|
+
adjusted_chunk_size = int(base_chunk_size * density_factor)
|
|
2112
|
+
else:
|
|
2113
|
+
adjusted_chunk_size = base_chunk_size
|
|
2114
|
+
|
|
2115
|
+
# Use semantic chunking with adjusted size
|
|
2116
|
+
return self._chunk_semantic(text, adjusted_chunk_size, overlap)
|
|
2117
|
+
|
|
2118
|
+
def _chunk_cross_page(self, pages: List[Dict[str, Any]], page_index: int, chunk_size: int, overlap: int,
|
|
2119
|
+
context_pages: int = 1, context_size: int = 200, **kwargs) -> List[tuple]:
|
|
2120
|
+
"""
|
|
2121
|
+
Cross-page context chunking - includes context from adjacent pages.
|
|
2122
|
+
|
|
2123
|
+
Args:
|
|
2124
|
+
pages: List of all pages
|
|
2125
|
+
page_index: Current page index
|
|
2126
|
+
chunk_size: Chunk size (default: 1000)
|
|
2127
|
+
overlap: Overlap (default: 200)
|
|
2128
|
+
context_pages: Number of adjacent pages (default: 1)
|
|
2129
|
+
context_size: Size of context from adjacent pages (default: 200)
|
|
2130
|
+
|
|
2131
|
+
Returns:
|
|
2132
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2133
|
+
"""
|
|
2134
|
+
if page_index >= len(pages):
|
|
2135
|
+
return []
|
|
2136
|
+
|
|
2137
|
+
current_page = pages[page_index]
|
|
2138
|
+
page_text = current_page.get("flat_text", "")
|
|
2139
|
+
|
|
2140
|
+
# Add context from previous page
|
|
2141
|
+
if page_index > 0 and context_pages > 0:
|
|
2142
|
+
prev_page = pages[page_index - 1]
|
|
2143
|
+
prev_text = prev_page.get("flat_text", "")
|
|
2144
|
+
context_from_prev = prev_text[-context_size:] if len(prev_text) > context_size else prev_text
|
|
2145
|
+
page_text = context_from_prev + "\n\n" + page_text
|
|
2146
|
+
|
|
2147
|
+
# Add context from next page
|
|
2148
|
+
if page_index < len(pages) - 1 and context_pages > 0:
|
|
2149
|
+
next_page = pages[page_index + 1]
|
|
2150
|
+
next_text = next_page.get("flat_text", "")
|
|
2151
|
+
context_from_next = next_text[:context_size] if len(next_text) > context_size else next_text
|
|
2152
|
+
page_text = page_text + "\n\n" + context_from_next
|
|
2153
|
+
|
|
2154
|
+
# Chunk with extended context
|
|
2155
|
+
return self._chunk_semantic(page_text, chunk_size, overlap)
|
|
2156
|
+
|
|
2157
|
+
def _chunk_entity_aware(self, text: str, chunk_size: int, overlap: int,
|
|
2158
|
+
entity_types: Optional[List[str]] = None, **kwargs) -> List[tuple]:
|
|
2159
|
+
"""
|
|
2160
|
+
Entity-aware chunking - ensures entities stay in same chunk.
|
|
2161
|
+
|
|
2162
|
+
Args:
|
|
2163
|
+
text: Text to chunk
|
|
2164
|
+
chunk_size: Chunk size (default: 1000)
|
|
2165
|
+
overlap: Overlap (default: 200)
|
|
2166
|
+
entity_types: Types of entities to preserve (default: ["PERSON", "ORG", "LOC"])
|
|
2167
|
+
|
|
2168
|
+
Returns:
|
|
2169
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2170
|
+
"""
|
|
2171
|
+
if entity_types is None:
|
|
2172
|
+
entity_types = ["PERSON", "ORG", "LOC"]
|
|
2173
|
+
|
|
2174
|
+
# Simple entity detection using patterns (for full NER, would need spaCy/NLTK)
|
|
2175
|
+
import re
|
|
2176
|
+
|
|
2177
|
+
# Common entity patterns
|
|
2178
|
+
entity_patterns = {
|
|
2179
|
+
"PERSON": r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', # "John Smith"
|
|
2180
|
+
"ORG": r'\b[A-Z][A-Za-z]+ (Inc|Corp|LLC|Ltd|Company)\b', # "Apple Inc"
|
|
2181
|
+
"LOC": r'\b[A-Z][a-z]+(?: [A-Z][a-z]+)*(?: City|State|Country)\b', # "New York City"
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
# Find entities
|
|
2185
|
+
entities = []
|
|
2186
|
+
for entity_type in entity_types:
|
|
2187
|
+
if entity_type in entity_patterns:
|
|
2188
|
+
matches = re.finditer(entity_patterns[entity_type], text)
|
|
2189
|
+
for match in matches:
|
|
2190
|
+
entities.append((match.start(), match.end(), entity_type))
|
|
2191
|
+
|
|
2192
|
+
# Sort entities by position
|
|
2193
|
+
entities.sort(key=lambda x: x[0])
|
|
2194
|
+
|
|
2195
|
+
if not entities:
|
|
2196
|
+
# No entities found, use semantic chunking
|
|
2197
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2198
|
+
|
|
2199
|
+
# Chunk while preserving entities
|
|
2200
|
+
chunks = []
|
|
2201
|
+
current_chunk = []
|
|
2202
|
+
current_size = 0
|
|
2203
|
+
start_char = 0
|
|
2204
|
+
entity_index = 0
|
|
2205
|
+
|
|
2206
|
+
# Split into sentences
|
|
2207
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
2208
|
+
|
|
2209
|
+
for sentence in sentences:
|
|
2210
|
+
sentence_start = text.find(sentence, start_char + current_size)
|
|
2211
|
+
sentence_end = sentence_start + len(sentence)
|
|
2212
|
+
|
|
2213
|
+
# Check if sentence contains entities
|
|
2214
|
+
sentence_entities = [e for e in entities if sentence_start <= e[0] < sentence_end]
|
|
2215
|
+
|
|
2216
|
+
# If adding this sentence would exceed chunk_size and it has entities, start new chunk
|
|
2217
|
+
if (current_size + len(sentence) > chunk_size and
|
|
2218
|
+
sentence_entities and current_chunk):
|
|
2219
|
+
# Save current chunk
|
|
2220
|
+
chunk_text = ' '.join(current_chunk)
|
|
2221
|
+
end_char = start_char + len(chunk_text)
|
|
2222
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2223
|
+
start_char = end_char
|
|
2224
|
+
current_chunk = [sentence]
|
|
2225
|
+
current_size = len(sentence)
|
|
2226
|
+
else:
|
|
2227
|
+
current_chunk.append(sentence)
|
|
2228
|
+
current_size += len(sentence) + 1
|
|
2229
|
+
|
|
2230
|
+
# Add final chunk
|
|
2231
|
+
if current_chunk:
|
|
2232
|
+
chunk_text = ' '.join(current_chunk)
|
|
2233
|
+
end_char = start_char + len(chunk_text)
|
|
2234
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2235
|
+
|
|
2236
|
+
return chunks if chunks else self._chunk_semantic(text, chunk_size, overlap)
|
|
2237
|
+
|
|
2238
|
+
def _chunk_multilang(self, text: str, chunk_size: int, overlap: int,
|
|
2239
|
+
per_language_chunking: bool = True, **kwargs) -> List[tuple]:
|
|
2240
|
+
"""
|
|
2241
|
+
Multi-language chunking - handles multiple languages.
|
|
2242
|
+
|
|
2243
|
+
Args:
|
|
2244
|
+
text: Text to chunk
|
|
2245
|
+
chunk_size: Chunk size (default: 1000)
|
|
2246
|
+
overlap: Overlap (default: 200)
|
|
2247
|
+
per_language_chunking: Chunk per language (default: True)
|
|
2248
|
+
|
|
2249
|
+
Returns:
|
|
2250
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2251
|
+
"""
|
|
2252
|
+
try:
|
|
2253
|
+
# Try language detection
|
|
2254
|
+
try:
|
|
2255
|
+
from langdetect import detect_langs, DetectorFactory
|
|
2256
|
+
DetectorFactory.seed = 0 # For consistent results
|
|
2257
|
+
|
|
2258
|
+
# Detect language of full text
|
|
2259
|
+
languages = detect_langs(text)
|
|
2260
|
+
primary_lang = languages[0].lang if languages else 'en'
|
|
2261
|
+
|
|
2262
|
+
if per_language_chunking and len(languages) > 1:
|
|
2263
|
+
# Multiple languages detected - chunk per language
|
|
2264
|
+
# Simple approach: use semantic chunking (works for most languages)
|
|
2265
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2266
|
+
else:
|
|
2267
|
+
# Single language or per-language disabled
|
|
2268
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2269
|
+
except ImportError:
|
|
2270
|
+
logger.warning("langdetect not available, using semantic chunking")
|
|
2271
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2272
|
+
except Exception as e:
|
|
2273
|
+
logger.warning(f"Language detection failed: {e}, using semantic chunking")
|
|
2274
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2275
|
+
|
|
2276
|
+
def _chunk_citation_aware(self, text: str, chunk_size: int, overlap: int,
|
|
2277
|
+
citation_patterns: Optional[List[str]] = None,
|
|
2278
|
+
citation_context: int = 300, **kwargs) -> List[tuple]:
|
|
2279
|
+
"""
|
|
2280
|
+
Citation-aware chunking - preserves citations with context.
|
|
2281
|
+
|
|
2282
|
+
Args:
|
|
2283
|
+
text: Text to chunk
|
|
2284
|
+
chunk_size: Chunk size (default: 1000)
|
|
2285
|
+
overlap: Overlap (default: 200)
|
|
2286
|
+
citation_patterns: Citation patterns (default: ["[1]", "(Smith, 2020)", "et al."])
|
|
2287
|
+
citation_context: Context around citations (default: 300)
|
|
2288
|
+
|
|
2289
|
+
Returns:
|
|
2290
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2291
|
+
"""
|
|
2292
|
+
if citation_patterns is None:
|
|
2293
|
+
citation_patterns = [
|
|
2294
|
+
r'\[\d+\]', # [1], [2], etc.
|
|
2295
|
+
r'\([A-Z][a-z]+,?\s+\d{4}\)', # (Smith, 2020)
|
|
2296
|
+
r'et al\.', # et al.
|
|
2297
|
+
r'\([A-Z][a-z]+ et al\.', # (Smith et al.
|
|
2298
|
+
]
|
|
2299
|
+
|
|
2300
|
+
import re
|
|
2301
|
+
|
|
2302
|
+
# Find all citations
|
|
2303
|
+
citations = []
|
|
2304
|
+
for pattern in citation_patterns:
|
|
2305
|
+
matches = re.finditer(pattern, text)
|
|
2306
|
+
for match in matches:
|
|
2307
|
+
citations.append((match.start(), match.end()))
|
|
2308
|
+
|
|
2309
|
+
# Sort by position
|
|
2310
|
+
citations.sort(key=lambda x: x[0])
|
|
2311
|
+
|
|
2312
|
+
if not citations:
|
|
2313
|
+
# No citations, use semantic chunking
|
|
2314
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2315
|
+
|
|
2316
|
+
# Chunk while preserving citation context
|
|
2317
|
+
chunks = []
|
|
2318
|
+
current_pos = 0
|
|
2319
|
+
|
|
2320
|
+
for cit_start, cit_end in citations:
|
|
2321
|
+
# Chunk text before citation
|
|
2322
|
+
if cit_start > current_pos:
|
|
2323
|
+
before_text = text[current_pos:cit_start]
|
|
2324
|
+
if before_text.strip():
|
|
2325
|
+
before_chunks = self._chunk_semantic(before_text, chunk_size, overlap)
|
|
2326
|
+
for chunk_text, chunk_start, chunk_end in before_chunks:
|
|
2327
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
2328
|
+
if before_chunks:
|
|
2329
|
+
current_pos = current_pos + before_chunks[-1][2]
|
|
2330
|
+
|
|
2331
|
+
# Include citation with context
|
|
2332
|
+
context_start = max(0, cit_start - citation_context)
|
|
2333
|
+
context_end = min(len(text), cit_end + citation_context)
|
|
2334
|
+
citation_chunk = text[context_start:context_end]
|
|
2335
|
+
|
|
2336
|
+
if citation_chunk.strip():
|
|
2337
|
+
chunks.append((citation_chunk, context_start, context_end))
|
|
2338
|
+
current_pos = context_end
|
|
2339
|
+
|
|
2340
|
+
# Chunk remaining text
|
|
2341
|
+
if current_pos < len(text):
|
|
2342
|
+
remaining_text = text[current_pos:]
|
|
2343
|
+
if remaining_text.strip():
|
|
2344
|
+
remaining_chunks = self._chunk_semantic(remaining_text, chunk_size, overlap)
|
|
2345
|
+
for chunk_text, chunk_start, chunk_end in remaining_chunks:
|
|
2346
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
2347
|
+
|
|
2348
|
+
return chunks if chunks else self._chunk_semantic(text, chunk_size, overlap)
|
|
2349
|
+
|
|
2350
|
+
def _chunk_dialogue_aware(self, text: str, chunk_size: int, overlap: int,
|
|
2351
|
+
speaker_detection: bool = True, preserve_turns: bool = True, **kwargs) -> List[tuple]:
|
|
2352
|
+
"""
|
|
2353
|
+
Dialogue-aware chunking - preserves dialogue structure.
|
|
2354
|
+
|
|
2355
|
+
Args:
|
|
2356
|
+
text: Text to chunk
|
|
2357
|
+
chunk_size: Chunk size (default: 1000)
|
|
2358
|
+
overlap: Overlap (default: 200)
|
|
2359
|
+
speaker_detection: Detect speakers (default: True)
|
|
2360
|
+
preserve_turns: Keep dialogue turns together (default: True)
|
|
2361
|
+
|
|
2362
|
+
Returns:
|
|
2363
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2364
|
+
"""
|
|
2365
|
+
import re
|
|
2366
|
+
|
|
2367
|
+
# Detect dialogue patterns
|
|
2368
|
+
# Common patterns: "Speaker: text", "Speaker said:", etc.
|
|
2369
|
+
dialogue_pattern = r'^([A-Z][A-Za-z\s]+?):\s+(.+)$'
|
|
2370
|
+
|
|
2371
|
+
lines = text.split('\n')
|
|
2372
|
+
chunks = []
|
|
2373
|
+
current_dialogue = []
|
|
2374
|
+
current_size = 0
|
|
2375
|
+
start_char = 0
|
|
2376
|
+
|
|
2377
|
+
for line in lines:
|
|
2378
|
+
line_match = re.match(dialogue_pattern, line)
|
|
2379
|
+
|
|
2380
|
+
if line_match and preserve_turns:
|
|
2381
|
+
# This is a dialogue line
|
|
2382
|
+
if current_dialogue and current_size + len(line) > chunk_size:
|
|
2383
|
+
# Save current dialogue chunk
|
|
2384
|
+
chunk_text = '\n'.join(current_dialogue)
|
|
2385
|
+
end_char = start_char + len(chunk_text)
|
|
2386
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2387
|
+
start_char = end_char
|
|
2388
|
+
current_dialogue = [line]
|
|
2389
|
+
current_size = len(line)
|
|
2390
|
+
else:
|
|
2391
|
+
current_dialogue.append(line)
|
|
2392
|
+
current_size += len(line) + 1
|
|
2393
|
+
else:
|
|
2394
|
+
# Not dialogue, save current dialogue if exists
|
|
2395
|
+
if current_dialogue:
|
|
2396
|
+
chunk_text = '\n'.join(current_dialogue)
|
|
2397
|
+
end_char = start_char + len(chunk_text)
|
|
2398
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2399
|
+
start_char = end_char
|
|
2400
|
+
current_dialogue = []
|
|
2401
|
+
current_size = 0
|
|
2402
|
+
|
|
2403
|
+
# Add regular text
|
|
2404
|
+
if line.strip():
|
|
2405
|
+
chunks.append((line, start_char, start_char + len(line)))
|
|
2406
|
+
start_char += len(line) + 1
|
|
2407
|
+
|
|
2408
|
+
# Add final dialogue chunk
|
|
2409
|
+
if current_dialogue:
|
|
2410
|
+
chunk_text = '\n'.join(current_dialogue)
|
|
2411
|
+
end_char = start_char + len(chunk_text)
|
|
2412
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2413
|
+
|
|
2414
|
+
return chunks if chunks else self._chunk_semantic(text, chunk_size, overlap)
|
|
2415
|
+
|
|
2416
|
+
def _chunk_formula_aware(self, text: str, chunk_size: int, overlap: int,
|
|
2417
|
+
formula_patterns: Optional[List[str]] = None,
|
|
2418
|
+
formula_context: int = 200, **kwargs) -> List[tuple]:
|
|
2419
|
+
"""
|
|
2420
|
+
Formula-aware chunking - preserves mathematical formulas.
|
|
2421
|
+
|
|
2422
|
+
Args:
|
|
2423
|
+
text: Text to chunk
|
|
2424
|
+
chunk_size: Chunk size (default: 1000)
|
|
2425
|
+
overlap: Overlap (default: 200)
|
|
2426
|
+
formula_patterns: Formula patterns (default: LaTeX, MathML patterns)
|
|
2427
|
+
formula_context: Context around formulas (default: 200)
|
|
2428
|
+
|
|
2429
|
+
Returns:
|
|
2430
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2431
|
+
"""
|
|
2432
|
+
if formula_patterns is None:
|
|
2433
|
+
# Common formula patterns
|
|
2434
|
+
formula_patterns = [
|
|
2435
|
+
r'\$[^$]+\$', # LaTeX inline: $formula$
|
|
2436
|
+
r'\$\$[^$]+\$\$', # LaTeX display: $$formula$$
|
|
2437
|
+
r'\\begin\{equation\}.*?\\end\{equation\}', # LaTeX equation
|
|
2438
|
+
r'<math>.*?</math>', # MathML
|
|
2439
|
+
]
|
|
2440
|
+
|
|
2441
|
+
import re
|
|
2442
|
+
|
|
2443
|
+
# Find all formulas
|
|
2444
|
+
formulas = []
|
|
2445
|
+
for pattern in formula_patterns:
|
|
2446
|
+
matches = re.finditer(pattern, text, re.DOTALL)
|
|
2447
|
+
for match in matches:
|
|
2448
|
+
formulas.append((match.start(), match.end()))
|
|
2449
|
+
|
|
2450
|
+
# Sort by position
|
|
2451
|
+
formulas.sort(key=lambda x: x[0])
|
|
2452
|
+
|
|
2453
|
+
if not formulas:
|
|
2454
|
+
# No formulas, use semantic chunking
|
|
2455
|
+
return self._chunk_semantic(text, chunk_size, overlap)
|
|
2456
|
+
|
|
2457
|
+
# Chunk while preserving formula context (similar to citation_aware)
|
|
2458
|
+
chunks = []
|
|
2459
|
+
current_pos = 0
|
|
2460
|
+
|
|
2461
|
+
for form_start, form_end in formulas:
|
|
2462
|
+
# Chunk text before formula
|
|
2463
|
+
if form_start > current_pos:
|
|
2464
|
+
before_text = text[current_pos:form_start]
|
|
2465
|
+
if before_text.strip():
|
|
2466
|
+
before_chunks = self._chunk_semantic(before_text, chunk_size, overlap)
|
|
2467
|
+
for chunk_text, chunk_start, chunk_end in before_chunks:
|
|
2468
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
2469
|
+
if before_chunks:
|
|
2470
|
+
current_pos = current_pos + before_chunks[-1][2]
|
|
2471
|
+
|
|
2472
|
+
# Include formula with context
|
|
2473
|
+
context_start = max(0, form_start - formula_context)
|
|
2474
|
+
context_end = min(len(text), form_end + formula_context)
|
|
2475
|
+
formula_chunk = text[context_start:context_end]
|
|
2476
|
+
|
|
2477
|
+
if formula_chunk.strip():
|
|
2478
|
+
chunks.append((formula_chunk, context_start, context_end))
|
|
2479
|
+
current_pos = context_end
|
|
2480
|
+
|
|
2481
|
+
# Chunk remaining text
|
|
2482
|
+
if current_pos < len(text):
|
|
2483
|
+
remaining_text = text[current_pos:]
|
|
2484
|
+
if remaining_text.strip():
|
|
2485
|
+
remaining_chunks = self._chunk_semantic(remaining_text, chunk_size, overlap)
|
|
2486
|
+
for chunk_text, chunk_start, chunk_end in remaining_chunks:
|
|
2487
|
+
chunks.append((chunk_text, current_pos + chunk_start, current_pos + chunk_end))
|
|
2488
|
+
|
|
2489
|
+
return chunks if chunks else self._chunk_semantic(text, chunk_size, overlap)
|
|
2490
|
+
|
|
2491
|
+
def _chunk_list_aware(self, page: Dict[str, Any], chunk_size: int, overlap: int,
|
|
2492
|
+
preserve_lists: bool = True, list_context: int = 200, **kwargs) -> List[tuple]:
|
|
2493
|
+
"""
|
|
2494
|
+
List-aware chunking - preserves list structure.
|
|
2495
|
+
|
|
2496
|
+
Args:
|
|
2497
|
+
page: Page dictionary with normalized blocks
|
|
2498
|
+
chunk_size: Chunk size (default: 1000)
|
|
2499
|
+
overlap: Overlap (default: 200)
|
|
2500
|
+
preserve_lists: Keep lists intact (default: True)
|
|
2501
|
+
list_context: Context around lists (default: 200)
|
|
2502
|
+
|
|
2503
|
+
Returns:
|
|
2504
|
+
List of (chunk_text, start_char, end_char) tuples
|
|
2505
|
+
"""
|
|
2506
|
+
normalized_blocks = page.get("normalized_blocks", [])
|
|
2507
|
+
flat_text = page.get("flat_text", "")
|
|
2508
|
+
|
|
2509
|
+
if not normalized_blocks:
|
|
2510
|
+
return self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
2511
|
+
|
|
2512
|
+
chunks = []
|
|
2513
|
+
current_chunk = []
|
|
2514
|
+
current_size = 0
|
|
2515
|
+
start_char = 0
|
|
2516
|
+
in_list = False
|
|
2517
|
+
|
|
2518
|
+
for block in normalized_blocks:
|
|
2519
|
+
block_type = block.get("type", "paragraph")
|
|
2520
|
+
block_text = block.get("text", "")
|
|
2521
|
+
|
|
2522
|
+
# Detect lists
|
|
2523
|
+
is_list = block_type in ["list", "list_item"] or (
|
|
2524
|
+
block_text.strip().startswith(('-', '*', '•', '1.', '2.', '3.'))
|
|
2525
|
+
)
|
|
2526
|
+
|
|
2527
|
+
if is_list and preserve_lists:
|
|
2528
|
+
# Save previous chunk
|
|
2529
|
+
if current_chunk and current_size > 0:
|
|
2530
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
2531
|
+
end_char = start_char + len(chunk_text)
|
|
2532
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2533
|
+
start_char = end_char
|
|
2534
|
+
current_chunk = []
|
|
2535
|
+
current_size = 0
|
|
2536
|
+
|
|
2537
|
+
# Add list as separate chunk with context
|
|
2538
|
+
# Add context before list
|
|
2539
|
+
context_before = flat_text[max(0, start_char - list_context):start_char]
|
|
2540
|
+
if context_before:
|
|
2541
|
+
current_chunk.append(context_before)
|
|
2542
|
+
|
|
2543
|
+
# Add list
|
|
2544
|
+
current_chunk.append(block_text)
|
|
2545
|
+
current_size = len(block_text) + len(context_before)
|
|
2546
|
+
|
|
2547
|
+
# Save list chunk
|
|
2548
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
2549
|
+
end_char = start_char + len(chunk_text)
|
|
2550
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2551
|
+
start_char = end_char
|
|
2552
|
+
current_chunk = []
|
|
2553
|
+
current_size = 0
|
|
2554
|
+
in_list = True
|
|
2555
|
+
else:
|
|
2556
|
+
# Regular text block
|
|
2557
|
+
if in_list:
|
|
2558
|
+
# Add context after list
|
|
2559
|
+
context_after = block_text[:list_context]
|
|
2560
|
+
if context_after:
|
|
2561
|
+
current_chunk.append(context_after)
|
|
2562
|
+
current_size += len(context_after)
|
|
2563
|
+
in_list = False
|
|
2564
|
+
|
|
2565
|
+
current_chunk.append(block_text)
|
|
2566
|
+
current_size += len(block_text) + 2
|
|
2567
|
+
|
|
2568
|
+
# If chunk is large enough, save it
|
|
2569
|
+
if current_size >= chunk_size:
|
|
2570
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
2571
|
+
end_char = start_char + len(chunk_text)
|
|
2572
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2573
|
+
start_char = end_char
|
|
2574
|
+
current_chunk = []
|
|
2575
|
+
current_size = 0
|
|
2576
|
+
|
|
2577
|
+
# Add final chunk
|
|
2578
|
+
if current_chunk:
|
|
2579
|
+
chunk_text = '\n\n'.join(current_chunk)
|
|
2580
|
+
end_char = start_char + len(chunk_text)
|
|
2581
|
+
chunks.append((chunk_text, start_char, end_char))
|
|
2582
|
+
|
|
2583
|
+
return chunks if chunks else self._chunk_semantic(flat_text, chunk_size, overlap)
|
|
2584
|
+
|