This commit is contained in:
2025-11-21 21:54:53 +00:00
parent c151f1a85c
commit f1b98dce00
34 changed files with 496 additions and 0 deletions

7
config/hosts.yaml Normal file
View File

@@ -0,0 +1,7 @@
hosts:
- id: desktop
host: 192.168.50.185
cdp_port: 9223
- id: laptop
host: 192.168.50.152
cdp_port: 9222

0
src/guide/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,20 @@
from guide.app.actions.base import ActionRegistry, DemoAction
from guide.app.actions.intake import FillIntakeBasicAction
from guide.app.actions.sourcing import AddThreeSuppliersAction
def default_registry() -> ActionRegistry:
actions: list[DemoAction] = [
FillIntakeBasicAction(),
AddThreeSuppliersAction(),
]
return ActionRegistry(actions)
__all__ = [
"ActionRegistry",
"DemoAction",
"default_registry",
"FillIntakeBasicAction",
"AddThreeSuppliersAction",
]

View File

@@ -0,0 +1,31 @@
from typing import Iterable, Protocol
from playwright.async_api import Page
from guide.app.domain.models import ActionContext, ActionMetadata, ActionResult
class DemoAction(Protocol):
id: str
description: str
category: str
async def run(self, page: Page, context: ActionContext) -> ActionResult: ...
class ActionRegistry:
"""Simple mapping of action id -> action instance."""
def __init__(self, actions: Iterable[DemoAction]):
self._actions: dict[str, DemoAction] = {action.id: action for action in actions}
def get(self, action_id: str) -> DemoAction:
if action_id not in self._actions:
raise KeyError(f"Unknown action '{action_id}'")
return self._actions[action_id]
def list_metadata(self) -> list[ActionMetadata]:
return [
ActionMetadata(id=action.id, description=action.description, category=action.category)
for action in self._actions.values()
]

View File

@@ -0,0 +1,3 @@
from guide.app.actions.intake.basic import FillIntakeBasicAction
__all__ = ["FillIntakeBasicAction"]

View File

@@ -0,0 +1,16 @@
from playwright.async_api import Page
from guide.app.actions.base import DemoAction
from guide.app.domain.models import ActionContext, ActionResult
from guide.app.strings import demo_texts, selectors
class FillIntakeBasicAction(DemoAction):
id = "fill-intake-basic"
description = "Fill the intake description and advance to the next step."
category = "intake"
async def run(self, page: Page, context: ActionContext) -> ActionResult:
await page.fill(selectors.INTAKE.DESCRIPTION_FIELD, demo_texts.INTAKE.CONVEYOR_BELT_REQUEST)
await page.click(selectors.INTAKE.NEXT_BUTTON)
return ActionResult(details={"message": "Intake filled"})

View File

@@ -0,0 +1,3 @@
from guide.app.actions.sourcing.add_suppliers import AddThreeSuppliersAction
__all__ = ["AddThreeSuppliersAction"]

View File

@@ -0,0 +1,17 @@
from playwright.async_api import Page
from guide.app.actions.base import DemoAction
from guide.app.domain.models import ActionContext, ActionResult
from guide.app.strings import demo_texts, selectors
class AddThreeSuppliersAction(DemoAction):
id = "add-three-suppliers"
description = "Adds three default suppliers to the sourcing event."
category = "sourcing"
async def run(self, page: Page, context: ActionContext) -> ActionResult:
for supplier in demo_texts.SUPPLIERS.DEFAULT_TRIO:
await page.fill(selectors.SOURCING.SUPPLIER_SEARCH_INPUT, supplier)
await page.click(selectors.SOURCING.ADD_SUPPLIER_BUTTON)
return ActionResult(details={"added_suppliers": demo_texts.SUPPLIERS.DEFAULT_TRIO})

View File

@@ -0,0 +1,10 @@
from fastapi import APIRouter
from guide.app.api.routes import actions, config, health
router = APIRouter()
router.include_router(health.router)
router.include_router(actions.router)
router.include_router(config.router)
__all__ = ["router"]

View File

@@ -0,0 +1,3 @@
from guide.app.api.routes import actions, config, health
__all__ = ["actions", "config", "health"]

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from guide.app.actions import ActionRegistry
from guide.app.browser.client import BrowserClient
from guide.app.domain.models import ActionContext, ActionRequest, ActionResponse
router = APIRouter()
def _registry(request: Request) -> ActionRegistry:
return request.app.state.action_registry
def _browser_client(request: Request) -> BrowserClient:
return request.app.state.browser_client
@router.get("/actions")
async def list_actions(registry: ActionRegistry = Depends(_registry)):
return registry.list_metadata()
@router.post("/actions/{action_id}/execute", response_model=ActionResponse)
async def execute_action(
action_id: str,
payload: ActionRequest,
registry: ActionRegistry = Depends(_registry),
browser_client: BrowserClient = Depends(_browser_client),
):
try:
action = registry.get(action_id)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown action '{action_id}'",
) from exc
context = ActionContext(
browser_host_id=payload.browser_host_id or browser_client.settings.default_browser_host_id,
params=payload.params or {},
)
try:
async with browser_client.open_page(context.browser_host_id) as page:
result = await action.run(page, context)
except Exception as exc:
return ActionResponse(
status="error",
action_id=action_id,
browser_host_id=context.browser_host_id,
correlation_id=context.correlation_id,
error=str(exc),
)
return ActionResponse(
status=result.status,
action_id=action_id,
browser_host_id=context.browser_host_id,
correlation_id=context.correlation_id,
details=result.details,
error=result.error,
)

