157 lines
6.1 KiB
Python
157 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
from collections.abc import Awaitable, Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
NotificationEventFactory = Callable[[dict[str, Any]], dict[str, Any] | None]
|
|
RunNanobotTurn = Callable[..., Awaitable[dict[str, Any]]]
|
|
|
|
|
|
class InboxService:
|
|
def __init__(self, *, repo_dir: Path, inbox_dir: Path) -> None:
|
|
self._repo_dir = repo_dir
|
|
self._inbox_dir = inbox_dir
|
|
self._module: Any | None = None
|
|
|
|
def _load_module(self):
|
|
if self._module is not None:
|
|
return self._module
|
|
|
|
script_path = self._repo_dir / "scripts" / "inbox_board.py"
|
|
if not script_path.exists():
|
|
raise RuntimeError(f"missing helper script: {script_path}")
|
|
spec = importlib.util.spec_from_file_location("nanobot_web_inbox_board", 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("nanobot_web_inbox_board", module)
|
|
spec.loader.exec_module(module)
|
|
module.ensure_inbox(self._inbox_dir)
|
|
self._module = module
|
|
return module
|
|
|
|
def list_items(
|
|
self,
|
|
*,
|
|
status_filter: str | None,
|
|
kind_filter: str | None,
|
|
include_closed: bool,
|
|
limit: int | None,
|
|
tags: list[str],
|
|
) -> list[dict[str, Any]]:
|
|
inbox_board = self._load_module()
|
|
items = inbox_board.filter_items(
|
|
inbox_board.collect_items(self._inbox_dir),
|
|
status=status_filter,
|
|
kind=kind_filter,
|
|
tags=tags,
|
|
include_closed=include_closed,
|
|
)
|
|
if limit is not None:
|
|
items = items[:limit]
|
|
return [item.to_dict() for item in items]
|
|
|
|
def open_items(self, limit: int = 4) -> list[dict[str, Any]]:
|
|
return self.list_items(
|
|
status_filter=None,
|
|
kind_filter=None,
|
|
include_closed=False,
|
|
limit=limit,
|
|
tags=[],
|
|
)
|
|
|
|
def capture_item(
|
|
self,
|
|
*,
|
|
title: str,
|
|
text: str,
|
|
kind: str,
|
|
source: str,
|
|
due: str,
|
|
body: str,
|
|
session_id: str,
|
|
tags: list[str],
|
|
confidence: int | float | None,
|
|
) -> dict[str, Any]:
|
|
inbox_board = self._load_module()
|
|
created_path = inbox_board.create_item(
|
|
self._inbox_dir,
|
|
title=title.strip(),
|
|
kind=kind,
|
|
source=source,
|
|
confidence=confidence,
|
|
suggested_due=due.strip(),
|
|
tags=tags,
|
|
body=body.strip(),
|
|
raw_text=text.strip(),
|
|
metadata={"source_session": session_id.strip()} if session_id.strip() else None,
|
|
)
|
|
return inbox_board.parse_item(created_path).to_dict()
|
|
|
|
def dismiss_item(self, item: str) -> dict[str, Any]:
|
|
inbox_board = self._load_module()
|
|
updated_path = inbox_board.dismiss_item(self._inbox_dir, item)
|
|
return inbox_board.parse_item(updated_path).to_dict()
|
|
|
|
async def accept_item_as_task(
|
|
self,
|
|
*,
|
|
item: str,
|
|
lane: str,
|
|
title: str,
|
|
due: str,
|
|
body_text: str | None,
|
|
tags: list[str] | None,
|
|
run_nanobot_turn: RunNanobotTurn,
|
|
notification_to_event: NotificationEventFactory,
|
|
) -> dict[str, Any]:
|
|
inbox_board = self._load_module()
|
|
parsed_item = inbox_board.parse_item(inbox_board.resolve_item_path(self._inbox_dir, item))
|
|
item_path = str(parsed_item.path)
|
|
|
|
instruction_lines = [
|
|
"This is a UI inbox action. Groom the inbox item into a task if it is actionable.",
|
|
f"Inbox item path: {item_path}",
|
|
"Use the inbox_board tool and the task_helper_card tool.",
|
|
"First inspect the item with action=show.",
|
|
"Then improve the title, description, tags, or due date only if the capture supports it.",
|
|
"If it is actionable, accept it into the task board.",
|
|
"After accepting it, decide whether the task would be easier to execute immediately with a linked helper card.",
|
|
"Create a helper card whenever it would clearly reduce friction for the user instead of making them search or prepare things manually.",
|
|
"Use these patterns by default when they fit:",
|
|
"- watch/learn -> create a watch helper card",
|
|
"- read/research -> create a reading/reference helper card",
|
|
"- go somewhere -> create a travel/map helper card",
|
|
"- buy/order -> create a shopping/product helper card",
|
|
"- call/email/reach out -> create an outreach draft helper card",
|
|
"If you have enough information to search or enrich the helper card, do that first.",
|
|
"If not, still create the fallback helper card from the task so the user has something actionable in the feed.",
|
|
"Prefer backlog unless it is clearly committed or in-progress work.",
|
|
"Do not invent details and do not ask follow-up questions.",
|
|
"Reply with a short confirmation of what you did, including whether you created a helper card.",
|
|
]
|
|
if title.strip():
|
|
instruction_lines.append(f"Title hint from UI: {title.strip()}")
|
|
if body_text is not None and body_text.rstrip():
|
|
instruction_lines.append(f"Description hint from UI: {body_text.rstrip()}")
|
|
if due.strip():
|
|
instruction_lines.append(f"Due hint from UI: {due.strip()}")
|
|
if tags:
|
|
instruction_lines.append(f"Tag hint from UI: {', '.join(str(tag) for tag in tags)}")
|
|
if lane and lane != "backlog":
|
|
instruction_lines.append(f"Preferred destination lane: {lane}")
|
|
|
|
return await run_nanobot_turn(
|
|
"\n".join(instruction_lines),
|
|
chat_id="inbox-groomer",
|
|
metadata={
|
|
"source": "web-inbox-card",
|
|
"ui_action": "accept_task",
|
|
"inbox_item": item_path,
|
|
},
|
|
timeout_seconds=90.0,
|
|
notification_to_event=notification_to_event,
|
|
)
|