fixed rp api

This commit is contained in:
2026-06-10 12:03:05 +03:00
parent 5844551038
commit 8eb6505724
17 changed files with 969 additions and 1 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, webhooks
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, shopping, webhooks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router, tags=["health"])
@@ -11,5 +11,6 @@ api_router.include_router(character.router, tags=["character"])
api_router.include_router(projects.router, tags=["projects"])
api_router.include_router(memory.router, tags=["memory"])
api_router.include_router(fitness.router, tags=["fitness"])
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
api_router.include_router(webhooks.router, tags=["webhooks"])
api_router.include_router(media.router, tags=["media"])
+116
View File
@@ -0,0 +1,116 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db.base import get_db
from app.shopping.service import ShoppingService
router = APIRouter()
class ListCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ListRename(BaseModel):
name: str = Field(min_length=1, max_length=255)
class ItemInput(BaseModel):
text: str = Field(min_length=1, max_length=500)
quantity: float | None = None
unit: str = ""
class ItemsAdd(BaseModel):
list_id: int | None = None
list_name: str | None = None
items: list[ItemInput] = Field(min_length=1)
class ItemChecked(BaseModel):
checked: bool
@router.get("")
def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]:
return ShoppingService(db).snapshot()
@router.get("/lists")
def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]:
return ShoppingService(db).list_lists(include_items=True)
@router.post("/lists")
def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).create_list(payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/lists/{list_id}")
def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
data = ShoppingService(db).get_list(list_id=list_id)
if not data:
raise HTTPException(status_code=404, detail="List not found")
return data
@router.patch("/lists/{list_id}")
def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).rename_list(list_id, payload.name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/lists/{list_id}")
def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).delete_list(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/items")
def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).add_items(
[i.model_dump() for i in payload.items],
list_id=payload.list_id,
list_name=payload.list_name,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/items/{item_id}")
def set_item_checked(
item_id: int,
payload: ItemChecked,
db: Session = Depends(get_db),
) -> dict[str, Any]:
try:
return ShoppingService(db).set_item_checked(item_id, payload.checked)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/items/{item_id}")
def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).remove_item(item_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/lists/{list_id}/clear-checked")
def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
try:
return ShoppingService(db).clear_checked(list_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+2
View File
@@ -23,6 +23,8 @@ TOOLS_INSTRUCTIONS = """
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list.
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
""".strip()
DEFAULT_CARD: dict[str, Any] = {
+35
View File
@@ -69,6 +69,15 @@ FITNESS_TOOL_NAMES = frozenset({
})
# Не засорять чат служебными ответами
SHOPPING_TOOL_NAMES = frozenset({
"list_shopping_lists",
"create_shopping_list",
"add_shopping_items",
"check_shopping_item",
"remove_shopping_item",
"delete_shopping_list",
})
TOOLS_SKIP_CHAT_NOTICE = frozenset({
"get_pomodoro_status",
"recall_memories",
@@ -78,6 +87,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"calc_fitness_targets",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
})
@@ -97,6 +107,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
prefix = "🧠"
elif tool_name in FITNESS_TOOL_NAMES:
prefix = "💪"
elif tool_name in SHOPPING_TOOL_NAMES:
prefix = "🛒"
else:
prefix = "📋"
return f"{prefix} {data['error']}"
@@ -196,6 +208,29 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
url = data.get("url", "")
return f"🎨 **Картинка готова**\n\n![image]({url})"
if tool_name == "create_shopping_list" and data.get("ok"):
lst = data.get("list") or {}
action = "создан" if data.get("created") else "уже был"
return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})"
if tool_name == "add_shopping_items" and data.get("ok"):
added = data.get("added") or []
names = ", ".join(i.get("text", "") for i in added[:5])
extra = f" +{len(added) - 5}" if len(added) > 5 else ""
return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}"
if tool_name == "check_shopping_item" and data.get("ok"):
item = data.get("item") or {}
state = "куплено" if item.get("checked") else "снята отметка"
return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}"
if tool_name == "remove_shopping_item" and data.get("ok"):
removed = data.get("removed") or {}
return f"🛒 **Удалено** · {removed.get('text')}"
if tool_name == "delete_shopping_list" and data.get("ok"):
return f"🛒 **Список удалён** · «{data.get('name')}»"
return None
+3
View File
@@ -22,6 +22,7 @@ from app.memory.context import (
)
from app.memory.extract import extract_after_turn
from app.projects.context import format_projects_context, get_projects_snapshot
from app.shopping.context import format_shopping_context, get_shopping_snapshot
from app.db.models import ChatSession, Message
from app.llm.client import LLMClient
from app.pomodoro.service import PomodoroService
@@ -63,12 +64,14 @@ class ChatService:
status = PomodoroService(self.db).get_status()
memory_snapshot = get_memory_snapshot(self.db, session_id)
fitness_snapshot = get_fitness_snapshot(self.db)
shopping_snapshot = get_shopping_snapshot(self.db)
projects_snapshot = get_projects_snapshot(self.db)
return (
f"{self.character.get_system_prompt()}\n\n"
f"{format_datetime_context(self.db)}\n\n"
f"{format_memory_context(memory_snapshot)}\n\n"
f"{format_fitness_context(fitness_snapshot)}\n\n"
f"{format_shopping_context(shopping_snapshot)}\n\n"
f"{format_weather_snapshot()}\n\n"
f"{format_pomodoro_context(status)}\n\n"
f"{format_projects_context(projects_snapshot)}"
+35
View File
@@ -215,6 +215,41 @@ class FitnessReminder(Base):
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ShoppingList(Base):
__tablename__ = "shopping_lists"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["ShoppingListItem"]] = relationship(
back_populates="shopping_list",
cascade="all, delete-orphan",
order_by="ShoppingListItem.sort_order, ShoppingListItem.id",
)
class ShoppingListItem(Base):
__tablename__ = "shopping_list_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
list_id: Mapped[int] = mapped_column(
ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True
)
text: Mapped[str] = mapped_column(String(500))
quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
unit: Mapped[str] = mapped_column(String(64), default="")
checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items")
class AssistantState(Base):
__tablename__ = "assistant_state"
View File
+47
View File
@@ -0,0 +1,47 @@
from typing import Any
from sqlalchemy.orm import Session
from app.shopping.service import ShoppingService
MAX_LISTS_IN_CONTEXT = 8
MAX_ITEMS_PER_LIST = 12
def get_shopping_snapshot(db: Session) -> dict[str, Any]:
return ShoppingService(db).snapshot()
def format_shopping_context(snapshot: dict[str, Any]) -> str:
lines = ["[Списки покупок]"]
lists = snapshot.get("lists") or []
if not lists:
lines.append("Списков пока нет. create_shopping_list или add_shopping_items.")
return "\n".join(lines)
lines.append(
f"Всего списков: {snapshot.get('list_count', len(lists))}, "
f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}."
)
lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.")
for lst in lists[:MAX_LISTS_IN_CONTEXT]:
items = lst.get("items") or []
unchecked = [i for i in items if not i.get("checked")]
preview = unchecked[:MAX_ITEMS_PER_LIST]
parts = []
for item in preview:
qty = item.get("quantity")
unit = (item.get("unit") or "").strip()
label = item["text"]
if qty is not None:
label = f"{label} ({qty}{' ' + unit if unit else ''})"
parts.append(f"#{item['id']} {label}")
tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else ""
if parts:
lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}")
else:
lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто")
return "\n".join(lines)
+223
View File
@@ -0,0 +1,223 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.db.models import ShoppingList, ShoppingListItem
class ShoppingService:
def __init__(self, db: Session):
self.db = db
def snapshot(self) -> dict[str, Any]:
lists = self.list_lists(include_items=True)
total_items = sum(len(lst.get("items") or []) for lst in lists)
unchecked = sum(
1
for lst in lists
for item in (lst.get("items") or [])
if not item.get("checked")
)
return {
"lists": lists,
"list_count": len(lists),
"total_items": total_items,
"unchecked_items": unchecked,
}
def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]:
stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name)
if include_items:
stmt = stmt.options(selectinload(ShoppingList.items))
rows = list(self.db.scalars(stmt).all())
return [self._list_to_dict(row, include_items=include_items) for row in rows]
def get_list(
self,
list_id: int | None = None,
*,
name: str | None = None,
) -> dict[str, Any] | None:
row = self._resolve_list(list_id=list_id, name=name)
if not row:
return None
return self._list_to_dict(row, include_items=True)
def create_list(self, name: str) -> dict[str, Any]:
clean = name.strip()
if not clean:
raise ValueError("Название списка не может быть пустым")
existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean))
if existing:
return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False}
max_order = self.db.scalar(select(func.max(ShoppingList.sort_order))) or 0
row = ShoppingList(name=clean, sort_order=max_order + 1)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True}
def rename_list(self, list_id: int, name: str) -> dict[str, Any]:
row = self._require_list(list_id)
clean = name.strip()
if not clean:
raise ValueError("Название списка не может быть пустым")
conflict = self.db.scalar(
select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id)
)
if conflict:
raise ValueError(f"Список «{clean}» уже существует")
row.name = clean
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(row)
return {"ok": True, "list": self._list_to_dict(row, include_items=True)}
def delete_list(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id)
name = row.name
self.db.delete(row)
self.db.commit()
return {"ok": True, "deleted_list_id": list_id, "name": name}
def add_items(
self,
items: list[dict[str, Any]],
*,
list_id: int | None = None,
list_name: str | None = None,
create_list: bool = True,
) -> dict[str, Any]:
if not items:
raise ValueError("Нужен хотя бы один товар")
row = self._resolve_list(list_id=list_id, name=list_name)
if not row and list_name and create_list:
created = self.create_list(list_name)
row = self._require_list(created["list"]["id"])
if not row:
raise ValueError("Укажи list_id или list_name")
max_order = self.db.scalar(
select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id)
) or 0
added: list[dict[str, Any]] = []
for idx, raw in enumerate(items, start=1):
text = (raw.get("text") or "").strip()
if not text:
continue
item = ShoppingListItem(
list_id=row.id,
text=text,
quantity=raw.get("quantity"),
unit=(raw.get("unit") or "").strip(),
sort_order=max_order + idx,
)
self.db.add(item)
added.append(item)
if not added:
raise ValueError("Нет валидных товаров для добавления")
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
for item in added:
self.db.refresh(item)
return {
"ok": True,
"list_id": row.id,
"list_name": row.name,
"added": [self._item_to_dict(i) for i in added],
"list": self._list_to_dict(self._require_list(row.id), include_items=True),
}
def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]:
item = self._require_item(item_id)
item.checked = checked
item.shopping_list.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {"ok": True, "item": self._item_to_dict(item)}
def remove_item(self, item_id: int) -> dict[str, Any]:
item = self._require_item(item_id)
data = self._item_to_dict(item)
list_id = item.list_id
self.db.delete(item)
self.db.commit()
return {"ok": True, "removed": data, "list_id": list_id}
def clear_checked(self, list_id: int) -> dict[str, Any]:
row = self._require_list(list_id)
removed = 0
for item in list(row.items):
if item.checked:
self.db.delete(item)
removed += 1
row.updated_at = datetime.now(timezone.utc)
self.db.commit()
return {
"ok": True,
"list_id": list_id,
"removed_count": removed,
"list": self._list_to_dict(self._require_list(list_id), include_items=True),
}
def _resolve_list(
self,
*,
list_id: int | None = None,
name: str | None = None,
) -> ShoppingList | None:
if list_id is not None:
return self.db.scalar(
select(ShoppingList)
.where(ShoppingList.id == list_id)
.options(selectinload(ShoppingList.items))
)
if name:
clean = name.strip()
return self.db.scalar(
select(ShoppingList)
.where(ShoppingList.name == clean)
.options(selectinload(ShoppingList.items))
)
return None
def _require_list(self, list_id: int) -> ShoppingList:
row = self._resolve_list(list_id=list_id)
if not row:
raise ValueError(f"Список #{list_id} не найден")
return row
def _require_item(self, item_id: int) -> ShoppingListItem:
item = self.db.get(ShoppingListItem, item_id)
if not item:
raise ValueError(f"Позиция #{item_id} не найдена")
return item
def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]:
return {
"id": item.id,
"list_id": item.list_id,
"text": item.text,
"quantity": item.quantity,
"unit": item.unit,
"checked": item.checked,
"sort_order": item.sort_order,
}
def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]:
data: dict[str, Any] = {
"id": row.id,
"name": row.name,
"sort_order": row.sort_order,
"item_count": len(row.items) if row.items is not None else 0,
"unchecked_count": sum(1 for i in (row.items or []) if not i.checked),
}
if include_items:
data["items"] = [self._item_to_dict(i) for i in (row.items or [])]
return data
+109
View File
@@ -13,6 +13,7 @@ from app.integrations.wger import WgerClient
from app.memory.service import MemoryService
from app.pomodoro.service import PomodoroService
from app.projects.service import ProjectService
from app.shopping.service import ShoppingService
TOOL_DEFINITIONS: list[dict[str, Any]] = [
{
@@ -496,6 +497,94 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
},
},
},
{
"type": "function",
"function": {
"name": "list_shopping_lists",
"description": "Все списки покупок с позициями. «Что купить», «покажи списки».",
"parameters": {"type": "object", "properties": {}, "required": []},
},
},
{
"type": "function",
"function": {
"name": "create_shopping_list",
"description": "Создать новый список покупок.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Название списка, например «Продукты»"},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "add_shopping_items",
"description": "Добавить товары в список. Список создаётся, если не существует.",
"parameters": {
"type": "object",
"properties": {
"list_name": {"type": "string", "description": "Название списка"},
"list_id": {"type": "integer"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {"type": "string"},
"quantity": {"type": "number"},
"unit": {"type": "string"},
},
"required": ["text"],
},
},
},
"required": ["items"],
},
},
},
{
"type": "function",
"function": {
"name": "check_shopping_item",
"description": "Отметить позицию как купленную (checked=true) или снять отметку (false).",
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "integer"},
"checked": {"type": "boolean"},
},
"required": ["item_id", "checked"],
},
},
},
{
"type": "function",
"function": {
"name": "remove_shopping_item",
"description": "Удалить позицию из списка по item_id.",
"parameters": {
"type": "object",
"properties": {"item_id": {"type": "integer"}},
"required": ["item_id"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_shopping_list",
"description": "Удалить весь список покупок.",
"parameters": {
"type": "object",
"properties": {"list_id": {"type": "integer"}},
"required": ["list_id"],
},
},
},
{
"type": "function",
"function": {
@@ -528,6 +617,7 @@ async def execute_tool(
projects = ProjectService(db)
memory = MemoryService(db)
fitness = FitnessService(db)
shopping = ShoppingService(db)
try:
if name == "get_pomodoro_status":
@@ -680,6 +770,25 @@ async def execute_tool(
draw_self=bool(arguments.get("draw_self")),
scene_description=arguments.get("scene_description", ""),
)
elif name == "list_shopping_lists":
result = shopping.list_lists(include_items=True)
elif name == "create_shopping_list":
result = shopping.create_list(arguments.get("name", ""))
elif name == "add_shopping_items":
result = shopping.add_items(
arguments.get("items") or [],
list_id=arguments.get("list_id"),
list_name=arguments.get("list_name"),
)
elif name == "check_shopping_item":
result = shopping.set_item_checked(
int(arguments["item_id"]),
bool(arguments.get("checked", True)),
)
elif name == "remove_shopping_item":
result = shopping.remove_item(int(arguments["item_id"]))
elif name == "delete_shopping_list":
result = shopping.delete_list(int(arguments["list_id"]))
else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)