satisfactoscript 1.0.0__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/PKG-INFO +4 -1
  2. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/pyproject.toml +7 -1
  3. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/cli.py +11 -27
  4. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/interpreter.py +2 -1
  5. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/ir.py +23 -1
  6. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/llm_provider.py +105 -4
  7. satisfactoscript-1.1.0/src/satisfactoscript/serving/__init__.py +20 -0
  8. satisfactoscript-1.1.0/src/satisfactoscript/serving/_response_serializer.py +96 -0
  9. satisfactoscript-1.1.0/src/satisfactoscript/serving/chat_model.py +122 -0
  10. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript.egg-info/PKG-INFO +4 -1
  11. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript.egg-info/SOURCES.txt +5 -0
  12. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript.egg-info/requires.txt +4 -0
  13. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_backend_sql_base.py +8 -0
  14. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_core_join.py +33 -0
  15. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_ir.py +25 -0
  16. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_llm_provider.py +107 -0
  17. satisfactoscript-1.1.0/tests/test_serving_chat_model.py +147 -0
  18. satisfactoscript-1.1.0/tests/test_serving_response_serializer.py +183 -0
  19. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/README.md +0 -0
  20. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/setup.cfg +0 -0
  21. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/__init__.py +0 -0
  22. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/__init__.py +0 -0
  23. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/agent.py +0 -0
  24. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/builder_agent.py +0 -0
  25. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/dictionary_agent.py +0 -0
  26. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/exporter.py +0 -0
  27. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/history.py +0 -0
  28. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/hub.py +0 -0
  29. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/lineage_agent.py +0 -0
  30. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/models.py +0 -0
  31. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/orchestrator.py +0 -0
  32. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/quality_agent.py +0 -0
  33. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/resolver.py +0 -0
  34. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/agentic/user_profile.py +0 -0
  35. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/backends/__init__.py +0 -0
  36. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/backends/bigquery.py +0 -0
  37. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/backends/snowpark.py +0 -0
  38. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/backends/spark.py +0 -0
  39. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/backends/sql_base.py +0 -0
  40. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/__init__.py +0 -0
  41. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/backend.py +0 -0
  42. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/catalog_inspector.py +0 -0
  43. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/config.py +0 -0
  44. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/context.py +0 -0
  45. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/core.py +0 -0
  46. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/environment.py +0 -0
  47. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/json_schema.py +0 -0
  48. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/loaders.py +0 -0
  49. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/op_catalog.py +0 -0
  50. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/operations.py +0 -0
  51. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/patterns.py +0 -0
  52. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/registry.py +0 -0
  53. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/rule_analyzer.py +0 -0
  54. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/rule_executor.py +0 -0
  55. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/rule_planner.py +0 -0
  56. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/sandbox.py +0 -0
  57. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/schema_loader.py +0 -0
  58. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/core/writer.py +0 -0
  59. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/lineage/__init__.py +0 -0
  60. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/lineage/dictionary.py +0 -0
  61. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/lineage/renderer.py +0 -0
  62. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/lineage/tracker.py +0 -0
  63. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/__init__.py +0 -0
  64. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/alerts.py +0 -0
  65. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/checks.py +0 -0
  66. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/contracts.py +0 -0
  67. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/history.py +0 -0
  68. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/monitor.py +0 -0
  69. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/observability/reporter.py +0 -0
  70. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/registry.py +0 -0
  71. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/__init__.py +0 -0
  72. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/builder.py +0 -0
  73. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/extractor.py +0 -0
  74. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/glossary.py +0 -0
  75. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/semantic.py +0 -0
  76. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/semantic/validator.py +0 -0
  77. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/sinks/__init__.py +0 -0
  78. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/sinks/jdbc.py +0 -0
  79. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/spark_factory.py +0 -0
  80. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript/utils.py +0 -0
  81. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript.egg-info/dependency_links.txt +0 -0
  82. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript.egg-info/entry_points.txt +0 -0
  83. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/src/satisfactoscript.egg-info/top_level.txt +0 -0
  84. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_agent.py +0 -0
  85. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_backend_bigquery.py +0 -0
  86. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_backend_protocol.py +0 -0
  87. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_backend_snowpark.py +0 -0
  88. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_backend_spark.py +0 -0
  89. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_builder_agent.py +0 -0
  90. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_catalog_inspector.py +0 -0
  91. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_cli.py +0 -0
  92. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_config.py +0 -0
  93. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_core.py +0 -0
  94. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_core_connect_patch.py +0 -0
  95. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_core_env_detection.py +0 -0
  96. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_core_username.py +0 -0
  97. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_dictionary_agent.py +0 -0
  98. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_dummy.py +0 -0
  99. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_engine_fake_backend.py +0 -0
  100. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_engine_with_backend.py +0 -0
  101. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_history.py +0 -0
  102. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_hub.py +0 -0
  103. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_interpreter.py +0 -0
  104. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_json_schema.py +0 -0
  105. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_lineage_agent.py +0 -0
  106. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_lineage_dictionary.py +0 -0
  107. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_lineage_renderer.py +0 -0
  108. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_lineage_tracker.py +0 -0
  109. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_loaders.py +0 -0
  110. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_observability.py +0 -0
  111. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_op_catalog.py +0 -0
  112. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_orchestrator.py +0 -0
  113. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_patterns.py +0 -0
  114. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_quality_agent.py +0 -0
  115. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_registry.py +0 -0
  116. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_registry_import_paths.py +0 -0
  117. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_resolver.py +0 -0
  118. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_rule_analyzer.py +0 -0
  119. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_rule_executor.py +0 -0
  120. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_rule_planner.py +0 -0
  121. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_sandbox.py +0 -0
  122. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_schema_loader.py +0 -0
  123. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_semantic_builder.py +0 -0
  124. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_semantic_engine_catalog.py +0 -0
  125. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_sink_jdbc.py +0 -0
  126. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_user_profile.py +0 -0
  127. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_utils_logging.py +0 -0
  128. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_utils_safe_columns.py +0 -0
  129. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_validator.py +0 -0
  130. {satisfactoscript-1.0.0 → satisfactoscript-1.1.0}/tests/test_writer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: satisfactoscript
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Declarative data engineering framework — multi-platform (Databricks, Snowflake, BigQuery).
5
5
  Author-email: julhouba <houbartjulien80@gmail.com>