View File

@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, Request
from guide.app.domain.models import BrowserHostsResponse
router = APIRouter()
def _settings(request: Request):
return request.app.state.settings
@router.get("/config/browser-hosts", response_model=BrowserHostsResponse)
async def list_browser_hosts(settings=Depends(_settings)) -> BrowserHostsResponse: # type: ignore[override]
return BrowserHostsResponse(
default_browser_host_id=settings.default_browser_host_id,
browser_hosts=settings.browser_hosts,
)

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/healthz")
async def health() -> dict[str, str]:
return {"status": "ok"}

View File

View File

@@ -0,0 +1,52 @@
import contextlib
from typing import AsyncIterator
from playwright.async_api import Browser, Page, async_playwright
from guide.app.core.config import AppSettings, BrowserHost
class BrowserClient:
"""CDP-aware connector that returns the active Raindrop page."""
def __init__(self, settings: AppSettings) -> None:
self.settings = settings
def _resolve_host(self, host_id: str | None) -> BrowserHost:
resolved_id = host_id or self.settings.default_browser_host_id
host = self.settings.browser_hosts.get(resolved_id)
if not host:
known = ", ".join(self.settings.browser_hosts.keys()) or "<none>"
raise ValueError(f"Unknown browser host '{resolved_id}'. Known: {known}")
return host
@contextlib.asynccontextmanager
async def open_page(self, host_id: str | None = None) -> AsyncIterator[Page]:
host = self._resolve_host(host_id)
cdp_url = f"http://{host.host}:{host.cdp_port}"
playwright = await async_playwright().start()
browser: Browser | None = None
try:
browser = await playwright.chromium.connect_over_cdp(cdp_url)
page = self._pick_raindrop_page(browser)
if not page:
raise RuntimeError("No Raindrop page found in connected browser.")
yield page
finally:
# Avoid killing the remote browser; just drop the connection.
with contextlib.suppress(Exception):
if browser:
browser.disconnect()
with contextlib.suppress(Exception):
await playwright.stop()
def _pick_raindrop_page(self, browser: Browser) -> Page | None:
target_substr = self.settings.raindrop_base_url
pages = []
for context in browser.contexts:
pages.extend(context.pages)
pages = pages or [page for page in browser.contexts[0].pages] if browser.contexts else []
for page in reversed(pages):
if target_substr in (page.url or ""):
return page
return pages[-1] if pages else None

View File

View File

@@ -0,0 +1,69 @@
import json
import os
from pathlib import Path
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
CONFIG_DIR = Path(__file__).resolve().parents[4] / "config"
HOSTS_FILE = CONFIG_DIR / "hosts.yaml"
class BrowserHost(BaseModel):
id: str
host: str
cdp_port: int
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="RAINDROP_DEMO_")
raindrop_base_url: str = "https://app.raindrop.com"
default_browser_host_id: str = "desktop"
browser_hosts: dict[str, BrowserHost] = Field(default_factory=dict)
def _load_hosts_file(path: Path) -> dict[str, BrowserHost]:
if not path.exists():
return {}
try:
import yaml # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"hosts.yaml found but PyYAML is not installed. "
"Add 'pyyaml' to dependencies or remove hosts.yaml."
) from exc
data = yaml.safe_load(path.read_text()) or {}
hosts_raw = data.get("hosts") or data
hosts: dict[str, BrowserHost] = {}
for item in hosts_raw:
host = BrowserHost.model_validate(item)
hosts[host.id] = host
return hosts
def _parse_hosts_json(value: str) -> dict[str, BrowserHost]:
decoded = json.loads(value)
hosts: dict[str, BrowserHost] = {}
for item in decoded:
host = BrowserHost.model_validate(item)
hosts[host.id] = host
return hosts
def load_settings() -> AppSettings:
settings = AppSettings()
merged_hosts: dict[str, BrowserHost] = {}
from_file = _load_hosts_file(HOSTS_FILE)
merged_hosts.update(from_file)
if env_json := os.environ.get("RAINDROP_DEMO_BROWSER_HOSTS_JSON"):
merged_hosts.update(_parse_hosts_json(env_json))
if merged_hosts:
settings = settings.model_copy(update={"browser_hosts": merged_hosts})
return settings

View File

@@ -0,0 +1,11 @@
import logging
from typing import Optional
def configure_logging(level: int | str = logging.INFO, correlation_id: Optional[str] = None) -> None:
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
)
if correlation_id:
logging.LoggerAdapter(logging.getLogger(), {"correlation_id": correlation_id})

View File

View File

