feat(ui): add chat-driven dataset review flow

Move dataset review clarification into the assistant workspace and
rework the review page into a chat-centric layout with execution rails.

Add session-scoped assistant actions for mappings, semantic fields,
and SQL preview generation. Introduce optimistic locking for dataset
review mutations, propagate session versions through API responses,
and mask imported filter values before assistant exposure.

Refresh tests, i18n, and spec artifacts to match the new workflow.

BREAKING CHANGE: dataset review mutation endpoints now require the
X-Session-Version header, and clarification is no longer handled
through ClarificationDialog-based flows
This commit is contained in:
2026-03-26 13:33:12 +03:00
parent c56b8dad92
commit 202823aba5
74 changed files with 6122 additions and 2970 deletions

View File

@@ -28,6 +28,106 @@ from src.core.superset_client import SupersetClient
logger = cast(Any, logger)
_EMAIL_PATTERN = re.compile(
r"(?P<local>[A-Za-z0-9._%+-]+)@(?P<domain>[A-Za-z0-9.-]+\.[A-Za-z]{2,})"
)
_UUID_PATTERN = re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
_LONG_DIGIT_PATTERN = re.compile(r"\b\d{6,}\b")
_MIXED_IDENTIFIER_PATTERN = re.compile(
r"\b(?=[A-Za-z0-9_-]{10,}\b)(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_-]+\b"
)
# [DEF:mask_pii_value:Function]
# @COMPLEXITY: 2
# @PURPOSE: Redact likely sensitive identifiers while preserving structural hints for assistant-facing dataset-review context.
def mask_pii_value(value: Any) -> tuple[Any, bool]:
if isinstance(value, str):
return _mask_sensitive_text(value)
if isinstance(value, list):
masked_any = False
masked_list: List[Any] = []
for item in value:
masked_item, item_masked = mask_pii_value(item)
masked_list.append(masked_item)
masked_any = masked_any or item_masked
return masked_list, masked_any
if isinstance(value, dict):
masked_any = False
masked_dict: Dict[str, Any] = {}
for key, item in value.items():
masked_item, item_masked = mask_pii_value(item)
masked_dict[key] = masked_item
masked_any = masked_any or item_masked
return masked_dict, masked_any
return value, False
# [/DEF:mask_pii_value:Function]
# [DEF:sanitize_imported_filter_for_assistant:Function]
# @COMPLEXITY: 2
# @PURPOSE: Build assistant-safe imported-filter payload with raw-value redaction metadata when sensitive tokens are detected.
def sanitize_imported_filter_for_assistant(
filter_payload: Dict[str, Any],
) -> Dict[str, Any]:
sanitized = deepcopy(filter_payload)
masked_raw_value, was_masked = mask_pii_value(filter_payload.get("raw_value"))
sanitized["raw_value"] = masked_raw_value
sanitized["raw_value_masked"] = bool(
filter_payload.get("raw_value_masked", False) or was_masked
)
return sanitized
# [/DEF:sanitize_imported_filter_for_assistant:Function]
# [DEF:_mask_sensitive_text:Function]
# @COMPLEXITY: 2
# @PURPOSE: Redact likely PII substrings from one string while preserving non-sensitive delimiters and token shape.
def _mask_sensitive_text(value: str) -> tuple[str, bool]:
masked = value
masked_any = False
def _replace_email(match: re.Match[str]) -> str:
nonlocal masked_any
masked_any = True
domain = match.group("domain")
return f"***@{domain}"
def _replace_uuid(match: re.Match[str]) -> str:
nonlocal masked_any
masked_any = True
token = match.group(0)
return "********-****-****-****-" + token[-12:]
def _replace_long_digits(match: re.Match[str]) -> str:
nonlocal masked_any
masked_any = True
token = match.group(0)
return "*" * max(len(token) - 2, 1) + token[-2:]
def _replace_mixed_identifier(match: re.Match[str]) -> str:
nonlocal masked_any
token = match.group(0)
if token.isupper() and len(token) <= 5:
return token
masked_any = True
return token[:2] + "***" + token[-2:]
masked = _EMAIL_PATTERN.sub(_replace_email, masked)
masked = _UUID_PATTERN.sub(_replace_uuid, masked)
masked = _LONG_DIGIT_PATTERN.sub(_replace_long_digits, masked)
masked = _MIXED_IDENTIFIER_PATTERN.sub(_replace_mixed_identifier, masked)
return masked, masked_any
# [/DEF:_mask_sensitive_text:Function]
# [DEF:SupersetParsedContext:Class]
# @COMPLEXITY: 2
@@ -44,6 +144,7 @@ class SupersetParsedContext:
imported_filters: List[Dict[str, Any]] = field(default_factory=list)
unresolved_references: List[str] = field(default_factory=list)
partial_recovery: bool = False
dataset_payload: Optional[Dict[str, Any]] = None
# [/DEF:SupersetParsedContext:Class]
@@ -313,11 +414,12 @@ class SupersetContextExtractor:
)
raise ValueError("Unsupported Superset link shape")
dataset_payload: Optional[Dict[str, Any]] = None
if dataset_id is not None:
try:
dataset_detail = self.client.get_dataset_detail(dataset_id)
table_name = str(dataset_detail.get("table_name") or "").strip()
schema_name = str(dataset_detail.get("schema") or "").strip()
dataset_payload = self.client.get_dataset_detail(dataset_id)
table_name = str(dataset_payload.get("table_name") or "").strip()
schema_name = str(dataset_payload.get("schema") or "").strip()
if table_name:
dataset_ref = (
f"{schema_name}.{table_name}" if schema_name else table_name
@@ -349,6 +451,7 @@ class SupersetContextExtractor:
imported_filters=imported_filters,
unresolved_references=unresolved_references,
partial_recovery=partial_recovery,
dataset_payload=dataset_payload,
)
logger.reflect(
"Superset link parsing completed",