Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,5 @@ __marimo__/
.env.*.local
.env.*
!.env.example

logs/*
2 changes: 1 addition & 1 deletion src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class Settings(BaseSettings):
video_default_width: int = 1280
video_default_height: int = 720
video_default_fps: int = 30
video_action_speed: float = 1.0
video_action_speed: float = 0.5
video_random_seed: int = 42
video_window_scale: float = Field(default=0.86, gt=0.0, le=1.0)
video_focus_zoom: float = Field(default=1.4, ge=1.0, le=2.0)
Expand Down
33 changes: 26 additions & 7 deletions src/models/bdd.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,27 @@
from enum import Enum
from typing import Any
from dataclasses import dataclass
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator


class StepType(str, Enum):
STATE = "STATE"
TRANSITION = "TRANSITION"
DESIGN_CLASS = "DESIGN_CLASS"
ASSERTION = "ASSERTION"
ACTION_HOOK = "ACTION_HOOK"


class FlowEditorStepKind(str, Enum):
DESIGN_CLASS = "design-class"
DESIGN_OPERATION = "design-operation"
ASSERTION = "assertion"
ACTION_HOOK = "action-hook"
GROUP = "group"

FLOW_TO_STEP_TYPE = {
FlowEditorStepKind.DESIGN_CLASS: StepType.DESIGN_CLASS,
FlowEditorStepKind.ASSERTION: StepType.ASSERTION,
FlowEditorStepKind.ACTION_HOOK: StepType.ACTION_HOOK,
}

class FlowEditorPositionEdge(str, Enum):
BEFORE = "before"
Expand Down Expand Up @@ -68,6 +72,23 @@ def validate_transition_ids(cls, value: list[str]) -> list[str]:
raise ValueError("transition_ids cannot contain empty values")
return value

@model_validator(mode="after")
def validate_editor_step_positions(self) -> "BddFlowInput":
transition_ids = set(self.transition_ids)
unknown = sorted(
{
step.position.transitionId
for step in self.editor_steps
if step.position.transitionId not in transition_ids
}
)
if unknown:
raise ValueError(
"editor_steps reference transition ids outside transition_ids: "
+ ", ".join(unknown)
)
return self


class BddGenerationInput(BaseModel):
graph_id: str = Field(min_length=1)
Expand Down Expand Up @@ -192,6 +213,7 @@ class ResolvedFlow(BaseModel):
flow_id: str | None = None
checkpoint: ResolvedState
transitions: list[ResolvedTransition]
editor_steps: list[FlowEditorDraftStep] = Field(default_factory=list)


class StepPlan(BaseModel):
Expand Down Expand Up @@ -223,7 +245,6 @@ class SemanticAssertion(BaseModel):
definition: dict[str, Any]
semantic: dict[str, Any] = Field(default_factory=dict)


@dataclass(frozen=True)
class CompiledFeature:
id: str
Expand All @@ -239,6 +260,4 @@ class CompiledBdd:
transitions: dict[str, dict]
assertions: dict[str, dict]
action_hooks: dict[str, dict]
design_class: dict[str, Any] | None = None
feature_name: str | None = None
feature_text: str | None = None
design_classes: dict[str, dict] | None = None
1 change: 1 addition & 0 deletions src/repositories/bdd_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ async def resolve_flows(
flow_id=requested.flow_id,
checkpoint=checkpoint,
transitions=transitions,
editor_steps=requested.editor_steps
)
)
return resolved
Expand Down
4 changes: 2 additions & 2 deletions src/services/assertions/scenario_context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from dataclasses import dataclass

from src.models.bdd import ResolvedFlow
from src.models.bdd import ResolvedFlow, ResolvedState
from src.services.assertions.html_summarizer import HtmlSummary, summarize_html


Expand Down Expand Up @@ -112,7 +112,7 @@ def build_scenario_contexts(
return contexts


def _state_context(state, html_summary_max_chars: int) -> StateContext:
def _state_context(state: ResolvedState, html_summary_max_chars: int) -> StateContext:
return StateContext(
db_id=state.db_id,
name=state.name,
Expand Down
203 changes: 203 additions & 0 deletions src/services/bdd/collect_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
from collections import Counter, defaultdict
from src.core.config import get_settings
from src.utils.helpers import jaccard
from urllib.parse import urlparse
from src.utils.helpers import words, upper_snake, title, url_area, jaccard
from src.models.bdd import ResolvedFlow

settings = get_settings()

GENERIC_FEATURE_WORDS = {
"action",
"click",
"flow",
"navigate",
"open",
"page",
"screen",
"transition",
"user",
"view",
}


def _unique_feature_names(names: list[str]) -> list[str]:
totals = Counter(names)
seen: Counter[str] = Counter()
unique: list[str] = []
for name in names:
if totals[name] == 1:
unique.append(name)
continue
seen[name] += 1
unique.append(f"{name} {seen[name]}")
return unique


def _destination_anchor(flow: ResolvedFlow) -> str:
end_state = flow.transitions[-1].to_state
area = url_area(end_state.url)
if area:
return f"url:{area}"
return f"state:{upper_snake(end_state.name, 'UNKNOWN')}"


def _group_centroid(group: list[int], profiles: list[set[str]]) -> set[str]:
centroid: set[str] = set()
for index in group:
centroid.update(profiles[index])
return centroid


def _flow_labels(flow: ResolvedFlow) -> list[str]:
labels = [flow.checkpoint.name]
labels.extend(transition.name for transition in flow.transitions)
labels.append(flow.transitions[-1].to_state.name)
return labels


def _flow_urls(flow: ResolvedFlow) -> list[str]:
urls = [flow.checkpoint.url]
for transition in flow.transitions:
urls.extend([transition.from_state.url, transition.to_state.url])
return urls


def _scenario_profile(
flow: ResolvedFlow, scenario_name: str, labels: list[str]
) -> set[str]:
tokens: set[str] = set()
labels = _flow_labels(flow)
labels.append(scenario_name)
labels.extend(transition.action for transition in flow.transitions)
for label in labels:
tokens.update(token.lower() for token in words(label))
for url in _flow_urls(flow):
parsed = urlparse(url)
if parsed.hostname:
tokens.update(words(parsed.hostname.split(".")[0].lower()))
tokens.update(token.lower() for token in words(parsed.path))
return {token for token in tokens if token not in GENERIC_FEATURE_WORDS}


def merge_groups(
groups: list[list[int]],
profiles: list[set[str]],
threshold: float,
) -> list[list[int]]:
merged = [list(group) for group in groups]
changed = True
while changed:
changed = False
best_pair: tuple[int, int] | None = None
best_score = threshold
for left_index in range(len(merged)):
left_profile = _group_centroid(merged[left_index], profiles)
for right_index in range(left_index + 1, len(merged)):
score = jaccard(
left_profile,
_group_centroid(merged[right_index], profiles),
)
if score >= best_score:
best_score = score
best_pair = (left_index, right_index)

if best_pair is None:
continue

left_index, right_index = best_pair
merged[left_index].extend(merged[right_index])
merged[left_index].sort()
del merged[right_index]
changed = True

return merged


def merge_singletons(
groups: list[list[int]],
profiles: list[set[str]],
threshold: float,
) -> list[list[int]]:
merged = [list(group) for group in groups]
for group in list(merged):
if len(group) != 1 or group not in merged:
continue
singleton_index = group[0]
best_group: list[int] | None = None
best_score = threshold
for candidate in merged:
if candidate == group:
continue
score = jaccard(
profiles[singleton_index],
_group_centroid(candidate, profiles),
)
if score >= best_score:
best_score = score
best_group = candidate
if best_group is None:
continue
best_group.append(singleton_index)
best_group.sort()
merged.remove(group)
return merged


def collect_features(
flows: list[ResolvedFlow], scenario_names: list[str]
) -> list[list[ResolvedFlow]]:
if not settings.bdd_split_features:
return [flows]

feature_similarity_threshold = settings.bdd_feature_similarity_threshold
singleton_merge_threshold = settings.bdd_singleton_merge_threshold

profiles = [
_scenario_profile(flow, scenario_name)
for flow, scenario_name in zip(flows, scenario_names)
]
anchored: dict[str, list[int]] = defaultdict(list)
for index, flow in enumerate(flows):
anchored[_destination_anchor(flow)].append(index)

groups = list(anchored.values())
groups = merge_groups(groups, profiles, feature_similarity_threshold)
groups = merge_singletons(groups, profiles, singleton_merge_threshold)
groups.sort(key=lambda group: min(group))
return [[flows[index] for index in group] for group in groups]


def _infer_feature_name(flows: list[ResolvedFlow]) -> str:
label_tokens: list[set[str]] = []
for flow in flows:
for label in _flow_labels(flow):
tokens = {
token.lower()
for token in words(label)
if token.lower() not in GENERIC_FEATURE_WORDS
}
if tokens:
label_tokens.append(tokens)

shared = set.intersection(*label_tokens) if label_tokens else set()
if shared:
ordered = sorted(shared)
return f"{' '.join(word.capitalize() for word in ordered)} User Flows"

hostnames = {
urlparse(flow.checkpoint.url).hostname
for flow in flows
if urlparse(flow.checkpoint.url).hostname
}
if len(hostnames) == 1:
hostname = next(iter(hostnames))
application = hostname.split(".")[0].replace("-", " ")
if application:
return f"{title(application)} User Flows"

return "Application User Flows"


def infer_feature_names(features):
return _unique_feature_names([_infer_feature_name(feature) for feature in features])
69 changes: 69 additions & 0 deletions src/services/bdd/editor_steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from collections import defaultdict
from typing import Literal

from src.models.bdd import (
FlowEditorDraftStep,
FlowEditorPositionEdge,
FlowEditorStepKind,
ResolvedFlow,
ResolvedState,
ResolvedTransition,
)

HookPhraseTiming = Literal["before", "after"]
HookMappingTiming = Literal["pre", "post"]

EDITOR_STEP_NAME_PREFIXES = {
FlowEditorStepKind.ASSERTION: "ASSERTION",
FlowEditorStepKind.ACTION_HOOK: "HOOK",
FlowEditorStepKind.DESIGN_CLASS: "DESIGN_CLASS",
}


def transition_by_input_id(flow: ResolvedFlow) -> dict[str, ResolvedTransition]:
return {transition.transition_id: transition for transition in flow.transitions}


def editor_steps_by_transition(
flow: ResolvedFlow,
) -> dict[str, dict[FlowEditorPositionEdge, list[FlowEditorDraftStep]]]:
grouped: dict[str, dict[FlowEditorPositionEdge, list[FlowEditorDraftStep]]] = (
defaultdict(
lambda: {
FlowEditorPositionEdge.BEFORE: [],
FlowEditorPositionEdge.AFTER: [],
}
)
)
for step in flow.editor_steps:
grouped[step.position.transitionId][step.position.edge].append(step)
return grouped


def generated_editor_step_names(flows: list[ResolvedFlow]) -> dict[int, str]:
counters: dict[FlowEditorStepKind, int] = defaultdict(int)
names: dict[int, str] = {}
for flow in flows:
for step in flow.editor_steps:
counters[step.kind] += 1
names[id(step)] = (
f"{EDITOR_STEP_NAME_PREFIXES[step.kind]}_{counters[step.kind]}"
)
return names


def hook_phrase_timing(edge: FlowEditorPositionEdge) -> HookPhraseTiming:
return "before" if edge == FlowEditorPositionEdge.BEFORE else "after"


def hook_mapping_timing(edge: FlowEditorPositionEdge) -> HookMappingTiming:
return "pre" if edge == FlowEditorPositionEdge.BEFORE else "post"


def target_state_for_editor_step(
step: FlowEditorDraftStep,
transition: ResolvedTransition,
) -> ResolvedState:
if step.position.edge == FlowEditorPositionEdge.BEFORE:
return transition.from_state
return transition.to_state
Loading
Loading