databuck-spark-sdk 0.5.1__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.
- databuck/__init__.py +8 -0
- databuck/__main__.py +7 -0
- databuck/agentic_rules.py +346 -0
- databuck/lake_rules.py +341 -0
- databuck/sdk.py +1328 -0
- databuck_spark_sdk-0.5.1.dist-info/METADATA +160 -0
- databuck_spark_sdk-0.5.1.dist-info/RECORD +9 -0
- databuck_spark_sdk-0.5.1.dist-info/WHEEL +5 -0
- databuck_spark_sdk-0.5.1.dist-info/top_level.txt +1 -0
databuck/__init__.py
ADDED
databuck/__main__.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Lightweight, DataFrame-first context-aware rule generation for notebooks."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_MODEL = "gemini-2.5-flash"
|
|
10
|
+
DATAFRAME_PLACEHOLDER = "{{DATAFRAME}}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_rules(df: Any, context: dict) -> list[dict[str, str]]:
|
|
14
|
+
"""Generate portable SQL data-quality rules for the supplied DataFrame.
|
|
15
|
+
|
|
16
|
+
This function uses the caller-owned DataFrame only. It does not create or
|
|
17
|
+
stop a Spark session, connect to DataBuck databases, read source systems,
|
|
18
|
+
or persist rules.
|
|
19
|
+
"""
|
|
20
|
+
api_key = (
|
|
21
|
+
context.get("gemini_api_key")
|
|
22
|
+
or os.environ.get("GEMINI_API_KEY")
|
|
23
|
+
or ""
|
|
24
|
+
).strip()
|
|
25
|
+
if not api_key:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
"A Gemini API key is required. Set context['gemini_api_key'], "
|
|
28
|
+
"or the GEMINI_API_KEY environment variable."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
rule_count = int(context.get("rule_count", 10))
|
|
32
|
+
if rule_count < 1 or rule_count > 50:
|
|
33
|
+
raise ValueError("context['rule_count'] must be between 1 and 50")
|
|
34
|
+
|
|
35
|
+
pdf_paths = _get_pdf_paths(context)
|
|
36
|
+
prompt = _build_prompt(df, context, rule_count, pdf_paths)
|
|
37
|
+
response_text, audit_response_text = _generate_and_audit_with_gemini(
|
|
38
|
+
api_key=api_key,
|
|
39
|
+
model=context.get("gemini_model", DEFAULT_MODEL),
|
|
40
|
+
prompt=prompt,
|
|
41
|
+
pdf_paths=pdf_paths,
|
|
42
|
+
)
|
|
43
|
+
rules = _parse_rules(response_text)
|
|
44
|
+
|
|
45
|
+
if not rules:
|
|
46
|
+
raise ValueError("Gemini did not return any valid SQL rules")
|
|
47
|
+
|
|
48
|
+
_apply_audit_results(rules, audit_response_text)
|
|
49
|
+
returned_rules = rules if context.get("include_failed_rules", False) else [
|
|
50
|
+
rule for rule in rules if rule["audit_status"] == "PASSED"
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
if not returned_rules:
|
|
54
|
+
raise ValueError("Gemini audit rejected every generated rule")
|
|
55
|
+
|
|
56
|
+
for index, rule in enumerate(returned_rules, start=1):
|
|
57
|
+
print("\nRule {}: {}".format(index, rule["name"]))
|
|
58
|
+
print("Description: {}".format(rule["description"]))
|
|
59
|
+
print("Reference context: {}".format(rule["reference_context"]))
|
|
60
|
+
print("Audit status: {}".format(rule["audit_status"]))
|
|
61
|
+
print("Audit reason: {}".format(rule["audit_reason"]))
|
|
62
|
+
print(rule["sql"])
|
|
63
|
+
|
|
64
|
+
return returned_rules
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_prompt(
|
|
68
|
+
df: Any, context: dict, rule_count: int, pdf_paths: list[str]
|
|
69
|
+
) -> str:
|
|
70
|
+
schema = [
|
|
71
|
+
{
|
|
72
|
+
"name": field.name,
|
|
73
|
+
"type": field.dataType.simpleString(),
|
|
74
|
+
"nullable": field.nullable,
|
|
75
|
+
}
|
|
76
|
+
for field in df.schema.fields
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
safe_context = {
|
|
80
|
+
key: value
|
|
81
|
+
for key, value in context.items()
|
|
82
|
+
if key not in {
|
|
83
|
+
"gemini_api_key",
|
|
84
|
+
"gemini_model",
|
|
85
|
+
"rule_count",
|
|
86
|
+
"pdf_paths",
|
|
87
|
+
"include_failed_rules",
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
document_names = [os.path.basename(path) for path in pdf_paths]
|
|
91
|
+
|
|
92
|
+
return """You are a data-quality rule generator.
|
|
93
|
+
Generate up to {rule_count} accurate data-quality SQL rules for the DataFrame
|
|
94
|
+
described below. It is acceptable to return fewer rules when the supplied
|
|
95
|
+
reference context does not define enough unambiguous controls.
|
|
96
|
+
The attached PDF documents are approved reference context. Use them when they
|
|
97
|
+
support a rule. If a rule is based on a PDF, identify the relevant policy text
|
|
98
|
+
in reference_context.
|
|
99
|
+
|
|
100
|
+
Return only a JSON array. Every item must contain exactly these string fields:
|
|
101
|
+
name, description, reference_context, sql.
|
|
102
|
+
|
|
103
|
+
Reference requirements:
|
|
104
|
+
- Every rule must be supported by exactly one explicit policy or business
|
|
105
|
+
statement from the supplied context or attached PDFs.
|
|
106
|
+
- reference_context must quote that exact supporting statement. Do not
|
|
107
|
+
paraphrase, combine statements, or claim a reference that does not exist.
|
|
108
|
+
- Do not infer additional business meaning, ID patterns, allowed values,
|
|
109
|
+
thresholds, column relationships, or statuses that are not explicitly stated.
|
|
110
|
+
- Do not downgrade a "valid" requirement into a null/empty check. A validity
|
|
111
|
+
rule requires an explicit format, allowed-value list, or validation condition.
|
|
112
|
+
- A null/empty check is permitted only when the reference explicitly says
|
|
113
|
+
required, present, non-null, non-empty, or mandatory.
|
|
114
|
+
- If a reference is ambiguous or does not define an executable check, omit it.
|
|
115
|
+
|
|
116
|
+
SQL requirements:
|
|
117
|
+
- Use generic SQL only. Do not use BigQuery, Databricks, Spark, Snowflake, or
|
|
118
|
+
database-vendor-specific syntax.
|
|
119
|
+
- Every SQL value must have exactly this shape:
|
|
120
|
+
SELECT * FROM {placeholder} WHERE <invalid-row condition>
|
|
121
|
+
- The WHERE condition must use only columns from the supplied DataFrame.
|
|
122
|
+
- Do not use joins, grouping, ordering, subqueries, or additional statements.
|
|
123
|
+
- A rule must return invalid rows, so its WHERE clause must describe the
|
|
124
|
+
condition that fails the rule.
|
|
125
|
+
- Use only columns in the supplied schema.
|
|
126
|
+
- Do not invent business values, tables, joins, columns, formats, thresholds,
|
|
127
|
+
allowed values, or rule logic.
|
|
128
|
+
- Do not generate INSERT, UPDATE, DELETE, MERGE, DROP, CREATE, ALTER, or CALL.
|
|
129
|
+
|
|
130
|
+
Before returning each rule, internally verify that the SQL checks exactly what
|
|
131
|
+
the quoted reference_context states. Omit the rule if they do not match.
|
|
132
|
+
|
|
133
|
+
DataFrame schema:
|
|
134
|
+
{schema}
|
|
135
|
+
|
|
136
|
+
Business context:
|
|
137
|
+
{context}
|
|
138
|
+
|
|
139
|
+
Attached PDF documents:
|
|
140
|
+
{documents}
|
|
141
|
+
""".format(
|
|
142
|
+
rule_count=rule_count,
|
|
143
|
+
placeholder=DATAFRAME_PLACEHOLDER,
|
|
144
|
+
schema=json.dumps(schema, indent=2),
|
|
145
|
+
context=json.dumps(safe_context, default=str, indent=2),
|
|
146
|
+
documents=json.dumps(document_names),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _generate_and_audit_with_gemini(
|
|
151
|
+
api_key: str, model: str, prompt: str, pdf_paths: list[str]
|
|
152
|
+
) -> tuple[str, str]:
|
|
153
|
+
try:
|
|
154
|
+
from google import genai
|
|
155
|
+
from google.genai import types
|
|
156
|
+
except ImportError as exc:
|
|
157
|
+
raise ImportError(
|
|
158
|
+
"Gemini rule discovery requires google-genai. Install it with "
|
|
159
|
+
"`pip install google-genai`."
|
|
160
|
+
) from exc
|
|
161
|
+
|
|
162
|
+
client = genai.Client(api_key=api_key)
|
|
163
|
+
uploaded_files = []
|
|
164
|
+
try:
|
|
165
|
+
for pdf_path in pdf_paths:
|
|
166
|
+
uploaded_files.append(client.files.upload(file=pdf_path))
|
|
167
|
+
|
|
168
|
+
response = client.models.generate_content(
|
|
169
|
+
model=model,
|
|
170
|
+
contents=[prompt, *uploaded_files],
|
|
171
|
+
config=types.GenerateContentConfig(
|
|
172
|
+
temperature=0.1,
|
|
173
|
+
response_mime_type="application/json",
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
candidate_response_text = (response.text or "").strip()
|
|
177
|
+
candidate_rules = _parse_rules(candidate_response_text)
|
|
178
|
+
if not candidate_rules:
|
|
179
|
+
return candidate_response_text, "[]"
|
|
180
|
+
|
|
181
|
+
audit_response = client.models.generate_content(
|
|
182
|
+
model=model,
|
|
183
|
+
contents=[_build_audit_prompt(prompt, candidate_rules), *uploaded_files],
|
|
184
|
+
config=types.GenerateContentConfig(
|
|
185
|
+
temperature=0.0,
|
|
186
|
+
response_mime_type="application/json",
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
return candidate_response_text, (audit_response.text or "").strip()
|
|
190
|
+
finally:
|
|
191
|
+
# The PDFs are needed only for this single rule-generation request.
|
|
192
|
+
for uploaded_file in uploaded_files:
|
|
193
|
+
try:
|
|
194
|
+
client.files.delete(name=uploaded_file.name)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _build_audit_prompt(reference_prompt: str, candidate_rules: list[dict[str, str]]) -> str:
|
|
200
|
+
return """You are an independent data-quality rule auditor. Do not create,
|
|
201
|
+
rewrite, repair, or improve rules. Audit every candidate rule against the
|
|
202
|
+
reference material below.
|
|
203
|
+
|
|
204
|
+
Return only a JSON array. Every item must contain exactly these fields:
|
|
205
|
+
rule_index (integer, starting at 1), audit_status (PASSED or FAILED), and
|
|
206
|
+
audit_reason (string).
|
|
207
|
+
|
|
208
|
+
Pass a rule only when all conditions are true:
|
|
209
|
+
- Its reference_context exactly quotes one explicit reference statement.
|
|
210
|
+
- Its SQL checks exactly the control stated in that reference.
|
|
211
|
+
- The SQL uses only the supplied DataFrame columns.
|
|
212
|
+
- The SQL has exactly this form: SELECT * FROM {placeholder} WHERE
|
|
213
|
+
<invalid-row condition>. It contains no joins, grouping, ordering,
|
|
214
|
+
subqueries, or additional statements.
|
|
215
|
+
- The SQL does not invent formats, values, thresholds, relationships, or logic.
|
|
216
|
+
- A "valid" reference has an explicit format, allowed-value list, or validation
|
|
217
|
+
condition; null/empty SQL alone must fail such a rule.
|
|
218
|
+
- A null/empty SQL check is supported only by a reference that explicitly says
|
|
219
|
+
required, present, non-null, non-empty, or mandatory.
|
|
220
|
+
|
|
221
|
+
Fail ambiguous, partly supported, over-broad, or mismatched rules. Explain the
|
|
222
|
+
specific mismatch in audit_reason.
|
|
223
|
+
|
|
224
|
+
Reference material:
|
|
225
|
+
{reference_prompt}
|
|
226
|
+
|
|
227
|
+
Candidate rules:
|
|
228
|
+
{candidate_rules}
|
|
229
|
+
""".format(
|
|
230
|
+
placeholder=DATAFRAME_PLACEHOLDER,
|
|
231
|
+
reference_prompt=reference_prompt,
|
|
232
|
+
candidate_rules=json.dumps(candidate_rules, indent=2),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _apply_audit_results(rules: list[dict[str, str]], audit_response_text: str) -> None:
|
|
237
|
+
audits_by_index = _parse_audit_results(audit_response_text, len(rules))
|
|
238
|
+
for index, rule in enumerate(rules, start=1):
|
|
239
|
+
audit = audits_by_index.get(index)
|
|
240
|
+
if audit is None:
|
|
241
|
+
rule["audit_status"] = "FAILED"
|
|
242
|
+
rule["audit_reason"] = "Auditor did not return a valid audit result."
|
|
243
|
+
else:
|
|
244
|
+
rule["audit_status"] = audit["audit_status"]
|
|
245
|
+
rule["audit_reason"] = audit["audit_reason"]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_audit_results(audit_response_text: str, rule_count: int) -> dict[int, dict[str, str]]:
|
|
249
|
+
try:
|
|
250
|
+
raw_audits = json.loads(_strip_markdown_fence(audit_response_text))
|
|
251
|
+
except json.JSONDecodeError:
|
|
252
|
+
return {}
|
|
253
|
+
|
|
254
|
+
if not isinstance(raw_audits, list):
|
|
255
|
+
return {}
|
|
256
|
+
|
|
257
|
+
audits_by_index = {}
|
|
258
|
+
for raw_audit in raw_audits:
|
|
259
|
+
if not isinstance(raw_audit, dict):
|
|
260
|
+
continue
|
|
261
|
+
rule_index = raw_audit.get("rule_index")
|
|
262
|
+
audit_status = str(raw_audit.get("audit_status", "")).strip().upper()
|
|
263
|
+
audit_reason = str(raw_audit.get("audit_reason", "")).strip()
|
|
264
|
+
if (
|
|
265
|
+
not isinstance(rule_index, int)
|
|
266
|
+
or rule_index < 1
|
|
267
|
+
or rule_index > rule_count
|
|
268
|
+
or audit_status not in {"PASSED", "FAILED"}
|
|
269
|
+
or not audit_reason
|
|
270
|
+
):
|
|
271
|
+
continue
|
|
272
|
+
audits_by_index[rule_index] = {
|
|
273
|
+
"audit_status": audit_status,
|
|
274
|
+
"audit_reason": audit_reason,
|
|
275
|
+
}
|
|
276
|
+
return audits_by_index
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _get_pdf_paths(context: dict) -> list[str]:
|
|
280
|
+
pdf_paths = context.get("pdf_paths", [])
|
|
281
|
+
if pdf_paths is None:
|
|
282
|
+
return []
|
|
283
|
+
if not isinstance(pdf_paths, (list, tuple)) or not all(
|
|
284
|
+
isinstance(path, str) and path.strip() for path in pdf_paths
|
|
285
|
+
):
|
|
286
|
+
raise TypeError("context['pdf_paths'] must be a list of non-empty file paths")
|
|
287
|
+
|
|
288
|
+
resolved_paths = []
|
|
289
|
+
for path in pdf_paths:
|
|
290
|
+
if not path.lower().endswith(".pdf"):
|
|
291
|
+
raise ValueError("Only PDF files are supported in context['pdf_paths']")
|
|
292
|
+
if not os.path.isfile(path):
|
|
293
|
+
raise FileNotFoundError(
|
|
294
|
+
"PDF was not found on the notebook driver: {}".format(path)
|
|
295
|
+
)
|
|
296
|
+
resolved_paths.append(path)
|
|
297
|
+
return resolved_paths
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_rules(response_text: str) -> list[dict[str, str]]:
|
|
301
|
+
try:
|
|
302
|
+
raw_rules = json.loads(_strip_markdown_fence(response_text))
|
|
303
|
+
except json.JSONDecodeError as exc:
|
|
304
|
+
raise ValueError("Gemini returned invalid JSON for generated rules") from exc
|
|
305
|
+
|
|
306
|
+
if not isinstance(raw_rules, list):
|
|
307
|
+
raise ValueError("Gemini must return a JSON array of rules")
|
|
308
|
+
|
|
309
|
+
rules = []
|
|
310
|
+
for raw_rule in raw_rules:
|
|
311
|
+
if not isinstance(raw_rule, dict):
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
name = str(raw_rule.get("name", "")).strip()
|
|
315
|
+
description = str(raw_rule.get("description", "")).strip()
|
|
316
|
+
reference_context = str(raw_rule.get("reference_context", "")).strip()
|
|
317
|
+
sql = str(raw_rule.get("sql", "")).strip()
|
|
318
|
+
if (
|
|
319
|
+
not name
|
|
320
|
+
or not description
|
|
321
|
+
or not reference_context
|
|
322
|
+
or not re.match(r"^SELECT\b", sql, re.IGNORECASE)
|
|
323
|
+
):
|
|
324
|
+
continue
|
|
325
|
+
if DATAFRAME_PLACEHOLDER not in sql:
|
|
326
|
+
continue
|
|
327
|
+
if re.search(r"\b(INSERT|UPDATE|DELETE|MERGE|DROP|CREATE|ALTER|CALL)\b", sql, re.IGNORECASE):
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
rules.append(
|
|
331
|
+
{
|
|
332
|
+
"name": name,
|
|
333
|
+
"description": description,
|
|
334
|
+
"reference_context": reference_context,
|
|
335
|
+
"sql": sql,
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return rules
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _strip_markdown_fence(value: str) -> str:
|
|
343
|
+
value = value.strip()
|
|
344
|
+
if value.startswith("```") and value.endswith("```"):
|
|
345
|
+
value = value.split("\n", 1)[1].rsplit("```", 1)[0]
|
|
346
|
+
return value.strip()
|