@@ -0,0 +1,43 @@
import uuid
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
from guide.app.core.config import BrowserHost
class ActionRequest(BaseModel):
browser_host_id: Optional[str] = None
params: dict[str, Any] = Field(default_factory=dict)
class ActionContext(BaseModel):
browser_host_id: str
params: dict[str, Any] = Field(default_factory=dict)
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
class ActionResult(BaseModel):
status: Literal["ok", "error"] = "ok"
details: dict[str, Any] = Field(default_factory=dict)
error: Optional[str] = None
class ActionMetadata(BaseModel):
id: str
description: str
category: str
class ActionResponse(BaseModel):
status: Literal["ok", "error"]
action_id: str
browser_host_id: str
correlation_id: Optional[str] = None
details: dict[str, Any] | None = None
error: Optional[str] = None
class BrowserHostsResponse(BaseModel):
default_browser_host_id: str
browser_hosts: dict[str, BrowserHost]

26
src/guide/app/main.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import FastAPI
from guide.app.actions import default_registry
from guide.app.browser.client import BrowserClient
from guide.app.core.config import load_settings
from guide.app.core.logging import configure_logging
from guide.app.api import router as api_router
def create_app() -> FastAPI:
configure_logging()
settings = load_settings()
registry = default_registry()
browser_client = BrowserClient(settings)
app = FastAPI(title="Raindrop Demo Automation", version="0.1.0")
app.state.settings = settings
app.state.action_registry = registry
app.state.browser_client = browser_client
app.include_router(api_router)
return app
app = create_app()

View File

@@ -0,0 +1,12 @@
from guide.app.strings.demo_texts import DemoTexts, texts
from guide.app.strings.labels import Labels, labels
from guide.app.strings.selectors import Selectors, selectors
__all__ = [
"DemoTexts",
"Labels",
"Selectors",
"texts",
"labels",
"selectors",
]

View File

@@ -0,0 +1,14 @@
from guide.app.strings.demo_texts.events import EventTexts
from guide.app.strings.demo_texts.intake import IntakeTexts
from guide.app.strings.demo_texts.suppliers import SupplierTexts
class DemoTexts:
INTAKE = IntakeTexts
SUPPLIERS = SupplierTexts
EVENTS = EventTexts
texts = DemoTexts()
__all__ = ["DemoTexts", "texts", "IntakeTexts", "SupplierTexts", "EventTexts"]

View File

@@ -0,0 +1,2 @@
class EventTexts:
THREE_BIDS_EVENT_NAME = "Three Bids and a Buy Demo"

View File

@@ -0,0 +1,5 @@
class IntakeTexts:
CONVEYOR_BELT_REQUEST = (
"Requesting 500 tons of replacement conveyor belts for Q4 maintenance window."
)
ALT_REQUEST = "Intake for rapid supplier onboarding and risk review."

View File

@@ -0,0 +1,7 @@
class SupplierTexts:
DEFAULT_TRIO = [
"Demo Supplier A",
"Demo Supplier B",
"Demo Supplier C",
]
NOTES = "Sourced automatically via demo action."

View File

@@ -0,0 +1,13 @@
from guide.app.strings.labels.intake import IntakeLabels
from guide.app.strings.labels.sourcing import SourcingLabels
class Labels:
INTAKE = IntakeLabels
SOURCING = SourcingLabels
NEXT_BUTTON = "Next"
labels = Labels()
__all__ = ["Labels", "labels", "IntakeLabels", "SourcingLabels"]

View File

@@ -0,0 +1,6 @@
"""User-visible labels for intake."""
class IntakeLabels:
DESCRIPTION_PLACEHOLDER = "Describe what you need"
NEXT_BUTTON = "Next"

View File

@@ -0,0 +1,6 @@
"""Labels for sourcing screens."""
class SourcingLabels:
SUPPLIERS_TAB = "Suppliers"
ADD_BUTTON = "Add"

View File

@@ -0,0 +1,14 @@
from guide.app.strings.selectors.intake import IntakeSelectors
from guide.app.strings.selectors.navigation import NavigationSelectors
from guide.app.strings.selectors.sourcing import SourcingSelectors
class Selectors:
INTAKE = IntakeSelectors
SOURCING = SourcingSelectors
NAVIGATION = NavigationSelectors
selectors = Selectors()
__all__ = ["Selectors", "selectors", "IntakeSelectors", "SourcingSelectors", "NavigationSelectors"]

View File

@@ -0,0 +1,6 @@
"""Selectors for the intake flow."""
class IntakeSelectors:
DESCRIPTION_FIELD = '[data-test="intake-description"]'
NEXT_BUTTON = '[data-test="intake-next"]'

View File

@@ -0,0 +1,6 @@
"""Selectors used for simple navigation helpers."""
class NavigationSelectors:
GLOBAL_SEARCH = '[data-test="global-search"]'
FIRST_RESULT = '[data-test="search-result"]:first-child'

View File

@@ -0,0 +1,7 @@
"""Selectors for sourcing event setup."""
class SourcingSelectors:
SUPPLIER_SEARCH_INPUT = '[data-test="supplier-search"]'
ADD_SUPPLIER_BUTTON = '[data-test="add-supplier"]'
SUPPLIER_ROW = '[data-test="supplier-row"]'