6
6
  License: MIT
@@ -43,6 +43,9 @@ Requires-Dist: google-generativeai>=0.5.0; extra == "llm-google"
43
43
  Provides-Extra: semantic-pdf
44
44
  Requires-Dist: fpdf2>=2.7.0; extra == "semantic-pdf"
45
45
  Requires-Dist: matplotlib>=3.7.0; extra == "semantic-pdf"
46
+ Provides-Extra: serving
47
+ Requires-Dist: mlflow<3,>=2.12.0; extra == "serving"
48
+ Requires-Dist: openai>=1.0.0; extra == "serving"
46
49
  Provides-Extra: semantic-full
47
50
  Requires-Dist: openai>=1.0.0; extra == "semantic-full"
48
51
  Requires-Dist: anthropic>=0.30.0; extra == "semantic-full"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "satisfactoscript"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "Declarative data engineering framework — multi-platform (Databricks, Snowflake, BigQuery)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -74,6 +74,12 @@ semantic-pdf = [
74
74
  "matplotlib>=3.7.0"
75
75
  ]
76
76
 
77
+ # --- Databricks Model Serving ---
78
+ serving = [
79
+ "mlflow>=2.12.0,<3",
80
+ "openai>=1.0.0",
81
+ ]
82
+
77
83
  # --- Bundles pratiques ---
