Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/routes/tangent/hooks/useRunScenarios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useLiveQuery } from "dexie-react-hooks";

import { type ScenarioEntry, tangentDb } from "@/routes/tangent/idb/tangentDb";

/**
* Reactively reads the saved experiment scenarios for a given run,
* newest first. Returns an empty array while loading or when no run id
* is provided.
*/
export function useRunScenarios(runId?: string): {
scenarios: ScenarioEntry[];
} {
const scenarios =
useLiveQuery(async () => {
if (!runId) return [];
const rows = await tangentDb.scenarios
.where("run.runId")
.equals(runId)
.sortBy("createdAt");
return rows.reverse();
}, [runId]) ?? [];

return { scenarios };
}
67 changes: 67 additions & 0 deletions src/routes/tangent/idb/tangentDb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Dexie, type EntityTable } from "dexie";

export type ScenarioIdeaType =
| "feature_engineering"
| "hyperparameter_optimization"
| "input_data"
| "model_architecture";

export type ScenarioImpact = "high" | "medium" | "low";

/** Reference back to the Tangle run a scenario was planned against. */
interface ScenarioRunRef {
runId: string;
/** Full URL captured at creation time (window.location.href). */
url: string;
}

export interface ScenarioIdea {
title: string;
ideaType: ScenarioIdeaType;
impact: ScenarioImpact;
evidence: string;
}

/**
* Placeholders for the richer ScenarioYaml planning fields
* (search_space, metrics, budget, ...). Only name/description are
* populated on creation; the rest are filled in later as the user
* fleshes out the experiment plan.
*/
interface ScenarioPlan {
name: string;
description: string;
pipeline?: { path: string; baseline_run_id: string };
metrics?: unknown;
search_space?: Record<string, unknown>;
experiment_actions?: Record<string, unknown>;
research?: unknown;
budget?: unknown;
timing?: unknown;
failure_playbook?: unknown[];
}

export interface ScenarioEntry {
id: string;
run: ScenarioRunRef;
score: number;
rationale: string;
summary: string;
/** Only the ideas the user selected when building the scenario. */
ideas: ScenarioIdea[];
plan: ScenarioPlan;
createdAt: number;
updatedAt: number;
}

export const tangentDb = new Dexie("tangle_tangent") as Dexie & {
scenarios: EntityTable<ScenarioEntry, "id">;
};

tangentDb.version(1).stores({
scenarios: "id, run.runId, createdAt",
});

export async function saveScenario(entry: ScenarioEntry): Promise<void> {
await tangentDb.scenarios.put(entry);
}
136 changes: 114 additions & 22 deletions src/routes/v2/shared/components/AiChat/components/TangentScenario.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Paragraph, Text } from "@/components/ui/typography";
import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider";
import {
saveScenario,
type ScenarioEntry,
type ScenarioIdea,
} from "@/routes/tangent/idb/tangentDb";
import { useMlExperimentPlannerWindow } from "@/routes/v2/shared/components/MlExperimentPlanner/useMlExperimentPlannerWindow";

type Impact = "high" | "medium" | "low";

Expand All @@ -11,7 +22,7 @@ type IdeaType =
| "input_data"
| "model_architecture";

