feat(ui): surface review readiness and blockers

Add readiness hints, blocker summaries, and progress cues across
the dataset review workspace to clarify partial recovery and
launch gating states.

Highlight read-only preview snapshots, pending semantic review,
clarification queue status, and unresolved validation blockers to
guide the next recommended action.

Document known limitations in backend test doubles so permission,
task dispatch, and uncovered persistence branches are explicit.
This commit is contained in:
2026-03-21 15:48:28 +03:00
parent ce9980829e
commit ea4f41c996
13 changed files with 323 additions and 15 deletions

View File

@@ -5,7 +5,7 @@ model: github-copilot/claude-opus-4.6
temperature: 0.0
permission:
edit: deny
bash: ask
bash: allow
browser: deny
task:
repair-worker: allow

View File

@@ -6,8 +6,10 @@
class FakeQuery:
"""Shared chainable query stub for route tests.
Warning: predicate arguments passed to filter() are recorded only for inspection
and are not evaluated; result sets remain predicate-blind.
WARNING: filter() is predicate-blind — all ownership and permission filters are
ignored. Tests using FakeQuery cannot verify scoped data access. This is a
known limitation; do not use for permission-sensitive test paths without a
spec-guarded replacement.
"""
def __init__(self, rows):

View File

@@ -256,6 +256,11 @@ def _make_us2_session():
# [DEF:_make_us3_session:Function]
# @RELATION: BINDS_TO -> DatasetReviewApiTests
def _make_us3_session():
"""Fake session factory for US3 flow tests.
`imported_filter` and `template_variable` are bare MagicMocks without spec;
ORM attribute access is unchecked.
"""
now = datetime.now(timezone.utc)
session = _make_session()
session.readiness_state = ReadinessState.MAPPING_REVIEW_NEEDED
@@ -587,6 +592,8 @@ def test_orchestrator_start_session_preserves_partial_recovery(
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=dataset_review_api_dependencies["config_manager"],
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
# Task dispatch failures are invisible to this test.
task_manager=None,
)
@@ -653,6 +660,8 @@ def test_orchestrator_start_session_bootstraps_recovery_state(
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=dataset_review_api_dependencies["config_manager"],
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
# Task dispatch failures are invisible to this test.
task_manager=None,
)
@@ -1255,6 +1264,8 @@ def test_execution_snapshot_includes_recovered_imported_filters_without_template
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=dataset_review_api_dependencies["config_manager"],
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
# Task dispatch failures are invisible to this test.
task_manager=None,
)
session = _make_preview_ready_session()
@@ -1326,6 +1337,8 @@ def test_execution_snapshot_preserves_mapped_template_variables_and_filter_conte
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=dataset_review_api_dependencies["config_manager"],
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
# Task dispatch failures are invisible to this test.
task_manager=None,
)
session = _make_preview_ready_session()
@@ -1363,6 +1376,8 @@ def test_execution_snapshot_skips_partial_imported_filters_without_values(
orchestrator = DatasetReviewOrchestrator(
repository=repository,
config_manager=dataset_review_api_dependencies["config_manager"],
# WARNING: task_manager=None — all async task dispatch paths are bypassed.
# Task dispatch failures are invisible to this test.
task_manager=None,
)
session = _make_preview_ready_session()

View File

@@ -31,6 +31,10 @@ mock_user.roles.append(admin_role)
@pytest.fixture(autouse=True)
def mock_deps():
"""Bare MagicMock — no spec guards. All service method calls succeed silently.
Authorization, data integrity, and error paths are invisible to this fixture.
"""
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass
config_manager = MagicMock()
# @INVARIANT: unconstrained mock — no spec= enforced; attribute typos will silently pass

View File

@@ -117,6 +117,12 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
# @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
# @INVARIANT: analyze_dashboard is side-effect free and returns schema-compatible PASS result.
class _FakeLLMClient:
"""Fake LLM client for persistence tests.
Always returns PASS status. FAIL and UNKNOWN persistence branches are NOT
covered by this fake — see coverage-planner findings.
"""
def __init__(self, **_kwargs):
return None

View File

@@ -51,6 +51,13 @@
}`
: "0 / 0",
);
const fatigueHint = $derived(
clarificationSession?.remaining_count > 3
? "Очередь длинная: отвечайте по одному вопросу, пропускайте сомнительные и возвращайтесь позже."
: clarificationSession?.remaining_count > 0
? "Осталось немного: после ответа сразу появится следующий приоритетный вопрос."
: "Очередь уточнений закрыта. При необходимости можно возобновить сессию уточнений.",
);
$effect(() => {
const questionId = currentQuestion?.question_id || "";
@@ -255,6 +262,10 @@
</div>
{/if}
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
{fatigueHint}
</div>
{#if hasQuestion}
<article class="mt-5 rounded-2xl border border-violet-200 bg-violet-50 p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
@@ -296,6 +307,9 @@
<h4 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.clarification?.options_title}
</h4>
<p class="mt-1 text-xs text-slate-500">
Следующий шаг: выбрать вариант, дать собственный ответ или пометить вопрос для expert review.
</p>
<div class="mt-3 space-y-2">
{#each currentQuestion.options || [] as option}

View File

@@ -37,6 +37,9 @@
const compiledBySuperset = $derived(String(preview?.compiled_by || "") === "superset");
const hasSql = $derived(Boolean(String(preview?.compiled_sql || "").trim()));
const previewTimestamp = $derived(preview?.compiled_at || preview?.created_at || "");
const isReadOnlySnapshot = $derived(
effectiveState === "stale" || effectiveState === "pending" || effectiveState === "failed",
);
function resolvePreviewState() {
const explicit = String(previewState || "").trim();
@@ -184,6 +187,12 @@
{getPreviewBodyText()}
</div>
{#if isReadOnlySnapshot}
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
Текущее состояние preview рассматривайте как read-only snapshot. Для launch требуется актуальный статус <span class="font-semibold">ready</span>.
</div>
{/if}
{#if preview}
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-600">
<span class="rounded-full bg-slate-100 px-2 py-1">

View File

@@ -47,6 +47,9 @@
);
const activeFieldCount = $derived(sortedFields.length);
const reviewPendingCount = $derived(
sortedFields.filter((field) => field.needs_review || field.has_conflict).length,
);
function updateFieldMessage(fieldId, patch) {
fieldMessages = {
@@ -315,6 +318,10 @@
</div>
<div class="mt-1 text-sm font-medium text-slate-900">{activeFieldCount}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
<div class="text-xs uppercase tracking-wide text-slate-500">Pending review</div>
<div class="mt-1 text-sm font-medium text-slate-900">{reviewPendingCount}</div>
</div>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
@@ -331,6 +338,12 @@
{$t.dataset_review?.semantics?.empty}
</div>
{:else}
{#if reviewPendingCount > 0}
<div class="mt-5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
Есть поля, требующие решения. Это не блокирует обзор, но может повлиять на итоговую готовность к launch.
</div>
{/if}
<div class="mt-5 space-y-4">
{#each sortedFields as field}
{@const message = fieldMessages[field.field_id] || { status: "", text: "" }}

View File

@@ -48,6 +48,15 @@
const canSubmit = $derived(
normalizedEnvironmentId.length > 0 && normalizedSourceInput.length > 0 && !submitting,
);
const progressionLabel = $derived(
submitting
? "Importing"
: linkLooksRecognized && isSupersetLinkMode
? "Recognized"
: normalizedSourceInput
? "Ready to import"
: "Awaiting input",
);
$effect(() => {
if (!touched && !submitAttempted) {
@@ -238,6 +247,10 @@
</button>
</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<span class="font-medium text-slate-700">Progress:</span> {progressionLabel}. После старта сессии можно продолжить review даже при частично восстановленном контексте.
</div>
<form class="mt-5 space-y-4" onsubmit={handleSubmit}>
<div class="grid gap-4 lg:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<label class="space-y-2">
@@ -310,6 +323,9 @@
{:else}
{$t.dataset_review?.source?.dataset_selection_recovery_note}
{/if}
<div class="mt-1">
Долгие шаги восстановления выполняются асинхронно: статус и следующий шаг будут видны в workspace.
</div>
</div>
<button

View File

@@ -27,6 +27,13 @@
const totalFindings = $derived(
blockingFindings.length + warningFindings.length + informationalFindings.length,
);
const unresolvedBlockingCount = $derived(
blockingFindings.filter(
(finding) =>
String(finding?.resolution_state || "open") !== "resolved" &&
String(finding?.resolution_state || "open") !== "approved",
).length,
);
function groupFindingsBySeverity(items) {
return (items || []).reduce(
@@ -119,6 +126,12 @@
</div>
</div>
{#if unresolvedBlockingCount > 0}
<div class="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
Обнаружены незакрытые launch-блокеры: {unresolvedBlockingCount}. Продолжать review можно, но запуск останется недоступен.
</div>
{/if}
{#if totalFindings === 0}
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
{$t.dataset_review?.findings?.empty}

View File

@@ -12,6 +12,8 @@
// @UX_STATE: Ready -> Session detail is available for UI binding.
// @UX_STATE: Saving -> Updates are being persisted.
// @UX_STATE: Error -> Failed to load or update session.
// @UX_STATE: PartialPreview -> Session has preview data but launch gates are not yet satisfied.
// @UX_STATE: LaunchBlocked -> Session remains reviewable while explicit launch blockers are unresolved.
// @UX_REACTIVITY: Uses Svelte writable store for session aggregate.
import { writable } from 'svelte/store';
@@ -90,4 +92,23 @@ export function patchSession(patch) {
});
}
/**
* Derive a conservative UI phase label from persisted session state.
* This helper is additive and does not alter orchestration behavior.
*
* @param {Object|null} session
* @returns {"empty"|"importing"|"review"|"partial_preview"|"launch_blocked"|"run_ready"|"run_in_progress"}
*/
export function getSessionUiPhase(session) {
if (!session) return "empty";
const readiness = String(session.readiness_state || "");
if (readiness === "importing") return "importing";
if (readiness === "partially_ready" || readiness === "compiled_preview_ready") return "partial_preview";
if (readiness === "recovery_required") return "launch_blocked";
if (readiness === "run_ready") return "run_ready";
if (readiness === "run_in_progress") return "run_in_progress";
return "review";
}
// [/DEF:datasetReviewSession:Store]

View File

@@ -135,6 +135,35 @@
return text.slice(0, maxLength) + "...";
}
function getReadinessTone(readinessState) {
if (readinessState === "run_ready") return "bg-emerald-100 text-emerald-700";
if (
readinessState === "partially_ready" ||
readinessState === "recovery_required" ||
readinessState === "compiled_preview_ready"
) {
return "bg-amber-100 text-amber-800";
}
if (readinessState === "importing") return "bg-blue-100 text-blue-700";
return "bg-slate-100 text-slate-700";
}
function getReadinessHint(readinessState) {
if (readinessState === "recovery_required") {
return "Контекст восстановлен частично: перед запуском проверьте фильтры, маппинг и превью SQL.";
}
if (readinessState === "partially_ready") {
return "Сессия продолжает быть полезной, но запуск остаётся заблокирован до обновления превью и закрытия блокеров.";
}
if (readinessState === "compiled_preview_ready") {
return "Превью собрано. Проверьте approvals и launch-блокеры перед запуском.";
}
if (readinessState === "run_ready") {
return "Сессия готова к запуску после финальной проверки.";
}
return "Откройте сессию, чтобы увидеть следующий рекомендуемый шаг.";
}
async function bootstrap() {
isBootstrapping = true;
try {
@@ -351,7 +380,11 @@
>
{status}
</span>
<span class="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
<span
class={`rounded-full px-2.5 py-1 text-xs font-medium ${getReadinessTone(
session.readiness_state,
)}`}
>
{getSessionMetaValue(session, "readiness_state", $t.common?.unknown || "unknown")}
</span>
<span class="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700">
@@ -359,6 +392,10 @@
</span>
</div>
<p class="mt-2 text-xs text-slate-600">
{getReadinessHint(session.readiness_state)}
</p>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div class="rounded-lg bg-slate-50 px-3 py-2">
<div class="text-xs uppercase tracking-wide text-slate-500">

View File

@@ -86,6 +86,20 @@
const latestRunContext = $derived(
session?.run_contexts?.length ? session.run_contexts[session.run_contexts.length - 1] : null,
);
const clarificationSession = $derived(clarificationState?.clarification_session || null);
const clarificationCurrentQuestion = $derived(clarificationState?.current_question || null);
const clarificationRemainingCount = $derived(
Number(clarificationSession?.remaining_count || 0),
);
const clarificationResolvedCount = $derived(
Number(clarificationSession?.resolved_count || 0),
);
const clarificationTotalCount = $derived(
clarificationRemainingCount + clarificationResolvedCount,
);
const previewGateState = $derived(
String(previewUiState || latestPreview?.preview_status || "missing"),
);
const exportDisabled = $derived(!session || isExporting);
const saveDisabled = $derived(!session || isSubmitting);
const launchDisabled = $derived(!session || isSubmitting || isExporting);
@@ -117,6 +131,28 @@
);
const importMilestones = $derived(buildImportMilestones());
const recentActions = $derived(buildRecentActions());
const unresolvedBlockingFindingsCount = $derived(
findings.filter(
(finding) =>
finding.severity === "blocking" &&
finding.resolution_state !== "resolved" &&
finding.resolution_state !== "approved",
).length,
);
const pendingApprovalMappingsCount = $derived(
executionMappings.filter(
(mapping) =>
mapping.requires_explicit_approval &&
mapping.approval_state !== "approved",
).length,
);
const workspaceLaunchBlockers = $derived(buildWorkspaceLaunchBlockers());
function normalizeJumpTarget(target) {
if (target === "preview") return "sql-preview";
if (target === "mappings") return "mapping";
return target || "";
}
function getWorkspaceStateLabel(state) {
return $t.dataset_review?.workspace?.state?.[state] || state;
@@ -215,6 +251,27 @@
return actions;
}
function buildWorkspaceLaunchBlockers() {
if (!session) {
return [];
}
const blockers = [];
if (unresolvedBlockingFindingsCount > 0) {
blockers.push(`Нерешённых blocking findings: ${unresolvedBlockingFindingsCount}`);
}
if (pendingApprovalMappingsCount > 0) {
blockers.push(`Требуют approval в маппинге: ${pendingApprovalMappingsCount}`);
}
if (previewGateState !== "ready") {
blockers.push(`Состояние SQL preview: ${previewGateState}`);
}
if (String(session.readiness_state || "") !== "run_ready") {
blockers.push(`Readiness: ${session.readiness_state || "unknown"}`);
}
return blockers;
}
function buildSessionUrl(sessionId) {
return `/datasets/review/${encodeURIComponent(String(sessionId))}`;
}
@@ -365,7 +422,7 @@
}
function handleFindingJump(event) {
jumpTarget = event?.target || "summary";
jumpTarget = normalizeJumpTarget(event?.target || "summary");
}
function startSummaryEdit() {
@@ -497,7 +554,7 @@
}
function handleSectionJump(event) {
jumpTarget = event?.target || "";
jumpTarget = normalizeJumpTarget(event?.target || "");
}
function primaryActionHandler() {
@@ -530,6 +587,36 @@
}
}
function getPrimaryActionCtaLabel() {
if (!session) {
return $t.dataset_review?.workspace?.next_action_label;
}
if (session.recommended_action === "resume_session") {
return $t.dataset_review?.workspace?.resume_action;
}
if (session.recommended_action === "generate_sql_preview") {
return $t.dataset_review?.preview?.generate_action;
}
if (session.recommended_action === "approve_mapping") {
return $t.dataset_review?.mapping?.approve_all_action;
}
if (session.recommended_action === "launch_dataset") {
return $t.dataset_review?.launch?.launch_action;
}
return $t.dataset_review?.workspace?.pause_action;
}
$effect(() => {
const target = normalizeJumpTarget(jumpTarget);
if (!target || typeof document === "undefined") {
return;
}
document.getElementById(target)?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
bootstrapWorkspace();
</script>
@@ -583,6 +670,43 @@
</div>
</div>
{#if session}
<section class="rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div class="text-xs uppercase tracking-wide text-slate-500">Фаза</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{session.current_phase}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div class="text-xs uppercase tracking-wide text-slate-500">Preview gate</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{previewGateState}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div class="text-xs uppercase tracking-wide text-slate-500">Clarification queue</div>
<div class="mt-1 text-sm font-semibold text-slate-900">
{clarificationRemainingCount} remaining / {clarificationTotalCount}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
<div class="text-xs uppercase tracking-wide text-slate-500">Launch blockers</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{workspaceLaunchBlockers.length}</div>
</div>
</div>
{#if session.readiness_state === "recovery_required" || session.readiness_state === "partially_ready"}
<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
Сессия в частично-восстановленном режиме: материалы можно просматривать и уточнять, но запуск будет заблокирован до закрытия блокеров и актуального SQL preview.
</div>
{/if}
{#if previewGateState === "stale" || previewGateState === "failed" || previewGateState === "pending"}
<div class="mt-3 rounded-xl border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-800">
Preview сейчас в состоянии <span class="font-semibold">{previewGateState}</span>. Это read-only/промежуточный снимок и не финальное основание для запуска.
</div>
{/if}
</section>
{/if}
{#if isBootstrapping}
<div class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
{$t.dataset_review?.workspace?.loading}
@@ -687,6 +811,16 @@
</span>
</div>
{#if session.readiness_state === "recovery_required"}
<p class="mt-3 text-xs text-amber-700">
Режим partial recovery: часть контекста импортирована, часть требует ручной проверки.
</p>
{:else if session.readiness_state === "partially_ready"}
<p class="mt-3 text-xs text-amber-700">
Снимок промежуточный: продолжайте review, затем пересоберите preview перед launch.
</p>
{/if}
<ol class="mt-4 space-y-2">
{#each importMilestones as milestone}
<li class="flex items-center gap-2 text-sm text-slate-700">
@@ -931,6 +1065,19 @@
/>
{#if clarificationState || session.clarification_sessions?.length}
<div class="rounded-xl border border-violet-200 bg-violet-50 px-4 py-3 text-sm text-violet-900">
<div class="font-medium">Очередь уточнений</div>
{#if clarificationCurrentQuestion}
<p class="mt-1 text-xs text-violet-700">
Следующий вопрос уже готов. Осталось: {clarificationRemainingCount}.
</p>
{:else}
<p class="mt-1 text-xs text-violet-700">
Активного вопроса нет. Можно продолжить через Resume clarification.
</p>
{/if}
</div>
<ClarificationDialog
sessionId={session.session_id}
clarificationState={clarificationState}
@@ -976,9 +1123,7 @@
class="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
onclick={primaryActionHandler}
>
{session.recommended_action === "resume_session"
? $t.dataset_review?.workspace?.resume_action
: $t.dataset_review?.workspace?.pause_action}
{getPrimaryActionCtaLabel()}
</button>
{#if jumpTarget}
@@ -986,6 +1131,19 @@
{$t.dataset_review?.workspace?.jump_target_label}: {jumpTarget}
</p>
{/if}
{#if workspaceLaunchBlockers.length > 0}
<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-amber-700">
Launch blockers snapshot
</div>
<ul class="mt-2 space-y-1 text-xs text-amber-800">
{#each workspaceLaunchBlockers.slice(0, 4) as blocker}
<li>{blocker}</li>
{/each}
</ul>
</div>
{/if}
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">