Implement AI orchestration wedding demo
This commit is contained in:
3
backend/app/ai/README.md
Normal file
3
backend/app/ai/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# AI
|
||||
|
||||
OpenAPI tool generation and registry code. REST operations are converted into planner-visible tool definitions so tools are not hand-authored.
|
||||
1
backend/app/ai/__init__.py
Normal file
1
backend/app/ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""AI planner and tool generation modules."""
|
||||
71
backend/app/ai/tool_generator.py
Normal file
71
backend/app/ai/tool_generator.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.domain.schemas import ToolDefinition
|
||||
|
||||
|
||||
def operation_name(method: str, path: str) -> str:
|
||||
clean = path.strip("/").replace("{", "").replace("}", "")
|
||||
parts = [part for part in re.split(r"[/_-]+", clean) if part and part != "id"]
|
||||
resource = "_".join(parts)
|
||||
verb = {"POST": "create", "PATCH": "update", "PUT": "replace", "GET": "get", "DELETE": "delete"}[method.upper()]
|
||||
return f"{verb}_{resource}".rstrip("_")
|
||||
|
||||
|
||||
def path_params(path: str) -> list[str]:
|
||||
return re.findall(r"{([^}]+)}", path)
|
||||
|
||||
|
||||
class OpenAPIToolGenerator:
|
||||
"""Generates callable tool metadata from OpenAPI operations."""
|
||||
|
||||
def __init__(self, spec: dict[str, Any]) -> None:
|
||||
self.spec = spec
|
||||
|
||||
def generate(self) -> list[ToolDefinition]:
|
||||
tools: list[ToolDefinition] = []
|
||||
for path, methods in self.spec.get("paths", {}).items():
|
||||
for method, operation in methods.items():
|
||||
upper = method.upper()
|
||||
if upper not in {"POST", "PATCH", "PUT", "DELETE", "GET"}:
|
||||
continue
|
||||
request_schema = self._request_schema(operation)
|
||||
params = path_params(path)
|
||||
if not request_schema and upper in {"POST", "PATCH", "PUT"}:
|
||||
continue
|
||||
schema = request_schema or {"type": "object", "properties": {}}
|
||||
if params:
|
||||
schema = {
|
||||
**schema,
|
||||
"properties": {
|
||||
**schema.get("properties", {}),
|
||||
**{param: {"type": "string"} for param in params},
|
||||
},
|
||||
"required": list(dict.fromkeys([*schema.get("required", []), *params])),
|
||||
}
|
||||
tools.append(
|
||||
ToolDefinition(
|
||||
name=operation.get("operationId") or operation_name(upper, path),
|
||||
description=operation.get("summary") or operation.get("description") or f"{upper} {path}",
|
||||
method=upper,
|
||||
path=path,
|
||||
schema=schema,
|
||||
required=schema.get("required", []),
|
||||
path_params=params,
|
||||
)
|
||||
)
|
||||
return tools
|
||||
|
||||
def _request_schema(self, operation: dict[str, Any]) -> dict[str, Any] | None:
|
||||
body = operation.get("requestBody", {}).get("content", {}).get("application/json", {})
|
||||
schema = body.get("schema")
|
||||
if not schema:
|
||||
return None
|
||||
return self._resolve(schema)
|
||||
|
||||
def _resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
ref = schema.get("$ref")
|
||||
if not ref:
|
||||
return schema
|
||||
name = ref.split("/")[-1]
|
||||
return self.spec.get("components", {}).get("schemas", {}).get(name, {})
|
||||
15
backend/app/ai/tool_registry.py
Normal file
15
backend/app/ai/tool_registry.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.domain.schemas import ToolDefinition
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self, tools: list[ToolDefinition]) -> None:
|
||||
self._tools = {tool.name: tool for tool in tools}
|
||||
|
||||
def list(self) -> list[ToolDefinition]:
|
||||
return list(self._tools.values())
|
||||
|
||||
def get(self, name: str) -> ToolDefinition | None:
|
||||
return self._tools.get(name)
|
||||
|
||||
def names(self) -> set[str]:
|
||||
return set(self._tools)
|
||||
168
backend/app/ai/wed_api_spec.py
Normal file
168
backend/app/ai/wed_api_spec.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Wed backend OpenAPI subset used to generate orchestration tools.
|
||||
|
||||
The source backend lives at `/Volumes/Corsair/Wed/wed-backend` and exposes these
|
||||
FastAPI routes:
|
||||
|
||||
- `POST /tasks/`
|
||||
- `PATCH /tasks/{task_id}`
|
||||
- `POST /notes/`
|
||||
- `PATCH /notes/{note_id}`
|
||||
- `POST /reservations/guest`
|
||||
- `PATCH /reservations/{reservation_id}`
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def wed_openapi_subset() -> dict[str, Any]:
|
||||
"""Return a minimal OpenAPI document matching the real Wed backend payloads."""
|
||||
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Wedding Plan API", "version": "1.0"},
|
||||
"paths": {
|
||||
"/tasks/": {
|
||||
"post": {
|
||||
"operationId": "create_task",
|
||||
"summary": "Create task in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/TaskCreate"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/tasks/{task_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_task",
|
||||
"summary": "Update task in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateTask"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/notes/": {
|
||||
"post": {
|
||||
"operationId": "create_note",
|
||||
"summary": "Create note in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NoteCreateSchema"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/notes/{note_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_note",
|
||||
"summary": "Update note in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NoteUpdateSchema"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/reservations/guest": {
|
||||
"post": {
|
||||
"operationId": "create_guest_reservation",
|
||||
"summary": "Create guest reservation request in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ReservationPendingCreateGuestSchema"}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/reservations/{reservation_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_reservation",
|
||||
"summary": "Update reservation in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/ReservationsUpdateSchema"}}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"TaskCreate": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
|
||||
"notes": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
|
||||
"is_completed": {"type": "boolean", "default": False},
|
||||
"due_date": {
|
||||
"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}],
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"UpdateTask": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"notes": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"is_completed": {"anyOf": [{"type": "boolean"}, {"type": "null"}]},
|
||||
"due_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
"NoteCreateSchema": {
|
||||
"type": "object",
|
||||
"required": ["content"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"reservation_id": {
|
||||
"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}],
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"NoteUpdateSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"content": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"reservation_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
"ReservationPendingCreateGuestSchema": {
|
||||
"type": "object",
|
||||
"required": ["partner_id", "guest_first_name", "guest_last_name", "guest_email"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"partner_id": {"type": "string", "format": "uuid"},
|
||||
"guest_first_name": {"type": "string"},
|
||||
"guest_last_name": {"type": "string"},
|
||||
"guest_email": {"type": "string", "format": "email"},
|
||||
"guest_phone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
|
||||
"event_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
"details": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"budget_per_reservation": {"anyOf": [{"type": "number"}, {"type": "null"}]},
|
||||
"interested_dates": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"guest_count": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
|
||||
"event_type": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"other_comments": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
"ReservationsUpdateSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"status": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"event_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
"details": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"budget_per_reservation": {"anyOf": [{"type": "number"}, {"type": "null"}]},
|
||||
"interested_dates": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"guest_count": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
|
||||
"event_type": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"other_comments": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user