interface ScenarioIdea {
interface ScenarioIdeaData {
title: string;
ideaType: IdeaType;
impact: Impact;
Expand All @@ -22,7 +33,7 @@ interface Scenario {
score: number;
rationale: string;
summary: string;
ideas: ScenarioIdea[];
ideas: ScenarioIdeaData[];
}

const IDEA_TYPE_LABEL: Record<IdeaType, string> = {
Expand All @@ -44,7 +55,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function parseIdea(value: unknown): ScenarioIdea | null {
function parseIdea(value: unknown): ScenarioIdeaData | null {
if (!isRecord(value)) return null;
const { title, ideaType, impact, evidence } = value;
if (typeof title !== "string") return null;
Expand Down Expand Up @@ -75,7 +86,7 @@ function parseScenario(raw: string): Scenario | null {
if (typeof summary !== "string") return null;
if (!Array.isArray(ideas)) return null;

const parsedIdeas: ScenarioIdea[] = [];
const parsedIdeas: ScenarioIdeaData[] = [];
for (const idea of ideas) {
const parsed = parseIdea(idea);
if (parsed) parsedIdeas.push(parsed);
Expand All @@ -90,26 +101,42 @@ function scoreVariant(score: number): BadgeVariant {
return "outline";
}

function IdeaCard({ idea }: { idea: ScenarioIdea }) {
interface IdeaCardProps {
idea: ScenarioIdeaData;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}

function IdeaCard({ idea, checked, onCheckedChange }: IdeaCardProps) {
return (
<Card className="gap-3 py-3">
<CardHeader className="px-4">
<CardTitle>
<Text as="span" size="sm" weight="semibold">
{idea.title}
</Text>
</CardTitle>
<InlineStack gap="1">
<Badge variant="outline" size="sm" shape="rounded">
{IDEA_TYPE_LABEL[idea.ideaType]}
</Badge>
<Badge
variant={IMPACT_VARIANT[idea.impact]}
size="sm"
shape="rounded"
>
{idea.impact} impact
</Badge>
<InlineStack gap="2" blockAlign="start" wrap="nowrap">
<Checkbox
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
aria-label={`Include idea ${idea.title}`}
className="mt-0.5"
/>
<BlockStack gap="1">
<CardTitle>
<Text as="span" size="sm" weight="semibold">
{idea.title}
</Text>
</CardTitle>
<InlineStack gap="1">
<Badge variant="outline" size="sm" shape="rounded">
{IDEA_TYPE_LABEL[idea.ideaType]}
</Badge>
<Badge
variant={IMPACT_VARIANT[idea.impact]}
size="sm"
shape="rounded"
>
{idea.impact} impact
</Badge>
</InlineStack>
</BlockStack>
</InlineStack>
</CardHeader>
<CardContent className="px-4">
Expand All @@ -123,6 +150,14 @@ function IdeaCard({ idea }: { idea: ScenarioIdea }) {

export function TangentScenario({ raw }: { raw: string }) {
const scenario = parseScenario(raw);
const ideaCount = scenario?.ideas.length ?? 0;
const [selected, setSelected] = useState<Set<number>>(
() => new Set(Array.from({ length: ideaCount }, (_, index) => index)),
);

const executionData = useExecutionDataOptional();
const runId = executionData?.runId ?? undefined;
const openPlanner = useMlExperimentPlannerWindow();

if (!scenario) {
return (
Expand All @@ -132,6 +167,49 @@ export function TangentScenario({ raw }: { raw: string }) {
);
}

const toggleIdea = (index: number, isChecked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (isChecked) {
next.add(index);
} else {
next.delete(index);
}
return next;
});
};

const handleUseScenario = async () => {
if (!runId) return;

const selectedIdeas: ScenarioIdea[] = scenario.ideas.filter((_, index) =>
selected.has(index),
);
const now = Date.now();
const id =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `scenario-${now}`;
const name = selectedIdeas[0]?.title ?? "Experiment plan";

const entry: ScenarioEntry = {
id,
run: { runId, url: window.location.href },
score: scenario.score,
rationale: scenario.rationale,
summary: scenario.summary,
ideas: selectedIdeas,
plan: { name, description: scenario.summary },
createdAt: now,
updatedAt: now,
};

await saveScenario(entry);
openPlanner(runId, id);
};

const canUseScenario = Boolean(runId) && selected.size > 0;

return (
<BlockStack gap="3">
<InlineStack gap="2" blockAlign="center">
Expand All @@ -157,10 +235,24 @@ export function TangentScenario({ raw }: { raw: string }) {
Ideas
</Text>
{scenario.ideas.map((idea, index) => (
<IdeaCard key={`${idea.title}-${index}`} idea={idea} />
<IdeaCard
key={`${idea.title}-${index}`}
idea={idea}
checked={selected.has(index)}
onCheckedChange={(isChecked) => toggleIdea(index, isChecked)}
/>
))}
</BlockStack>
)}

<Button
size="sm"
onClick={handleUseScenario}
disabled={!canUseScenario}
title={runId ? undefined : "Open a run to plan an experiment scenario"}
>
Use scenario
</Button>
</BlockStack>
);
}
Loading
Loading