diff --git a/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.html b/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.html new file mode 100644 index 00000000000..4cb40ca26ce --- /dev/null +++ b/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.html @@ -0,0 +1,360 @@ + + + + +
+ + +
+

AI Workflow Wizard

+ + +
+ + +
+ + +
+
+
{{ s }}
+
+ + Analysis Goal + Data Source + Framework + Guardrails + +
+
+
+ + +
+

Step 1: Select Analysis Goal

+

What do you want to accomplish with your data?

+
+
+

{{ g }}

+

{{ goalDescriptions[g] }}

+
+
+ +
+ +
+
+ + +
+

Step 2: Select Data Source

+

Where is your data located?

+
+
+

{{ ds }}

+
+
+ +
+

+ Pick a CSV file from a dataset you've uploaded via the Datasets app. We'll resolve a real backend path + (e.g. /<owner>/<dataset>/v1/<file>.csv) that CSVFileScan can open. +

+ +
+ Selected: {{ state.existingDatasetPath }} +
+ + Don't see your dataset? Upload it in the Texera Datasets app first, then come back and click the + picker again — it lists every dataset you have access to. + +
+ +
+

Pick a curated biomedical dataset. The workflow will reference it via CSVFileScan.

+
+
+

{{ ds.name }}

+

{{ ds.description }}

+ Schema: {{ ds.schema }} +
+
+
+ Resolved path: {{ state.dknetDataset.fileName }} +
+ If you don't have this dataset loaded in Texera, you'll see "schema is not available" after Apply. + Fix: upload the CSV via Datasets UI, then edit the CSVFileScan operator's fileName in the + right-hand Property Editor. +
+
+ + +
+ Detected schema ({{ state.dataProfile.rowCount }} rows): + + + + + + + + + + + + + + + + + + + +
ColumnTypeNull %UniqueSample
{{ c.name }}{{ c.dtype }}{{ (c.nullRate * 100).toFixed(1) }}%{{ c.uniqueCount }}{{ c.sampleValues.slice(0, 3).join(', ') }}
+
+
+ + +
+

Step 3: Select Scientific Framework

+

Choose a methodology to structure your workflow.

+
+
+

{{ f }}

+

{{ frameworkDescriptions[f] }}

+
+
+ +
+ + + + This text is sent verbatim to the workflow generator as soft guidance. Add domain knowledge here. + +
+ Methodology ≠ Guardrails. + The white-box guardrails configured in Step 4 (mandatory train/test split, no data leakage, mandatory + evaluation, no synthetic data) are enforced by the validator independently of the text above. + Use this box to add domain knowledge — not to relax safety rules. +
+
+
+ + +
+

Step 4: Guardrails

+

These best practices will be enforced on the generated workflow.

+
+ +
+
+ + +
+ + + +
+ +
{{ generationError }}
+ + +
+

Review & Approve Workflow

+ +
+ Warning: some required properties are unset — Texera will likely report "invalid workflow" after Apply. + Fix the (unset) values below or regenerate. +
+ + +
+ + {{ op.operatorID }} + [{{ op.operatorType }}] + + {{ missingRequiredPaths(op).length }} missing + + +
+ Why: {{ whyExplanations[op.operatorID] }} +
+
+ Needs your input — required fields still unset: +
    +
  • {{ mp }}
  • +
+ + Edit the JSON value below (or click the operator on the canvas after Apply and use the Property + Editor). Nested objects/arrays accept inline JSON, e.g. + [{{ '{' }}"attribute":"Glucose","aggFunction":"average","result attribute":"avg_glucose"{{ '}' }}]. + +
+ + + + + + + + + + + + + +
PropertyValue (editable — JSON for objects/arrays)
+ {{ p.key }} + required + + +
+
+ +
+ + +
+ Workflow applied to canvas. Edit any field above to re-arm review. + + +
+ Validator attempts ({{ attempts.length }}) +
    +
  • + Attempt {{ a.attempt }}: {{ a.errorCount }} errors +
      +
    • {{ e }}
    • +
    +
  • +
+
+ + +
+ + +
{{ modificationError }}
+
+ +
+ Edit history ({{ editHistory.length }}) +
    +
  1. {{ e }}
  2. +
