my agent
This commit is contained in:
parent
23e36bfbdf
commit
b91879a59e
|
|
@ -0,0 +1,76 @@
|
|||
"""Registry describing files the natural-language assistant can modify."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resource:
|
||||
key: str
|
||||
path: Path
|
||||
description: str
|
||||
kind: str # e.g., "text" or "calendar"
|
||||
keywords: List[str]
|
||||
|
||||
|
||||
RESOURCE_REGISTRY: Dict[str, Resource] = {
|
||||
"shopping_list": Resource(
|
||||
key="shopping_list",
|
||||
path=BASE_DIR / "shopping_list.txt",
|
||||
description="Items to purchase on the next store run.",
|
||||
kind="text",
|
||||
keywords=["buy", "purchase", "shop", "grocery", "groceries"],
|
||||
),
|
||||
"home_todo": Resource(
|
||||
key="home_todo",
|
||||
path=BASE_DIR / "home_todo.txt",
|
||||
description="Household maintenance or chores.",
|
||||
kind="text",
|
||||
keywords=["home", "house", "chore", "laundry", "clean"],
|
||||
),
|
||||
"work_todo": Resource(
|
||||
key="work_todo",
|
||||
path=BASE_DIR / "work_todo.txt",
|
||||
description="Tasks related to your job or ongoing projects.",
|
||||
kind="text",
|
||||
keywords=["work", "office", "project", "client", "email","gavilan", "efw", ],
|
||||
),
|
||||
"school_courses": Resource(
|
||||
key="school_courses",
|
||||
path=BASE_DIR / "school_courses.txt",
|
||||
description="Assignments or study notes for school work.",
|
||||
kind="text",
|
||||
keywords=["school", "class", "course", "study", "assignment"],
|
||||
),
|
||||
"ideas": Resource(
|
||||
key="ideas",
|
||||
path=BASE_DIR / "ideas.txt",
|
||||
description="Random ideas, inspiration, or brainstorming notes.",
|
||||
kind="text",
|
||||
keywords=["idea", "brainstorm", "concept", "inspiration"],
|
||||
),
|
||||
"calendar": Resource(
|
||||
key="calendar",
|
||||
path=BASE_DIR / "calendar.ics",
|
||||
description="Time-based events saved in an iCalendar file.",
|
||||
kind="calendar",
|
||||
keywords=["meeting", "schedule", "calendar", "appointment", "event"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def resource_descriptions() -> str:
|
||||
"""Return a human-readable summary of supported resources."""
|
||||
lines = [
|
||||
"The assistant can update the following resources:",
|
||||
]
|
||||
for res in RESOURCE_REGISTRY.values():
|
||||
lines.append(f"- {res.key}: {res.description} (file: {res.path.name})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
__all__ = ["RESOURCE_REGISTRY", "resource_descriptions", "Resource"]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//PocketFlow//Assistant//EN
|
||||
END:VCALENDAR
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
"""Natural-language assistant that routes commands to specific personal files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, date, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pocketflow import Flow, Node
|
||||
|
||||
from assistant_resources import RESOURCE_REGISTRY, resource_descriptions
|
||||
from utils.call_llm import call_llm_json
|
||||
|
||||
COMMAND_PROMPT = f"""
|
||||
You convert a user's short reminder into a JSON command so an agent can update files.
|
||||
{resource_descriptions()}
|
||||
|
||||
Always respond with a JSON object using this schema:
|
||||
{{
|
||||
"action": "append",
|
||||
"target": "<one of: {', '.join(RESOURCE_REGISTRY.keys())}>",
|
||||
"entry": "<text to append or store>",
|
||||
"metadata": {{ # optional object for structured info (e.g., calendar dates)
|
||||
...
|
||||
}}
|
||||
}}
|
||||
If you cannot determine a valid target, respond with:
|
||||
{{"action": "unknown", "target": "", "entry": "", "metadata": {{}}}}
|
||||
Make entries concise; when the target is calendar include any useful scheduling details inside metadata.
|
||||
User reminder: {{command}}
|
||||
""".strip()
|
||||
|
||||
REMOVE_PREFIXES = [
|
||||
"remember to",
|
||||
"please",
|
||||
"don't forget to",
|
||||
"dont forget to",
|
||||
"remind me to",
|
||||
"i need to",
|
||||
"i should",
|
||||
]
|
||||
|
||||
|
||||
class InterpretCommandNode(Node):
|
||||
"""Use an LLM (with fallback heuristics) to map a reminder to an actionable command."""
|
||||
|
||||
def prep(self, shared: Dict[str, Any]) -> str:
|
||||
return shared["command"]
|
||||
|
||||
def exec(self, command: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return call_llm_json(COMMAND_PROMPT.format(command=command))
|
||||
except Exception:
|
||||
return self._fallback(command)
|
||||
|
||||
def post(self, shared: Dict[str, Any], prep_result: str, exec_result: Dict[str, Any]) -> str:
|
||||
shared["parsed_action"] = exec_result
|
||||
return exec_result.get("action", "unknown")
|
||||
|
||||
@staticmethod
|
||||
def _fallback(command: str) -> Dict[str, Any]:
|
||||
lowered = command.lower()
|
||||
target_key: Optional[str] = None
|
||||
highest_score = 0
|
||||
for key, resource in RESOURCE_REGISTRY.items():
|
||||
score = sum(1 for kw in resource.keywords if kw in lowered)
|
||||
if score > highest_score:
|
||||
highest_score = score
|
||||
target_key = key
|
||||
if not target_key:
|
||||
return {"action": "unknown", "target": "", "entry": "", "metadata": {}}
|
||||
|
||||
entry = InterpretCommandNode._extract_entry(command, target_key)
|
||||
metadata: Dict[str, Any] = {}
|
||||
if target_key == "calendar":
|
||||
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", command)
|
||||
if date_match:
|
||||
metadata["date"] = date_match.group(1)
|
||||
return {
|
||||
"action": "append",
|
||||
"target": target_key,
|
||||
"entry": entry.strip(),
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_entry(command: str, target_key: str) -> str:
|
||||
cleaned = InterpretCommandNode._remove_prefixes(command).strip()
|
||||
lowered = cleaned.lower()
|
||||
if target_key == "shopping_list":
|
||||
match = re.search(r"buy\s+([a-zA-Z0-9\s]+)", lowered)
|
||||
if match:
|
||||
return match.group(1)
|
||||
if target_key in {"home_todo", "work_todo", "school_courses"}:
|
||||
for verb in ("finish", "do", "complete", "send", "write", "study"):
|
||||
if lowered.startswith(f"{verb} "):
|
||||
return cleaned
|
||||
if target_key == "ideas":
|
||||
return cleaned
|
||||
if target_key == "calendar":
|
||||
return cleaned
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _remove_prefixes(command: str) -> str:
|
||||
lowered = command.lower()
|
||||
for prefix in REMOVE_PREFIXES:
|
||||
if lowered.startswith(prefix):
|
||||
return command[len(prefix) :].strip()
|
||||
return command
|
||||
|
||||
|
||||
class AppendEntryNode(Node):
|
||||
"""Append entries to the resource indicated by the interpreter."""
|
||||
|
||||
def prep(self, shared: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return shared["parsed_action"]
|
||||
|
||||
def exec(self, action: Dict[str, Any]) -> str:
|
||||
target_key = action.get("target", "")
|
||||
entry = (action.get("entry") or "").strip()
|
||||
metadata = action.get("metadata") or {}
|
||||
|
||||
if target_key not in RESOURCE_REGISTRY:
|
||||
raise ValueError(f"Unsupported target '{target_key}'")
|
||||
if not entry:
|
||||
raise ValueError("Empty entry cannot be appended")
|
||||
|
||||
resource = RESOURCE_REGISTRY[target_key]
|
||||
if resource.kind == "text":
|
||||
self._append_to_text(resource.path, entry)
|
||||
elif resource.kind == "calendar":
|
||||
self._append_to_calendar(resource.path, entry, metadata)
|
||||
else:
|
||||
raise ValueError(f"Unknown resource kind '{resource.kind}'")
|
||||
return f"{target_key}:{entry}"
|
||||
|
||||
def post(self, shared: Dict[str, Any], prep_result: Dict[str, Any], exec_result: str) -> None:
|
||||
shared.setdefault("updates", []).append(exec_result)
|
||||
|
||||
@staticmethod
|
||||
def _append_to_text(path: Path, entry: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"{entry}\n")
|
||||
|
||||
@staticmethod
|
||||
def _append_to_calendar(path: Path, summary: str, metadata: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
path.write_text("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//PocketFlow//Assistant//EN\nEND:VCALENDAR\n", encoding="utf-8")
|
||||
|
||||
content = path.read_text(encoding="utf-8").splitlines()
|
||||
if not content or content[-1].strip() != "END:VCALENDAR":
|
||||
content.append("END:VCALENDAR")
|
||||
|
||||
event_lines = AppendEntryNode._build_event(summary, metadata)
|
||||
# insert before END:VCALENDAR
|
||||
end_index = len(content) - 1
|
||||
content = content[:end_index] + event_lines + content[end_index:]
|
||||
path.write_text("\n".join(content) + "\n", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _build_event(summary: str, metadata: Dict[str, Any]) -> list[str]:
|
||||
now = datetime.now(timezone.utc)
|
||||
dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
dtstart, dtend = AppendEntryNode._resolve_calendar_times(metadata, now)
|
||||
description = metadata.get("notes") or summary
|
||||
return [
|
||||
"BEGIN:VEVENT",
|
||||
f"DTSTAMP:{dtstamp}",
|
||||
f"SUMMARY:{summary}",
|
||||
f"DTSTART;VALUE=DATE:{dtstart}",
|
||||
f"DTEND;VALUE=DATE:{dtend}",
|
||||
f"DESCRIPTION:{description}",
|
||||
"END:VEVENT",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_calendar_times(metadata: Dict[str, Any], fallback: datetime) -> tuple[str, str]:
|
||||
start = AppendEntryNode._parse_date(metadata.get("date") or metadata.get("start"))
|
||||
if start is None:
|
||||
start = fallback.date()
|
||||
end = AppendEntryNode._parse_date(metadata.get("end"))
|
||||
if end is None:
|
||||
end_date = start + timedelta(days=1)
|
||||
else:
|
||||
end_date = end
|
||||
return start.strftime("%Y%m%d"), end_date.strftime("%Y%m%d")
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(value: Any) -> Optional[date]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
try:
|
||||
# Try ISO date first
|
||||
dt = datetime.fromisoformat(str(value))
|
||||
return dt.date()
|
||||
except ValueError:
|
||||
patterns = ["%Y-%m-%d", "%m/%d/%Y", "%b %d %Y"]
|
||||
text = str(value)
|
||||
for pattern in patterns:
|
||||
try:
|
||||
return datetime.strptime(text, pattern).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class UnknownCommandNode(Node):
|
||||
def prep(self, shared: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return shared.get("parsed_action", {})
|
||||
|
||||
def exec(self, action: Dict[str, Any]) -> None:
|
||||
raise ValueError(f"Unsupported command: {action}")
|
||||
|
||||
|
||||
def build_flow() -> Flow:
|
||||
interpreter = InterpretCommandNode()
|
||||
append_entry = AppendEntryNode()
|
||||
unknown = UnknownCommandNode()
|
||||
|
||||
flow = Flow(start=interpreter)
|
||||
interpreter - "append" >> append_entry
|
||||
interpreter - "unknown" >> unknown
|
||||
return flow
|
||||
|
||||
|
||||
def handle_command(command: str) -> Dict[str, Any]:
|
||||
shared: Dict[str, Any] = {"command": command}
|
||||
flow = build_flow()
|
||||
try:
|
||||
flow.run(shared)
|
||||
except ValueError as exc:
|
||||
shared["error"] = str(exc)
|
||||
return shared
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Process a reminder and update the relevant file")
|
||||
parser.add_argument("command", help="Reminder text, e.g. 'email client tomorrow about proposal'")
|
||||
args = parser.parse_args()
|
||||
result = handle_command(args.command)
|
||||
print(result)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
apples
|
||||
cheese
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"""Minimal OpenAI Chat Completions helper used by Pocket Flow demos."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Iterable, Optional
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
OpenAI = None # type: ignore[misc,assignment]
|
||||
|
||||
try:
|
||||
from llm_secrets import OPENAI_API_KEY
|
||||
except ImportError as exc:
|
||||
raise ImportError("Create llm_secrets.py with OPENAI_API_KEY before calling call_llm") from exc
|
||||
|
||||
_client: Optional[OpenAI] = None
|
||||
|
||||
|
||||
def _get_client() -> OpenAI:
|
||||
global _client
|
||||
if OpenAI is None: # type: ignore[truthy-function]
|
||||
raise RuntimeError("Install the 'openai' package to use call_llm")
|
||||
if _client is None:
|
||||
if not OPENAI_API_KEY or OPENAI_API_KEY == "REPLACE_WITH_YOUR_KEY":
|
||||
raise ValueError("Set OPENAI_API_KEY in llm_secrets.py before calling call_llm")
|
||||
_client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
return _client
|
||||
|
||||
|
||||
def call_llm(messages: Iterable[dict] | str, model: str = "gpt-4o-mini") -> str:
|
||||
"""Send a prompt or list of chat messages to OpenAI and return the text reply."""
|
||||
client = _get_client()
|
||||
chat_messages = (
|
||||
[{"role": "user", "content": messages}]
|
||||
if isinstance(messages, str)
|
||||
else list(messages)
|
||||
)
|
||||
response = client.chat.completions.create(model=model, messages=chat_messages)
|
||||
message = response.choices[0].message.content or ""
|
||||
return message.strip()
|
||||
|
||||
|
||||
def call_llm_json(messages: Iterable[dict] | str, model: str = "gpt-4o-mini") -> dict:
|
||||
"""Convenience wrapper that expects a JSON object in the response."""
|
||||
raw = call_llm(messages, model=model)
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start == -1 or end == -1:
|
||||
raise ValueError(f"LLM response does not contain JSON: {raw}")
|
||||
return json.loads(raw[start : end + 1])
|
||||
|
||||
|
||||
__all__ = ["call_llm", "call_llm_json"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
gavilan: call ted
|
||||
Loading…
Reference in New Issue