78
84
  semantic-full = [
79
85
  "openai>=1.0.0",
@@ -117,7 +117,6 @@ def _run_hub(args: argparse.Namespace) -> None:
117
117
  """Boucle REPL principale du SatisfactoHub."""
118
118
  try:
119
119
  from rich.console import Console
120
- from rich.table import Table as RichTable
121
120
  except ImportError:
122
121
  print("[ERROR] Le module 'rich' est requis pour le hub. Installez-le avec : pip install rich")
123
122
  sys.exit(1)
@@ -175,7 +174,7 @@ def _run_hub(args: argparse.Namespace) -> None:
175
174
 
176
175
  if not args.new_session and len(session) > 0:
177
176
  ts = session.created_at.strftime("%Y-%m-%d") if hasattr(session.created_at, "strftime") else str(session.created_at)
178
- console.print(f" [1] Nouvelle session (efface la session précédente)")
177
+ console.print(" [1] Nouvelle session (efface la session précédente)")
179
178
  console.print(f" [2] Reprendre la session du {ts} ({len(session)} messages)\n")
180
179
  try:
181
180
  choice = input("Choix [1/2] : ").strip()
@@ -255,8 +254,8 @@ def _run_hub(args: argparse.Namespace) -> None:
255
254
  session.save(scope_id)
256
255
 
257
256
 
258
- def _render_response(console: "Console", response: object) -> None: # type: ignore[name-defined]
259
- """Affiche la réponse dans le terminal selon son type."""
257
+ def _render_response(console: object, response: object) -> None:
258
+ """Render a HubResponse in the terminal with Rich formatting."""
260
259
  from .agentic.models import (
261
260
  AgentResponse,
262
261
  BuilderResponse,
@@ -264,6 +263,7 @@ def _render_response(console: "Console", response: object) -> None: # type: ign
264
263
  LineageResponse,
265
264
  QualityResponse,
266
265
  )
266
+ from .serving._response_serializer import hub_response_to_text
267
267
 
268
268
  if isinstance(response, AgentResponse):
269
269
  if response.mode == "error":
@@ -271,27 +271,12 @@ def _render_response(console: "Console", response: object) -> None: # type: ign
271
271
  elif response.mode in ("ambiguous", "needs_clarification"):
272
272
  console.print(f"[yellow]{response.clarification_question or response.explanation}[/yellow]")
273
273
  else:
274
- _render_agent_response(console, response)
274
+ _render_agent_response_rich(console, response)
275
275
 
276
- elif isinstance(response, LineageResponse):
277
- if response.error:
278
- console.print(f"[red]{response.error}[/red]")
279
- elif response.diagram:
280
- console.print(response.diagram)
281
- else:
282
- console.print(response.narrative or f"Lignage ({response.mode}) — colonne : {response.column}")
283
-
284
- elif isinstance(response, QualityResponse):
285
- if response.error:
286
- console.print(f"[red]{response.error}[/red]")
287
- else:
288
- console.print(response.text_output or response.narrative or "Checks exécutés.")
289
-
290
- elif isinstance(response, DictionaryResponse):
291
- if response.error:
292
- console.print(f"[red]{response.error}[/red]")
293
- else:
294
- console.print(response.text_output or response.narrative or f"Champ : {response.column}")
276
+ elif isinstance(response, (LineageResponse, QualityResponse, DictionaryResponse)):
277
+ text = hub_response_to_text(response)
278
+ style = "red" if getattr(response, "error", None) else ""
279
+ console.print(f"[{style}]{text}[/{style}]" if style else text)
295
280
 
296
281
  elif isinstance(response, BuilderResponse):
297
282
  if not response.success:
@@ -305,8 +290,8 @@ def _render_response(console: "Console", response: object) -> None: # type: ign
305
290
  console.print(str(response))
306
291
 
307
292
 
308
- def _render_agent_response(console: "Console", response: object) -> None: # type: ignore[name-defined]
309
- """Affiche un AgentResponse en mode query selon le format du résultat."""
293
+ def _render_agent_response_rich(console: object, response: object) -> None:
294
+ """Render an AgentResponse with Rich formatting (tables, colours)."""
310
295
  try:
311
296
  from rich.table import Table as RichTable
312
297
  from .agentic.models import ResponseFormat
@@ -324,7 +309,6 @@ def _render_agent_response(console: "Console", response: object) -> None: # typ
324
309
  console.print(f" {result.kpi_label or ''}: [green]{result.kpi_value}[/green]")
325
310
 
326
311
  elif fmt == ResponseFormat.TABLE:
327
- # Essayer d'afficher via rich.Table si le DataFrame est disponible
328
312
  data_df = getattr(result, "data", None)
329
313
  if data_df is not None:
330
314
  try:
@@ -15,6 +15,7 @@ import logging
15
15
  from functools import reduce
16
16
  from typing import TYPE_CHECKING, Any
17
17
 
18
+ from satisfactoscript.core.ir import _normalise_join_type
18
19
  from satisfactoscript.core.registry import RuleRegistry
19
20
  from satisfactoscript.core.sandbox import SandboxResolver
20
21
  from satisfactoscript.core.rule_analyzer import RuleAnalyzer
@@ -340,7 +341,7 @@ class SchemaInterpreter:
340
341
 
341
342
  on_l = key_l if isinstance(key_l, list) else [key_l]
342
343
  on_r = key_r if isinstance(key_r, list) else [key_r]
343
- join_type = j.get("type", "left")
344
+ join_type = _normalise_join_type(j.get("type", "left"))
344
345
 
345
346
  logger.info(" -> [Join] %s JOIN %s(%s) -> %s(%s)", join_type.upper(), alias_l, on_l, alias_r, on_r)
346
347
  df_to = dfs[alias_r]
@@ -11,6 +11,21 @@ from __future__ import annotations
11
11
  from dataclasses import dataclass, field
12
12
  from typing import Any
13
13
 
14
+ VALID_JOIN_TYPES: frozenset[str] = frozenset({"left", "right", "inner", "full", "cross"})
15
+
16
+ _JOIN_TYPE_ALIASES: dict[str, str] = {
17
+ "left outer": "left",
18
+ "right outer": "right",
19
+ "outer": "full",
20
+ "full outer": "full",
21
+ "full_outer": "full",
22
+ }
23
+
24
+
25
+ def _normalise_join_type(raw: str) -> str:
26
+ key = raw.strip().lower().replace("-", " ")
27
+ return _JOIN_TYPE_ALIASES.get(key, key)
28
+
14
29
 
15
30
  # ---------------------------------------------------------------------------
16
31
  # Leaf types
@@ -180,12 +195,19 @@ def parse_to_ir(schema_dict: dict) -> ParsedSchema:
180
195
  else:
181
196
  alias_r = table_to
182
197
  key_r = j.get("on_to")
198
+ raw_type = j.get("type", "left")
199
+ join_type = _normalise_join_type(raw_type)
200
+ if join_type not in VALID_JOIN_TYPES:
201
+ raise ValueError(
202
+ f"Invalid join type {raw_type!r} for join {alias_l!r} → {alias_r!r}. "
203
+ f"Valid types: {', '.join(sorted(VALID_JOIN_TYPES))}"
204
+ )
183
205
  joins.append(ParsedJoin(
184
206
  alias_left=alias_l,
185
207
  keys_left=key_l if isinstance(key_l, list) else [key_l],
186
208
  alias_right=alias_r,
187
209
  keys_right=key_r if isinstance(key_r, list) else [key_r],
188
- join_type=j.get("type", "left"),
210
+ join_type=join_type,
189
211
  ))
190
212
 
191
213
  # --- select_final / add_columns ---
@@ -261,6 +261,103 @@ class GoogleProvider(LLMProvider):
261
261
  return response.text
262
262
 
263
263
 
264
+ # ---------------------------------------------------------------------------
265
+ # Databricks Foundation Models
266
+ # ---------------------------------------------------------------------------
267
+
268
+ class DatabricksProvider(LLMProvider):
269
+ """
270
+ LLM provider for Databricks Foundation Models API (OpenAI-compatible).
271
+
272
+ The Databricks Foundation Models API is OpenAI-compatible and is exposed at
273
+ ``{DATABRICKS_HOST}/serving-endpoints``. This provider wraps the OpenAI client
274
+ with the correct base_url and uses a Databricks PAT as the API key.
275
+
276
+ Configuration via environment variables:
277
+ DATABRICKS_HOST — workspace URL (e.g. https://adb-xxx.azuredatabricks.net)
278
+ DATABRICKS_TOKEN — personal access token or service principal secret
279
+ DATABRICKS_LLM_MODEL — serving endpoint name (default: databricks-dbrx-instruct)
280
+ """
281
+
282
+ def __init__(
283
+ self,
284
+ host: str | None = None,
285
+ token: str | None = None,
286
+ model: str | None = None,
287
+ **kwargs: Any,
288
+ ) -> None:
289
+ try:
290
+ import openai as _openai
291
+ except ImportError as exc:
292
+ raise ImportError(
293
+ "[LLMProvider] 'openai' is required for DatabricksProvider. "
294
+ "Install it with: pip install openai"
295
+ ) from exc
296
+
297
+ resolved_host = host or os.environ.get("DATABRICKS_HOST", "")
298
+ resolved_token = token or os.environ.get("DATABRICKS_TOKEN", "")
299
+
300
+ if not resolved_host:
301
+ raise ValueError(
302
+ "[DatabricksProvider] DATABRICKS_HOST is not set. "
303
+ "Set it to your workspace URL (e.g. https://adb-xxx.azuredatabricks.net)."
304
+ )
305
+ if not resolved_token:
306
+ raise ValueError(
307
+ "[DatabricksProvider] DATABRICKS_TOKEN is not set. "
308
+ "Set it to a personal access token or service principal secret."
309
+ )
310
+
311
+ base_url = resolved_host.rstrip("/") + "/serving-endpoints"
312
+ self._client = _openai.OpenAI(
313
+ api_key=resolved_token,
314
+ base_url=base_url,
315
+ )
316
+ self._model = model or os.environ.get(
317
+ "DATABRICKS_LLM_MODEL", "databricks-dbrx-instruct"
318
+ )
319
+
320
+ @property
321
+ def provider_name(self) -> str:
322
+ return "databricks"
323
+
324
+ def complete(
325
+ self,
326
+ system_prompt: str,
327
+ user_message: str,
328
+ temperature: float = 0.0,
329
+ response_format: dict | None = None,
330
+ **kwargs: Any,
331
+ ) -> str:
332
+ return self.complete_with_history(
333
+ system_prompt=system_prompt,
334
+ history=[{"role": "user", "content": user_message}],
335
+ temperature=temperature,
336
+ response_format=response_format,
337
+ **kwargs,
338
+ )
339
+
340
+ def complete_with_history(
341
+ self,
342
+ system_prompt: str,
343
+ history: list[dict],
344
+ temperature: float = 0.0,
345
+ response_format: dict | None = None,
346
+ **kwargs: Any,
347
+ ) -> str:
348
+ call_kwargs: dict[str, Any] = {
349
+ "model": kwargs.pop("model", self._model),
350
+ "messages": [{"role": "system", "content": system_prompt}] + history,
351
+ "temperature": temperature,
352
+ }
353
+ if response_format:
354
+ call_kwargs["response_format"] = response_format
355
+ call_kwargs.update(kwargs)
356
+
357
+ response = self._client.chat.completions.create(**call_kwargs)
358
+ return response.choices[0].message.content
359
+
360
+
264
361
  # ---------------------------------------------------------------------------
265
362
  # Factory
266
363
  # ---------------------------------------------------------------------------
@@ -269,6 +366,7 @@ _PROVIDER_MAP = {
269
366
  "openai": OpenAIProvider,
270
367
  "anthropic": AnthropicProvider,
271
368
  "google": GoogleProvider,
369
+ "databricks": DatabricksProvider,
272
370
  }
273
371
 
274
372
 
@@ -283,8 +381,9 @@ def get_llm_provider(
283
381
  1. Argument ``provider`` explicite.
284
382
  2. Variable d'environnement ``LLM_PROVIDER``.
285
383
  3. Auto-détection depuis les clés API présentes dans l'environnement
286
- (OPENAI_API_KEYopenai, ANTHROPIC_API_KEY → anthropic, GOOGLE_API_KEY → google).
287
- 4. Fallback sur ``openai``.
384
+ (DATABRICKS_HOST+DATABRICKS_TOKENdatabricks, ANTHROPIC_API_KEY → anthropic,
385
+ OPENAI_API_KEY openai, GOOGLE_API_KEY → google).
386
+ 4. Erreur si aucune clé détectée.
288
387
 
289
388
  Args:
290
389
  provider: Nom du provider parmi {'openai', 'anthropic', 'google'}.
@@ -314,6 +413,8 @@ def get_llm_provider(
314
413
 
315
414
  def _auto_detect_provider() -> str:
316
415
  """Détecte le provider depuis les clés API présentes dans l'environnement."""
416
+ if os.environ.get("DATABRICKS_HOST") and os.environ.get("DATABRICKS_TOKEN"):
417
+ return "databricks"
317
418
  if os.environ.get("ANTHROPIC_API_KEY"):
318
419
  return "anthropic"
319
420
  if os.environ.get("OPENAI_API_KEY"):
@@ -322,6 +423,6 @@ def _auto_detect_provider() -> str:
322
423
  return "google"
323
424
  raise ValueError(
324
425
  "[LLMProvider] Aucune clé API détectée. "
325
- "Définissez ANTHROPIC_API_KEY, OPENAI_API_KEY ou GOOGLE_API_KEY "
326
- "dans votre .env ou passez provider= explicitement."
426
+ "Définissez DATABRICKS_HOST+DATABRICKS_TOKEN, ANTHROPIC_API_KEY, "
427
+ "OPENAI_API_KEY ou GOOGLE_API_KEY dans votre .env ou passez provider= explicitement."
327
428
  )
@@ -0,0 +1,20 @@
1
+ """
2
+ satisfactoscript.serving — MLflow ChatModel serving layer.
3
+
4
+ Exposes AgenticHub as an OpenAI-compatible REST endpoint on Databricks Model Serving.
5
+
6
+ Public exports:
7
+ SatisfactoChatModel — mlflow.pyfunc.ChatModel wrapping AgenticHub
8
+ hub_response_to_text — convert any HubResponse to a plain text string
9
+ """
10
+
11
+ from ._response_serializer import hub_response_to_text
12
+
13
+ __all__ = ["SatisfactoChatModel", "hub_response_to_text"]
14
+
15
+
16
+ def __getattr__(name: str): # noqa: N807
17
+ if name == "SatisfactoChatModel":
18
+ from .chat_model import SatisfactoChatModel # noqa: PLC0415
19
+ return SatisfactoChatModel
20
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,96 @@
1
+ """
2
+ hub_response_to_text — converts any HubResponse to a plain text string.
3
+
4
+ Shared by the CLI renderer and the MLflow serving layer so serialization
5
+ logic is not duplicated.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def hub_response_to_text(response: Any) -> str:
14
+ """
15
+ Convert any HubResponse dataclass to a plain text string.
16
+
17
+ Handles AgentResponse, LineageResponse, QualityResponse,
18
+ DictionaryResponse, and BuilderResponse. Falls back to str() for
19
+ any unrecognised type.
20
+ """
21
+ from satisfactoscript.agentic.models import (
22
+ AgentResponse,
23
+ BuilderResponse,
24
+ DictionaryResponse,
25
+ LineageResponse,
26
+ QualityResponse,
27
+ )
28
+
29
+ if isinstance(response, AgentResponse):
30
+ return _serialize_agent_response(response)
31
+
32
+ if isinstance(response, LineageResponse):
33
+ if response.error:
34
+ return f"Error: {response.error}"
35
+ return response.diagram or response.narrative or f"Lineage ({response.mode}) — column: {response.column}"
36
+
37
+ if isinstance(response, QualityResponse):
38
+ if response.error:
39
+ return f"Error: {response.error}"
40
+ return response.text_output or response.narrative or "Quality checks executed."
41
+
42
+ if isinstance(response, DictionaryResponse):
43
+ if response.error:
44
+ return f"Error: {response.error}"
45
+ return response.text_output or response.narrative or f"Field: {response.column}"
46
+
47
+ if isinstance(response, BuilderResponse):
48
+ if not response.success:
49
+ return f"Error: {response.error}"
50
+ parts = [response.yaml_content]
51
+ if response.output_path:
52
+ parts.append(f"Saved to: {response.output_path}")
53
+ return "\n".join(parts)
54
+
55
+ return str(response)
56
+
57
+
58
+ def _serialize_agent_response(response: Any) -> str:
59
+ """Serialize an AgentResponse to plain text."""
60
+ from satisfactoscript.agentic.models import ResponseFormat
61
+
62
+ if response.mode == "error":
63
+ return f"Error: {response.error}"
64
+
65
+ if response.mode in ("ambiguous", "needs_clarification"):
66
+ return response.clarification_question or response.explanation or "Clarification needed."
67
+
68
+ result = getattr(response, "result", None)
69
+ if result is None:
70
+ return response.explanation or "Response received."
71
+
72
+ fmt = getattr(result, "format", None)
73
+ title = getattr(result, "title", "")
74
+
75
+ if fmt == ResponseFormat.KPI:
76
+ label = result.kpi_label or title or response.explanation or "Value"
77
+ return f"{label}: {result.kpi_value}"
78
+
79
+ if fmt == ResponseFormat.TABLE:
80
+ data = getattr(result, "data", None)
81
+ if data is not None:
82
+ try:
83
+ rows = data.limit(20).collect()
84
+ cols = data.columns
85
+ lines = [title] if title else []
86
+ lines.append(" | ".join(cols))
87
+ lines.append("-" * (len(" | ".join(cols))))
88
+ for row in rows:
89
+ lines.append(" | ".join(str(v) for v in row))
90
+ return "\n".join(lines)
91
+ except Exception:
92
+ pass
93
+ return title or "No data."
94
+
95
+ text = getattr(result, "text_summary", None) or response.explanation
96
+ return text or "Response received."
@@ -0,0 +1,122 @@
1
+ """
2
+ SatisfactoChatModel — MLflow ChatModel wrapping AgenticHub.
3
+
4
+ Exposes AgenticHub.ask() as an OpenAI-compatible /chat/completions endpoint
5
+ on Databricks Model Serving. load_context() is called once at pod startup;
6
+ predict() handles each inbound request.
7
+
8
+ Expected artifacts at log time:
9
+ "config" — path to config.yaml
10
+ "models_dir" — path to the semantic models directory
11
+
12
+ Expected environment variables at serving time:
13
+ DATABRICKS_HOST — workspace URL
14
+ DATABRICKS_TOKEN — PAT or service principal secret
15
+ DATABRICKS_LLM_MODEL — Foundation Model endpoint name (optional)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SatisfactoChatModel:
27
+ """
28
+ MLflow pyfunc ChatModel that delegates to AgenticHub.
29
+
30
+ Lazily imports mlflow so the class can be imported in environments
31
+ where mlflow is not installed (e.g. during unit tests that mock it).
32
+ """
33
+
34
+ def load_context(self, context: Any) -> None:
35
+ """
36
+ Called once when the serving endpoint pod starts.
37
+
38
+ Initialises SparkSession, ConfigurationManager, SatisfactoEngine,
39
+ SemanticEngine, DatabricksLLMProvider, and AgenticHub. All heavy
40
+ dependencies are imported here so the module itself stays importable
41
+ without them installed.
42
+ """
43
+ from pyspark.sql import SparkSession
44
+
45
+ from satisfactoscript.agentic.hub import AgenticHub
46
+ from satisfactoscript.core.config import ConfigurationManager
47
+ from satisfactoscript.core.core import SatisfactoEngine
48
+ from satisfactoscript.semantic.llm_provider import get_llm_provider
49
+ from satisfactoscript.semantic.semantic import SemanticEngine
50
+
51
+ artifacts = getattr(context, "artifacts", {}) or {}
52
+ config_path = artifacts.get("config", "config.yaml")
53
+ models_dir = artifacts.get("models_dir", "semantic")
54
+
55
+ spark = SparkSession.builder.getOrCreate()
56
+ config_mgr = ConfigurationManager(config_path=config_path)
57
+ engine = SatisfactoEngine(spark, config_manager=config_mgr)
58
+ semantic = SemanticEngine(engine, models_dir=models_dir)
59
+ llm = get_llm_provider("databricks")
60
+
61
+ self._hub = AgenticHub(semantic_engine=semantic, llm_provider=llm)
62
+ logger.info("SatisfactoChatModel: AgenticHub initialised.")
63
+
64
+ def predict(
65
+ self,
66
+ context: Any,
67
+ messages: list[dict[str, Any]],
68
+ params: dict[str, Any] | None = None,
69
+ ) -> dict[str, Any]:
70
+ """
71
+ Handle a single chat completion request.
72
+
73
+ Extracts the last user message from the OpenAI-format messages list,
74
+ calls hub.ask(), and returns an OpenAI-compatible response dict.
75
+ """
76
+ from satisfactoscript.serving._response_serializer import hub_response_to_text
77
+
78
+ question = _extract_last_user_message(messages)
79
+ response = self._hub.ask(question)
80
+ text = hub_response_to_text(response)
81
+
82
+ return {
83
+ "choices": [
84
+ {
85
+ "index": 0,
86
+ "message": {"role": "assistant", "content": text},
87
+ "finish_reason": "stop",
88
+ }
89
+ ]
90
+ }
91
+
92
+
93
+ def _extract_last_user_message(messages: list[dict[str, Any]]) -> str:
94
+ """
95
+ Return the content of the last message with role='user'.
96
+
97
+ Raises ValueError if no user message is found.
98
+ """
99
+ for msg in reversed(messages):
100
+ if isinstance(msg, dict) and msg.get("role") == "user":
101
+ return str(msg.get("content", ""))
102
+ raise ValueError(
103
+ "SatisfactoChatModel.predict(): no message with role='user' found in messages."
104
+ )
105
+
106
+
107
+ def _build_mlflow_model() -> "SatisfactoChatModel": # noqa: F821
108
+ """
109
+ Return a SatisfactoChatModel instance registered as an mlflow.pyfunc.ChatModel.
110
+
111
+ Registers the mlflow.pyfunc flavour so the returned object can be passed
112
+ directly to mlflow.pyfunc.log_model(python_model=...).
113
+
114
+ This is a factory rather than direct inheritance because mlflow is an
115
+ optional dependency — we avoid a top-level mlflow import.
116
+ """
117
+ import mlflow
118
+
119
+ class _Registered(SatisfactoChatModel, mlflow.pyfunc.ChatModel):
120
+ pass
121
+
122
+ return _Registered()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: satisfactoscript
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Declarative data engineering framework — multi-platform (Databricks, Snowflake, BigQuery).
5
5
  Author-email: julhouba <houbartjulien80@gmail.com>
6
6
  License: MIT
@@ -43,6 +43,9 @@ Requires-Dist: google-generativeai>=0.5.0; extra == "llm-google"
43
43
  Provides-Extra: semantic-pdf
44
44
  Requires-Dist: fpdf2>=2.7.0; extra == "semantic-pdf"
45
45
  Requires-Dist: matplotlib>=3.7.0; extra == "semantic-pdf"
46
+ Provides-Extra: serving
47
+ Requires-Dist: mlflow<3,>=2.12.0; extra == "serving"
48
+ Requires-Dist: openai>=1.0.0; extra == "serving"
46
49
  Provides-Extra: semantic-full
47
50
  Requires-Dist: openai>=1.0.0; extra == "semantic-full"
48
51
  Requires-Dist: anthropic>=0.30.0; extra == "semantic-full"
@@ -68,6 +68,9 @@ src/satisfactoscript/semantic/glossary.py
68
68
  src/satisfactoscript/semantic/llm_provider.py
69
69
  src/satisfactoscript/semantic/semantic.py
70
70
  src/satisfactoscript/semantic/validator.py
71
+ src/satisfactoscript/serving/__init__.py
72
+ src/satisfactoscript/serving/_response_serializer.py
73
+ src/satisfactoscript/serving/chat_model.py
71
74
  src/satisfactoscript/sinks/__init__.py
72
75
  src/satisfactoscript/sinks/jdbc.py
73
76
  tests/test_agent.py
@@ -115,6 +118,8 @@ tests/test_sandbox.py
115
118
  tests/test_schema_loader.py
116
119
  tests/test_semantic_builder.py
117
120
  tests/test_semantic_engine_catalog.py
121
+ tests/test_serving_chat_model.py
122
+ tests/test_serving_response_serializer.py
118
123
  tests/test_sink_jdbc.py
119
124
  tests/test_user_profile.py
120
125
  tests/test_utils_logging.py
@@ -30,6 +30,10 @@ matplotlib>=3.7.0
30
30
  fpdf2>=2.7.0
31
31
  matplotlib>=3.7.0
32
32
 
33
+ [serving]
34
+ mlflow<3,>=2.12.0
35
+ openai>=1.0.0
36
+
33
37
  [snowflake]
34
38
  snowflake-snowpark-python>=1.0.0
35
39
 
@@ -226,6 +226,14 @@ class TestSQLQuery:
226
226
  assert "LEFT JOIN" in sql
227
227
  assert "cust_id" in sql
228
228
 
229
+ def test_join_full_outer(self):
230
+ left = SQLQuery("`orders`")
231
+ right = SQLQuery("`customers`")
232
+ left._joins.append(("full", right, ["customer_id"]))
233
+ sql = left.to_sql()
234
+ assert "FULL OUTER JOIN" in sql
235
+ assert "customer_id" in sql
236
+
229
237
  def test_qualify(self):
230
238
  q = SQLQuery("`orders`")
231
239
  q._qualify = "ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY 1) = 1"
@@ -154,3 +154,36 @@ class TestProcessSchemaJoin:
154
154
  # drop must receive both right-side key refs
155
155
  joined.drop.assert_called_once_with(df_b["org_r"], df_b["date_r"])
156
156
  df_b.columns.assert_not_called()
157
+
158
+ def test_full_outer_join_same_keys(self):
159
+ """type: full → join(..., how='full') with list-join path."""
160
+ df_a = MagicMock(name="df_a")
161
+ df_b = MagicMock(name="df_b")
162
+ df_a.join.return_value = _joined_df()
163
+
164
+ schema = {
165
+ "join": [{"table_from": "a", "table_to": "b", "on_from": "id", "on_to": "id", "type": "full"}]
166
+ }
167
+
168
+ engine = _make_engine()
169
+ engine.process_schema(schema, dataframes_in={"a": df_a, "b": df_b})
170
+
171
+ df_a.join.assert_called_once_with(df_b, on=["id"], how="full")
172
+
173
+ def test_full_outer_join_different_keys(self):
174
+ """type: full outer (alias) → expression join, right key dropped."""
175
+ df_a = MagicMock(name="df_a")
176
+ df_b = MagicMock(name="df_b")
177
+ joined = _joined_df()
178
+ df_a.join.return_value = joined
179
+
180
+ schema = {
181
+ "join": [{"table_from": "a", "table_to": "b", "on_from": "order_id", "on_to": "id", "type": "full outer"}]
182
+ }
183
+
184
+ engine = _make_engine()
185
+ engine.process_schema(schema, dataframes_in={"a": df_a, "b": df_b})
186
+
187
+ _, join_kwargs = df_a.join.call_args
188
+ assert join_kwargs.get("how") == "full"
189
+ joined.drop.assert_called_once_with(df_b["id"])