+
+
+
+ + +
diff --git a/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.scss b/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.scss new file mode 100644 index 00000000000..178a24f55e4 --- /dev/null +++ b/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.scss @@ -0,0 +1,427 @@ +/* AI Wizard panel styling. Mirrors the agent-panel docked-floating pattern + so cdkDrag + nz-resizable behave the same way the original agent panel does. */ + +:host { + display: block; + width: 100%; + height: 100%; + position: fixed; + top: 0; + left: 0; + z-index: 3; + /* Let clicks pass through the empty host area to the canvas below; + children re-enable events. */ + pointer-events: none; +} + +#ai-wizard-docked-button, +#ai-wizard-container.ai-wizard-box { + pointer-events: auto; +} + +#ai-wizard-docked-button { + position: fixed; + bottom: 95px; /* stack above the agent-panel docked button (bottom: 40px) */ + right: 10px; + z-index: 4; + box-shadow: + 0 3px 1px -2px #0003, + 0 2px 2px #00000024, + 0 1px 5px #0000001f; +} + +#ai-wizard-container.ai-wizard-box { + position: absolute; + top: 80px; + right: 80px; + background: #ffffff; + border: 1px solid #d9d9d9; + border-radius: 10px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + overflow: hidden; + z-index: 3; + display: flex; + flex-direction: column; +} + +#ai-wizard-return-button { + align-self: flex-end; + margin: 4px; + background: transparent; + border: 0; + position: absolute; + top: 0; + right: 0; + z-index: 3; +} + +#ai-wizard-content { + flex: 1; + overflow-y: auto; + padding: 12px 18px 18px; + display: flex; + flex-direction: column; + gap: 14px; + color: #1f2937; + font-size: 13px; +} + +#ai-wizard-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + color: #111827; + cursor: move; +} + +.model-picker { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + font-size: 12px; + label { font-weight: 600; color: #166534; } + select { + flex: 1; + padding: 4px 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + background: white; + } +} + +/* ----- step progress ----- */ +.step-progress { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding-bottom: 6px; + border-bottom: 1px solid #e5e7eb; +} +.progress-step { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + opacity: 0.4; + &.active { opacity: 1; } +} +.step-circle { + width: 24px; + height: 24px; + border-radius: 50%; + background: #2563eb; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; +} +.step-label { font-size: 11px; margin-top: 2px; } + +/* ----- generic step ----- */ +.wizard-step h3 { + margin: 0 0 4px; + font-size: 15px; +} +.step-help { + margin: 0 0 10px; + color: #6b7280; +} + +.options-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 12px; +} +.option-card { + border: 1.5px solid #e5e7eb; + border-radius: 8px; + padding: 10px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + background: #fafafa; + h4 { margin: 0 0 4px; font-size: 13px; font-weight: 600; } + p { margin: 0; font-size: 11.5px; color: #4b5563; } + small { display: block; margin-top: 4px; color: #6b7280; } + &:hover { border-color: #93c5fd; } + &.selected { + border-color: #2563eb; + background: #eff6ff; + } +} + +/* ----- freetext / config blocks ----- */ +.freetext-block { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 10px; + margin-bottom: 12px; + display: flex; + flex-direction: column; + gap: 6px; + label { font-weight: 500; font-size: 12px; display: block; } + textarea, input[type="text"], input[type="number"] { + width: 100%; + margin-top: 4px; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + resize: vertical; + } + textarea.framework-textarea { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + line-height: 1.5; + } +} +.hint { + color: #6b7280; + font-size: 11px; +} + +.callout-warn { + margin-top: 6px; + padding: 8px 10px; + background: #fffbeb; + border: 1px solid #fcd34d; + border-radius: 6px; + font-size: 11.5px; + color: #78350f; + line-height: 1.5; +} + +.callout-info { + margin-top: 6px; + padding: 8px 10px; + background: #eff6ff; + border: 1px solid #93c5fd; + border-radius: 6px; + font-size: 11.5px; + color: #1e3a8a; + line-height: 1.5; + code { + background: white; + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + } +} + +.file-info { + padding: 4px 8px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 12px; +} + +/* ----- data profile ----- */ +.data-profile-card { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 8px; + padding: 8px; + font-size: 11.5px; +} +.profile-table { + width: 100%; + border-collapse: collapse; + margin-top: 6px; + th, td { + text-align: left; + border-bottom: 1px solid #e5e7eb; + padding: 4px 6px; + } + th { color: #1e3a8a; } + code { font-size: 11px; } + code.sample { color: #2563eb; } +} + +/* ----- guardrails ----- */ +.guardrails-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.guardrail-row { + display: flex; + gap: 8px; + align-items: flex-start; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px; + input[type="checkbox"] { margin-top: 3px; } + strong { font-size: 12.5px; } + p { margin: 2px 0 0; font-size: 11.5px; color: #4b5563; } +} + +/* ----- actions ----- */ +.wizard-actions { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #e5e7eb; +} + +.error-banner { + margin-top: 8px; + padding: 8px 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + color: #991b1b; + font-size: 12px; +} + +/* ----- result panel ----- */ +.result-panel { + border-top: 2px solid #e5e7eb; + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 10px; + h3 { margin: 0; font-size: 15px; } +} +.result-actions { + display: flex; + gap: 8px; +} +.why-block, .attempts-block, .edit-history-block { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 6px 10px; + font-size: 11.5px; + summary { cursor: pointer; font-weight: 500; } + ul, ol { margin: 6px 0 0 18px; } + code { font-size: 11px; } +} + +/* Per-operator review block */ +.review-block { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 6px 10px; + font-size: 11.5px; + summary { + cursor: pointer; + font-weight: 500; + display: flex; + gap: 6px; + align-items: center; + .op-type { + color: #2563eb; + font-weight: 600; + } + } + .why-row { + margin: 6px 0; + padding: 6px 8px; + background: #f0fdf4; + border-left: 3px solid #22c55e; + border-radius: 4px; + line-height: 1.4; + } +} + +.prop-table { + width: 100%; + border-collapse: collapse; + margin-top: 6px; + th, td { + text-align: left; + border-bottom: 1px solid #e5e7eb; + padding: 4px 6px; + vertical-align: top; + } + th { color: #374151; font-size: 11px; } + code { font-size: 11px; } + input[type="text"] { + width: 100%; + padding: 4px 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 11.5px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + .required-row { + background: #fef9c3; + code { font-weight: 600; } + } +} +.req-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 5px; + font-size: 10px; + font-weight: 600; + background: #fde047; + color: #713f12; + border-radius: 3px; +} + +.op-warn-badge { + display: inline-block; + margin-left: auto; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + background: #fee2e2; + color: #991b1b; + border: 1px solid #fca5a5; + border-radius: 3px; +} + +.missing-paths { + margin: 6px 0; + padding: 8px 10px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + font-size: 11.5px; + color: #7f1d1d; + line-height: 1.5; + ul { + margin: 4px 0 6px 16px; + padding: 0; + } + code { + background: white; + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + } + small { color: #991b1b; } +} + +.nl-edit { + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; + label { font-weight: 500; font-size: 12px; } + textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 12px; + resize: vertical; + } +} diff --git a/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.ts b/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.ts new file mode 100644 index 00000000000..451a951274b --- /dev/null +++ b/frontend/src/app/workspace/component/agent/ai-wizard-panel/ai-wizard-panel.component.ts @@ -0,0 +1,517 @@ +/** + * AI Wizard Panel — a sibling dock to the agent-panel. Hosts a 4-step wizard + * that produces a Texera workflow and applies it to the canvas via + * WorkflowActionService.reloadWorkflow(). + * + * Mounted in workspace.component.html as . + * Design-doc §5 (P0 wizard + chat) and §4.2 (data profiler). + */ + +import { CommonModule } from "@angular/common"; +import { Component, HostListener, OnDestroy, OnInit } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { CdkDrag, CdkDragHandle } from "@angular/cdk/drag-drop"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzResizableDirective, NzResizeEvent, NzResizeHandlesComponent } from "ng-zorro-antd/resizable"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; +import { NgClass } from "@angular/common"; + +import { NzModalService } from "ng-zorro-antd/modal"; +import { firstValueFrom } from "rxjs"; + +import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { AgentService, ModelType } from "../../../service/agent/agent.service"; +import { OperatorMetadataService } from "../../../service/operator-metadata/operator-metadata.service"; +import { OperatorMetadata, OperatorSchema } from "../../../types/operator-schema.interface"; +import { OperatorPredicate } from "../../../types/workflow-common.interface"; +import { WorkflowGeneratorService, GeneratedWorkflow } from "../../../service/ai-wizard/workflow-generator.service"; +import { DataProfilerService } from "../../../service/ai-wizard/data-profiler.service"; +import { findMissingRequiredPaths } from "../../../service/ai-wizard/prompt-builders"; +import { DatasetService } from "../../../../dashboard/service/user/dataset/dataset.service"; +import { DatasetSelectionModalComponent } from "../../dataset-selection-modal/dataset-selection-modal.component"; +import { DEFAULT_GUARDRAILS, getGuardrailsPrompt } from "../../../service/ai-wizard/data/guardrails"; +import { FRAMEWORKS } from "../../../service/ai-wizard/data/frameworks"; +import { DKNET_DATASETS } from "../../../service/ai-wizard/data/dknet-datasets"; +import { + AnalysisGoal, + AttemptLog, + DataSource, + DknetDataset, + ScientificFramework, + WizardState, +} from "../../../service/ai-wizard/types"; +import { WorkflowContent } from "../../../../common/type/workflow"; + +const ANALYSIS_GOALS: AnalysisGoal[] = ["EDA", "Predictive Modeling", "Data Cleaning", "NLP", "Custom"]; +const DATA_SOURCES: DataSource[] = ["Existing Dataset", "dkNET Dataset"]; +const FRAMEWORK_NAMES: ScientificFramework[] = ["CRISP-DM", "SEMMA", "KDD", "Custom"]; + +const GOAL_DESCRIPTIONS: Record = { + EDA: "Exploratory Data Analysis - distributions, correlations, patterns", + "Predictive Modeling": "Build and evaluate ML models", + "Data Cleaning": "Clean, transform, and prepare data", + NLP: "Natural Language Processing on text", + Custom: "Describe your own analysis goal (power-user free text)", +}; + +const FRAMEWORK_DESCRIPTIONS: Record = { + "CRISP-DM": "Business → Data → Preparation → Modeling → Evaluation", + SEMMA: "Sample → Explore → Modify → Model → Assess", + KDD: "Selection → Preprocessing → Transformation → Mining → Interpretation", + Custom: "Write your own methodology and domain-specific guidance from scratch", +}; + +@UntilDestroy() +@Component({ + selector: "texera-ai-wizard-panel", + templateUrl: "ai-wizard-panel.component.html", + styleUrls: ["ai-wizard-panel.component.scss"], + imports: [ + CommonModule, + FormsModule, + NgClass, + CdkDrag, + CdkDragHandle, + NzButtonComponent, + NzIconDirective, + NzResizableDirective, + NzResizeHandlesComponent, + NzTooltipDirective, + NzMenuDirective, + NzMenuItemComponent, + ], +}) +export class AiWizardPanelComponent implements OnInit, OnDestroy { + protected readonly window = window; + private static readonly MIN_W = 480; + private static readonly MIN_H = 540; + + // Panel chrome + width = 0; + height = Math.max(AiWizardPanelComponent.MIN_H, window.innerHeight * 0.75); + dragPosition = { x: 0, y: 0 }; + isDocked = true; + private resizeRaf = -1; + + // Wizard data + readonly goals = ANALYSIS_GOALS; + readonly dataSources = DATA_SOURCES; + readonly frameworks = FRAMEWORK_NAMES; + readonly dknetDatasets = DKNET_DATASETS; + readonly goalDescriptions = GOAL_DESCRIPTIONS; + readonly frameworkDescriptions = FRAMEWORK_DESCRIPTIONS; + + state: WizardState = { + step: 1, + guardrails: DEFAULT_GUARDRAILS.map(g => ({ ...g })), + }; + + // Available LLM models (fetched from /api/models via AgentService). + availableModels: ModelType[] = []; + + // Live operator catalog for the review panel. + operatorCatalog: OperatorMetadata | null = null; + + // Whether the user has reviewed + approved before Apply (Stage B gate). + reviewApproved = false; + + // Generation results + generatedWorkflow: WorkflowContent | null = null; + whyExplanations: Record = {}; + attempts: AttemptLog[] = []; + isGenerating = false; + isModifying = false; + generationError: string | null = null; + modificationError: string | null = null; + nlInstruction = ""; + editHistory: string[] = []; + + constructor( + private generator: WorkflowGeneratorService, + private profiler: DataProfilerService, + private workflowActionService: WorkflowActionService, + private notificationService: NotificationService, + private agentService: AgentService, + private operatorMetadataService: OperatorMetadataService, + private modalService: NzModalService, + private datasetService: DatasetService + ) {} + + ngOnInit(): void { + const savedW = Number(localStorage.getItem("ai-wizard-width")); + const savedH = Number(localStorage.getItem("ai-wizard-height")); + const wasDocked = localStorage.getItem("ai-wizard-docked"); + if (wasDocked === "false" && !isNaN(savedW) && savedW >= AiWizardPanelComponent.MIN_W) { + this.width = savedW; + } + if (!isNaN(savedH) && savedH >= AiWizardPanelComponent.MIN_H) { + this.height = savedH; + } + // Load available LLM models from Texera's LiteLLM proxy. + this.agentService + .fetchModelTypes() + .pipe(untilDestroyed(this)) + .subscribe(models => { + this.availableModels = models; + const saved = localStorage.getItem("ai-wizard-model"); + if (saved && models.some(m => m.id === saved)) { + this.state.model = saved; + } else if (models.length > 0 && !this.state.model) { + this.state.model = models[0].id; + } + }); + // Cache the operator catalog for the review panel. + this.operatorMetadataService + .getOperatorMetadata() + .pipe(untilDestroyed(this)) + .subscribe(md => { + this.operatorCatalog = md; + }); + } + + @HostListener("window:beforeunload") + ngOnDestroy(): void { + localStorage.setItem("ai-wizard-width", String(this.width)); + localStorage.setItem("ai-wizard-height", String(this.height)); + localStorage.setItem("ai-wizard-docked", String(this.width === 0)); + } + + // ---------- Panel chrome ---------- + + openOrClosePanel(): void { + if (this.width === 0) { + this.width = AiWizardPanelComponent.MIN_W; + this.isDocked = false; + } else { + this.width = 0; + this.isDocked = true; + } + } + + onResize({ width, height }: NzResizeEvent): void { + cancelAnimationFrame(this.resizeRaf); + this.resizeRaf = requestAnimationFrame(() => { + this.width = width!; + this.height = height!; + }); + } + + // ---------- Step navigation ---------- + + nextStep(): void { + if (this.state.step < 4) this.state.step++; + } + prevStep(): void { + if (this.state.step > 1) this.state.step--; + } + + canProceed(): boolean { + if (this.state.step === 1) { + if (!this.state.analysisGoal) return false; + if (this.state.analysisGoal === "Custom") return !!this.state.customAnalysisGoal?.trim(); + return true; + } + if (this.state.step === 2) { + if (!this.state.dataSource) return false; + if (this.state.dataSource === "dkNET Dataset") return !!this.state.dknetDataset; + if (this.state.dataSource === "Existing Dataset") return !!this.state.existingDatasetPath; + return true; + } + if (this.state.step === 3) { + if (!this.state.framework) return false; + return !!this.state.frameworkPrompt?.trim(); + } + return true; + } + + // ---------- Step 1 actions ---------- + + selectGoal(goal: AnalysisGoal): void { + this.state.analysisGoal = goal; + } + onCustomGoalChange(text: string): void { + this.state.customAnalysisGoal = text; + } + + // ---------- Step 2 actions ---------- + + selectDataSource(source: DataSource): void { + this.state.dataSource = source; + } + + /** Open Texera's existing dataset-file picker modal. Returns a path like + * "///v1/" that CSVFileScan can read on the backend. + * We also fetch the file bytes and run PapaParse so the LLM gets a real + * data profile (design-doc §4.2 schema-aware generation). */ + openExistingDatasetPicker(): void { + const modal = this.modalService.create({ + nzContent: DatasetSelectionModalComponent, + nzFooter: null, + nzData: { + fileMode: true, + selectedPath: this.state.existingDatasetPath ?? "", + }, + nzBodyStyle: { + resize: "both", + overflow: "auto", + minHeight: "300px", + minWidth: "600px", + maxWidth: "90vw", + maxHeight: "80vh", + }, + nzWidth: "fit-content", + }); + modal.afterClose.pipe(untilDestroyed(this)).subscribe((selectedPath: string | undefined) => { + if (!selectedPath) return; + this.state.existingDatasetPath = selectedPath; + this.state.dataProfile = undefined; + void this.profileExistingDataset(selectedPath); + }); + } + + private async profileExistingDataset(path: string): Promise { + try { + const blob = await firstValueFrom(this.datasetService.retrieveDatasetVersionSingleFile(path, true)); + const text = await blob.text(); + const profile = this.profiler.profileCsvText(text); + if (profile) { + this.state.dataProfile = profile; + } else { + this.notificationService.warning( + `Picked dataset file but couldn't parse it as CSV. The workflow will still generate but without a Data Profile.` + ); + } + } catch (err) { + console.warn("Failed to fetch/profile existing dataset:", err); + this.notificationService.warning( + "Couldn't fetch the dataset file to profile it. Workflow can still be generated." + ); + } + } + + selectDknetDataset(ds: DknetDataset): void { + this.state.dknetDataset = ds; + if (ds.profile) { + this.state.dataProfile = ds.profile; + } + } + + // ---------- Step 3 actions ---------- + + selectFramework(framework: ScientificFramework): void { + this.state.framework = framework; + this.state.frameworkPrompt = FRAMEWORKS[framework].prompt; + } + onFrameworkPromptChange(text: string): void { + this.state.frameworkPrompt = text; + } + resetFrameworkTemplate(): void { + if (this.state.framework) { + this.state.frameworkPrompt = FRAMEWORKS[this.state.framework].prompt; + } + } + + // ---------- Step 4 actions ---------- + + toggleGuardrail(id: string): void { + this.state.guardrails = this.state.guardrails.map(g => (g.id === id ? { ...g, enabled: !g.enabled } : g)); + } + + // ---------- Generate / Apply / NL-edit ---------- + + async onGenerate(): Promise { + if (!this.canGenerate()) return; + this.isGenerating = true; + this.generationError = null; + this.attempts = []; + try { + const result = await this.generator.generate(this.state, this.state.model); + this.applyGeneratedResult(result); + } catch (err: any) { + this.attempts = err?.attempts ?? this.attempts; + this.generationError = err?.message ?? "Failed to generate workflow."; + } finally { + this.isGenerating = false; + } + } + + onModelChange(modelId: string): void { + this.state.model = modelId; + localStorage.setItem("ai-wizard-model", modelId); + } + + canGenerate(): boolean { + if (this.isGenerating) return false; + if (!this.state.analysisGoal) return false; + if (this.state.analysisGoal === "Custom" && !this.state.customAnalysisGoal?.trim()) return false; + if (!this.state.dataSource) return false; + return true; + } + + applyToCanvas(): void { + if (!this.generatedWorkflow) return; + if (!this.reviewApproved) { + this.notificationService.warning("Please review each operator and click 'Approve all' before Apply."); + return; + } + const currentMeta = this.workflowActionService.getWorkflowMetadata(); + const wf = { ...currentMeta, content: this.generatedWorkflow } as any; + this.workflowActionService.reloadWorkflow(wf, false, false); + this.notificationService.success("Workflow applied to canvas."); + } + + approveAndApply(): void { + this.reviewApproved = true; + this.applyToCanvas(); + } + + /** + * Edit an auto-filled property value in place. Tries to parse JSON first so + * users can edit nested objects/arrays in the same text field; falls back to + * storing the raw string for simple string properties. + */ + updateOperatorProperty(opIndex: number, key: string, newValueRaw: string): void { + if (!this.generatedWorkflow) return; + const ops = [...this.generatedWorkflow.operators]; + const op = ops[opIndex]; + if (!op) return; + + let parsed: any = newValueRaw; + const trimmed = newValueRaw.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + /^(true|false|null|-?\d+(\.\d+)?)$/.test(trimmed) + ) { + try { + parsed = JSON.parse(trimmed); + } catch { + parsed = newValueRaw; // keep raw if invalid JSON; user can fix + } + } + + const updated: OperatorPredicate = { + ...op, + operatorProperties: { ...op.operatorProperties, [key]: parsed }, + }; + ops[opIndex] = updated; + this.generatedWorkflow = { ...this.generatedWorkflow, operators: ops }; + // Manual edit re-arms the review gate so the user must explicitly approve again. + this.reviewApproved = false; + } + + /** Look up operator schema (required[] and full properties) from the live catalog. */ + schemaForOperator(operatorType: string): OperatorSchema | undefined { + return this.operatorCatalog?.operators.find(s => s.operatorType === operatorType); + } + + /** Properties that the operator's jsonSchema declares as required. */ + requiredKeysFor(operatorType: string): string[] { + const schema = this.schemaForOperator(operatorType); + const req = (schema?.jsonSchema as any)?.required; + return Array.isArray(req) ? req : []; + } + + /** Helper used by template *ngFor over an operator's properties. */ + propertyEntries(op: OperatorPredicate): { key: string; value: any; required: boolean }[] { + const required = new Set(this.requiredKeysFor(op.operatorType)); + const props = op.operatorProperties ?? {}; + // Show required keys first (whether filled or missing), then the rest. + const declaredRequired = Array.from(required); + const optionalKeys = Object.keys(props).filter(k => !required.has(k)); + const all = [...declaredRequired, ...optionalKeys]; + return all.map(k => ({ + key: k, + value: props[k], + required: required.has(k), + })); + } + + /** Deep-check: list each operator's missing required paths (incl. nested). */ + missingRequiredPaths(op: OperatorPredicate): string[] { + const schema = this.schemaForOperator(op.operatorType); + if (!schema) return []; + return findMissingRequiredPaths(op.operatorProperties ?? {}, schema.jsonSchema, ""); + } + + /** True if any operator has any unset required property (top or nested). */ + hasMissingRequired(): boolean { + if (!this.generatedWorkflow) return false; + return this.generatedWorkflow.operators.some(op => this.missingRequiredPaths(op).length > 0); + } + + /** trackBy for the property *ngFor — keeps the same input DOM node across + * change detection so the user's caret position / focus isn't dropped on every + * keystroke. Without this, the input flashes and the value field reads as + * uneditable. */ + trackPropertyKey = (_: number, p: { key: string }) => p.key; + + /** trackBy for the operator *ngFor — same reasoning, keeps each operator + * card's DOM (and its open/closed
state) stable. */ + trackOperatorId = (_: number, op: OperatorPredicate) => op.operatorID; + + formatPropValue(v: any): string { + if (v === undefined || v === null) return "(unset)"; + if (typeof v === "string") return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } + } + + downloadJson(): void { + if (!this.generatedWorkflow) return; + const payload = { ...this.generatedWorkflow, whyExplanations: this.whyExplanations }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `texera-workflow-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + async onNlEdit(): Promise { + if (!this.generatedWorkflow || !this.nlInstruction.trim()) return; + this.isModifying = true; + this.modificationError = null; + try { + const result = await this.generator.modify( + this.generatedWorkflow, + this.whyExplanations, + this.nlInstruction, + this.state.dataProfile, + this.state.model + ); + this.applyGeneratedResult(result); + this.editHistory = [...this.editHistory, this.nlInstruction]; + this.nlInstruction = ""; + } catch (err: any) { + this.modificationError = err?.message ?? "Failed to modify workflow."; + } finally { + this.isModifying = false; + } + } + + private applyGeneratedResult(result: GeneratedWorkflow): void { + this.generatedWorkflow = result.workflow; + this.whyExplanations = result.whyExplanations; + this.attempts = result.attempts; + // New generation invalidates any prior review approval. + this.reviewApproved = false; + } + + guardrailsSummary(): string { + return getGuardrailsPrompt(this.state.guardrails).slice(0, 200); + } + + // Helper for template (record entries). + operatorIdsWithWhy(): { id: string; explanation: string }[] { + return Object.entries(this.whyExplanations).map(([id, explanation]) => ({ id, explanation })); + } +} diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html index c54446fb318..91ede31f72b 100644 --- a/frontend/src/app/workspace/component/workspace.component.html +++ b/frontend/src/app/workspace/component/workspace.component.html @@ -36,5 +36,6 @@ + diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts index 9968c26f647..9fd5c7acebd 100644 --- a/frontend/src/app/workspace/component/workspace.component.ts +++ b/frontend/src/app/workspace/component/workspace.component.ts @@ -59,6 +59,7 @@ import { MenuComponent } from "./menu/menu.component"; import { MiniMapComponent } from "./workflow-editor/mini-map/mini-map.component"; import { LeftPanelComponent } from "./left-panel/left-panel.component"; import { AgentPanelComponent } from "./agent/agent-panel/agent-panel.component"; +import { AiWizardPanelComponent } from "./agent/ai-wizard-panel/ai-wizard-panel.component"; import { PropertyEditorComponent } from "./property-editor/property-editor.component"; import { FormlyRepeatDndComponent } from "../../common/formly/repeat-dnd/repeat-dnd.component"; @@ -82,6 +83,7 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000; LeftPanelComponent, NgIf, AgentPanelComponent, + AiWizardPanelComponent, PropertyEditorComponent, FormlyRepeatDndComponent, ], diff --git a/frontend/src/app/workspace/service/ai-wizard/data-profiler.service.ts b/frontend/src/app/workspace/service/ai-wizard/data-profiler.service.ts new file mode 100644 index 00000000000..0ec2df82267 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/data-profiler.service.ts @@ -0,0 +1,91 @@ +/** + * Browser-side CSV profiler. Parses up to MAX_ROWS from a File via PapaParse + * and produces a DataProfile that the LLM can consume so it never has to + * guess column names (design-doc §4.2, §7 point 1 — schema-aware generation). + * + * Intentionally lives in the browser, not the agent-service backend: the + * design doc treats the wizard as a frontend-driven copilot and we want + * profiling to work without requiring the user to upload to a backend first. + */ + +import { Injectable } from "@angular/core"; +import * as Papa from "papaparse"; +import { ColumnDtype, ColumnProfile, DataProfile } from "./types"; + +const MAX_ROWS = 5000; +const SAMPLE_VALUES_PER_COLUMN = 5; + +@Injectable({ providedIn: "root" }) +export class DataProfilerService { + /** + * Profile a CSV File object the user picked from . + * Returns null if the file can't be parsed. + */ + public async profileCsvFile(file: File): Promise { + const text = await this.readSlice(file, MAX_ROWS); + return this.profileCsvText(text); + } + + public profileCsvText(csvText: string): DataProfile | null { + const parsed = Papa.parse>(csvText, { + header: true, + skipEmptyLines: true, + dynamicTyping: false, + transformHeader: h => h.trim(), + }); + if (parsed.errors.length > 0 && parsed.data.length === 0) { + return null; + } + const rows = parsed.data as Array>; + const headers = parsed.meta.fields ?? []; + if (headers.length === 0) return null; + + const columns: ColumnProfile[] = headers.map(name => this.profileColumn(name, rows)); + return { + rowCount: rows.length, + columns, + source: "csv-upload", + }; + } + + private profileColumn(name: string, rows: Array>): ColumnProfile { + const values = rows.map(r => r[name]); + let nulls = 0; + const unique = new Set(); + const sampleValues: string[] = []; + for (const v of values) { + if (v === undefined || v === null || v === "") { + nulls++; + continue; + } + unique.add(v); + if (sampleValues.length < SAMPLE_VALUES_PER_COLUMN) sampleValues.push(v); + } + const total = values.length || 1; + return { + name, + dtype: this.inferDtype(sampleValues), + nullRate: Number((nulls / total).toFixed(3)), + uniqueCount: unique.size, + sampleValues, + }; + } + + private inferDtype(samples: string[]): ColumnDtype { + if (samples.length === 0) return "str"; + const allInt = samples.every(v => /^-?\d+$/.test(v.trim())); + if (allInt) return "int"; + const allFloat = samples.every(v => /^-?\d+(\.\d+)?$/.test(v.trim())); + if (allFloat) return "float"; + const lower = samples.map(v => v.trim().toLowerCase()); + if (lower.every(v => v === "true" || v === "false" || v === "0" || v === "1")) return "bool"; + if (samples.every(v => !isNaN(Date.parse(v)))) return "date"; + return "str"; + } + + // Read enough of the file to cover MAX_ROWS lines. Cheap for the demo + // (Pima diabetes is 768 rows total). + private async readSlice(file: File, _maxRows: number): Promise { + return await file.text(); + } +} diff --git a/frontend/src/app/workspace/service/ai-wizard/data/dknet-datasets.ts b/frontend/src/app/workspace/service/ai-wizard/data/dknet-datasets.ts new file mode 100644 index 00000000000..dca57602cd4 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/data/dknet-datasets.ts @@ -0,0 +1,139 @@ +/** + * Curated dkNET-style biomedical datasets. The Pima Indians Diabetes dataset + * comes pre-baked with a DataProfile so the LLM never has to guess column + * names for the demo path (design-doc §4.2 Data Profiler). + */ + +import { DknetDataset } from "../types"; + +export const DKNET_DATASETS: DknetDataset[] = [ + { + id: "iris-example", + name: "Iris Species (Texera example dataset)", + description: + "Classic 150-row Iris flower dataset, shipped with Texera's example loader (bin/single-node/examples/load-examples.sh). Use this for a quick end-to-end demo — path resolves on the backend without extra setup.", + fileName: "/texera/iris/v1/Iris.csv", + schema: + "Id (integer), SepalLengthCm (float), SepalWidthCm (float), PetalLengthCm (float), PetalWidthCm (float), Species (string — Iris-setosa / Iris-versicolor / Iris-virginica). 150 rows.", + profile: { + rowCount: 150, + source: "dknet-prebaked", + columns: [ + { name: "Id", dtype: "int", nullRate: 0, uniqueCount: 150, sampleValues: ["1", "2", "3", "4", "5"] }, + { + name: "SepalLengthCm", + dtype: "float", + nullRate: 0, + uniqueCount: 35, + sampleValues: ["5.1", "4.9", "4.7", "4.6", "5.0"], + }, + { + name: "SepalWidthCm", + dtype: "float", + nullRate: 0, + uniqueCount: 23, + sampleValues: ["3.5", "3.0", "3.2", "3.1", "3.6"], + }, + { + name: "PetalLengthCm", + dtype: "float", + nullRate: 0, + uniqueCount: 43, + sampleValues: ["1.4", "1.4", "1.3", "1.5", "1.4"], + }, + { + name: "PetalWidthCm", + dtype: "float", + nullRate: 0, + uniqueCount: 22, + sampleValues: ["0.2", "0.2", "0.2", "0.2", "0.2"], + }, + { + name: "Species", + dtype: "str", + nullRate: 0, + uniqueCount: 3, + sampleValues: ["Iris-setosa", "Iris-versicolor", "Iris-virginica", "Iris-setosa", "Iris-setosa"], + }, + ], + }, + }, + { + id: "diabetes-cohort", + name: "Pima Indians Diabetes", + description: + "768-patient cohort (Pima Indian heritage, NIDDK study). Source: Kaggle akshaydattatraykhare/diabetes-dataset, uploaded to Texera at /texera/diabetes/v1/dknet-diabetes.csv. Suitable for predictive modeling on the Outcome (binary diabetes diagnosis) target.", + fileName: "/texera/diabetes/v1/dknet-diabetes.csv", + schema: + "Pregnancies (integer), Glucose (integer, mg/dL — 2-hr OGTT), BloodPressure (integer, diastolic mm Hg), SkinThickness (integer, triceps mm), Insulin (integer, 2-hr serum mu U/ml), BMI (float, kg/m²), DiabetesPedigreeFunction (float), Age (integer, years), Outcome (0/1, target label)", + profile: { + rowCount: 768, + source: "dknet-prebaked", + columns: [ + { + name: "Pregnancies", + dtype: "int", + nullRate: 0, + uniqueCount: 17, + sampleValues: ["6", "1", "8", "0", "3"], + }, + { + name: "Glucose", + dtype: "int", + nullRate: 0, + uniqueCount: 136, + sampleValues: ["148", "85", "183", "89", "137"], + }, + { + name: "BloodPressure", + dtype: "int", + nullRate: 0, + uniqueCount: 47, + sampleValues: ["72", "66", "64", "0", "40"], + }, + { + name: "SkinThickness", + dtype: "int", + nullRate: 0, + uniqueCount: 51, + sampleValues: ["35", "29", "0", "23", "35"], + }, + { + name: "Insulin", + dtype: "int", + nullRate: 0, + uniqueCount: 186, + sampleValues: ["0", "94", "168", "88", "543"], + }, + { + name: "BMI", + dtype: "float", + nullRate: 0, + uniqueCount: 248, + sampleValues: ["33.6", "26.6", "23.3", "28.1", "43.1"], + }, + { + name: "DiabetesPedigreeFunction", + dtype: "float", + nullRate: 0, + uniqueCount: 517, + sampleValues: ["0.627", "0.351", "0.672", "0.167", "2.288"], + }, + { + name: "Age", + dtype: "int", + nullRate: 0, + uniqueCount: 52, + sampleValues: ["50", "31", "32", "21", "33"], + }, + { + name: "Outcome", + dtype: "int", + nullRate: 0, + uniqueCount: 2, + sampleValues: ["1", "0", "1", "0", "1"], + }, + ], + }, + }, +]; diff --git a/frontend/src/app/workspace/service/ai-wizard/data/few-shot-examples.ts b/frontend/src/app/workspace/service/ai-wizard/data/few-shot-examples.ts new file mode 100644 index 00000000000..78b61ef8a8f --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/data/few-shot-examples.ts @@ -0,0 +1,156 @@ +/** + * Few-shot examples extracted from Texera's bundled example workflows + * (bin/single-node/examples/workflows/). Every operator below is a real, + * runnable config — shapes were verified against the production schema. + * Goal: give the LLM concrete templates for filling Aggregate, Filter, + * Sklearn, BarChart, PieChart, TablesPlot, Split, Scorer, Scatterplot. + */ + +export function getFewShotPrompt(): string { + return `## Few-Shot Examples (real Texera workflows — copy these shapes, NEVER use dummy/placeholder values) + +The properties below are extracted from bundled Texera example workflows. +ALWAYS substitute the column names with real columns from the Data Profile. + +### Example A: ML pipeline on Iris (CSVFileScan → Projection → Split → SklearnLogisticRegression → SklearnPrediction → Scorer) + +\`\`\`json +{ + "operators": [ + { + "operatorID": "CSVFileScan-operator-A1", + "operatorType": "CSVFileScan", + "operatorVersion": "1.0", + "operatorProperties": { + "fileEncoding": "UTF_8", + "customDelimiter": ",", + "hasHeader": true, + "fileName": "/texera/iris/v1/Iris.csv" + }, + "inputPorts": [], + "outputPorts": [{ "portID": "output-0", "displayName": "" }] + }, + { + "operatorID": "Projection-operator-A2", + "operatorType": "Projection", + "operatorVersion": "1.0", + "operatorProperties": { + "isDrop": false, + "attributes": [ + { "originalAttribute": "SepalWidthCm" }, + { "originalAttribute": "PetalWidthCm" }, + { "originalAttribute": "Species" } + ] + }, + "inputPorts": [{ "portID": "input-0", "displayName": "" }], + "outputPorts": [{ "portID": "output-0", "displayName": "" }] + }, + { + "operatorID": "Split-operator-A3", + "operatorType": "Split", + "operatorVersion": "1.0", + "operatorProperties": { "k": 70, "random": true, "seed": 1 }, + "inputPorts": [{ "portID": "input-0", "displayName": "" }], + "outputPorts": [ + { "portID": "output-0", "displayName": "training" }, + { "portID": "output-1", "displayName": "testing" } + ] + }, + { + "operatorID": "SklearnLogisticRegression-operator-A4", + "operatorType": "SklearnLogisticRegression", + "operatorVersion": "1.0", + "operatorProperties": { + "countVectorizer": false, + "tfidfTransformer": false, + "target": "Species" + }, + "inputPorts": [{ "portID": "input-0", "displayName": "" }], + "outputPorts": [{ "portID": "output-0", "displayName": "" }] + }, + { + "operatorID": "SklearnPrediction-operator-A5", + "operatorType": "SklearnPrediction", + "operatorVersion": "1.0", + "operatorProperties": { + "Model Attribute": "model", + "Output Attribute Name": "prediction", + "Ground Truth Attribute Name to Ignore": "Species" + }, + "inputPorts": [ + { "portID": "input-0", "displayName": "model" }, + { "portID": "input-1", "displayName": "test data" } + ], + "outputPorts": [{ "portID": "output-0", "displayName": "" }] + }, + { + "operatorID": "Scorer-operator-A6", + "operatorType": "Scorer", + "operatorVersion": "1.0", + "operatorProperties": { + "isRegression": false, + "actualValueColumn": "Species", + "predictValueColumn": "prediction" + }, + "inputPorts": [{ "portID": "input-0", "displayName": "" }], + "outputPorts": [{ "portID": "output-0", "displayName": "" }] + } + ] +} +\`\`\` + +### Example B: EDA pipeline shapes (Filter, Aggregate, BarChart, PieChart, TablesPlot, Scatterplot) + +Use these exact shapes — every key here is required by Texera. Replace the +column names with ones from the Data Profile. + +\`\`\`json +{ + "Filter": { + "predicates": [ + { "attribute": "Species", "condition": "=", "value": "Iris-setosa" } + ] + }, + "Aggregate": { + "aggregations": [ + { "aggFunction": "count", "attribute": "Species", "result attribute": "#rows" } + ], + "groupByKeys": ["Species"] + }, + "BarChart": { + "categoryColumn": "Species", + "horizontalOrientation": false, + "fields": "Species", + "value": "#rows" + }, + "PieChart": { + "value": "#rows", + "name": "Species" + }, + "TablesPlot": { + "add attribute": [ + { "attributeName": "Species" }, + { "attributeName": "SepalLengthCm" }, + { "attributeName": "PetalLengthCm" } + ] + }, + "Scatterplot": { + "xLogScale": false, + "yLogScale": false, + "xColumn": "SepalWidthCm", + "yColumn": "PetalWidthCm", + "colorColumn": "Species", + "alpha": 1 + }, + "Split": { "k": 70, "random": true, "seed": 1 } +} +\`\`\` + +### Critical conventions + +- Filter.predicates[].condition must be one of: =, !=, <, <=, >, >=, regex, contains +- Aggregate.aggregations[].aggFunction must be: sum | count | average | min | max | concat (lowercase) +- All "*Column" / "attribute" / "target" fields point at REAL columns from the Data Profile (case-sensitive) +- NEVER emit operator properties named dummyProperty / dummyValue / dummyPropertyList — those are Texera placeholders unused by real workflows +`; +} diff --git a/frontend/src/app/workspace/service/ai-wizard/data/frameworks.ts b/frontend/src/app/workspace/service/ai-wizard/data/frameworks.ts new file mode 100644 index 00000000000..addcbaefd82 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/data/frameworks.ts @@ -0,0 +1,65 @@ +/** + * Scientific data analysis frameworks. Ported from TexeraHackathon. + * The Custom framework is editable freeform text. + */ + +import { ScientificFramework } from "../types"; + +export interface FrameworkDefinition { + name: ScientificFramework; + description: string; + phases: string[]; + prompt: string; +} + +export const FRAMEWORKS: Record = { + "CRISP-DM": { + name: "CRISP-DM", + description: "Cross-Industry Standard Process for Data Mining", + phases: ["Business Understanding", "Data Understanding", "Data Preparation", "Modeling", "Evaluation", "Deployment"], + prompt: `Follow the CRISP-DM methodology: +1. **Data Understanding**: Start with exploratory data analysis (EDA) - use visualization operators to understand distributions, correlations, missing values +2. **Data Preparation**: Clean data - handle nulls, remove duplicates, filter outliers, select relevant columns +3. **Modeling**: Apply appropriate algorithms based on the analysis goal +4. **Evaluation**: Include visualization operators to evaluate results +5. Structure the workflow with clear stages following this order`, + }, + SEMMA: { + name: "SEMMA", + description: "Sample, Explore, Modify, Model, Assess", + phases: ["Sample", "Explore", "Modify", "Model", "Assess"], + prompt: `Follow the SEMMA methodology: +1. **Sample**: Use Limit operator to sample representative data subset +2. **Explore**: Use visualization operators (ScatterMatrix, BarChart, etc.) for EDA +3. **Modify**: Transform and prepare data (Filter, TypeCasting, Projection, Aggregate) +4. **Model**: Apply modeling operators (if applicable) +5. **Assess**: Visualize and evaluate results +6. Structure the workflow with these clear phases`, + }, + KDD: { + name: "KDD", + description: "Knowledge Discovery in Databases", + phases: ["Selection", "Preprocessing", "Transformation", "Data Mining", "Interpretation/Evaluation"], + prompt: `Follow the KDD process: +1. **Selection**: Start with data source operator, optionally filter to select relevant subset +2. **Preprocessing**: Clean and prepare data (handle missing values, remove noise) +3. **Transformation**: Transform data into suitable format (aggregations, type conversions, feature engineering) +4. **Data Mining**: Apply pattern discovery or modeling operators +5. **Interpretation/Evaluation**: Visualize and interpret results +6. Structure operators in this sequential order`, + }, + Custom: { + name: "Custom", + description: "Define your own methodology and domain knowledge", + phases: [], + prompt: `Follow this custom methodology: +1. **Step 1**: Describe the first phase of your analysis. +2. **Step 2**: Describe the second phase of your analysis. +3. **Domain knowledge**: Add any constraints, biases, or domain-specific guidance here. +4. Structure the workflow according to this methodology.`, + }, +}; + +export function getFrameworkPrompt(framework: ScientificFramework): string { + return FRAMEWORKS[framework].prompt; +} diff --git a/frontend/src/app/workspace/service/ai-wizard/data/guardrails.ts b/frontend/src/app/workspace/service/ai-wizard/data/guardrails.ts new file mode 100644 index 00000000000..8d81eb18424 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/data/guardrails.ts @@ -0,0 +1,92 @@ +/** + * White-box guardrails enforced regardless of methodology text. Ported from + * TexeraHackathon. Design-doc §4.2: guardrails are validator-checkable rules. + */ + +import { Guardrail } from "../types"; + +export const DEFAULT_GUARDRAILS: Guardrail[] = [ + { + id: "data-validation", + name: "Data Validation", + description: "Always validate input data quality and schema before processing", + enabled: true, + }, + { + id: "error-handling", + name: "Error Handling", + description: "Include proper error handling operators to catch data issues", + enabled: true, + }, + { + id: "sample-first", + name: "Sample Data First", + description: "Use Limit operator to sample data before expensive operations", + enabled: true, + }, + { + id: "type-safety", + name: "Type Safety", + description: "Ensure column types are properly cast before operations", + enabled: true, + }, + { + id: "visualization", + name: "Visualization", + description: "Include visualization operators to inspect intermediate results", + enabled: true, + }, + { + id: "reproducibility", + name: "Reproducibility", + description: "Set random seeds for random operations (Split, sampling, etc.)", + enabled: true, + }, + { + id: "null-handling", + name: "Null Value Handling", + description: "Filter or handle null values before aggregations", + enabled: true, + }, + { + id: "performance", + name: "Performance Optimization", + description: "Apply filters early in the pipeline to reduce data volume", + enabled: true, + }, + { + id: "train-test-split", + name: "Mandatory Train/Test Split", + description: + "For Predictive Modeling, ALWAYS insert a Split operator (e.g., 80/20) BEFORE any modeling operator. Train on the training partition only.", + enabled: true, + }, + { + id: "data-leakage", + name: "Prevent Data Leakage", + description: + "Never apply transformations fit on the full dataset to the test partition. Any sampling, scaling, or feature engineering that uses dataset statistics must be done AFTER the train/test split, fit on training data only.", + enabled: true, + }, + { + id: "evaluation", + name: "Mandatory Evaluation", + description: + "For Predictive Modeling, ALWAYS include at least one evaluation operator (e.g., Scatterplot of predicted vs. actual, or an aggregate of error metrics) on the test-set predictions.", + enabled: true, + }, + { + id: "no-synthetic-data", + name: "No Synthetic Data by Default", + description: + "Do NOT introduce synthetic samples (e.g., SMOTE-style oversampling, generated rows) unless the user explicitly requests it. Class imbalance should be reported, not silently fixed.", + enabled: true, + }, +]; + +export function getGuardrailsPrompt(guardrails: Guardrail[]): string { + const enabled = guardrails.filter(g => g.enabled); + if (enabled.length === 0) return ""; + const rules = enabled.map((g, idx) => `${idx + 1}. **${g.name}**: ${g.description}`).join("\n"); + return `\n\n## Guardrails\nThe following guardrails MUST be followed:\n${rules}`; +} diff --git a/frontend/src/app/workspace/service/ai-wizard/prompt-builders.ts b/frontend/src/app/workspace/service/ai-wizard/prompt-builders.ts new file mode 100644 index 00000000000..9bf967faaef --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/prompt-builders.ts @@ -0,0 +1,322 @@ +/** + * Prompt builders for the AI wizard. Each builder returns a single string + * that we send to /aiassistant/openai via AiAnalystService.sendPromptToOpenAI. + * + * Structure mirrors the design-doc §4.2 Prompt Builder table: + * System / Operators / Methodology / Guardrails / Data / Examples / Task + */ + +import { WorkflowContent } from "../../../common/type/workflow"; +import { OperatorMetadata } from "../../types/operator-schema.interface"; +import { DataProfile, ValidationResult, WizardState } from "./types"; +import { getFrameworkPrompt } from "./data/frameworks"; +import { getGuardrailsPrompt } from "./data/guardrails"; +import { getFewShotPrompt } from "./data/few-shot-examples"; + +const PORT_AND_FORMAT_RULES = `## CRITICAL: Port ID Convention (must follow exactly) +Every operator MUST declare its input and output ports using this exact naming scheme: +- inputPorts: an array with one entry per input port. The i-th entry has portID "input-{i}" (zero-indexed). +- outputPorts: an array with one entry per output port. The i-th entry has portID "output-{i}" (zero-indexed). + +Every link MUST reference these exact portIDs: +- link.source.portID = "output-{i}" referencing an output port that actually exists on the source operator +- link.target.portID = "input-{i}" referencing an input port that actually exists on the target operator + +Source operators (0 input ports) can only appear as a link source, never a target. + +## CRITICAL: Operator Property Completeness (the workflow MUST compile) +Every operator must be IMMEDIATELY runnable. Texera shows "invalid workflow" if any required property +is missing or references a column that doesn't exist. Therefore: + +1. For each operator, consult the operator catalog above and identify which properties are marked "required". +2. Fill EVERY required property. Do NOT leave any required field as null, empty string, or a placeholder. +3. When a property references a column (Filter.attribute, Aggregate.groupByKeys, BarChart.x, Visualization axes, + Sklearn target/feature columns, etc.), use the EXACT column name from the Data Profile section above. + Never invent column names. Match capitalization exactly. +4. When the analysis goal implies a target column (predictive modeling → outcome/label, EDA on time series + → date column), pick the most plausible match from the Data Profile. +5. For each property you auto-filled where you had to make a judgment (especially when multiple columns + could fit), record a short rationale in whyExplanations so the user can audit your choice. Example: + "Filter-operator-1: Filters on Species=Iris-setosa. (Auto-filled: Species is the only categorical + column in the profile.)" +6. Do NOT add a Filter or any column-referencing operator with empty predicates. + +## CRITICAL: Why Explanations +Include a top-level "whyExplanations" object mapping every operatorID to a short (1-3 sentence) plain-English +explanation suitable for a biomedical researcher who does not write code. Reference framework phase, +guardrail, or data column when applicable. For any property where you had to choose among alternatives, +note "(Auto-filled: )" inside the same explanation. + +## Output Format +Return ONLY a single JSON object (no markdown fences, no commentary): + +{ + "operators": [...], + "operatorPositions": { "operatorID": { "x": number, "y": number } }, + "links": [...], + "commentBoxes": [], + "settings": { "dataTransferBatchSize": 400, "executionMode": "PIPELINED" }, + "whyExplanations": { "operatorID": "explanation string" } +}`; + +export function operatorCatalogText(metadata: OperatorMetadata | null | undefined): string { + if (!metadata || metadata.operators.length === 0) { + return "(operator catalog unavailable — generation may be unreliable)"; + } + const lines: string[] = []; + for (const op of metadata.operators) { + const am = op.additionalMetadata; + const definitions = (op.jsonSchema as any)?.definitions ?? {}; + const body = renderJsonSchemaProps(op.jsonSchema as any, definitions, " ", 0); + lines.push( + `### ${op.operatorType} - ${am.userFriendlyName}\nCategory: ${am.operatorGroupName}\nDescription: ${am.operatorDescription ?? ""}\nInput Ports: ${am.inputPorts.length}\nOutput Ports: ${am.outputPorts.length}\nProperties:\n${body || " (none)"}` + ); + } + return lines.join("\n\n"); +} + +/** Resolve a "#/definitions/X" $ref against the operator schema's definitions block. */ +function resolveRef(schema: any, definitions: Record): any { + if (!schema || typeof schema !== "object") return schema; + if (typeof schema.$ref === "string") { + const m = /^#\/definitions\/(.+)$/.exec(schema.$ref); + if (m) { + const target = definitions[m[1]]; + if (target) return { ...target, ...schema, $ref: undefined }; + } + } + return schema; +} + +/** + * Recursively render a JSON schema's properties (incl. resolved $refs) so the + * LLM sees every nested required field. Texera's schemas use $ref into the + * top-level definitions block — without resolving them the LLM has no way to + * know that aggregations[].attribute / aggFunction / "result attribute" exist. + */ +/** Skip Texera's scaffolding placeholder properties so the LLM doesn't fill workflows + * with dummyProperty / dummyValue (legitimate workflows never use them). */ +function isPlaceholderProp(name: string): boolean { + return /^dummy/i.test(name); +} + +function renderJsonSchemaProps( + schemaRaw: any, + definitions: Record, + indent: string, + depth: number +): string { + if (depth > 6) return ""; // safety cap + const schema = resolveRef(schemaRaw, definitions); + if (!schema || typeof schema !== "object" || !schema.properties) return ""; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + const lines: string[] = []; + for (const [name, subRaw] of Object.entries(schema.properties)) { + if (isPlaceholderProp(name) && !required.has(name)) continue; + const sub = resolveRef(subRaw, definitions); + const type = sub?.type ?? "any"; + const isReq = required.has(name); + const desc = sub?.description ?? sub?.title ?? ""; + lines.push(`${indent}- ${name} (${type}${isReq ? ", required" : ", optional"}): ${desc}`); + if (Array.isArray(sub?.enum)) { + lines.push(`${indent} enum: ${sub.enum.join(" | ")}`); + } + if (sub?.type === "object") { + const nested = renderJsonSchemaProps(sub, definitions, indent + " ", depth + 1); + if (nested) lines.push(`${indent} fields:\n${nested}`); + } else if (sub?.type === "array") { + const items = resolveRef(sub.items, definitions); + if (items?.type === "object") { + const nested = renderJsonSchemaProps(items, definitions, indent + " ", depth + 1); + if (nested) lines.push(`${indent} items[]:\n${nested}`); + } else if (items?.type) { + lines.push(`${indent} items[]: ${items.type}`); + if (Array.isArray(items.enum)) lines.push(`${indent} items enum: ${items.enum.join(" | ")}`); + } + } + } + return lines.join("\n"); +} + +/** + * Walk a generated operator's properties against its schema and report any + * required fields that are unset, empty, or array-with-empty-items. Used by + * the wizard's review UI to surface "needs your input" gaps. Resolves $refs + * against the top-level definitions block so nested required fields under + * Texera's schema-by-reference style (e.g. aggregations[].attribute) get + * checked too. + */ +export function findMissingRequiredPaths(value: any, schema: any, path: string): string[] { + const definitions = collectDefinitions(schema); + return walkRequired(value, schema, path, definitions, 0); +} + +function collectDefinitions(rootSchema: any): Record { + return rootSchema?.definitions && typeof rootSchema.definitions === "object" ? rootSchema.definitions : {}; +} + +function walkRequired( + value: any, + schemaRaw: any, + path: string, + definitions: Record, + depth: number +): string[] { + if (depth > 6) return []; + const schema = resolveRef(schemaRaw, definitions); + if (!schema || typeof schema !== "object") return []; + const required: string[] = Array.isArray(schema.required) ? schema.required : []; + const missing: string[] = []; + if (schema.type === "object" && schema.properties) { + for (const key of required) { + const sub = resolveRef(schema.properties[key], definitions); + const v = (value ?? {})[key]; + const subPath = path ? `${path}.${key}` : key; + if (v === undefined || v === null || v === "") { + missing.push(subPath); + continue; + } + if (Array.isArray(v) && v.length === 0) { + missing.push(`${subPath} (empty array)`); + continue; + } + if (sub?.type === "object" && typeof v === "object") { + missing.push(...walkRequired(v, sub, subPath, definitions, depth + 1)); + } else if (sub?.type === "array" && Array.isArray(v)) { + const items = resolveRef(sub.items, definitions); + if (items?.type === "object") { + v.forEach((item: any, i: number) => { + missing.push(...walkRequired(item, items, `${subPath}[${i}]`, definitions, depth + 1)); + }); + } + } + } + } + return missing; +} + +function dataProfileText(profile: DataProfile | undefined): string { + if (!profile) return "(no data profile available — LLM should not assume column names)"; + if (profile.source === "unavailable") return "(profile unavailable for this data source)"; + const cols = profile.columns + .map( + c => + ` - ${c.name} (${c.dtype}, ${c.nullRate * 100}% null, ${c.uniqueCount} unique). Sample: [${c.sampleValues.slice(0, 5).join(", ")}]` + ) + .join("\n"); + return `Row count: ${profile.rowCount}\nColumns:\n${cols}`; +} + +function dataSourceConfigText(state: WizardState): string { + const { dataSource, existingDatasetPath, dknetDataset } = state; + if (dataSource === "Existing Dataset") { + return existingDatasetPath + ? `Existing Texera dataset file. Use CSVFileScan with fileName "${existingDatasetPath}".` + : "Existing dataset (path to be specified by user via Datasets picker)"; + } + if (dataSource === "dkNET Dataset" && dknetDataset) { + return `dkNET curated biomedical dataset: ${dknetDataset.name}\nSchema: ${dknetDataset.schema}\nUse CSVFileScan with fileName "${dknetDataset.fileName}".`; + } + return ""; +} + +export function buildGeneratePrompt(state: WizardState, operatorCatalog: OperatorMetadata | null): string { + const { analysisGoal, customAnalysisGoal, dataSource, framework, frameworkPrompt, guardrails, dataProfile } = state; + + const goalText = + analysisGoal === "Custom" + ? `Custom (free-text): ${customAnalysisGoal?.trim() ?? ""}` + : (analysisGoal ?? ""); + + const additionalContext = + analysisGoal && analysisGoal !== "Custom" && customAnalysisGoal?.trim() ? customAnalysisGoal.trim() : ""; + + const fwPromptText = frameworkPrompt?.trim() + ? frameworkPrompt + : framework + ? getFrameworkPrompt(framework) + : ""; + + return `You are a Texera workflow generation expert. Generate a complete Texera workflow JSON for the following requirements: + +## Analysis Goal +${goalText} +${additionalContext ? `\n## Additional Context (user-provided domain notes — soft guidance)\n${additionalContext}\n` : ""} +## Data Source +${dataSource ?? "(none)"} +${dataSourceConfigText(state)} + +## Data Profile (REAL column names — use these, do NOT guess) +${dataProfileText(dataProfile)} + +## Scientific Framework +${framework ?? "None specified"} +${fwPromptText} + +${getGuardrailsPrompt(guardrails)} + +## Available Operators +${operatorCatalogText(operatorCatalog)} + +${PORT_AND_FORMAT_RULES} + +${getFewShotPrompt()} + +Generate the workflow now.`; +} + +export function buildModifyPrompt( + current: WorkflowContent, + currentWhy: Record, + instruction: string, + operatorCatalog: OperatorMetadata | null, + dataProfile: DataProfile | undefined +): string { + const merged = { ...current, whyExplanations: currentWhy }; + return `You are a Texera workflow editor. The user wants to modify the following existing workflow. + +## Current Workflow +\`\`\`json +${JSON.stringify(merged, null, 2)} +\`\`\` + +## Data Profile (REAL column names — use these, do NOT guess) +${dataProfileText(dataProfile)} + +## User Instruction +${instruction} + +## Editing Rules +- Apply the user instruction precisely. Do not refactor unrelated parts. +- Preserve operatorIDs of operators that are NOT being changed. +- If you add new operators, give them fresh IDs in the format "{operatorType}-operator-{shortuuid}". +- Update operatorPositions and links to keep the workflow valid and connected. +- Update or extend whyExplanations to cover any new or changed operators, and explain WHY this edit was made. + +## Available Operators +${operatorCatalogText(operatorCatalog)} + +${PORT_AND_FORMAT_RULES} + +Return the FULL updated workflow JSON (not a diff).`; +} + +export function buildRetryPrompt( + originalPrompt: string, + prevWorkflow: WorkflowContent, + validation: ValidationResult +): string { + const errorList = validation.errors.map(e => `- ${e.field}: ${e.message}`).join("\n"); + return `${originalPrompt} + +## PREVIOUS ATTEMPT FAILED VALIDATION +Your previous response was: +\`\`\`json +${JSON.stringify(prevWorkflow, null, 2)} +\`\`\` + +The validator reported these errors that MUST be fixed: +${errorList} + +Carefully re-read the operator catalog and the Port ID Convention. Produce a corrected workflow JSON that fixes every error above. Do not introduce new errors.`; +} diff --git a/frontend/src/app/workspace/service/ai-wizard/types.ts b/frontend/src/app/workspace/service/ai-wizard/types.ts new file mode 100644 index 00000000000..16de6f8f083 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/types.ts @@ -0,0 +1,76 @@ +/** + * Types for the AI Wizard. Ported from TexeraHackathon/src/types/wizard.ts and + * extended with DataProfile (design-doc §4.2). + */ + +export type AnalysisGoal = "EDA" | "Predictive Modeling" | "Data Cleaning" | "NLP" | "Custom"; + +export type DataSource = "Existing Dataset" | "dkNET Dataset"; + +export type ScientificFramework = "CRISP-DM" | "SEMMA" | "KDD" | "Custom"; + +export interface DknetDataset { + id: string; + name: string; + description: string; + fileName: string; + schema: string; + profile?: DataProfile; +} + +export interface Guardrail { + id: string; + name: string; + description: string; + enabled: boolean; +} + +export type ColumnDtype = "int" | "float" | "str" | "bool" | "date"; + +export interface ColumnProfile { + name: string; + dtype: ColumnDtype; + nullRate: number; + uniqueCount: number; + sampleValues: string[]; +} + +export interface DataProfile { + rowCount: number; + columns: ColumnProfile[]; + source: "csv-upload" | "dknet-prebaked" | "unavailable"; +} + +export interface WizardState { + step: number; + analysisGoal?: AnalysisGoal; + customAnalysisGoal?: string; + dataSource?: DataSource; + framework?: ScientificFramework; + frameworkPrompt?: string; + guardrails: Guardrail[]; + /** When dataSource === "Existing Dataset": the Texera-resolved path + * (e.g. "///v1/.csv") chosen via DatasetSelectionModal. */ + existingDatasetPath?: string; + dknetDataset?: DknetDataset; + dataProfile?: DataProfile; + /** LLM model id passed to /api/chat/completions (e.g. claude-haiku-4.5). */ + model?: string; +} + +export interface AttemptLog { + attempt: number; + errorCount: number; + errors: string[]; +} + +export interface ValidationError { + field: string; + message: string; +} + +export interface ValidationResult { + isValid: boolean; + errors: ValidationError[]; + warnings?: string[]; +} diff --git a/frontend/src/app/workspace/service/ai-wizard/workflow-generator.service.ts b/frontend/src/app/workspace/service/ai-wizard/workflow-generator.service.ts new file mode 100644 index 00000000000..90f24c9dda2 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/workflow-generator.service.ts @@ -0,0 +1,215 @@ +/** + * AI Wizard workflow generator. Calls Texera's existing LiteLLM proxy at + * /api/chat/completions (see AccessControlResource.LiteLLMProxyResource). + * The proxy uses llm.conf credentials server-side — no LLM key ships to + * the browser, no /aiassistant/openai config-gated path is touched + * (design-doc §3.5: don't ship LLM keys, reuse working backend). + */ + +import { HttpClient } from "@angular/common/http"; +import { Injectable, inject } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { AppSettings } from "../../../common/app-setting"; +import { WorkflowContent } from "../../../common/type/workflow"; +import { OperatorMetadata } from "../../types/operator-schema.interface"; +import { OperatorMetadataService } from "../operator-metadata/operator-metadata.service"; +import { WorkflowValidatorService } from "./workflow-validator.service"; +import { buildGeneratePrompt, buildModifyPrompt, buildRetryPrompt } from "./prompt-builders"; +import { AttemptLog, DataProfile, ValidationResult, WizardState } from "./types"; + +interface OpenAIResponse { + choices: { message: { content: string } }[]; +} + +const MAX_ATTEMPTS = 3; + +// Texera's LiteLLM proxy is mounted by AccessControlResource at +// /api/chat/completions. It uses llm.conf for credentials, no extra config +// needed. Default model is claude-haiku-4.5 (fast + cheap); override via +// VITE/ng env if other models become available. +const DEFAULT_MODEL = "claude-haiku-4.5"; + +export interface GeneratedWorkflow { + workflow: WorkflowContent; + whyExplanations: Record; + attempts: AttemptLog[]; +} + +@Injectable({ providedIn: "root" }) +export class WorkflowGeneratorService { + private readonly http = inject(HttpClient); + private readonly operatorMetadataService = inject(OperatorMetadataService); + private readonly validator = inject(WorkflowValidatorService); + + private operatorCatalog: OperatorMetadata | null = null; + private readonly chatUrl = `${AppSettings.getApiEndpoint()}/chat/completions`; + + constructor() { + // Cache the operator catalog once; refresh subscriptions still fire on changes. + this.operatorMetadataService.getOperatorMetadata().subscribe(md => { + this.operatorCatalog = md; + }); + } + + public async generate(state: WizardState, model: string = DEFAULT_MODEL): Promise { + const prompt = buildGeneratePrompt(state, this.operatorCatalog); + return this.runWithRetry(prompt, model); + } + + public async modify( + current: WorkflowContent, + currentWhy: Record, + instruction: string, + dataProfile: DataProfile | undefined, + model: string = DEFAULT_MODEL + ): Promise { + const prompt = buildModifyPrompt(current, currentWhy, instruction, this.operatorCatalog, dataProfile); + return this.runWithRetry(prompt, model); + } + + private async runWithRetry(basePrompt: string, model: string): Promise { + const attempts: AttemptLog[] = []; + let lastParse: { workflow: WorkflowContent; whyExplanations: Record } | null = null; + let lastValidation: ValidationResult | null = null; + + for (let i = 1; i <= MAX_ATTEMPTS; i++) { + const prompt = i === 1 ? basePrompt : buildRetryPrompt(basePrompt, lastParse!.workflow, lastValidation!); + + let responseText: string; + try { + const body = { + model, + messages: [ + { + role: "system", + content: + "You are a Texera workflow generation expert. Your response MUST be a single raw JSON object — no markdown fences, no commentary before or after. Start with { and end with }. " + + "For every operator's operatorProperties, fill ALL required keys AND every required sub-key inside arrays/objects. Empty arrays, empty strings, and missing fields are NOT acceptable. " + + "When a property has an enum (shown as 'enum: a | b | c' in the catalog), use one of those exact values. " + + "When a property is a column name (e.g. Aggregate.aggregations[].attribute, Filter.predicates[].attribute, Aggregate.groupByKeys, BarChart axes, sklearn target columns, TablesPlot.'add attribute'[].attributeName), use a real column name from the Data Profile — match capitalization exactly. " + + "NEVER use 'dummyProperty', 'dummyValue', or 'dummyPropertyList' — these are Texera scaffolding placeholders and have NO place in a real workflow. Always use the real property names shown in the operator catalog and few-shot examples (e.g. 'aggregations' / 'predicates' / 'add attribute', not 'dummyPropertyList').", + }, + { role: "user", content: prompt }, + ], + max_tokens: 8192, + temperature: 0.2, + }; + const resp = await firstValueFrom(this.http.post(this.chatUrl, body)); + responseText = resp?.choices?.[0]?.message?.content?.trim() ?? ""; + } catch (httpErr: any) { + const status = httpErr?.status; + const detail = httpErr?.error + ? typeof httpErr.error === "string" + ? httpErr.error + : JSON.stringify(httpErr.error) + : httpErr?.message; + if (status === 401 || status === 403) { + throw new Error( + `LLM call failed: HTTP ${status}. You must be logged in to Texera, and copilotEnabled must be true.` + ); + } + throw new Error(`LLM call failed: HTTP ${status ?? "unknown"} — ${detail ?? "no detail"}`); + } + if (!responseText) { + throw new Error(`LLM returned an empty response from ${this.chatUrl}. Check backend logs.`); + } + // Sanitize: drop Texera's dummyPropertyList placeholders if the LLM + // ignored the instructions. Done client-side so a generation is rescued + // even if the model emits them; the next attempt won't fight us. + responseText = stripDummyProperties(responseText); + const parsed = this.extractWorkflowJson(responseText); + const validation = this.validator.validate(parsed.workflow, this.operatorCatalog); + + attempts.push({ + attempt: i, + errorCount: validation.errors.length, + errors: validation.errors.map(e => `${e.field}: ${e.message}`), + }); + + if (validation.isValid) { + return { ...parsed, attempts }; + } + lastParse = parsed; + lastValidation = validation; + } + + const final = lastValidation!.errors.map(e => `${e.field}: ${e.message}`).join("\n"); + const err = new Error(`Workflow failed validation after ${MAX_ATTEMPTS} attempts:\n${final}`); + (err as any).attempts = attempts; + throw err; + } + + private extractWorkflowJson(text: string): { + workflow: WorkflowContent; + whyExplanations: Record; + } { + const json = this.tryExtractJsonString(text); + let parsed: any; + try { + parsed = JSON.parse(json); + } catch (e) { + console.error("Failed to parse LLM response as JSON.", { error: e, rawResponse: text, attempted: json }); + const preview = text.length > 400 ? `${text.slice(0, 400)}…` : text; + throw new Error( + `Failed to parse workflow JSON from LLM response. ` + + `The LLM returned text that wasn't valid JSON. First 400 chars: ${preview}` + ); + } + + const whyExplanations: Record = + parsed.whyExplanations && typeof parsed.whyExplanations === "object" ? parsed.whyExplanations : {}; + const { whyExplanations: _drop, ...workflowOnly } = parsed; + return { workflow: workflowOnly as WorkflowContent, whyExplanations }; + } + + /** + * Pull the JSON block out of an LLM response that may include markdown + * fences, prose before/after, or a "Here is the workflow:" preamble. + * Falls back to the original text if no clear block boundaries are found. + */ + // (stripDummyProperties is a free function, see end of file) + private tryExtractJsonString(raw: string): string { + let s = raw.trim(); + // Strip markdown fences if present. + const fenceMatch = s.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) s = fenceMatch[1].trim(); + // Slice from first { to matching last } (handles trailing prose). + const first = s.indexOf("{"); + const last = s.lastIndexOf("}"); + if (first >= 0 && last > first) { + return s.slice(first, last + 1); + } + return s; + } +} + +/** + * Strip Texera-scaffolding "dummy*" property keys from an LLM-generated workflow + * JSON string. Done as a post-processing safety net so the user never sees a + * generated Aggregate / TablesPlot filled with dummyProperty / dummyValue. + */ +function stripDummyProperties(rawJson: string): string { + try { + const obj = JSON.parse(rawJson); + walk(obj); + return JSON.stringify(obj); + } catch { + return rawJson; + } +} + +function walk(node: any): void { + if (Array.isArray(node)) { + node.forEach(walk); + return; + } + if (node && typeof node === "object") { + for (const key of Object.keys(node)) { + if (/^dummy/i.test(key)) { + delete node[key]; + continue; + } + walk(node[key]); + } + } +} diff --git a/frontend/src/app/workspace/service/ai-wizard/workflow-validator.service.ts b/frontend/src/app/workspace/service/ai-wizard/workflow-validator.service.ts new file mode 100644 index 00000000000..617401dc869 --- /dev/null +++ b/frontend/src/app/workspace/service/ai-wizard/workflow-validator.service.ts @@ -0,0 +1,219 @@ +/** + * Workflow validator. Ported from TexeraHackathon but uses Texera's live + * OperatorMetadata for the operator catalog (design-doc §4.2: "operator + * catalog 不允许手工维护 JSON"). + */ + +import { Injectable } from "@angular/core"; +import { WorkflowContent } from "../../../common/type/workflow"; +import { OperatorLink, OperatorPredicate } from "../../types/workflow-common.interface"; +import { OperatorMetadata } from "../../types/operator-schema.interface"; +import { ValidationError, ValidationResult } from "./types"; + +@Injectable({ providedIn: "root" }) +export class WorkflowValidatorService { + public validate(workflow: WorkflowContent, catalog: OperatorMetadata | null): ValidationResult { + const errors: ValidationError[] = []; + const warnings: string[] = []; + + if (!workflow.operators || !Array.isArray(workflow.operators)) { + errors.push({ field: "operators", message: "Workflow must contain an operators array" }); + return { isValid: false, errors, warnings }; + } + if (!workflow.links || !Array.isArray(workflow.links)) { + errors.push({ field: "links", message: "Workflow must contain a links array" }); + } + if (!workflow.operatorPositions || typeof workflow.operatorPositions !== "object") { + errors.push({ field: "operatorPositions", message: "Workflow must contain operatorPositions object" }); + } + if (!workflow.settings) { + warnings.push("Missing workflow settings - using defaults"); + } + + const validTypes = new Set(catalog?.operators.map(op => op.operatorType) ?? []); + const operatorIds = new Set(); + + workflow.operators.forEach((op, i) => { + errors.push(...this.validateOperator(op, i, validTypes, catalog)); + if (op.operatorID) { + if (operatorIds.has(op.operatorID)) { + errors.push({ + field: `operators[${i}].operatorID`, + message: `Duplicate operator ID: ${op.operatorID}`, + }); + } + operatorIds.add(op.operatorID); + } + }); + + if (workflow.operatorPositions) { + for (const opId of operatorIds) { + const pos = workflow.operatorPositions[opId]; + if (!pos) { + errors.push({ field: "operatorPositions", message: `Missing position for operator: ${opId}` }); + } else if (typeof pos.x !== "number" || typeof pos.y !== "number") { + errors.push({ + field: `operatorPositions[${opId}]`, + message: "Position must have numeric x and y coordinates", + }); + } + } + } + + const linkIds = new Set(); + (workflow.links ?? []).forEach((link, i) => { + errors.push(...this.validateLink(link, i, operatorIds, workflow.operators)); + if (link.linkID) { + if (linkIds.has(link.linkID)) { + errors.push({ field: `links[${i}].linkID`, message: `Duplicate link ID: ${link.linkID}` }); + } + linkIds.add(link.linkID); + } + }); + + warnings.push(...this.validateConnectivity(workflow.operators, workflow.links ?? [], catalog)); + + return { + isValid: errors.length === 0, + errors, + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + + private validateOperator( + op: OperatorPredicate, + i: number, + validTypes: Set, + catalog: OperatorMetadata | null + ): ValidationError[] { + const errors: ValidationError[] = []; + if (!op.operatorID) { + errors.push({ field: `operators[${i}].operatorID`, message: "Operator must have an operatorID" }); + } + if (!op.operatorType) { + errors.push({ field: `operators[${i}].operatorType`, message: "Operator must have an operatorType" }); + } else if (catalog && !validTypes.has(op.operatorType)) { + errors.push({ + field: `operators[${i}].operatorType`, + message: `Unknown operator type: ${op.operatorType}. Must be one of the registered operators.`, + }); + } + if (!op.operatorProperties || typeof op.operatorProperties !== "object") { + errors.push({ + field: `operators[${i}].operatorProperties`, + message: "Operator must have operatorProperties object", + }); + } + if (!Array.isArray(op.inputPorts)) { + errors.push({ field: `operators[${i}].inputPorts`, message: "Operator must have inputPorts array" }); + } + if (!Array.isArray(op.outputPorts)) { + errors.push({ field: `operators[${i}].outputPorts`, message: "Operator must have outputPorts array" }); + } + + // Required-property check using the live operator schema. + if (op.operatorType && catalog) { + const schema = catalog.operators.find(s => s.operatorType === op.operatorType); + const required: string[] = Array.isArray((schema?.jsonSchema as any)?.required) + ? ((schema?.jsonSchema as any).required as string[]) + : []; + for (const prop of required) { + if (op.operatorProperties && !(prop in op.operatorProperties)) { + errors.push({ + field: `operators[${i}].operatorProperties.${prop}`, + message: `Missing required property: ${prop}`, + }); + } + } + } + return errors; + } + + private validateLink( + link: OperatorLink, + i: number, + operatorIds: Set, + operators: readonly OperatorPredicate[] + ): ValidationError[] { + const errors: ValidationError[] = []; + if (!link.linkID) errors.push({ field: `links[${i}].linkID`, message: "Link must have a linkID" }); + + if (!link.source?.operatorID || !link.source?.portID) { + errors.push({ + field: `links[${i}].source`, + message: "Link must have valid source with operatorID and portID", + }); + } else { + if (!operatorIds.has(link.source.operatorID)) { + errors.push({ + field: `links[${i}].source.operatorID`, + message: `Source operator not found: ${link.source.operatorID}`, + }); + } else { + const sourceOp = operators.find(o => o.operatorID === link.source.operatorID); + if (sourceOp && !sourceOp.outputPorts.some(p => p.portID === link.source.portID)) { + errors.push({ + field: `links[${i}].source.portID`, + message: `Source port ${link.source.portID} not found in operator ${link.source.operatorID}`, + }); + } + } + } + + if (!link.target?.operatorID || !link.target?.portID) { + errors.push({ + field: `links[${i}].target`, + message: "Link must have valid target with operatorID and portID", + }); + } else { + if (!operatorIds.has(link.target.operatorID)) { + errors.push({ + field: `links[${i}].target.operatorID`, + message: `Target operator not found: ${link.target.operatorID}`, + }); + } else { + const targetOp = operators.find(o => o.operatorID === link.target.operatorID); + if (targetOp && !targetOp.inputPorts.some(p => p.portID === link.target.portID)) { + errors.push({ + field: `links[${i}].target.portID`, + message: `Target port ${link.target.portID} not found in operator ${link.target.operatorID}`, + }); + } + } + } + return errors; + } + + private validateConnectivity( + operators: readonly OperatorPredicate[], + links: readonly OperatorLink[], + catalog: OperatorMetadata | null + ): string[] { + const warnings: string[] = []; + const incoming = new Map(); + const outgoing = new Map(); + for (const link of links) { + outgoing.set(link.source.operatorID, (outgoing.get(link.source.operatorID) ?? 0) + 1); + incoming.set(link.target.operatorID, (incoming.get(link.target.operatorID) ?? 0) + 1); + } + for (const op of operators) { + const schema = catalog?.operators.find(s => s.operatorType === op.operatorType); + const inputCount = schema?.additionalMetadata.inputPorts.length ?? op.inputPorts.length; + const outputCount = schema?.additionalMetadata.outputPorts.length ?? op.outputPorts.length; + const category = schema?.additionalMetadata.operatorGroupName ?? ""; + const hasIncoming = incoming.has(op.operatorID); + const hasOutgoing = outgoing.has(op.operatorID); + + if (inputCount === 0 && hasIncoming) { + warnings.push(`Source operator ${op.operatorID} has incoming links`); + } + if (inputCount > 0 && !hasIncoming) { + warnings.push(`Operator ${op.operatorID} has no incoming links`); + } + if (outputCount > 0 && !hasOutgoing && !category.toLowerCase().includes("visualization")) { + warnings.push(`Operator ${op.operatorID} has no outgoing links`); + } + } + return warnings; + } +} diff --git a/frontend/src/assets/mock-data/dknet-diabetes.csv b/frontend/src/assets/mock-data/dknet-diabetes.csv new file mode 100644 index 00000000000..dcaf5fe82ee --- /dev/null +++ b/frontend/src/assets/mock-data/dknet-diabetes.csv @@ -0,0 +1,769 @@ +Pregnancies,Glucose,BloodPressure,SkinThickness,Insulin,BMI,DiabetesPedigreeFunction,Age,Outcome +6,148,72,35,0,33.6,0.627,50,1 +1,85,66,29,0,26.6,0.351,31,0 +8,183,64,0,0,23.3,0.672,32,1 +1,89,66,23,94,28.1,0.167,21,0 +0,137,40,35,168,43.1,2.288,33,1 +5,116,74,0,0,25.6,0.201,30,0 +3,78,50,32,88,31,0.248,26,1 +10,115,0,0,0,35.3,0.134,29,0 +2,197,70,45,543,30.5,0.158,53,1 +8,125,96,0,0,0,0.232,54,1 +4,110,92,0,0,37.6,0.191,30,0 +10,168,74,0,0,38,0.537,34,1 +10,139,80,0,0,27.1,1.441,57,0 +1,189,60,23,846,30.1,0.398,59,1 +5,166,72,19,175,25.8,0.587,51,1 +7,100,0,0,0,30,0.484,32,1 +0,118,84,47,230,45.8,0.551,31,1 +7,107,74,0,0,29.6,0.254,31,1 +1,103,30,38,83,43.3,0.183,33,0 +1,115,70,30,96,34.6,0.529,32,1 +3,126,88,41,235,39.3,0.704,27,0 +8,99,84,0,0,35.4,0.388,50,0 +7,196,90,0,0,39.8,0.451,41,1 +9,119,80,35,0,29,0.263,29,1 +11,143,94,33,146,36.6,0.254,51,1 +10,125,70,26,115,31.1,0.205,41,1 +7,147,76,0,0,39.4,0.257,43,1 +1,97,66,15,140,23.2,0.487,22,0 +13,145,82,19,110,22.2,0.245,57,0 +5,117,92,0,0,34.1,0.337,38,0 +5,109,75,26,0,36,0.546,60,0 +3,158,76,36,245,31.6,0.851,28,1 +3,88,58,11,54,24.8,0.267,22,0 +6,92,92,0,0,19.9,0.188,28,0 +10,122,78,31,0,27.6,0.512,45,0 +4,103,60,33,192,24,0.966,33,0 +11,138,76,0,0,33.2,0.42,35,0 +9,102,76,37,0,32.9,0.665,46,1 +2,90,68,42,0,38.2,0.503,27,1 +4,111,72,47,207,37.1,1.39,56,1 +3,180,64,25,70,34,0.271,26,0 +7,133,84,0,0,40.2,0.696,37,0 +7,106,92,18,0,22.7,0.235,48,0 +9,171,110,24,240,45.4,0.721,54,1 +7,159,64,0,0,27.4,0.294,40,0 +0,180,66,39,0,42,1.893,25,1 +1,146,56,0,0,29.7,0.564,29,0 +2,71,70,27,0,28,0.586,22,0 +7,103,66,32,0,39.1,0.344,31,1 +7,105,0,0,0,0,0.305,24,0 +1,103,80,11,82,19.4,0.491,22,0 +1,101,50,15,36,24.2,0.526,26,0 +5,88,66,21,23,24.4,0.342,30,0 +8,176,90,34,300,33.7,0.467,58,1 +7,150,66,42,342,34.7,0.718,42,0 +1,73,50,10,0,23,0.248,21,0 +7,187,68,39,304,37.7,0.254,41,1 +0,100,88,60,110,46.8,0.962,31,0 +0,146,82,0,0,40.5,1.781,44,0 +0,105,64,41,142,41.5,0.173,22,0 +2,84,0,0,0,0,0.304,21,0 +8,133,72,0,0,32.9,0.27,39,1 +5,44,62,0,0,25,0.587,36,0 +2,141,58,34,128,25.4,0.699,24,0 +7,114,66,0,0,32.8,0.258,42,1 +5,99,74,27,0,29,0.203,32,0 +0,109,88,30,0,32.5,0.855,38,1 +2,109,92,0,0,42.7,0.845,54,0 +1,95,66,13,38,19.6,0.334,25,0 +4,146,85,27,100,28.9,0.189,27,0 +2,100,66,20,90,32.9,0.867,28,1 +5,139,64,35,140,28.6,0.411,26,0 +13,126,90,0,0,43.4,0.583,42,1 +4,129,86,20,270,35.1,0.231,23,0 +1,79,75,30,0,32,0.396,22,0 +1,0,48,20,0,24.7,0.14,22,0 +7,62,78,0,0,32.6,0.391,41,0 +5,95,72,33,0,37.7,0.37,27,0 +0,131,0,0,0,43.2,0.27,26,1 +2,112,66,22,0,25,0.307,24,0 +3,113,44,13,0,22.4,0.14,22,0 +2,74,0,0,0,0,0.102,22,0 +7,83,78,26,71,29.3,0.767,36,0 +0,101,65,28,0,24.6,0.237,22,0 +5,137,108,0,0,48.8,0.227,37,1 +2,110,74,29,125,32.4,0.698,27,0 +13,106,72,54,0,36.6,0.178,45,0 +2,100,68,25,71,38.5,0.324,26,0 +15,136,70,32,110,37.1,0.153,43,1 +1,107,68,19,0,26.5,0.165,24,0 +1,80,55,0,0,19.1,0.258,21,0 +4,123,80,15,176,32,0.443,34,0 +7,81,78,40,48,46.7,0.261,42,0 +4,134,72,0,0,23.8,0.277,60,1 +2,142,82,18,64,24.7,0.761,21,0 +6,144,72,27,228,33.9,0.255,40,0 +2,92,62,28,0,31.6,0.13,24,0 +1,71,48,18,76,20.4,0.323,22,0 +6,93,50,30,64,28.7,0.356,23,0 +1,122,90,51,220,49.7,0.325,31,1 +1,163,72,0,0,39,1.222,33,1 +1,151,60,0,0,26.1,0.179,22,0 +0,125,96,0,0,22.5,0.262,21,0 +1,81,72,18,40,26.6,0.283,24,0 +2,85,65,0,0,39.6,0.93,27,0 +1,126,56,29,152,28.7,0.801,21,0 +1,96,122,0,0,22.4,0.207,27,0 +4,144,58,28,140,29.5,0.287,37,0 +3,83,58,31,18,34.3,0.336,25,0 +0,95,85,25,36,37.4,0.247,24,1 +3,171,72,33,135,33.3,0.199,24,1 +8,155,62,26,495,34,0.543,46,1 +1,89,76,34,37,31.2,0.192,23,0 +4,76,62,0,0,34,0.391,25,0 +7,160,54,32,175,30.5,0.588,39,1 +4,146,92,0,0,31.2,0.539,61,1 +5,124,74,0,0,34,0.22,38,1 +5,78,48,0,0,33.7,0.654,25,0 +4,97,60,23,0,28.2,0.443,22,0 +4,99,76,15,51,23.2,0.223,21,0 +0,162,76,56,100,53.2,0.759,25,1 +6,111,64,39,0,34.2,0.26,24,0 +2,107,74,30,100,33.6,0.404,23,0 +5,132,80,0,0,26.8,0.186,69,0 +0,113,76,0,0,33.3,0.278,23,1 +1,88,30,42,99,55,0.496,26,1 +3,120,70,30,135,42.9,0.452,30,0 +1,118,58,36,94,33.3,0.261,23,0 +1,117,88,24,145,34.5,0.403,40,1 +0,105,84,0,0,27.9,0.741,62,1 +4,173,70,14,168,29.7,0.361,33,1 +9,122,56,0,0,33.3,1.114,33,1 +3,170,64,37,225,34.5,0.356,30,1 +8,84,74,31,0,38.3,0.457,39,0 +2,96,68,13,49,21.1,0.647,26,0 +2,125,60,20,140,33.8,0.088,31,0 +0,100,70,26,50,30.8,0.597,21,0 +0,93,60,25,92,28.7,0.532,22,0 +0,129,80,0,0,31.2,0.703,29,0 +5,105,72,29,325,36.9,0.159,28,0 +3,128,78,0,0,21.1,0.268,55,0 +5,106,82,30,0,39.5,0.286,38,0 +2,108,52,26,63,32.5,0.318,22,0 +10,108,66,0,0,32.4,0.272,42,1 +4,154,62,31,284,32.8,0.237,23,0 +0,102,75,23,0,0,0.572,21,0 +9,57,80,37,0,32.8,0.096,41,0 +2,106,64,35,119,30.5,1.4,34,0 +5,147,78,0,0,33.7,0.218,65,0 +2,90,70,17,0,27.3,0.085,22,0 +1,136,74,50,204,37.4,0.399,24,0 +4,114,65,0,0,21.9,0.432,37,0 +9,156,86,28,155,34.3,1.189,42,1 +1,153,82,42,485,40.6,0.687,23,0 +8,188,78,0,0,47.9,0.137,43,1 +7,152,88,44,0,50,0.337,36,1 +2,99,52,15,94,24.6,0.637,21,0 +1,109,56,21,135,25.2,0.833,23,0 +2,88,74,19,53,29,0.229,22,0 +17,163,72,41,114,40.9,0.817,47,1 +4,151,90,38,0,29.7,0.294,36,0 +7,102,74,40,105,37.2,0.204,45,0 +0,114,80,34,285,44.2,0.167,27,0 +2,100,64,23,0,29.7,0.368,21,0 +0,131,88,0,0,31.6,0.743,32,1 +6,104,74,18,156,29.9,0.722,41,1 +3,148,66,25,0,32.5,0.256,22,0 +4,120,68,0,0,29.6,0.709,34,0 +4,110,66,0,0,31.9,0.471,29,0 +3,111,90,12,78,28.4,0.495,29,0 +6,102,82,0,0,30.8,0.18,36,1 +6,134,70,23,130,35.4,0.542,29,1 +2,87,0,23,0,28.9,0.773,25,0 +1,79,60,42,48,43.5,0.678,23,0 +2,75,64,24,55,29.7,0.37,33,0 +8,179,72,42,130,32.7,0.719,36,1 +6,85,78,0,0,31.2,0.382,42,0 +0,129,110,46,130,67.1,0.319,26,1 +5,143,78,0,0,45,0.19,47,0 +5,130,82,0,0,39.1,0.956,37,1 +6,87,80,0,0,23.2,0.084,32,0 +0,119,64,18,92,34.9,0.725,23,0 +1,0,74,20,23,27.7,0.299,21,0 +5,73,60,0,0,26.8,0.268,27,0 +4,141,74,0,0,27.6,0.244,40,0 +7,194,68,28,0,35.9,0.745,41,1 +8,181,68,36,495,30.1,0.615,60,1 +1,128,98,41,58,32,1.321,33,1 +8,109,76,39,114,27.9,0.64,31,1 +5,139,80,35,160,31.6,0.361,25,1 +3,111,62,0,0,22.6,0.142,21,0 +9,123,70,44,94,33.1,0.374,40,0 +7,159,66,0,0,30.4,0.383,36,1 +11,135,0,0,0,52.3,0.578,40,1 +8,85,55,20,0,24.4,0.136,42,0 +5,158,84,41,210,39.4,0.395,29,1 +1,105,58,0,0,24.3,0.187,21,0 +3,107,62,13,48,22.9,0.678,23,1 +4,109,64,44,99,34.8,0.905,26,1 +4,148,60,27,318,30.9,0.15,29,1 +0,113,80,16,0,31,0.874,21,0 +1,138,82,0,0,40.1,0.236,28,0 +0,108,68,20,0,27.3,0.787,32,0 +2,99,70,16,44,20.4,0.235,27,0 +6,103,72,32,190,37.7,0.324,55,0 +5,111,72,28,0,23.9,0.407,27,0 +8,196,76,29,280,37.5,0.605,57,1 +5,162,104,0,0,37.7,0.151,52,1 +1,96,64,27,87,33.2,0.289,21,0 +7,184,84,33,0,35.5,0.355,41,1 +2,81,60,22,0,27.7,0.29,25,0 +0,147,85,54,0,42.8,0.375,24,0 +7,179,95,31,0,34.2,0.164,60,0 +0,140,65,26,130,42.6,0.431,24,1 +9,112,82,32,175,34.2,0.26,36,1 +12,151,70,40,271,41.8,0.742,38,1 +5,109,62,41,129,35.8,0.514,25,1 +6,125,68,30,120,30,0.464,32,0 +5,85,74,22,0,29,1.224,32,1 +5,112,66,0,0,37.8,0.261,41,1 +0,177,60,29,478,34.6,1.072,21,1 +2,158,90,0,0,31.6,0.805,66,1 +7,119,0,0,0,25.2,0.209,37,0 +7,142,60,33,190,28.8,0.687,61,0 +1,100,66,15,56,23.6,0.666,26,0 +1,87,78,27,32,34.6,0.101,22,0 +0,101,76,0,0,35.7,0.198,26,0 +3,162,52,38,0,37.2,0.652,24,1 +4,197,70,39,744,36.7,2.329,31,0 +0,117,80,31,53,45.2,0.089,24,0 +4,142,86,0,0,44,0.645,22,1 +6,134,80,37,370,46.2,0.238,46,1 +1,79,80,25,37,25.4,0.583,22,0 +4,122,68,0,0,35,0.394,29,0 +3,74,68,28,45,29.7,0.293,23,0 +4,171,72,0,0,43.6,0.479,26,1 +7,181,84,21,192,35.9,0.586,51,1 +0,179,90,27,0,44.1,0.686,23,1 +9,164,84,21,0,30.8,0.831,32,1 +0,104,76,0,0,18.4,0.582,27,0 +1,91,64,24,0,29.2,0.192,21,0 +4,91,70,32,88,33.1,0.446,22,0 +3,139,54,0,0,25.6,0.402,22,1 +6,119,50,22,176,27.1,1.318,33,1 +2,146,76,35,194,38.2,0.329,29,0 +9,184,85,15,0,30,1.213,49,1 +10,122,68,0,0,31.2,0.258,41,0 +0,165,90,33,680,52.3,0.427,23,0 +9,124,70,33,402,35.4,0.282,34,0 +1,111,86,19,0,30.1,0.143,23,0 +9,106,52,0,0,31.2,0.38,42,0 +2,129,84,0,0,28,0.284,27,0 +2,90,80,14,55,24.4,0.249,24,0 +0,86,68,32,0,35.8,0.238,25,0 +12,92,62,7,258,27.6,0.926,44,1 +1,113,64,35,0,33.6,0.543,21,1 +3,111,56,39,0,30.1,0.557,30,0 +2,114,68,22,0,28.7,0.092,25,0 +1,193,50,16,375,25.9,0.655,24,0 +11,155,76,28,150,33.3,1.353,51,1 +3,191,68,15,130,30.9,0.299,34,0 +3,141,0,0,0,30,0.761,27,1 +4,95,70,32,0,32.1,0.612,24,0 +3,142,80,15,0,32.4,0.2,63,0 +4,123,62,0,0,32,0.226,35,1 +5,96,74,18,67,33.6,0.997,43,0 +0,138,0,0,0,36.3,0.933,25,1 +2,128,64,42,0,40,1.101,24,0 +0,102,52,0,0,25.1,0.078,21,0 +2,146,0,0,0,27.5,0.24,28,1 +10,101,86,37,0,45.6,1.136,38,1 +2,108,62,32,56,25.2,0.128,21,0 +3,122,78,0,0,23,0.254,40,0 +1,71,78,50,45,33.2,0.422,21,0 +13,106,70,0,0,34.2,0.251,52,0 +2,100,70,52,57,40.5,0.677,25,0 +7,106,60,24,0,26.5,0.296,29,1 +0,104,64,23,116,27.8,0.454,23,0 +5,114,74,0,0,24.9,0.744,57,0 +2,108,62,10,278,25.3,0.881,22,0 +0,146,70,0,0,37.9,0.334,28,1 +10,129,76,28,122,35.9,0.28,39,0 +7,133,88,15,155,32.4,0.262,37,0 +7,161,86,0,0,30.4,0.165,47,1 +2,108,80,0,0,27,0.259,52,1 +7,136,74,26,135,26,0.647,51,0 +5,155,84,44,545,38.7,0.619,34,0 +1,119,86,39,220,45.6,0.808,29,1 +4,96,56,17,49,20.8,0.34,26,0 +5,108,72,43,75,36.1,0.263,33,0 +0,78,88,29,40,36.9,0.434,21,0 +0,107,62,30,74,36.6,0.757,25,1 +2,128,78,37,182,43.3,1.224,31,1 +1,128,48,45,194,40.5,0.613,24,1 +0,161,50,0,0,21.9,0.254,65,0 +6,151,62,31,120,35.5,0.692,28,0 +2,146,70,38,360,28,0.337,29,1 +0,126,84,29,215,30.7,0.52,24,0 +14,100,78,25,184,36.6,0.412,46,1 +8,112,72,0,0,23.6,0.84,58,0 +0,167,0,0,0,32.3,0.839,30,1 +2,144,58,33,135,31.6,0.422,25,1 +5,77,82,41,42,35.8,0.156,35,0 +5,115,98,0,0,52.9,0.209,28,1 +3,150,76,0,0,21,0.207,37,0 +2,120,76,37,105,39.7,0.215,29,0 +10,161,68,23,132,25.5,0.326,47,1 +0,137,68,14,148,24.8,0.143,21,0 +0,128,68,19,180,30.5,1.391,25,1 +2,124,68,28,205,32.9,0.875,30,1 +6,80,66,30,0,26.2,0.313,41,0 +0,106,70,37,148,39.4,0.605,22,0 +2,155,74,17,96,26.6,0.433,27,1 +3,113,50,10,85,29.5,0.626,25,0 +7,109,80,31,0,35.9,1.127,43,1 +2,112,68,22,94,34.1,0.315,26,0 +3,99,80,11,64,19.3,0.284,30,0 +3,182,74,0,0,30.5,0.345,29,1 +3,115,66,39,140,38.1,0.15,28,0 +6,194,78,0,0,23.5,0.129,59,1 +4,129,60,12,231,27.5,0.527,31,0 +3,112,74,30,0,31.6,0.197,25,1 +0,124,70,20,0,27.4,0.254,36,1 +13,152,90,33,29,26.8,0.731,43,1 +2,112,75,32,0,35.7,0.148,21,0 +1,157,72,21,168,25.6,0.123,24,0 +1,122,64,32,156,35.1,0.692,30,1 +10,179,70,0,0,35.1,0.2,37,0 +2,102,86,36,120,45.5,0.127,23,1 +6,105,70,32,68,30.8,0.122,37,0 +8,118,72,19,0,23.1,1.476,46,0 +2,87,58,16,52,32.7,0.166,25,0 +1,180,0,0,0,43.3,0.282,41,1 +12,106,80,0,0,23.6,0.137,44,0 +1,95,60,18,58,23.9,0.26,22,0 +0,165,76,43,255,47.9,0.259,26,0 +0,117,0,0,0,33.8,0.932,44,0 +5,115,76,0,0,31.2,0.343,44,1 +9,152,78,34,171,34.2,0.893,33,1 +7,178,84,0,0,39.9,0.331,41,1 +1,130,70,13,105,25.9,0.472,22,0 +1,95,74,21,73,25.9,0.673,36,0 +1,0,68,35,0,32,0.389,22,0 +5,122,86,0,0,34.7,0.29,33,0 +8,95,72,0,0,36.8,0.485,57,0 +8,126,88,36,108,38.5,0.349,49,0 +1,139,46,19,83,28.7,0.654,22,0 +3,116,0,0,0,23.5,0.187,23,0 +3,99,62,19,74,21.8,0.279,26,0 +5,0,80,32,0,41,0.346,37,1 +4,92,80,0,0,42.2,0.237,29,0 +4,137,84,0,0,31.2,0.252,30,0 +3,61,82,28,0,34.4,0.243,46,0 +1,90,62,12,43,27.2,0.58,24,0 +3,90,78,0,0,42.7,0.559,21,0 +9,165,88,0,0,30.4,0.302,49,1 +1,125,50,40,167,33.3,0.962,28,1 +13,129,0,30,0,39.9,0.569,44,1 +12,88,74,40,54,35.3,0.378,48,0 +1,196,76,36,249,36.5,0.875,29,1 +5,189,64,33,325,31.2,0.583,29,1 +5,158,70,0,0,29.8,0.207,63,0 +5,103,108,37,0,39.2,0.305,65,0 +4,146,78,0,0,38.5,0.52,67,1 +4,147,74,25,293,34.9,0.385,30,0 +5,99,54,28,83,34,0.499,30,0 +6,124,72,0,0,27.6,0.368,29,1 +0,101,64,17,0,21,0.252,21,0 +3,81,86,16,66,27.5,0.306,22,0 +1,133,102,28,140,32.8,0.234,45,1 +3,173,82,48,465,38.4,2.137,25,1 +0,118,64,23,89,0,1.731,21,0 +0,84,64,22,66,35.8,0.545,21,0 +2,105,58,40,94,34.9,0.225,25,0 +2,122,52,43,158,36.2,0.816,28,0 +12,140,82,43,325,39.2,0.528,58,1 +0,98,82,15,84,25.2,0.299,22,0 +1,87,60,37,75,37.2,0.509,22,0 +4,156,75,0,0,48.3,0.238,32,1 +0,93,100,39,72,43.4,1.021,35,0 +1,107,72,30,82,30.8,0.821,24,0 +0,105,68,22,0,20,0.236,22,0 +1,109,60,8,182,25.4,0.947,21,0 +1,90,62,18,59,25.1,1.268,25,0 +1,125,70,24,110,24.3,0.221,25,0 +1,119,54,13,50,22.3,0.205,24,0 +5,116,74,29,0,32.3,0.66,35,1 +8,105,100,36,0,43.3,0.239,45,1 +5,144,82,26,285,32,0.452,58,1 +3,100,68,23,81,31.6,0.949,28,0 +1,100,66,29,196,32,0.444,42,0 +5,166,76,0,0,45.7,0.34,27,1 +1,131,64,14,415,23.7,0.389,21,0 +4,116,72,12,87,22.1,0.463,37,0 +4,158,78,0,0,32.9,0.803,31,1 +2,127,58,24,275,27.7,1.6,25,0 +3,96,56,34,115,24.7,0.944,39,0 +0,131,66,40,0,34.3,0.196,22,1 +3,82,70,0,0,21.1,0.389,25,0 +3,193,70,31,0,34.9,0.241,25,1 +4,95,64,0,0,32,0.161,31,1 +6,137,61,0,0,24.2,0.151,55,0 +5,136,84,41,88,35,0.286,35,1 +9,72,78,25,0,31.6,0.28,38,0 +5,168,64,0,0,32.9,0.135,41,1 +2,123,48,32,165,42.1,0.52,26,0 +4,115,72,0,0,28.9,0.376,46,1 +0,101,62,0,0,21.9,0.336,25,0 +8,197,74,0,0,25.9,1.191,39,1 +1,172,68,49,579,42.4,0.702,28,1 +6,102,90,39,0,35.7,0.674,28,0 +1,112,72,30,176,34.4,0.528,25,0 +1,143,84,23,310,42.4,1.076,22,0 +1,143,74,22,61,26.2,0.256,21,0 +0,138,60,35,167,34.6,0.534,21,1 +3,173,84,33,474,35.7,0.258,22,1 +1,97,68,21,0,27.2,1.095,22,0 +4,144,82,32,0,38.5,0.554,37,1 +1,83,68,0,0,18.2,0.624,27,0 +3,129,64,29,115,26.4,0.219,28,1 +1,119,88,41,170,45.3,0.507,26,0 +2,94,68,18,76,26,0.561,21,0 +0,102,64,46,78,40.6,0.496,21,0 +2,115,64,22,0,30.8,0.421,21,0 +8,151,78,32,210,42.9,0.516,36,1 +4,184,78,39,277,37,0.264,31,1 +0,94,0,0,0,0,0.256,25,0 +1,181,64,30,180,34.1,0.328,38,1 +0,135,94,46,145,40.6,0.284,26,0 +1,95,82,25,180,35,0.233,43,1 +2,99,0,0,0,22.2,0.108,23,0 +3,89,74,16,85,30.4,0.551,38,0 +1,80,74,11,60,30,0.527,22,0 +2,139,75,0,0,25.6,0.167,29,0 +1,90,68,8,0,24.5,1.138,36,0 +0,141,0,0,0,42.4,0.205,29,1 +12,140,85,33,0,37.4,0.244,41,0 +5,147,75,0,0,29.9,0.434,28,0 +1,97,70,15,0,18.2,0.147,21,0 +6,107,88,0,0,36.8,0.727,31,0 +0,189,104,25,0,34.3,0.435,41,1 +2,83,66,23,50,32.2,0.497,22,0 +4,117,64,27,120,33.2,0.23,24,0 +8,108,70,0,0,30.5,0.955,33,1 +4,117,62,12,0,29.7,0.38,30,1 +0,180,78,63,14,59.4,2.42,25,1 +1,100,72,12,70,25.3,0.658,28,0 +0,95,80,45,92,36.5,0.33,26,0 +0,104,64,37,64,33.6,0.51,22,1 +0,120,74,18,63,30.5,0.285,26,0 +1,82,64,13,95,21.2,0.415,23,0 +2,134,70,0,0,28.9,0.542,23,1 +0,91,68,32,210,39.9,0.381,25,0 +2,119,0,0,0,19.6,0.832,72,0 +2,100,54,28,105,37.8,0.498,24,0 +14,175,62,30,0,33.6,0.212,38,1 +1,135,54,0,0,26.7,0.687,62,0 +5,86,68,28,71,30.2,0.364,24,0 +10,148,84,48,237,37.6,1.001,51,1 +9,134,74,33,60,25.9,0.46,81,0 +9,120,72,22,56,20.8,0.733,48,0 +1,71,62,0,0,21.8,0.416,26,0 +8,74,70,40,49,35.3,0.705,39,0 +5,88,78,30,0,27.6,0.258,37,0 +10,115,98,0,0,24,1.022,34,0 +0,124,56,13,105,21.8,0.452,21,0 +0,74,52,10,36,27.8,0.269,22,0 +0,97,64,36,100,36.8,0.6,25,0 +8,120,0,0,0,30,0.183,38,1 +6,154,78,41,140,46.1,0.571,27,0 +1,144,82,40,0,41.3,0.607,28,0 +0,137,70,38,0,33.2,0.17,22,0 +0,119,66,27,0,38.8,0.259,22,0 +7,136,90,0,0,29.9,0.21,50,0 +4,114,64,0,0,28.9,0.126,24,0 +0,137,84,27,0,27.3,0.231,59,0 +2,105,80,45,191,33.7,0.711,29,1 +7,114,76,17,110,23.8,0.466,31,0 +8,126,74,38,75,25.9,0.162,39,0 +4,132,86,31,0,28,0.419,63,0 +3,158,70,30,328,35.5,0.344,35,1 +0,123,88,37,0,35.2,0.197,29,0 +4,85,58,22,49,27.8,0.306,28,0 +0,84,82,31,125,38.2,0.233,23,0 +0,145,0,0,0,44.2,0.63,31,1 +0,135,68,42,250,42.3,0.365,24,1 +1,139,62,41,480,40.7,0.536,21,0 +0,173,78,32,265,46.5,1.159,58,0 +4,99,72,17,0,25.6,0.294,28,0 +8,194,80,0,0,26.1,0.551,67,0 +2,83,65,28,66,36.8,0.629,24,0 +2,89,90,30,0,33.5,0.292,42,0 +4,99,68,38,0,32.8,0.145,33,0 +4,125,70,18,122,28.9,1.144,45,1 +3,80,0,0,0,0,0.174,22,0 +6,166,74,0,0,26.6,0.304,66,0 +5,110,68,0,0,26,0.292,30,0 +2,81,72,15,76,30.1,0.547,25,0 +7,195,70,33,145,25.1,0.163,55,1 +6,154,74,32,193,29.3,0.839,39,0 +2,117,90,19,71,25.2,0.313,21,0 +3,84,72,32,0,37.2,0.267,28,0 +6,0,68,41,0,39,0.727,41,1 +7,94,64,25,79,33.3,0.738,41,0 +3,96,78,39,0,37.3,0.238,40,0 +10,75,82,0,0,33.3,0.263,38,0 +0,180,90,26,90,36.5,0.314,35,1 +1,130,60,23,170,28.6,0.692,21,0 +2,84,50,23,76,30.4,0.968,21,0 +8,120,78,0,0,25,0.409,64,0 +12,84,72,31,0,29.7,0.297,46,1 +0,139,62,17,210,22.1,0.207,21,0 +9,91,68,0,0,24.2,0.2,58,0 +2,91,62,0,0,27.3,0.525,22,0 +3,99,54,19,86,25.6,0.154,24,0 +3,163,70,18,105,31.6,0.268,28,1 +9,145,88,34,165,30.3,0.771,53,1 +7,125,86,0,0,37.6,0.304,51,0 +13,76,60,0,0,32.8,0.18,41,0 +6,129,90,7,326,19.6,0.582,60,0 +2,68,70,32,66,25,0.187,25,0 +3,124,80,33,130,33.2,0.305,26,0 +6,114,0,0,0,0,0.189,26,0 +9,130,70,0,0,34.2,0.652,45,1 +3,125,58,0,0,31.6,0.151,24,0 +3,87,60,18,0,21.8,0.444,21,0 +1,97,64,19,82,18.2,0.299,21,0 +3,116,74,15,105,26.3,0.107,24,0 +0,117,66,31,188,30.8,0.493,22,0 +0,111,65,0,0,24.6,0.66,31,0 +2,122,60,18,106,29.8,0.717,22,0 +0,107,76,0,0,45.3,0.686,24,0 +1,86,66,52,65,41.3,0.917,29,0 +6,91,0,0,0,29.8,0.501,31,0 +1,77,56,30,56,33.3,1.251,24,0 +4,132,0,0,0,32.9,0.302,23,1 +0,105,90,0,0,29.6,0.197,46,0 +0,57,60,0,0,21.7,0.735,67,0 +0,127,80,37,210,36.3,0.804,23,0 +3,129,92,49,155,36.4,0.968,32,1 +8,100,74,40,215,39.4,0.661,43,1 +3,128,72,25,190,32.4,0.549,27,1 +10,90,85,32,0,34.9,0.825,56,1 +4,84,90,23,56,39.5,0.159,25,0 +1,88,78,29,76,32,0.365,29,0 +8,186,90,35,225,34.5,0.423,37,1 +5,187,76,27,207,43.6,1.034,53,1 +4,131,68,21,166,33.1,0.16,28,0 +1,164,82,43,67,32.8,0.341,50,0 +4,189,110,31,0,28.5,0.68,37,0 +1,116,70,28,0,27.4,0.204,21,0 +3,84,68,30,106,31.9,0.591,25,0 +6,114,88,0,0,27.8,0.247,66,0 +1,88,62,24,44,29.9,0.422,23,0 +1,84,64,23,115,36.9,0.471,28,0 +7,124,70,33,215,25.5,0.161,37,0 +1,97,70,40,0,38.1,0.218,30,0 +8,110,76,0,0,27.8,0.237,58,0 +11,103,68,40,0,46.2,0.126,42,0 +11,85,74,0,0,30.1,0.3,35,0 +6,125,76,0,0,33.8,0.121,54,1 +0,198,66,32,274,41.3,0.502,28,1 +1,87,68,34,77,37.6,0.401,24,0 +6,99,60,19,54,26.9,0.497,32,0 +0,91,80,0,0,32.4,0.601,27,0 +2,95,54,14,88,26.1,0.748,22,0 +1,99,72,30,18,38.6,0.412,21,0 +6,92,62,32,126,32,0.085,46,0 +4,154,72,29,126,31.3,0.338,37,0 +0,121,66,30,165,34.3,0.203,33,1 +3,78,70,0,0,32.5,0.27,39,0 +2,130,96,0,0,22.6,0.268,21,0 +3,111,58,31,44,29.5,0.43,22,0 +2,98,60,17,120,34.7,0.198,22,0 +1,143,86,30,330,30.1,0.892,23,0 +1,119,44,47,63,35.5,0.28,25,0 +6,108,44,20,130,24,0.813,35,0 +2,118,80,0,0,42.9,0.693,21,1 +10,133,68,0,0,27,0.245,36,0 +2,197,70,99,0,34.7,0.575,62,1 +0,151,90,46,0,42.1,0.371,21,1 +6,109,60,27,0,25,0.206,27,0 +12,121,78,17,0,26.5,0.259,62,0 +8,100,76,0,0,38.7,0.19,42,0 +8,124,76,24,600,28.7,0.687,52,1 +1,93,56,11,0,22.5,0.417,22,0 +8,143,66,0,0,34.9,0.129,41,1 +6,103,66,0,0,24.3,0.249,29,0 +3,176,86,27,156,33.3,1.154,52,1 +0,73,0,0,0,21.1,0.342,25,0 +11,111,84,40,0,46.8,0.925,45,1 +2,112,78,50,140,39.4,0.175,24,0 +3,132,80,0,0,34.4,0.402,44,1 +2,82,52,22,115,28.5,1.699,25,0 +6,123,72,45,230,33.6,0.733,34,0 +0,188,82,14,185,32,0.682,22,1 +0,67,76,0,0,45.3,0.194,46,0 +1,89,24,19,25,27.8,0.559,21,0 +1,173,74,0,0,36.8,0.088,38,1 +1,109,38,18,120,23.1,0.407,26,0 +1,108,88,19,0,27.1,0.4,24,0 +6,96,0,0,0,23.7,0.19,28,0 +1,124,74,36,0,27.8,0.1,30,0 +7,150,78,29,126,35.2,0.692,54,1 +4,183,0,0,0,28.4,0.212,36,1 +1,124,60,32,0,35.8,0.514,21,0 +1,181,78,42,293,40,1.258,22,1 +1,92,62,25,41,19.5,0.482,25,0 +0,152,82,39,272,41.5,0.27,27,0 +1,111,62,13,182,24,0.138,23,0 +3,106,54,21,158,30.9,0.292,24,0 +3,174,58,22,194,32.9,0.593,36,1 +7,168,88,42,321,38.2,0.787,40,1 +6,105,80,28,0,32.5,0.878,26,0 +11,138,74,26,144,36.1,0.557,50,1 +3,106,72,0,0,25.8,0.207,27,0 +6,117,96,0,0,28.7,0.157,30,0 +2,68,62,13,15,20.1,0.257,23,0 +9,112,82,24,0,28.2,1.282,50,1 +0,119,0,0,0,32.4,0.141,24,1 +2,112,86,42,160,38.4,0.246,28,0 +2,92,76,20,0,24.2,1.698,28,0 +6,183,94,0,0,40.8,1.461,45,0 +0,94,70,27,115,43.5,0.347,21,0 +2,108,64,0,0,30.8,0.158,21,0 +4,90,88,47,54,37.7,0.362,29,0 +0,125,68,0,0,24.7,0.206,21,0 +0,132,78,0,0,32.4,0.393,21,0 +5,128,80,0,0,34.6,0.144,45,0 +4,94,65,22,0,24.7,0.148,21,0 +7,114,64,0,0,27.4,0.732,34,1 +0,102,78,40,90,34.5,0.238,24,0 +2,111,60,0,0,26.2,0.343,23,0 +1,128,82,17,183,27.5,0.115,22,0 +10,92,62,0,0,25.9,0.167,31,0 +13,104,72,0,0,31.2,0.465,38,1 +5,104,74,0,0,28.8,0.153,48,0 +2,94,76,18,66,31.6,0.649,23,0 +7,97,76,32,91,40.9,0.871,32,1 +1,100,74,12,46,19.5,0.149,28,0 +0,102,86,17,105,29.3,0.695,27,0 +4,128,70,0,0,34.3,0.303,24,0 +6,147,80,0,0,29.5,0.178,50,1 +4,90,0,0,0,28,0.61,31,0 +3,103,72,30,152,27.6,0.73,27,0 +2,157,74,35,440,39.4,0.134,30,0 +1,167,74,17,144,23.4,0.447,33,1 +0,179,50,36,159,37.8,0.455,22,1 +11,136,84,35,130,28.3,0.26,42,1 +0,107,60,25,0,26.4,0.133,23,0 +1,91,54,25,100,25.2,0.234,23,0 +1,117,60,23,106,33.8,0.466,27,0 +5,123,74,40,77,34.1,0.269,28,0 +2,120,54,0,0,26.8,0.455,27,0 +1,106,70,28,135,34.2,0.142,22,0 +2,155,52,27,540,38.7,0.24,25,1 +2,101,58,35,90,21.8,0.155,22,0 +1,120,80,48,200,38.9,1.162,41,0 +11,127,106,0,0,39,0.19,51,0 +3,80,82,31,70,34.2,1.292,27,1 +10,162,84,0,0,27.7,0.182,54,0 +1,199,76,43,0,42.9,1.394,22,1 +8,167,106,46,231,37.6,0.165,43,1 +9,145,80,46,130,37.9,0.637,40,1 +6,115,60,39,0,33.7,0.245,40,1 +1,112,80,45,132,34.8,0.217,24,0 +4,145,82,18,0,32.5,0.235,70,1 +10,111,70,27,0,27.5,0.141,40,1 +6,98,58,33,190,34,0.43,43,0 +9,154,78,30,100,30.9,0.164,45,0 +6,165,68,26,168,33.6,0.631,49,0 +1,99,58,10,0,25.4,0.551,21,0 +10,68,106,23,49,35.5,0.285,47,0 +3,123,100,35,240,57.3,0.88,22,0 +8,91,82,0,0,35.6,0.587,68,0 +6,195,70,0,0,30.9,0.328,31,1 +9,156,86,0,0,24.8,0.23,53,1 +0,93,60,0,0,35.3,0.263,25,0 +3,121,52,0,0,36,0.127,25,1 +2,101,58,17,265,24.2,0.614,23,0 +2,56,56,28,45,24.2,0.332,22,0 +0,162,76,36,0,49.6,0.364,26,1 +0,95,64,39,105,44.6,0.366,22,0 +4,125,80,0,0,32.3,0.536,27,1 +5,136,82,0,0,0,0.64,69,0 +2,129,74,26,205,33.2,0.591,25,0 +3,130,64,0,0,23.1,0.314,22,0 +1,107,50,19,0,28.3,0.181,29,0 +1,140,74,26,180,24.1,0.828,23,0 +1,144,82,46,180,46.1,0.335,46,1 +8,107,80,0,0,24.6,0.856,34,0 +13,158,114,0,0,42.3,0.257,44,1 +2,121,70,32,95,39.1,0.886,23,0 +7,129,68,49,125,38.5,0.439,43,1 +2,90,60,0,0,23.5,0.191,25,0 +7,142,90,24,480,30.4,0.128,43,1 +3,169,74,19,125,29.9,0.268,31,1 +0,99,0,0,0,25,0.253,22,0 +4,127,88,11,155,34.5,0.598,28,0 +4,118,70,0,0,44.5,0.904,26,0 +2,122,76,27,200,35.9,0.483,26,0 +6,125,78,31,0,27.6,0.565,49,1 +1,168,88,29,0,35,0.905,52,1 +2,129,0,0,0,38.5,0.304,41,0 +4,110,76,20,100,28.4,0.118,27,0 +6,80,80,36,0,39.8,0.177,28,0 +10,115,0,0,0,0,0.261,30,1 +2,127,46,21,335,34.4,0.176,22,0 +9,164,78,0,0,32.8,0.148,45,1 +2,93,64,32,160,38,0.674,23,1 +3,158,64,13,387,31.2,0.295,24,0 +5,126,78,27,22,29.6,0.439,40,0 +10,129,62,36,0,41.2,0.441,38,1 +0,134,58,20,291,26.4,0.352,21,0 +3,102,74,0,0,29.5,0.121,32,0 +7,187,50,33,392,33.9,0.826,34,1 +3,173,78,39,185,33.8,0.97,31,1 +10,94,72,18,0,23.1,0.595,56,0 +1,108,60,46,178,35.5,0.415,24,0 +5,97,76,27,0,35.6,0.378,52,1 +4,83,86,19,0,29.3,0.317,34,0 +1,114,66,36,200,38.1,0.289,21,0 +1,149,68,29,127,29.3,0.349,42,1 +5,117,86,30,105,39.1,0.251,42,0 +1,111,94,0,0,32.8,0.265,45,0 +4,112,78,40,0,39.4,0.236,38,0 +1,116,78,29,180,36.1,0.496,25,0 +0,141,84,26,0,32.4,0.433,22,0 +2,175,88,0,0,22.9,0.326,22,0 +2,92,52,0,0,30.1,0.141,22,0 +3,130,78,23,79,28.4,0.323,34,1 +8,120,86,0,0,28.4,0.259,22,1 +2,174,88,37,120,44.5,0.646,24,1 +2,106,56,27,165,29,0.426,22,0 +2,105,75,0,0,23.3,0.56,53,0 +4,95,60,32,0,35.4,0.284,28,0 +0,126,86,27,120,27.4,0.515,21,0 +8,65,72,23,0,32,0.6,42,0 +2,99,60,17,160,36.6,0.453,21,0 +1,102,74,0,0,39.5,0.293,42,1 +11,120,80,37,150,42.3,0.785,48,1 +3,102,44,20,94,30.8,0.4,26,0 +1,109,58,18,116,28.5,0.219,22,0 +9,140,94,0,0,32.7,0.734,45,1 +13,153,88,37,140,40.6,1.174,39,0 +12,100,84,33,105,30,0.488,46,0 +1,147,94,41,0,49.3,0.358,27,1 +1,81,74,41,57,46.3,1.096,32,0 +3,187,70,22,200,36.4,0.408,36,1 +6,162,62,0,0,24.3,0.178,50,1 +4,136,70,0,0,31.2,1.182,22,1 +1,121,78,39,74,39,0.261,28,0 +3,108,62,24,0,26,0.223,25,0 +0,181,88,44,510,43.3,0.222,26,1 +8,154,78,32,0,32.4,0.443,45,1 +1,128,88,39,110,36.5,1.057,37,1 +7,137,90,41,0,32,0.391,39,0 +0,123,72,0,0,36.3,0.258,52,1 +1,106,76,0,0,37.5,0.197,26,0 +6,190,92,0,0,35.5,0.278,66,1 +2,88,58,26,16,28.4,0.766,22,0 +9,170,74,31,0,44,0.403,43,1 +9,89,62,0,0,22.5,0.142,33,0 +10,101,76,48,180,32.9,0.171,63,0 +2,122,70,27,0,36.8,0.34,27,0 +5,121,72,23,112,26.2,0.245,30,0 +1,126,60,0,0,30.1,0.349,47,1 +1,93,70,31,0,30.4,0.315,23,0