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.
Files changed (776) hide show
  1. contextsynapse/__init__.py +107 -0
  2. contextsynapse/__main__.py +9 -0
  3. contextsynapse/a2a/__init__.py +28 -0
  4. contextsynapse/a2a/client.py +120 -0
  5. contextsynapse/a2a/discovery.py +128 -0
  6. contextsynapse/a2a/handlers.py +164 -0
  7. contextsynapse/a2a/models.py +246 -0
  8. contextsynapse/a2a/streaming.py +92 -0
  9. contextsynapse/a2a/task_manager.py +275 -0
  10. contextsynapse/adapters/__init__.py +98 -0
  11. contextsynapse/adapters/_base.py +298 -0
  12. contextsynapse/adapters/autogen/__init__.py +4 -0
  13. contextsynapse/adapters/autogen/tools.py +34 -0
  14. contextsynapse/adapters/crewai/__init__.py +4 -0
  15. contextsynapse/adapters/crewai/tools.py +63 -0
  16. contextsynapse/adapters/langchain/__init__.py +19 -0
  17. contextsynapse/adapters/langchain/message_history.py +17 -0
  18. contextsynapse/adapters/langchain/retriever.py +158 -0
  19. contextsynapse/adapters/langchain/tools.py +43 -0
  20. contextsynapse/adapters/langgraph/__init__.py +21 -0
  21. contextsynapse/adapters/langgraph/checkpoint.py +223 -0
  22. contextsynapse/adapters/langgraph/context_tools.py +341 -0
  23. contextsynapse/adapters/langgraph/message_history.py +138 -0
  24. contextsynapse/adapters/langgraph/tools.py +43 -0
  25. contextsynapse/adapters/llamaindex/__init__.py +4 -0
  26. contextsynapse/adapters/llamaindex/tools.py +64 -0
  27. contextsynapse/adapters/openai/__init__.py +24 -0
  28. contextsynapse/adapters/openai/tools.py +206 -0
  29. contextsynapse/adapters/pydantic_ai/__init__.py +4 -0
  30. contextsynapse/adapters/pydantic_ai/tools.py +36 -0
  31. contextsynapse/adapters/swarm/__init__.py +4 -0
  32. contextsynapse/adapters/swarm/tools.py +44 -0
  33. contextsynapse/agents/__init__.py +6 -0
  34. contextsynapse/agents/auto_capture.py +80 -0
  35. contextsynapse/agents/dispatcher.py +299 -0
  36. contextsynapse/agents/notifications.py +112 -0
  37. contextsynapse/aiql/__init__.py +71 -0
  38. contextsynapse/aiql/cache.py +341 -0
  39. contextsynapse/aiql/compiler/__init__.py +62 -0
  40. contextsynapse/aiql/compiler/optimizer.py +126 -0
  41. contextsynapse/aiql/compiler/planner.py +366 -0
  42. contextsynapse/aiql/compiler/statistics.py +235 -0
  43. contextsynapse/aiql/compiler/validator.py +425 -0
  44. contextsynapse/aiql/engine/__init__.py +11 -0
  45. contextsynapse/aiql/engine/chunk_storage.py +2584 -0
  46. contextsynapse/aiql/engine/executor.py +7737 -0
  47. contextsynapse/aiql/engine/extraction_integration.py +442 -0
  48. contextsynapse/aiql/engine/graph_builder.py +405 -0
  49. contextsynapse/aiql/engine/node_creation_from_normalized.py +691 -0
  50. contextsynapse/aiql/engine/performance_monitor.py +289 -0
  51. contextsynapse/aiql/engine/postprocessing.py +365 -0
  52. contextsynapse/aiql/engine/ray_stage_executors.py +918 -0
  53. contextsynapse/aiql/engine/redis_cache.py +106 -0
  54. contextsynapse/aiql/engine/streaming_json.py +366 -0
  55. contextsynapse/aiql/engine/streaming_parquet.py +568 -0
  56. contextsynapse/aiql/engine/streaming_ray_chunking.py +419 -0
  57. contextsynapse/aiql/grammar/__init__.py +14 -0
  58. contextsynapse/aiql/grammar/aiql_grammar.py +1822 -0
  59. contextsynapse/aiql/grammar/base.py +71 -0
  60. contextsynapse/aiql/parser/__init__.py +46 -0
  61. contextsynapse/aiql/parser/aiql_parser.py +11330 -0
  62. contextsynapse/aiql/parser/base_parser.py +47 -0
  63. contextsynapse/api/__init__.py +5 -0
  64. contextsynapse/api/a2a_router.py +233 -0
  65. contextsynapse/api/admin_router.py +140 -0
  66. contextsynapse/api/agent_worker_router.py +978 -0
  67. contextsynapse/api/algorithms_router.py +317 -0
  68. contextsynapse/api/api.py +4144 -0
  69. contextsynapse/api/audit.py +204 -0
  70. contextsynapse/api/auth.py +441 -0
  71. contextsynapse/api/auth_router.py +537 -0
  72. contextsynapse/api/billing.py +261 -0
  73. contextsynapse/api/billing_router.py +275 -0
  74. contextsynapse/api/boundary_router.py +452 -0
  75. contextsynapse/api/cognition_router.py +116 -0
  76. contextsynapse/api/connector_router.py +237 -0
  77. contextsynapse/api/context_router.py +1619 -0
  78. contextsynapse/api/dashboard_router.py +11367 -0
  79. contextsynapse/api/errors.py +183 -0
  80. contextsynapse/api/events.py +88 -0
  81. contextsynapse/api/experiment_bootstrap.py +321 -0
  82. contextsynapse/api/experiment_orchestrator.py +1078 -0
  83. contextsynapse/api/experiment_router.py +2089 -0
  84. contextsynapse/api/feature_gate.py +89 -0
  85. contextsynapse/api/federation_router.py +108 -0
  86. contextsynapse/api/integrations.py +567 -0
  87. contextsynapse/api/integrations_router.py +461 -0
  88. contextsynapse/api/intelligence_router.py +1028 -0
  89. contextsynapse/api/metering.py +155 -0
  90. contextsynapse/api/metrics.py +112 -0
  91. contextsynapse/api/models.py +38 -0
  92. contextsynapse/api/monitoring_router.py +174 -0
  93. contextsynapse/api/pipeline_router.py +1437 -0
  94. contextsynapse/api/playground_conn.py +116 -0
  95. contextsynapse/api/projects_router.py +131 -0
  96. contextsynapse/api/queries_router.py +282 -0
  97. contextsynapse/api/quota.py +95 -0
  98. contextsynapse/api/rate_limit.py +64 -0
  99. contextsynapse/api/search_router.py +261 -0
  100. contextsynapse/api/shield_router.py +447 -0
  101. contextsynapse/api/tenant_guard.py +103 -0
  102. contextsynapse/api/tenant_quotas.py +231 -0
  103. contextsynapse/api/tenants.py +305 -0
  104. contextsynapse/api/users.py +646 -0
  105. contextsynapse/api/v1.py +167 -0
  106. contextsynapse/api/vertical_builder_router.py +387 -0
  107. contextsynapse/benchmark/__init__.py +15 -0
  108. contextsynapse/benchmark/__main__.py +64 -0
  109. contextsynapse/benchmark/agents.py +338 -0
  110. contextsynapse/benchmark/live_ab.py +338 -0
  111. contextsynapse/benchmark/reporter.py +149 -0
  112. contextsynapse/benchmark/runner.py +264 -0
  113. contextsynapse/benchmark/savings.py +266 -0
  114. contextsynapse/benchmark/token_counter.py +212 -0
  115. contextsynapse/benchmark/workloads.py +289 -0
  116. contextsynapse/buffer/__init__.py +154 -0
  117. contextsynapse/buffer/base.py +257 -0
  118. contextsynapse/buffer/duckdb_buffer.py +246 -0
  119. contextsynapse/buffer/file_buffer.py +229 -0
  120. contextsynapse/buffer/lmdb_buffer.py +286 -0
  121. contextsynapse/buffer/local_buffer.py +154 -0
  122. contextsynapse/buffer/redis_buffer.py +211 -0
  123. contextsynapse/buffer/sqlite_buffer.py +266 -0
  124. contextsynapse/cache.py +2 -0
  125. contextsynapse/cli/__init__.py +40 -0
  126. contextsynapse/cli/cli.py +475 -0
  127. contextsynapse/cli/config_cli.py +177 -0
  128. contextsynapse/cli/scaffold.py +465 -0
  129. contextsynapse/cognition/__init__.py +31 -0
  130. contextsynapse/cognition/derivation.py +55 -0
  131. contextsynapse/cognition/events.py +135 -0
  132. contextsynapse/cognition/feedback.py +52 -0
  133. contextsynapse/cognition/hallucination.py +90 -0
  134. contextsynapse/cognition/invalidation.py +70 -0
  135. contextsynapse/cognition/read_tracker.py +50 -0
  136. contextsynapse/collaboration.py +2 -0
  137. contextsynapse/comparison.py +2 -0
  138. contextsynapse/config/__init__.py +10 -0
  139. contextsynapse/config/connectors/amfi_nav.yaml +18 -0
  140. contextsynapse/config/connectors/auto_sector.yaml +20 -0
  141. contextsynapse/config/connectors/banking_sector.yaml +20 -0
  142. contextsynapse/config/connectors/energy_sector.yaml +20 -0
  143. contextsynapse/config/connectors/fmcg_sector.yaml +20 -0
  144. contextsynapse/config/connectors/nsdl_fpi.yaml +9 -0
  145. contextsynapse/config/connectors/nse_price.yaml +61 -0
  146. contextsynapse/config/connectors/pharma_sector.yaml +20 -0
  147. contextsynapse/config/connectors/rbi_dbie.yaml +12 -0
  148. contextsynapse/config/context_presets/ai_research.json +25 -0
  149. contextsynapse/config/context_presets/chatgpt_conversations.json +19 -0
  150. contextsynapse/config/context_presets/claude_conversations.json +19 -0
  151. contextsynapse/config/context_presets/legal_compliance.json +21 -0
  152. contextsynapse/config/context_presets/meeting_notes.json +21 -0
  153. contextsynapse/config/context_presets/slack_engineering.json +21 -0
  154. contextsynapse/config/context_presets/tech_climate.json +24 -0
  155. contextsynapse/config/context_presets/toi_politics.json +25 -0
  156. contextsynapse/config/database_config.py +453 -0
  157. contextsynapse/config/deployment.py +221 -0
  158. contextsynapse/config/gpt_action_schema.json +164 -0
  159. contextsynapse/config/indicators/india_macro.yaml +213 -0
  160. contextsynapse/config/logging_config.py +88 -0
  161. contextsynapse/config/mode_config.py +223 -0
  162. contextsynapse/config/schemas/api_spec.yaml +48 -0
  163. contextsynapse/config/schemas/architecture_doc.yaml +51 -0
  164. contextsynapse/config/schemas/chat_history.yaml +90 -0
  165. contextsynapse/config/schemas/chatgpt_conversation.yaml +101 -0
  166. contextsynapse/config/schemas/claude_conversation.yaml +148 -0
  167. contextsynapse/config/schemas/contract.yaml +48 -0
  168. contextsynapse/config/schemas/conversation.yaml +167 -0
  169. contextsynapse/config/schemas/database.yaml +74 -0
  170. contextsynapse/config/schemas/decision.yaml +72 -0
  171. contextsynapse/config/schemas/ecommerce.yaml +56 -0
  172. contextsynapse/config/schemas/education.yaml +42 -0
  173. contextsynapse/config/schemas/healthcare.yaml +133 -0
  174. contextsynapse/config/schemas/hr_document.yaml +48 -0
  175. contextsynapse/config/schemas/invoice.yaml +40 -0
  176. contextsynapse/config/schemas/knowledge_base.yaml +75 -0
  177. contextsynapse/config/schemas/legal.yaml +42 -0
  178. contextsynapse/config/schemas/marketing_content.yaml +48 -0
  179. contextsynapse/config/schemas/meeting_notes.yaml +51 -0
  180. contextsynapse/config/schemas/news_article.yaml +61 -0
  181. contextsynapse/config/schemas/real_estate.yaml +49 -0
  182. contextsynapse/config/schemas/requirements_doc.yaml +48 -0
  183. contextsynapse/config/schemas/research_paper.yaml +56 -0
  184. contextsynapse/config/schemas/resume.yaml +49 -0
  185. contextsynapse/config/schemas/retail.yaml +42 -0
  186. contextsynapse/config/schemas/rule_meta.yaml +173 -0
  187. contextsynapse/config/schemas/rules.yaml +72 -0
  188. contextsynapse/config/schemas/screener_meta.yaml +236 -0
  189. contextsynapse/config/schemas/sdlc.yaml +103 -0
  190. contextsynapse/config/schemas/session_graph.yaml +172 -0
  191. contextsynapse/config/schemas/support_ticket.yaml +48 -0
  192. contextsynapse/config/schemas/web.yaml +64 -0
  193. contextsynapse/config/sensors/earnings_calendar.yaml +52 -0
  194. contextsynapse/config/sensors/global_risk.yaml +53 -0
  195. contextsynapse/config/sensors/government_policy.yaml +54 -0
  196. contextsynapse/config/sensors/supply_chain.yaml +50 -0
  197. contextsynapse/config/sensors/templates/behavioral.yaml +56 -0
  198. contextsynapse/config/sensors/templates/equity_sentiment.yaml +48 -0
  199. contextsynapse/config/sensors/templates/global_macro.yaml +47 -0
  200. contextsynapse/config/sensors/templates/market_data.yaml +118 -0
  201. contextsynapse/config/sensors/templates/sector_technical.yaml +47 -0
  202. contextsynapse/config/sensors/weather.yaml +52 -0
  203. contextsynapse/config/storage_strategy.py +202 -0
  204. contextsynapse/config/templates/competitive_intel.yaml +39 -0
  205. contextsynapse/config/templates/geopolitical_risk.yaml +46 -0
  206. contextsynapse/config/templates/market_analysis.yaml +58 -0
  207. contextsynapse/connectors/__init__.py +15 -0
  208. contextsynapse/connectors/api_router.py +121 -0
  209. contextsynapse/connectors/base.py +357 -0
  210. contextsynapse/connectors/financial/audio.py +170 -0
  211. contextsynapse/connectors/financial/youtube.py +272 -0
  212. contextsynapse/connectors/github.py +164 -0
  213. contextsynapse/connectors/jira.py +120 -0
  214. contextsynapse/connectors/kafka_connector.py +124 -0
  215. contextsynapse/connectors/pipeline_connector.py +747 -0
  216. contextsynapse/connectors/registry.py +116 -0
  217. contextsynapse/connectors/scheduler.py +87 -0
  218. contextsynapse/connectors/universal.py +360 -0
  219. contextsynapse/context/__init__.py +96 -0
  220. contextsynapse/context/__main__.py +4 -0
  221. contextsynapse/context/acl.py +125 -0
  222. contextsynapse/context/agent_card.py +201 -0
  223. contextsynapse/context/agent_memory.py +814 -0
  224. contextsynapse/context/agents.py +472 -0
  225. contextsynapse/context/agents_redis.py +391 -0
  226. contextsynapse/context/assembled.py +204 -0
  227. contextsynapse/context/attribution.py +113 -0
  228. contextsynapse/context/blob.py +184 -0
  229. contextsynapse/context/boundaries.py +187 -0
  230. contextsynapse/context/boundary.py +507 -0
  231. contextsynapse/context/bundle.py +155 -0
  232. contextsynapse/context/cli.py +519 -0
  233. contextsynapse/context/collaboration.py +165 -0
  234. contextsynapse/context/compiler.py +604 -0
  235. contextsynapse/context/composite.py +536 -0
  236. contextsynapse/context/compression.py +147 -0
  237. contextsynapse/context/context_manager.py +888 -0
  238. contextsynapse/context/context_schema.py +90 -0
  239. contextsynapse/context/context_schema.yaml +105 -0
  240. contextsynapse/context/context_units.py +891 -0
  241. contextsynapse/context/conversation.py +488 -0
  242. contextsynapse/context/dedup.py +207 -0
  243. contextsynapse/context/document_processor.py +698 -0
  244. contextsynapse/context/embedding_hooks.py +338 -0
  245. contextsynapse/context/execution_schema.py +274 -0
  246. contextsynapse/context/fan_out.py +138 -0
  247. contextsynapse/context/frozen.py +80 -0
  248. contextsynapse/context/gravity.py +227 -0
  249. contextsynapse/context/hub.py +1401 -0
  250. contextsynapse/context/ingest.py +391 -0
  251. contextsynapse/context/injection.py +226 -0
  252. contextsynapse/context/interaction_graphifier.py +562 -0
  253. contextsynapse/context/layers.py +574 -0
  254. contextsynapse/context/projection.py +1195 -0
  255. contextsynapse/context/promotion.py +634 -0
  256. contextsynapse/context/proof.py +131 -0
  257. contextsynapse/context/propagation.py +577 -0
  258. contextsynapse/context/pubsub.py +135 -0
  259. contextsynapse/context/quality.py +1054 -0
  260. contextsynapse/context/quality_tracker.py +270 -0
  261. contextsynapse/context/scoping.py +370 -0
  262. contextsynapse/context/seed.py +992 -0
  263. contextsynapse/context/session.py +1142 -0
  264. contextsynapse/context/session_graph.py +593 -0
  265. contextsynapse/context/session_memory.py +309 -0
  266. contextsynapse/context/session_resolver.py +111 -0
  267. contextsynapse/context/skill_learning.py +237 -0
  268. contextsynapse/context/smart_budget.py +129 -0
  269. contextsynapse/context/source_policy.py +242 -0
  270. contextsynapse/context/store_factory.py +53 -0
  271. contextsynapse/context/sync.py +648 -0
  272. contextsynapse/context/token.py +236 -0
  273. contextsynapse/context/vector_integration.py +297 -0
  274. contextsynapse/context/webhooks.py +194 -0
  275. contextsynapse/context/working_memory.py +224 -0
  276. contextsynapse/core/__init__.py +18 -0
  277. contextsynapse/core/archiver.py +237 -0
  278. contextsynapse/core/change_stream.py +282 -0
  279. contextsynapse/core/checkpoint.py +310 -0
  280. contextsynapse/core/cloud_storage.py +387 -0
  281. contextsynapse/core/context_state.py +651 -0
  282. contextsynapse/core/cron.py +324 -0
  283. contextsynapse/core/datasource.py +643 -0
  284. contextsynapse/core/db.py +289 -0
  285. contextsynapse/core/federation.py +283 -0
  286. contextsynapse/core/graph_coordinator.py +201 -0
  287. contextsynapse/core/graph_intelligence.py +388 -0
  288. contextsynapse/core/graph_structures.py +106 -0
  289. contextsynapse/core/graph_sync.py +157 -0
  290. contextsynapse/core/hybrid_graph_storage.py +2998 -0
  291. contextsynapse/core/lifecycle.py +691 -0
  292. contextsynapse/core/multiworker.py +378 -0
  293. contextsynapse/core/pruning.py +312 -0
  294. contextsynapse/core/redis_registry.py +524 -0
  295. contextsynapse/core/registry.py +1043 -0
  296. contextsynapse/core/registry_factory.py +51 -0
  297. contextsynapse/core/registry_metadata.py +294 -0
  298. contextsynapse/core/replay.py +354 -0
  299. contextsynapse/core/replication.py +339 -0
  300. contextsynapse/core/time_travel.py +260 -0
  301. contextsynapse/core/write_behind.py +152 -0
  302. contextsynapse/core/write_context.py +39 -0
  303. contextsynapse/db/__init__.py +1 -0
  304. contextsynapse/db/postgres.py +267 -0
  305. contextsynapse/db/profile.py +236 -0
  306. contextsynapse/db/rules.py +328 -0
  307. contextsynapse/demo/__init__.py +0 -0
  308. contextsynapse/demo/report.py +147 -0
  309. contextsynapse/demo/runner.py +520 -0
  310. contextsynapse/demo/scenario.py +165 -0
  311. contextsynapse/demo/session_demo.py +280 -0
  312. contextsynapse/demo/session_report.py +143 -0
  313. contextsynapse/demo/universal_runner.py +257 -0
  314. contextsynapse/engine/__init__.py +15 -0
  315. contextsynapse/engine/core.py +555 -0
  316. contextsynapse/extraction/__init__.py +69 -0
  317. contextsynapse/extraction/cli.py +274 -0
  318. contextsynapse/extraction/config.py +107 -0
  319. contextsynapse/extraction/examples/example_usage.py +118 -0
  320. contextsynapse/extraction/examples/sample_manifest.json +53 -0
  321. contextsynapse/extraction/examples/sample_normalized.json +185 -0
  322. contextsynapse/extraction/extract_engine.py +440 -0
  323. contextsynapse/extraction/extractors/__init__.py +37 -0
  324. contextsynapse/extraction/extractors/chat_export_extractor.py +189 -0
  325. contextsynapse/extraction/extractors/csv_extractor.py +83 -0
  326. contextsynapse/extraction/extractors/docx_extractor.py +129 -0
  327. contextsynapse/extraction/extractors/excel_extractor.py +152 -0
  328. contextsynapse/extraction/extractors/html_extractor.py +146 -0
  329. contextsynapse/extraction/extractors/pdf_extractor.py +635 -0
  330. contextsynapse/extraction/extractors/pdf_extractor_layoutparser.py +408 -0
  331. contextsynapse/extraction/extractors/register_extractors.py +63 -0
  332. contextsynapse/extraction/extractors/text_extractor.py +65 -0
  333. contextsynapse/extraction/extractors/txt_extractor.py +120 -0
  334. contextsynapse/extraction/extractors/website_extractor.py +246 -0
  335. contextsynapse/extraction/fact_extractor.py +236 -0
  336. contextsynapse/extraction/file_manager.py +353 -0
  337. contextsynapse/extraction/hierarchy.py +239 -0
  338. contextsynapse/extraction/id_generator.py +450 -0
  339. contextsynapse/extraction/layout_detector.py +409 -0
  340. contextsynapse/extraction/llm_entity_extractor.py +301 -0
  341. contextsynapse/extraction/normalized_store.py +480 -0
  342. contextsynapse/extraction/normalizer.py +594 -0
  343. contextsynapse/extraction/parquet_writer.py +598 -0
  344. contextsynapse/extraction/post_processors/__init__.py +40 -0
  345. contextsynapse/extraction/post_processors/column_reorganizer.py +507 -0
  346. contextsynapse/extraction/ray_normalization.py +233 -0
  347. contextsynapse/extraction/ray_runner.py +207 -0
  348. contextsynapse/extraction/redis_schema_registry.py +222 -0
  349. contextsynapse/extraction/registry.py +204 -0
  350. contextsynapse/extraction/schema_loader.py +663 -0
  351. contextsynapse/extraction/schema_registry.py +230 -0
  352. contextsynapse/extraction/semantic_normalizer.py +245 -0
  353. contextsynapse/extraction/version_manager.py +314 -0
  354. contextsynapse/gateway/__init__.py +24 -0
  355. contextsynapse/gateway/action_emitter.py +212 -0
  356. contextsynapse/gateway/cost_tracker.py +508 -0
  357. contextsynapse/gateway/gateway.py +377 -0
  358. contextsynapse/gateway/policy.py +303 -0
  359. contextsynapse/governance/__init__.py +13 -0
  360. contextsynapse/governance/layer.py +513 -0
  361. contextsynapse/ingestion/__init__.py +15 -0
  362. contextsynapse/ingestion/amplifier.py +288 -0
  363. contextsynapse/ingestion/async_ingest.py +463 -0
  364. contextsynapse/ingestion/chunker.py +303 -0
  365. contextsynapse/ingestion/chunking.py +58 -0
  366. contextsynapse/ingestion/cleaner.py +190 -0
  367. contextsynapse/ingestion/cleanse.py +252 -0
  368. contextsynapse/ingestion/connectors/__init__.py +38 -0
  369. contextsynapse/ingestion/connectors/base.py +300 -0
  370. contextsynapse/ingestion/connectors/chatgpt_memory.py +247 -0
  371. contextsynapse/ingestion/connectors/claude_memory.py +274 -0
  372. contextsynapse/ingestion/connectors/sap_ingestor.py +208 -0
  373. contextsynapse/ingestion/content_detector.py +80 -0
  374. contextsynapse/ingestion/content_monitor.py +502 -0
  375. contextsynapse/ingestion/dedup.py +106 -0
  376. contextsynapse/ingestion/feed.py +640 -0
  377. contextsynapse/ingestion/filters.py +461 -0
  378. contextsynapse/ingestion/graph_builder.py +705 -0
  379. contextsynapse/ingestion/input_classifier.py +307 -0
  380. contextsynapse/ingestion/job_manager.py +571 -0
  381. contextsynapse/ingestion/llm_extractor.py +610 -0
  382. contextsynapse/ingestion/orchestrator.py +116 -0
  383. contextsynapse/ingestion/parsers/__init__.py +19 -0
  384. contextsynapse/ingestion/parsers/graph_parser_registry.py +103 -0
  385. contextsynapse/ingestion/parsers/graphml_parser.py +141 -0
  386. contextsynapse/ingestion/parsers/jsonld_parser.py +171 -0
  387. contextsynapse/ingestion/parsers/rdf_parser.py +150 -0
  388. contextsynapse/ingestion/pipeline_context.py +228 -0
  389. contextsynapse/ingestion/pipeline_store.py +1195 -0
  390. contextsynapse/ingestion/queue.py +133 -0
  391. contextsynapse/ingestion/reprocess.py +179 -0
  392. contextsynapse/ingestion/scenario_router.py +224 -0
  393. contextsynapse/ingestion/schema_extractor.py +759 -0
  394. contextsynapse/ingestion/schema_validator.py +274 -0
  395. contextsynapse/ingestion/sdlc_ingest.py +87 -0
  396. contextsynapse/ingestion/smart_ingest.py +2451 -0
  397. contextsynapse/ingestion/source_connector.py +171 -0
  398. contextsynapse/ingestion/stage_executor.py +2095 -0
  399. contextsynapse/ingestion/stages/__init__.py +9 -0
  400. contextsynapse/ingestion/stages/tabular_to_graph.py +289 -0
  401. contextsynapse/ingestion/strategies.py +386 -0
  402. contextsynapse/ingestion/universal/__init__.py +4 -0
  403. contextsynapse/ingestion/universal/_operator_registry.py +59 -0
  404. contextsynapse/ingestion/universal/ingest_content.py +32 -0
  405. contextsynapse/ingestion/universal/operators/__init__.py +1 -0
  406. contextsynapse/ingestion/universal/operators/base.py +19 -0
  407. contextsynapse/ingestion/universal/operators/build_edges.py +75 -0
  408. contextsynapse/ingestion/universal/operators/cluster_topics.py +397 -0
  409. contextsynapse/ingestion/universal/operators/deduplicate.py +45 -0
  410. contextsynapse/ingestion/universal/operators/detect_signals.py +90 -0
  411. contextsynapse/ingestion/universal/operators/embed.py +80 -0
  412. contextsynapse/ingestion/universal/operators/extract_entities.py +253 -0
  413. contextsynapse/ingestion/universal/operators/extract_preferences.py +117 -0
  414. contextsynapse/ingestion/universal/operators/filter_content.py +69 -0
  415. contextsynapse/ingestion/universal/operators/index_bm25.py +52 -0
  416. contextsynapse/ingestion/universal/operators/infer_domains.py +69 -0
  417. contextsynapse/ingestion/universal/operators/link_cross_reference.py +210 -0
  418. contextsynapse/ingestion/universal/operators/parse_records.py +66 -0
  419. contextsynapse/ingestion/universal/operators/resolve_entities.py +261 -0
  420. contextsynapse/ingestion/universal/operators/scanners/__init__.py +16 -0
  421. contextsynapse/ingestion/universal/operators/scanners/edges.py +173 -0
  422. contextsynapse/ingestion/universal/operators/scanners/git.py +187 -0
  423. contextsynapse/ingestion/universal/operators/scanners/github.py +135 -0
  424. contextsynapse/ingestion/universal/operators/scanners/github_api.py +509 -0
  425. contextsynapse/ingestion/universal/operators/scanners/helpers.py +67 -0
  426. contextsynapse/ingestion/universal/operators/scanners/jira.py +65 -0
  427. contextsynapse/ingestion/universal/operators/scanners/llm_enrichment.py +82 -0
  428. contextsynapse/ingestion/universal/operators/scanners/repo.py +149 -0
  429. contextsynapse/ingestion/universal/operators/sdlc_scan.py +213 -0
  430. contextsynapse/ingestion/universal/operators/spec_scanner.py +149 -0
  431. contextsynapse/ingestion/universal/operators/store_documents.py +72 -0
  432. contextsynapse/ingestion/universal/operators/synthesize_cu.py +180 -0
  433. contextsynapse/ingestion/universal/operators/validate_gate.py +65 -0
  434. contextsynapse/ingestion/universal/parsers/__init__.py +5 -0
  435. contextsynapse/ingestion/universal/parsers/record_parser.py +203 -0
  436. contextsynapse/ingestion/universal/parsers/turn_parser.py +329 -0
  437. contextsynapse/ingestion/universal/shared_signals.py +161 -0
  438. contextsynapse/ingestion/universal/stage_executor.py +219 -0
  439. contextsynapse/ingestion/web_crawler.py +265 -0
  440. contextsynapse/intelligence/__init__.py +53 -0
  441. contextsynapse/intelligence/alerts.py +198 -0
  442. contextsynapse/intelligence/collector.py +655 -0
  443. contextsynapse/intelligence/comparison.py +198 -0
  444. contextsynapse/intelligence/config.py +56 -0
  445. contextsynapse/intelligence/conflict_detector.py +182 -0
  446. contextsynapse/intelligence/context_gaps.py +192 -0
  447. contextsynapse/intelligence/context_radar.py +135 -0
  448. contextsynapse/intelligence/correlation_engine.py +416 -0
  449. contextsynapse/intelligence/correlation_template.py +120 -0
  450. contextsynapse/intelligence/dedup.py +296 -0
  451. contextsynapse/intelligence/event_bus.py +91 -0
  452. contextsynapse/intelligence/feedback_loop.py +101 -0
  453. contextsynapse/intelligence/freshness.py +307 -0
  454. contextsynapse/intelligence/fundamental_feed.py +551 -0
  455. contextsynapse/intelligence/geo_data.py +170 -0
  456. contextsynapse/intelligence/geo_reference.py +112 -0
  457. contextsynapse/intelligence/graph_fusion.py +465 -0
  458. contextsynapse/intelligence/heuristics.py +176 -0
  459. contextsynapse/intelligence/impact_tracker.py +336 -0
  460. contextsynapse/intelligence/macro_indicators.py +319 -0
  461. contextsynapse/intelligence/ocr.py +104 -0
  462. contextsynapse/intelligence/persistence.py +267 -0
  463. contextsynapse/intelligence/pipeline_health.py +130 -0
  464. contextsynapse/intelligence/price_feed.py +221 -0
  465. contextsynapse/intelligence/rate_limiter.py +130 -0
  466. contextsynapse/intelligence/reactive.py +411 -0
  467. contextsynapse/intelligence/runtime_context.py +390 -0
  468. contextsynapse/intelligence/sentiment_decay.py +426 -0
  469. contextsynapse/intelligence/session.py +277 -0
  470. contextsynapse/intelligence/signal_hierarchy.py +438 -0
  471. contextsynapse/intelligence/signals.py +185 -0
  472. contextsynapse/intelligence/source_watcher.py +132 -0
  473. contextsynapse/intelligence/table_enricher.py +256 -0
  474. contextsynapse/intelligence/tagger.py +319 -0
  475. contextsynapse/intelligence/timeseries.py +209 -0
  476. contextsynapse/intelligence/tracker.py +318 -0
  477. contextsynapse/intelligence/watchdog.py +392 -0
  478. contextsynapse/intelligence/watchdog_manager.py +263 -0
  479. contextsynapse/intelligence/webhook_receiver.py +165 -0
  480. contextsynapse/intelligence/weight_learner.py +234 -0
  481. contextsynapse/llm/__init__.py +8 -0
  482. contextsynapse/llm/client.py +348 -0
  483. contextsynapse/llm/rate_limiter.py +166 -0
  484. contextsynapse/logging_config.py +2 -0
  485. contextsynapse/marketplace/__init__.py +7 -0
  486. contextsynapse/marketplace/registry.py +173 -0
  487. contextsynapse/mcp/__init__.py +11 -0
  488. contextsynapse/mcp/__main__.py +4 -0
  489. contextsynapse/mcp/auth_middleware.py +212 -0
  490. contextsynapse/mcp/connection_pool.py +128 -0
  491. contextsynapse/mcp/server.py +986 -0
  492. contextsynapse/mcp/session_context.py +67 -0
  493. contextsynapse/memory/__init__.py +34 -0
  494. contextsynapse/memory/engine.py +475 -0
  495. contextsynapse/memory/temporal.py +327 -0
  496. contextsynapse/metadata/__init__.py +12 -0
  497. contextsynapse/metadata/metadata_db.py +272 -0
  498. contextsynapse/metadata/metadata_query_tool.py +300 -0
  499. contextsynapse/metadata/metadata_tracker.py +425 -0
  500. contextsynapse/models/__init__.py +27 -0
  501. contextsynapse/models/embedding_service.py +360 -0
  502. contextsynapse/models/model_registry.py +228 -0
  503. contextsynapse/multiworker.py +2 -0
  504. contextsynapse/pipelines/__init__.py +1 -0
  505. contextsynapse/pipelines/executor.py +851 -0
  506. contextsynapse/pipelines/filters.py +189 -0
  507. contextsynapse/pipelines/models.py +176 -0
  508. contextsynapse/pipelines/scheduler.py +268 -0
  509. contextsynapse/playground/__init__.py +1 -0
  510. contextsynapse/playground/templates.py +85 -0
  511. contextsynapse/plugins/__init__.py +43 -0
  512. contextsynapse/plugins/api.py +66 -0
  513. contextsynapse/plugins/base.py +350 -0
  514. contextsynapse/plugins/domain_interface.py +508 -0
  515. contextsynapse/plugins/domain_loader.py +241 -0
  516. contextsynapse/plugins/loader.py +148 -0
  517. contextsynapse/plugins/registry.py +265 -0
  518. contextsynapse/project/__init__.py +11 -0
  519. contextsynapse/project/code_context.py +807 -0
  520. contextsynapse/project/connectors/__init__.py +4 -0
  521. contextsynapse/project/connectors/github_connector.py +113 -0
  522. contextsynapse/project/connectors/jira_connector.py +160 -0
  523. contextsynapse/project/graph.py +1023 -0
  524. contextsynapse/project/project_context.py +545 -0
  525. contextsynapse/project/scanner_registry.py +61 -0
  526. contextsynapse/project/schema_manager.py +322 -0
  527. contextsynapse/project/schemas/knowledge.yaml +59 -0
  528. contextsynapse/project/schemas/sdlc.yaml +369 -0
  529. contextsynapse/project/sdlc_schema.py +172 -0
  530. contextsynapse/project/spec_parser.py +85 -0
  531. contextsynapse/project/task_lock.py +181 -0
  532. contextsynapse/project/task_stream.py +203 -0
  533. contextsynapse/project/team_tools.py +537 -0
  534. contextsynapse/py.typed +0 -0
  535. contextsynapse/realtime/__init__.py +6 -0
  536. contextsynapse/realtime/collaboration.py +147 -0
  537. contextsynapse/rules/__init__.py +10 -0
  538. contextsynapse/rules/engine.py +116 -0
  539. contextsynapse/rules/evaluators/__init__.py +6 -0
  540. contextsynapse/rules/evaluators/aggregate.py +205 -0
  541. contextsynapse/rules/evaluators/custom.py +170 -0
  542. contextsynapse/rules/evaluators/pre_action.py +125 -0
  543. contextsynapse/rules/evaluators/temporal.py +129 -0
  544. contextsynapse/rules/loader.py +52 -0
  545. contextsynapse/rules/models.py +124 -0
  546. contextsynapse/rules/universal.py +393 -0
  547. contextsynapse/scheduler.py +229 -0
  548. contextsynapse/schema/__init__.py +42 -0
  549. contextsynapse/schema/compiler.py +252 -0
  550. contextsynapse/schema/composer.py +158 -0
  551. contextsynapse/schema/dedup.py +73 -0
  552. contextsynapse/schema/derivation.py +172 -0
  553. contextsynapse/schema/sdl.py +326 -0
  554. contextsynapse/schema/validation_gate.py +124 -0
  555. contextsynapse/sdk/__init__.py +63 -0
  556. contextsynapse/sdk/agent.py +214 -0
  557. contextsynapse/sdk/client.py +269 -0
  558. contextsynapse/sdk/exceptions.py +23 -0
  559. contextsynapse/sdk/models.py +78 -0
  560. contextsynapse/sdk/session.py +408 -0
  561. contextsynapse/sdk/tracked.py +218 -0
  562. contextsynapse/sdk/worker.py +266 -0
  563. contextsynapse/sdk/workspace_client.py +98 -0
  564. contextsynapse/sdk/ws.py +41 -0
  565. contextsynapse/search/__init__.py +20 -0
  566. contextsynapse/search/embedding_cache.py +140 -0
  567. contextsynapse/search/enhanced_search.py +152 -0
  568. contextsynapse/search/fulltext.py +130 -0
  569. contextsynapse/search/graph_search.py +634 -0
  570. contextsynapse/search/lmdb_index.py +1218 -0
  571. contextsynapse/search/nl_to_aiql.py +199 -0
  572. contextsynapse/search/rag.py +1346 -0
  573. contextsynapse/search/rag_cache.py +167 -0
  574. contextsynapse/search/reasoning_chain.py +231 -0
  575. contextsynapse/search/redis_search.py +207 -0
  576. contextsynapse/search/retrieval_quality.py +314 -0
  577. contextsynapse/search/semantic_query.py +455 -0
  578. contextsynapse/search/text_resolver.py +159 -0
  579. contextsynapse/search/whoosh_search.py +373 -0
  580. contextsynapse/security/__init__.py +17 -0
  581. contextsynapse/security/audit.py +297 -0
  582. contextsynapse/security/audit_logger.py +97 -0
  583. contextsynapse/security/audit_trail.py +270 -0
  584. contextsynapse/security/auth_unified.py +284 -0
  585. contextsynapse/security/auto_tagger.py +230 -0
  586. contextsynapse/security/data_security.py +218 -0
  587. contextsynapse/security/encryption.py +207 -0
  588. contextsynapse/security/identity.py +447 -0
  589. contextsynapse/security/jwt_identity.py +59 -0
  590. contextsynapse/security/middleware.py +336 -0
  591. contextsynapse/security/pii.py +233 -0
  592. contextsynapse/security/rbac.py +96 -0
  593. contextsynapse/security/rls.py +205 -0
  594. contextsynapse/security/sanitize.py +233 -0
  595. contextsynapse/security/scoped_encryption.py +131 -0
  596. contextsynapse/security/tenant.py +401 -0
  597. contextsynapse/shield/__init__.py +21 -0
  598. contextsynapse/shield/anomaly.py +56 -0
  599. contextsynapse/shield/permissions.py +99 -0
  600. contextsynapse/shield/profile.py +103 -0
  601. contextsynapse/shield/shield.py +108 -0
  602. contextsynapse/shield/trust_engine.py +69 -0
  603. contextsynapse/skills/__init__.py +14 -0
  604. contextsynapse/skills/engine.py +233 -0
  605. contextsynapse/skills/loader.py +59 -0
  606. contextsynapse/skills/models.py +139 -0
  607. contextsynapse/storage/__init__.py +7 -0
  608. contextsynapse/storage/cache.py +208 -0
  609. contextsynapse/storage/columnar_store_v2.py +178 -0
  610. contextsynapse/storage/csr_graph_storage.py +587 -0
  611. contextsynapse/storage/csr_store.py +388 -0
  612. contextsynapse/storage/document_store.py +491 -0
  613. contextsynapse/storage/hnsw_index.py +294 -0
  614. contextsynapse/storage/hybrid_store_v2.py +278 -0
  615. contextsynapse/storage/lmdb_graph_storage.py +366 -0
  616. contextsynapse/storage/migration.py +279 -0
  617. contextsynapse/storage/namespace_store.py +346 -0
  618. contextsynapse/storage/pure_graph_storage.py +185 -0
  619. contextsynapse/storage/redis_graph_adapter.py +186 -0
  620. contextsynapse/storage/redis_graph_storage.py +375 -0
  621. contextsynapse/storage/router/__init__.py +29 -0
  622. contextsynapse/storage/router/duckdb_store.py +398 -0
  623. contextsynapse/storage/router/factory.py +83 -0
  624. contextsynapse/storage/router/interface.py +125 -0
  625. contextsynapse/storage/router/postgres_store.py +339 -0
  626. contextsynapse/storage/router/resolver.py +192 -0
  627. contextsynapse/storage/router/router.py +300 -0
  628. contextsynapse/storage/single_file_storage.py +391 -0
  629. contextsynapse/storage/storage_manager.py +214 -0
  630. contextsynapse/storage/storage_query_optimizer.py +314 -0
  631. contextsynapse/storage/wal.py +359 -0
  632. contextsynapse/temporal/__init__.py +0 -0
  633. contextsynapse/temporal/storage.py +106 -0
  634. contextsynapse/tests/__init__.py +11 -0
  635. contextsynapse/tests/regression/__init__.py +1 -0
  636. contextsynapse/tests/regression/add_metadata.py +395 -0
  637. contextsynapse/tests/regression/add_skip_flags.py +85 -0
  638. contextsynapse/tests/regression/cases/test_basic_create_node.yaml +20 -0
  639. contextsynapse/tests/regression/cases/test_basic_select.yaml +21 -0
  640. contextsynapse/tests/regression/cases/test_blockchain_audit_trail.yaml +37 -0
  641. contextsynapse/tests/regression/cases/test_blockchain_audit_trail_filtered.yaml +39 -0
  642. contextsynapse/tests/regression/cases/test_blockchain_blocks_range.yaml +39 -0
  643. contextsynapse/tests/regression/cases/test_blockchain_get_block.yaml +35 -0
  644. contextsynapse/tests/regression/cases/test_blockchain_latest_block.yaml +35 -0
  645. contextsynapse/tests/regression/cases/test_blockchain_length.yaml +37 -0
  646. contextsynapse/tests/regression/cases/test_blockchain_merkle_root.yaml +37 -0
  647. contextsynapse/tests/regression/cases/test_blockchain_verify.yaml +35 -0
  648. contextsynapse/tests/regression/cases/test_blockchain_verify_block.yaml +35 -0
  649. contextsynapse/tests/regression/cases/test_create_edge.yaml +28 -0
  650. contextsynapse/tests/regression/cases/test_delete_edge_basic.yaml +29 -0
  651. contextsynapse/tests/regression/cases/test_delete_node_basic.yaml +29 -0
  652. contextsynapse/tests/regression/cases/test_evaluation_mrr.yaml +40 -0
  653. contextsynapse/tests/regression/cases/test_evaluation_ndcg.yaml +40 -0
  654. contextsynapse/tests/regression/cases/test_evaluation_precision.yaml +40 -0
  655. contextsynapse/tests/regression/cases/test_evaluation_recall.yaml +40 -0
  656. contextsynapse/tests/regression/cases/test_group_by_basic.yaml +56 -0
  657. contextsynapse/tests/regression/cases/test_match_node_basic.yaml +29 -0
  658. contextsynapse/tests/regression/cases/test_match_node_with_order_limit.yaml +31 -0
  659. contextsynapse/tests/regression/cases/test_namespace_switch.yaml +18 -0
  660. contextsynapse/tests/regression/cases/test_pipeline_cascaded.yaml +61 -0
  661. contextsynapse/tests/regression/cases/test_pipeline_fixed_size_chunking.yaml +48 -0
  662. contextsynapse/tests/regression/cases/test_pipeline_gpt4_chunking.yaml +54 -0
  663. contextsynapse/tests/regression/cases/test_pipeline_hybrid_indexing.yaml +57 -0
  664. contextsynapse/tests/regression/cases/test_pipeline_large_embedding.yaml +52 -0
  665. contextsynapse/tests/regression/cases/test_pipeline_paragraph_chunking.yaml +49 -0
  666. contextsynapse/tests/regression/cases/test_pipeline_recursive_chunking.yaml +47 -0
  667. contextsynapse/tests/regression/cases/test_pipeline_run_pipeline.yaml +56 -0
  668. contextsynapse/tests/regression/cases/test_pipeline_section_based_chunking.yaml +53 -0
  669. contextsynapse/tests/regression/cases/test_pipeline_semantic_chunking.yaml +51 -0
  670. contextsynapse/tests/regression/cases/test_pipeline_with_entity_extraction.yaml +57 -0
  671. contextsynapse/tests/regression/cases/test_pipeline_with_relationships.yaml +62 -0
  672. contextsynapse/tests/regression/cases/test_rag_evaluation.yaml +38 -0
  673. contextsynapse/tests/regression/cases/test_rag_graph_bfs_with_eval.yaml +48 -0
  674. contextsynapse/tests/regression/cases/test_rag_graph_centrality.yaml +40 -0
  675. contextsynapse/tests/regression/cases/test_rag_graph_centrality_eval.yaml +35 -0
  676. contextsynapse/tests/regression/cases/test_rag_graph_community.yaml +40 -0
  677. contextsynapse/tests/regression/cases/test_rag_graph_community_eval.yaml +35 -0
  678. contextsynapse/tests/regression/cases/test_rag_graph_dfs_with_eval.yaml +35 -0
  679. contextsynapse/tests/regression/cases/test_rag_graph_multi_hop_eval.yaml +35 -0
  680. contextsynapse/tests/regression/cases/test_rag_graph_path.yaml +40 -0
  681. contextsynapse/tests/regression/cases/test_rag_graph_search_basic.yaml +38 -0
  682. contextsynapse/tests/regression/cases/test_rag_graph_search_bfs.yaml +52 -0
  683. contextsynapse/tests/regression/cases/test_rag_graph_search_dfs.yaml +41 -0
  684. contextsynapse/tests/regression/cases/test_rag_graph_search_multi_hop.yaml +43 -0
  685. contextsynapse/tests/regression/cases/test_rag_graph_search_shortest_path.yaml +41 -0
  686. contextsynapse/tests/regression/cases/test_rag_graph_search_weighted.yaml +43 -0
  687. contextsynapse/tests/regression/cases/test_rag_graph_shortest_path_eval.yaml +35 -0
  688. contextsynapse/tests/regression/cases/test_rag_graph_traversal.yaml +30 -0
  689. contextsynapse/tests/regression/cases/test_rag_graph_weighted_eval.yaml +35 -0
  690. contextsynapse/tests/regression/cases/test_rag_hybrid_search.yaml +33 -0
  691. contextsynapse/tests/regression/cases/test_rag_hybrid_with_prompt_embedding.yaml +44 -0
  692. contextsynapse/tests/regression/cases/test_rag_keyword_search.yaml +32 -0
  693. contextsynapse/tests/regression/cases/test_rag_multi_hop_graph.yaml +32 -0
  694. contextsynapse/tests/regression/cases/test_rag_path_based.yaml +34 -0
  695. contextsynapse/tests/regression/cases/test_rag_profile_based.yaml +32 -0
  696. contextsynapse/tests/regression/cases/test_rag_vector_search.yaml +32 -0
  697. contextsynapse/tests/regression/cases/test_rag_weighted_graph.yaml +34 -0
  698. contextsynapse/tests/regression/cases/test_rag_weighted_hybrid.yaml +31 -0
  699. contextsynapse/tests/regression/cases/test_semantic_boundary_chunking.yaml +57 -0
  700. contextsynapse/tests/regression/cases/test_semantic_boundary_detection.yaml +33 -0
  701. contextsynapse/tests/regression/cases/test_semantic_hash_function.yaml +30 -0
  702. contextsynapse/tests/regression/cases/test_semantic_hash_with_model.yaml +31 -0
  703. contextsynapse/tests/regression/cases/test_tcs_ingestion_fixed_size.yaml +44 -0
  704. contextsynapse/tests/regression/cases/test_tcs_ingestion_recursive.yaml +46 -0
  705. contextsynapse/tests/regression/cases/test_tcs_ingestion_section.yaml +46 -0
  706. contextsynapse/tests/regression/cases/test_tcs_ingestion_semantic.yaml +44 -0
  707. contextsynapse/tests/regression/cases/test_time_travel_as_of.yaml +35 -0
  708. contextsynapse/tests/regression/cases/test_time_travel_at_commit.yaml +33 -0
  709. contextsynapse/tests/regression/cases/test_time_travel_at_timestamp.yaml +33 -0
  710. contextsynapse/tests/regression/cases/test_time_travel_for_system_time_all.yaml +37 -0
  711. contextsynapse/tests/regression/cases/test_time_travel_versions_between.yaml +39 -0
  712. contextsynapse/tests/regression/cases/test_traverse_basic.yaml +31 -0
  713. contextsynapse/tests/regression/cases/test_traverse_max_depth.yaml +34 -0
  714. contextsynapse/tests/regression/cases/test_update_edge_basic.yaml +29 -0
  715. contextsynapse/tests/regression/evaluation_metrics.py +227 -0
  716. contextsynapse/tests/regression/regression_runner.py +1081 -0
  717. contextsynapse/tests/regression/setup_regression_data.py +212 -0
  718. contextsynapse/tests/regression/test_basic_only.py +84 -0
  719. contextsynapse/tests/regression/test_namespace_creation.py +29 -0
  720. contextsynapse/tests/regression/test_single_fix.py +61 -0
  721. contextsynapse/tests/regression/test_single_query.py +20 -0
  722. contextsynapse/tests/regression/validate_test_syntax.py +179 -0
  723. contextsynapse/tests/test_advanced_features.py +347 -0
  724. contextsynapse/tests/test_aiql_summary.py +283 -0
  725. contextsynapse/tests/test_all_aiql_queries.py +948 -0
  726. contextsynapse/tests/test_all_create_queries.py +213 -0
  727. contextsynapse/tests/test_api_vs_native.py +280 -0
  728. contextsynapse/tests/test_chunking_dedup.py +199 -0
  729. contextsynapse/tests/test_connectors.py +171 -0
  730. contextsynapse/tests/test_edges_and_match.py +202 -0
  731. contextsynapse/tests/test_extraction_pipeline_integration.py +581 -0
  732. contextsynapse/tests/test_name_property.py +224 -0
  733. contextsynapse/tests/test_node_edge_metadata.py +250 -0
  734. contextsynapse/tests/test_playground_conn.py +114 -0
  735. contextsynapse/tests/test_query_cache.py +213 -0
  736. contextsynapse/tests/test_source_policy_auth.py +348 -0
  737. contextsynapse/tests/test_temporal_and_domain.py +224 -0
  738. contextsynapse/tests/test_time_travel_e2e.py +571 -0
  739. contextsynapse/tests/test_traversal_and_aggregation.py +275 -0
  740. contextsynapse/tools/__init__.py +36 -0
  741. contextsynapse/tools/a2a_tools.py +106 -0
  742. contextsynapse/tools/cognition_tools.py +118 -0
  743. contextsynapse/tools/context.py +905 -0
  744. contextsynapse/tools/formatting.py +92 -0
  745. contextsynapse/tools/gateway_tools.py +186 -0
  746. contextsynapse/tools/graph.py +1700 -0
  747. contextsynapse/tools/intelligence.py +235 -0
  748. contextsynapse/tools/memory.py +118 -0
  749. contextsynapse/tools/registry.py +373 -0
  750. contextsynapse/tools/shield_tools.py +159 -0
  751. contextsynapse/tools/tasks.py +341 -0
  752. contextsynapse/tools/workspace_tools.py +124 -0
  753. contextsynapse/utils/__init__.py +15 -0
  754. contextsynapse/utils/config_loader.py +198 -0
  755. contextsynapse/utils/env_loader.py +156 -0
  756. contextsynapse/utils/file_utils.py +288 -0
  757. contextsynapse/utils/logging_optimizer.py +119 -0
  758. contextsynapse/vector/__init__.py +30 -0
  759. contextsynapse/vector/chroma_vector_store.py +152 -0
  760. contextsynapse/vector/custom_vector_store.py +537 -0
  761. contextsynapse/vector/faiss_vector_store.py +296 -0
  762. contextsynapse/vector/qdrant_vector_store.py +163 -0
  763. contextsynapse/vector/vector_db.py +953 -0
  764. contextsynapse/vector/vector_db_manager.py +197 -0
  765. contextsynapse/workspace/__init__.py +27 -0
  766. contextsynapse/workspace/base.py +94 -0
  767. contextsynapse/workspace/git.py +228 -0
  768. contextsynapse/workspace/github.py +78 -0
  769. contextsynapse/workspace/local.py +73 -0
  770. contextsynapse/workspace/tools.py +222 -0
  771. contextsynapse-1.0.0.dist-info/METADATA +847 -0
  772. contextsynapse-1.0.0.dist-info/RECORD +776 -0
  773. contextsynapse-1.0.0.dist-info/WHEEL +5 -0
  774. contextsynapse-1.0.0.dist-info/entry_points.txt +4 -0
  775. contextsynapse-1.0.0.dist-info/licenses/LICENSE +200 -0
  776. 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
+