nanobot-voice-interface/workbench_store.py
kacper 4dfb7ca3cc
Some checks failed
CI / Backend Checks (push) Failing after 36s
CI / Frontend Checks (push) Failing after 40s
feat: unify card runtime and event-driven web ui
2026-04-06 15:42:53 -04:00

246 lines
8.4 KiB
Python

from __future__ import annotations
import html
import importlib.util
import os
import sys
from pathlib import Path
from typing import Any
from card_store import (
NANOBOT_WORKSPACE,
json_script_text,
normalize_card_id,
persist_card,
template_html_path,
)
from session_store import normalize_session_chat_id
BASE_DIR = Path(__file__).resolve().parent
NANOBOT_REPO_DIR = BASE_DIR.parent / "nanobot"
NANOBOT_RUNTIME_WORKSPACE = Path(
os.getenv(
"NANOBOT_RUNTIME_WORKSPACE",
str(
(NANOBOT_WORKSPACE / "workspace")
if (NANOBOT_WORKSPACE / "workspace").exists()
else NANOBOT_WORKSPACE
),
)
).expanduser()
WORKBENCH_DIR = NANOBOT_RUNTIME_WORKSPACE / "workbench"
def ensure_workbench_store_dirs() -> None:
WORKBENCH_DIR.mkdir(parents=True, exist_ok=True)
_script_modules: dict[str, Any] = {}
def _load_script_module(module_name: str, script_path: Path):
cached = _script_modules.get(module_name)
if cached is not None:
return cached
if not script_path.exists():
raise RuntimeError(f"missing helper script: {script_path}")
spec = importlib.util.spec_from_file_location(module_name, script_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"failed to load helper script: {script_path}")
module = importlib.util.module_from_spec(spec)
sys.modules.setdefault(module_name, module)
spec.loader.exec_module(module)
_script_modules[module_name] = module
return module
def _workbench_board_module():
ensure_workbench_store_dirs()
module = _load_script_module(
"nanobot_web_workbench_board",
NANOBOT_REPO_DIR / "scripts" / "workbench_board.py",
)
module.ensure_workbench(WORKBENCH_DIR)
return module
def _coerce_workbench_record(raw: dict[str, Any]) -> dict[str, Any] | None:
item_id = normalize_card_id(str(raw.get("id", "")))
chat_id = normalize_session_chat_id(str(raw.get("chat_id", "web") or "web"))
if not item_id or not chat_id:
return None
kind = str(raw.get("kind", "text") or "text").strip().lower()
if kind not in {"text", "question"}:
kind = "text"
raw_choices = raw.get("choices", [])
choices = [str(choice) for choice in raw_choices] if isinstance(raw_choices, list) else []
raw_template_state = raw.get("template_state", {})
template_state = raw_template_state if isinstance(raw_template_state, dict) else {}
return {
"id": item_id,
"chat_id": chat_id,
"kind": kind,
"title": str(raw.get("title", "")),
"content": str(raw.get("content", "")),
"question": str(raw.get("question", "")),
"choices": choices,
"response_value": str(raw.get("response_value", "")),
"slot": str(raw.get("slot", "")),
"template_key": str(raw.get("template_key", "")),
"template_state": template_state,
"context_summary": str(raw.get("context_summary", "")),
"promotable": bool(raw.get("promotable", True)),
"source_card_id": str(raw.get("source_card_id", "")),
"created_at": str(raw.get("created_at", "")),
"updated_at": str(raw.get("updated_at", "")),
}
def _materialize_workbench_content(item: dict[str, Any]) -> str:
if item.get("kind") != "text":
return str(item.get("content", ""))
template_key = str(item.get("template_key", "")).strip()
if not template_key:
return str(item.get("content", ""))
html_path = template_html_path(template_key)
try:
template_html = html_path.read_text(encoding="utf-8")
except Exception:
return (
'<div style="padding:16px;border:1px solid #fecaca;border-radius:12px;'
'background:#fef2f2;color:#991b1b;font:600 14px/1.4 system-ui,sans-serif;">'
f"Missing template: {html.escape(template_key)}"
"</div>"
)
state_payload = item.get("template_state", {})
if not isinstance(state_payload, dict):
state_payload = {}
item_id = html.escape(str(item.get("id", "")))
safe_template_key = html.escape(template_key)
return (
f'<div data-nanobot-card-root data-card-id="{item_id}" data-template-key="{safe_template_key}">'
f'<script type="application/json" data-card-state>{json_script_text(state_payload)}</script>'
f"{template_html}"
"</div>"
)
def _workbench_notification_target(raw: dict[str, Any]) -> bool:
for key in ("surface", "target", "placement"):
value = str(raw.get(key, "") or "").strip().lower()
if value == "workbench":
return True
return False
def _persist_workbench_item(
raw: dict[str, Any], *, default_chat_id: str | None = None
) -> dict[str, Any] | None:
normalized_chat_id = normalize_session_chat_id(
str(raw.get("chat_id", default_chat_id or "web") or default_chat_id or "web")
)
if not normalized_chat_id:
return None
template_state = raw.get("template_state", {})
if not isinstance(template_state, dict):
template_state = {}
workbench_board = _workbench_board_module()
item = workbench_board.upsert_item(
WORKBENCH_DIR,
chat_id=normalized_chat_id,
item_id=str(raw.get("id", "") or ""),
kind=str(raw.get("kind", "text") or "text"),
title=str(raw.get("title", "")),
content=str(raw.get("content", "")),
question=str(raw.get("question", "")),
choices=[str(choice) for choice in raw.get("choices", [])]
if isinstance(raw.get("choices"), list)
else [],
response_value=str(raw.get("response_value", "")),
slot=str(raw.get("slot", "")),
template_key=str(raw.get("template_key", "")),
template_state=template_state,
context_summary=str(raw.get("context_summary", "")),
promotable=bool(raw.get("promotable", True)),
source_card_id=str(raw.get("source_card_id", "")),
)
normalized = _coerce_workbench_record(item)
if normalized is None:
return None
normalized["content"] = _materialize_workbench_content(normalized)
return normalized
def _load_workbench_items(chat_id: str) -> list[dict[str, Any]]:
workbench_board = _workbench_board_module()
items = workbench_board.collect_items(WORKBENCH_DIR, chat_id)
rendered: list[dict[str, Any]] = []
for item in items:
normalized = _coerce_workbench_record(item)
if normalized is None:
continue
normalized["content"] = _materialize_workbench_content(normalized)
rendered.append(normalized)
rendered.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
return rendered
def _delete_workbench_item(chat_id: str, item_id: str) -> bool:
workbench_board = _workbench_board_module()
return bool(workbench_board.delete_item(WORKBENCH_DIR, chat_id, item_id))
def _promote_workbench_item(chat_id: str, item_id: str) -> dict[str, Any]:
workbench_board = _workbench_board_module()
item = workbench_board.load_item(WORKBENCH_DIR, chat_id, item_id)
card_payload = {
"id": f"workbench-{item['id']}",
"kind": item.get("kind", "text"),
"title": item.get("title", ""),
"content": item.get("content", ""),
"question": item.get("question", ""),
"choices": item.get("choices", []),
"response_value": item.get("response_value", ""),
"slot": f"workbench:{chat_id}:{item['id']}",
"lane": "context",
"priority": 78,
"state": "active",
"template_key": item.get("template_key", ""),
"template_state": item.get("template_state", {}),
"context_summary": item.get("context_summary", ""),
"chat_id": chat_id,
}
persisted = persist_card(card_payload)
if persisted is None:
raise RuntimeError("failed to promote workbench item to card")
workbench_board.delete_item(WORKBENCH_DIR, chat_id, item_id)
return persisted
coerce_workbench_record = _coerce_workbench_record
persist_workbench_item = _persist_workbench_item
load_workbench_items = _load_workbench_items
delete_workbench_item = _delete_workbench_item
promote_workbench_item = _promote_workbench_item
workbench_notification_target = _workbench_notification_target
__all__ = [
"WORKBENCH_DIR",
"coerce_workbench_record",
"delete_workbench_item",
"ensure_workbench_store_dirs",
"load_workbench_items",
"persist_workbench_item",
"promote_workbench_item",
"workbench_notification_target",
]