added memmory
This commit is contained in:
@@ -165,9 +165,41 @@ frontend/ React + Vite, чат и таймер
|
|||||||
data/ SQLite БД (создаётся автоматически)
|
data/ SQLite БД (создаётся автоматически)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Память и контекст (фаза 3a)
|
||||||
|
|
||||||
|
Долгосрочная память в SQLite, без векторов:
|
||||||
|
|
||||||
|
| Слой | Что хранит |
|
||||||
|
|------|------------|
|
||||||
|
| **Профиль** | имя, timezone, language, notes |
|
||||||
|
| **Факты** | устойчивые знания с категорией и важностью |
|
||||||
|
| **Сводка чата** | краткое содержание длинной сессии |
|
||||||
|
|
||||||
|
В system prompt на каждый ответ: персонаж → **память** → помидоро → проекты.
|
||||||
|
История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
- `remember_fact` — «запомни, что…»
|
||||||
|
- `recall_memories` — поиск по памяти
|
||||||
|
- `forget_memory` — удалить факт по id
|
||||||
|
- `update_profile` — имя, часовой пояс и т.д.
|
||||||
|
- `update_session_summary` — сжать тему длинного чата
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
| Method | Path | Описание |
|
||||||
|
|--------|------|----------|
|
||||||
|
| GET | `/api/v1/memory` | снимок памяти (+ `?session_id=`) |
|
||||||
|
| GET/PUT | `/api/v1/profile` | профиль |
|
||||||
|
| GET/POST | `/api/v1/memory/facts` | список / создать факт |
|
||||||
|
| DELETE | `/api/v1/memory/facts/{id}` | забыть |
|
||||||
|
| PUT | `/api/v1/memory/sessions/{id}/summary` | сводка чата |
|
||||||
|
|
||||||
## Следующие фазы
|
## Следующие фазы
|
||||||
|
|
||||||
- RAG с Qdrant для документов
|
- Qdrant: семантический поиск по фактам и документам
|
||||||
|
- RAG: загрузка файлов, `search_documents`
|
||||||
- Проактивные чаты по расписанию
|
- Проактивные чаты по расписанию
|
||||||
- Фитнес-трекер
|
- Фитнес-трекер
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import character, chat, health, pomodoro, projects, webhooks
|
from app.api.routes import character, chat, health, memory, pomodoro, projects, webhooks
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
@@ -8,4 +8,5 @@ api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
|||||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
api_router.include_router(character.router, tags=["character"])
|
api_router.include_router(character.router, tags=["character"])
|
||||||
api_router.include_router(projects.router, tags=["projects"])
|
api_router.include_router(projects.router, tags=["projects"])
|
||||||
|
api_router.include_router(memory.router, tags=["memory"])
|
||||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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.memory.service import MemoryService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
updates: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FactCreate(BaseModel):
|
||||||
|
content: str = Field(min_length=1)
|
||||||
|
category: str = "fact"
|
||||||
|
importance: int = Field(default=3, ge=1, le=5)
|
||||||
|
session_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSummaryUpdate(BaseModel):
|
||||||
|
summary: str = Field(min_length=1)
|
||||||
|
message_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory")
|
||||||
|
def get_memory_snapshot(
|
||||||
|
session_id: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return MemoryService(db).snapshot(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile")
|
||||||
|
def get_profile(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
return MemoryService(db).get_profile()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile")
|
||||||
|
def update_profile(
|
||||||
|
payload: ProfileUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return MemoryService(db).update_profile(payload.updates)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory/facts")
|
||||||
|
def list_facts(
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
limit: int = 30,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return MemoryService(db).recall_memories(query=query, category=category, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/memory/facts")
|
||||||
|
def create_fact(
|
||||||
|
payload: FactCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return MemoryService(db).remember_fact(
|
||||||
|
payload.content,
|
||||||
|
category=payload.category,
|
||||||
|
session_id=payload.session_id,
|
||||||
|
importance=payload.importance,
|
||||||
|
source="api",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/memory/facts/{memory_id}")
|
||||||
|
def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return MemoryService(db).forget_memory(memory_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/memory/sessions/{session_id}/summary")
|
||||||
|
def update_session_summary(
|
||||||
|
session_id: int,
|
||||||
|
payload: SessionSummaryUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return MemoryService(db).update_session_summary(
|
||||||
|
session_id,
|
||||||
|
payload.summary,
|
||||||
|
message_count=payload.message_count,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
@@ -12,7 +12,10 @@ TOOLS_INSTRUCTIONS = """
|
|||||||
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga).
|
||||||
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
|
||||||
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
|
||||||
- Снимок проектов/задач есть в контексте, но для актуализации вызывай tools. Никогда не пиши «ожидаю ответа от системы».
|
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
|
||||||
|
- «Запомни» → remember_fact. «Что помнишь» → recall_memories или снимок памяти в контексте.
|
||||||
|
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
|
||||||
|
- Никогда не пиши «ожидаю ответа от системы».
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
DEFAULT_CARD: dict[str, Any] = {
|
DEFAULT_CARD: dict[str, Any] = {
|
||||||
|
|||||||
@@ -47,9 +47,18 @@ POMODORO_TOOL_NAMES = frozenset({
|
|||||||
"get_pomodoro_history",
|
"get_pomodoro_history",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
MEMORY_TOOL_NAMES = frozenset({
|
||||||
|
"remember_fact",
|
||||||
|
"recall_memories",
|
||||||
|
"forget_memory",
|
||||||
|
"update_profile",
|
||||||
|
"update_session_summary",
|
||||||
|
})
|
||||||
|
|
||||||
# Не засорять чат служебными ответами
|
# Не засорять чат служебными ответами
|
||||||
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
||||||
"get_pomodoro_status",
|
"get_pomodoro_status",
|
||||||
|
"recall_memories",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +72,12 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(data, dict) and "error" in data:
|
if isinstance(data, dict) and "error" in data:
|
||||||
prefix = "⏱" if tool_name in POMODORO_TOOL_NAMES else "📋"
|
if tool_name in POMODORO_TOOL_NAMES:
|
||||||
|
prefix = "⏱"
|
||||||
|
elif tool_name in MEMORY_TOOL_NAMES:
|
||||||
|
prefix = "🧠"
|
||||||
|
else:
|
||||||
|
prefix = "📋"
|
||||||
return f"{prefix} {data['error']}"
|
return f"{prefix} {data['error']}"
|
||||||
|
|
||||||
if tool_name == "reset_pomodoro_cycle":
|
if tool_name == "reset_pomodoro_cycle":
|
||||||
@@ -109,6 +123,21 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
|
lines.append(f"- `{p.get('slug')}`: {p.get('name')} · Gitea: {gitea}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
if tool_name == "remember_fact" and data.get("ok"):
|
||||||
|
action = "обновлено" if data.get("action") == "updated" else "сохранено"
|
||||||
|
return f"🧠 **Память {action}** · #{data.get('memory_id')}: {data.get('content')}"
|
||||||
|
|
||||||
|
if tool_name == "forget_memory" and data.get("ok"):
|
||||||
|
return f"🧠 **Забыто** · #{data.get('memory_id')}: {data.get('forgotten')}"
|
||||||
|
|
||||||
|
if tool_name == "update_profile" and data.get("ok"):
|
||||||
|
profile = data.get("profile") or {}
|
||||||
|
parts = [f"{k}={v}" for k, v in profile.items() if v]
|
||||||
|
return f"🧠 **Профиль обновлён** · {', '.join(parts) or 'пусто'}"
|
||||||
|
|
||||||
|
if tool_name == "update_session_summary" and data.get("ok"):
|
||||||
|
return "🧠 **Сводка чата сохранена**"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.chat.notices import (
|
|||||||
format_pomodoro_context,
|
format_pomodoro_context,
|
||||||
format_tool_notice,
|
format_tool_notice,
|
||||||
)
|
)
|
||||||
|
from app.memory.context import format_memory_context, get_memory_snapshot
|
||||||
from app.projects.context import format_projects_context, get_projects_snapshot
|
from app.projects.context import format_projects_context, get_projects_snapshot
|
||||||
from app.db.models import ChatSession, Message
|
from app.db.models import ChatSession, Message
|
||||||
from app.llm.client import LLMClient
|
from app.llm.client import LLMClient
|
||||||
@@ -18,6 +19,7 @@ from app.pomodoro.service import PomodoroService
|
|||||||
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
from app.tools.registry import TOOL_DEFINITIONS, execute_tool
|
||||||
|
|
||||||
MAX_TOOL_ROUNDS = 5
|
MAX_TOOL_ROUNDS = 5
|
||||||
|
MAX_HISTORY_MESSAGES = 40
|
||||||
|
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
@@ -48,23 +50,31 @@ class ChatService:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _build_system_prompt(self) -> str:
|
def _build_system_prompt(self, session_id: int | None = None) -> str:
|
||||||
status = PomodoroService(self.db).get_status()
|
status = PomodoroService(self.db).get_status()
|
||||||
|
memory_snapshot = get_memory_snapshot(self.db, session_id)
|
||||||
projects_snapshot = get_projects_snapshot(self.db)
|
projects_snapshot = get_projects_snapshot(self.db)
|
||||||
return (
|
return (
|
||||||
f"{self.character.get_system_prompt()}\n\n"
|
f"{self.character.get_system_prompt()}\n\n"
|
||||||
|
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||||
f"{format_pomodoro_context(status)}\n\n"
|
f"{format_pomodoro_context(status)}\n\n"
|
||||||
f"{format_projects_context(projects_snapshot)}"
|
f"{format_projects_context(projects_snapshot)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
def _build_messages(self, session: ChatSession) -> list[dict[str, Any]]:
|
||||||
|
system_prompt = self._build_system_prompt(session.id)
|
||||||
|
all_chat = [m for m in session.messages if m.role != "notice"]
|
||||||
|
if len(all_chat) > MAX_HISTORY_MESSAGES:
|
||||||
|
system_prompt += (
|
||||||
|
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
|
||||||
|
f"из {len(all_chat)} сообщений. Раннее — в сводке сессии, если сохранена.]"
|
||||||
|
)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": self._build_system_prompt()}
|
{"role": "system", "content": system_prompt}
|
||||||
]
|
]
|
||||||
for msg in session.messages:
|
chat_messages = all_chat[-MAX_HISTORY_MESSAGES:] if len(all_chat) > MAX_HISTORY_MESSAGES else all_chat
|
||||||
if msg.role == "notice":
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
for msg in chat_messages:
|
||||||
content = msg.content or None
|
content = msg.content or None
|
||||||
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
entry: dict[str, Any] = {"role": msg.role, "content": content}
|
||||||
if msg.tool_calls_json:
|
if msg.tool_calls_json:
|
||||||
@@ -136,7 +146,9 @@ class ChatService:
|
|||||||
for tool_call in tool_calls:
|
for tool_call in tool_calls:
|
||||||
fn = tool_call["function"]
|
fn = tool_call["function"]
|
||||||
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
args = LLMClient.parse_tool_arguments(fn.get("arguments", ""))
|
||||||
result = await execute_tool(self.db, fn["name"], args)
|
result = await execute_tool(
|
||||||
|
self.db, fn["name"], args, session_id=session_id
|
||||||
|
)
|
||||||
tool_message = {
|
tool_message = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tool_call["id"],
|
"tool_call_id": tool_call["id"],
|
||||||
|
|||||||
@@ -93,6 +93,48 @@ class ProjectBinding(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
__tablename__ = "user_profile"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
data_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryFact(Base):
|
||||||
|
__tablename__ = "memory_facts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(64), default="fact", index=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="user")
|
||||||
|
session_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("chat_sessions.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
importance: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSummary(Base):
|
||||||
|
__tablename__ = "session_summaries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("chat_sessions.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkItem(Base):
|
class WorkItem(Base):
|
||||||
__tablename__ = "work_items"
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.memory.service import MemoryService
|
||||||
|
|
||||||
|
MAX_FACTS_IN_CONTEXT = 25
|
||||||
|
PROFILE_KEYS = ("name", "timezone", "language", "notes")
|
||||||
|
|
||||||
|
|
||||||
|
def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]:
|
||||||
|
return MemoryService(db).snapshot(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def format_memory_context(snapshot: dict[str, Any]) -> str:
|
||||||
|
lines = ["[Память и профиль — долгосрочный контекст]"]
|
||||||
|
|
||||||
|
profile = snapshot.get("profile") or {}
|
||||||
|
profile_lines = []
|
||||||
|
for key in PROFILE_KEYS:
|
||||||
|
value = (profile.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
profile_lines.append(f"- {key}: {value}")
|
||||||
|
if profile_lines:
|
||||||
|
lines.append("Профиль пользователя:")
|
||||||
|
lines.extend(profile_lines)
|
||||||
|
else:
|
||||||
|
lines.append("Профиль: не заполнен (можно уточнить имя, часовой пояс).")
|
||||||
|
|
||||||
|
summary = (snapshot.get("session_summary") or "").strip()
|
||||||
|
if summary:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Сводка текущего чата (ранние сообщения):")
|
||||||
|
lines.append(summary)
|
||||||
|
|
||||||
|
facts = snapshot.get("facts") or []
|
||||||
|
if facts:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Запомненные факты ({snapshot.get('total_facts', len(facts))}):")
|
||||||
|
for fact in facts[:MAX_FACTS_IN_CONTEXT]:
|
||||||
|
lines.append(
|
||||||
|
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Запомненные факты: пока нет.")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
"Правила памяти: "
|
||||||
|
"«запомни» → remember_fact. "
|
||||||
|
"«что ты помнишь» → recall_memories или ответ из снимка выше. "
|
||||||
|
"«забудь #N» → forget_memory. "
|
||||||
|
"Профиль (имя, timezone) → update_profile. "
|
||||||
|
"Длинный чат — update_session_summary с краткой сводкой темы. "
|
||||||
|
"Не выдумывай факты — только то, что в профиле/фактах или сказал пользователь."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import MemoryFact, SessionSummary, UserProfile
|
||||||
|
|
||||||
|
DEFAULT_PROFILE: dict[str, Any] = {
|
||||||
|
"name": "",
|
||||||
|
"timezone": "",
|
||||||
|
"language": "ru",
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_profile(self) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(select(UserProfile).limit(1))
|
||||||
|
if not row:
|
||||||
|
return dict(DEFAULT_PROFILE)
|
||||||
|
try:
|
||||||
|
data = json.loads(row.data_json or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
merged = dict(DEFAULT_PROFILE)
|
||||||
|
merged.update(data)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def update_profile(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = self.db.scalar(select(UserProfile).limit(1))
|
||||||
|
if not row:
|
||||||
|
row = UserProfile(data_json="{}")
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
current = self.get_profile()
|
||||||
|
for key, value in updates.items():
|
||||||
|
if value is None:
|
||||||
|
current.pop(key, None)
|
||||||
|
else:
|
||||||
|
current[key] = value
|
||||||
|
|
||||||
|
row.data_json = json.dumps(current, ensure_ascii=False)
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "profile": current}
|
||||||
|
|
||||||
|
def remember_fact(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
category: str = "fact",
|
||||||
|
source: str = "user",
|
||||||
|
session_id: int | None = None,
|
||||||
|
importance: int = 3,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = content.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустой факт")
|
||||||
|
|
||||||
|
existing = self.db.scalar(
|
||||||
|
select(MemoryFact).where(
|
||||||
|
MemoryFact.active.is_(True),
|
||||||
|
func.lower(MemoryFact.content) == text.lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
existing.category = category or existing.category
|
||||||
|
existing.importance = max(existing.importance, min(5, max(1, importance)))
|
||||||
|
existing.updated_at = datetime.now(timezone.utc)
|
||||||
|
if session_id:
|
||||||
|
existing.session_id = session_id
|
||||||
|
self.db.commit()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"action": "updated",
|
||||||
|
"memory_id": existing.id,
|
||||||
|
"content": existing.content,
|
||||||
|
"category": existing.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
fact = MemoryFact(
|
||||||
|
category=(category or "fact")[:64],
|
||||||
|
content=text[:2000],
|
||||||
|
source=source[:32],
|
||||||
|
session_id=session_id,
|
||||||
|
importance=min(5, max(1, importance)),
|
||||||
|
)
|
||||||
|
self.db.add(fact)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(fact)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"action": "created",
|
||||||
|
"memory_id": fact.id,
|
||||||
|
"content": fact.content,
|
||||||
|
"category": fact.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
def recall_memories(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(MemoryFact).order_by(
|
||||||
|
MemoryFact.importance.desc(),
|
||||||
|
MemoryFact.updated_at.desc(),
|
||||||
|
)
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(MemoryFact.active.is_(True))
|
||||||
|
if category:
|
||||||
|
stmt = stmt.where(MemoryFact.category == category)
|
||||||
|
if query:
|
||||||
|
pattern = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
MemoryFact.content.ilike(pattern),
|
||||||
|
MemoryFact.category.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
facts = self.db.scalars(stmt.limit(min(limit, 50))).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
"source": f.source,
|
||||||
|
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
]
|
||||||
|
|
||||||
|
def forget_memory(self, memory_id: int) -> dict[str, Any]:
|
||||||
|
fact = self.db.get(MemoryFact, memory_id)
|
||||||
|
if not fact:
|
||||||
|
raise ValueError(f"Память #{memory_id} не найдена")
|
||||||
|
fact.active = False
|
||||||
|
fact.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "memory_id": memory_id, "forgotten": fact.content}
|
||||||
|
|
||||||
|
def get_active_facts(self, limit: int = 25) -> list[MemoryFact]:
|
||||||
|
return list(
|
||||||
|
self.db.scalars(
|
||||||
|
select(MemoryFact)
|
||||||
|
.where(MemoryFact.active.is_(True))
|
||||||
|
.order_by(MemoryFact.importance.desc(), MemoryFact.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session_summary(self, session_id: int) -> SessionSummary | None:
|
||||||
|
return self.db.scalar(
|
||||||
|
select(SessionSummary).where(SessionSummary.session_id == session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_session_summary(
|
||||||
|
self,
|
||||||
|
session_id: int,
|
||||||
|
summary: str,
|
||||||
|
*,
|
||||||
|
message_count: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
text = summary.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Пустая сводка")
|
||||||
|
|
||||||
|
row = self.get_session_summary(session_id)
|
||||||
|
if not row:
|
||||||
|
row = SessionSummary(session_id=session_id)
|
||||||
|
self.db.add(row)
|
||||||
|
|
||||||
|
row.summary = text[:4000]
|
||||||
|
row.message_count = message_count
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
return {"ok": True, "session_id": session_id, "summary": row.summary}
|
||||||
|
|
||||||
|
def snapshot(self, session_id: int | None = None) -> dict[str, Any]:
|
||||||
|
facts = self.get_active_facts()
|
||||||
|
summary_row = self.get_session_summary(session_id) if session_id else None
|
||||||
|
return {
|
||||||
|
"profile": self.get_profile(),
|
||||||
|
"facts": [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"category": f.category,
|
||||||
|
"content": f.content,
|
||||||
|
"importance": f.importance,
|
||||||
|
}
|
||||||
|
for f in facts
|
||||||
|
],
|
||||||
|
"session_summary": summary_row.summary if summary_row else "",
|
||||||
|
"total_facts": len(facts),
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.memory.service import MemoryService
|
||||||
from app.pomodoro.service import PomodoroService
|
from app.pomodoro.service import PomodoroService
|
||||||
from app.projects.service import ProjectService
|
from app.projects.service import ProjectService
|
||||||
|
|
||||||
@@ -173,6 +174,99 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "remember_fact",
|
||||||
|
"description": (
|
||||||
|
"Сохранить факт в долгосрочную память. "
|
||||||
|
"Когда пользователь просит «запомни», или сообщает устойчивое предпочтение/факт."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {"type": "string", "description": "Что запомнить"},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "preference, person, habit, project, fact",
|
||||||
|
},
|
||||||
|
"importance": {"type": "integer", "description": "1-5, по умолчанию 3"},
|
||||||
|
},
|
||||||
|
"required": ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "recall_memories",
|
||||||
|
"description": (
|
||||||
|
"Поиск в долгосрочной памяти. "
|
||||||
|
"Когда спрашивают «что ты помнишь», «что я говорил про X»."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Подстрока для поиска"},
|
||||||
|
"category": {"type": "string"},
|
||||||
|
"limit": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "forget_memory",
|
||||||
|
"description": "Удалить (деактивировать) факт по id из recall_memories или снимка памяти.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"memory_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["memory_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "update_profile",
|
||||||
|
"description": (
|
||||||
|
"Обновить профиль пользователя: name, timezone, language, notes. "
|
||||||
|
"Передавай только изменившиеся поля."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"timezone": {"type": "string"},
|
||||||
|
"language": {"type": "string"},
|
||||||
|
"notes": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "update_session_summary",
|
||||||
|
"description": (
|
||||||
|
"Сохранить краткую сводку темы текущего чата "
|
||||||
|
"(когда диалог длинный или пользователь просит «сожми контекст»)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": {"type": "string", "description": "2-5 предложений о теме чата"},
|
||||||
|
"session_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["summary", "session_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -194,9 +288,16 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str:
|
async def execute_tool(
|
||||||
|
db: Session,
|
||||||
|
name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
*,
|
||||||
|
session_id: int | None = None,
|
||||||
|
) -> str:
|
||||||
pomodoro = PomodoroService(db)
|
pomodoro = PomodoroService(db)
|
||||||
projects = ProjectService(db)
|
projects = ProjectService(db)
|
||||||
|
memory = MemoryService(db)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if name == "get_pomodoro_status":
|
if name == "get_pomodoro_status":
|
||||||
@@ -240,6 +341,34 @@ async def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str
|
|||||||
limit=arguments.get("limit", 20),
|
limit=arguments.get("limit", 20),
|
||||||
status=arguments.get("status"),
|
status=arguments.get("status"),
|
||||||
)
|
)
|
||||||
|
elif name == "remember_fact":
|
||||||
|
result = memory.remember_fact(
|
||||||
|
arguments.get("content", ""),
|
||||||
|
category=arguments.get("category", "fact"),
|
||||||
|
importance=arguments.get("importance", 3),
|
||||||
|
session_id=session_id,
|
||||||
|
source="tool",
|
||||||
|
)
|
||||||
|
elif name == "recall_memories":
|
||||||
|
result = memory.recall_memories(
|
||||||
|
query=arguments.get("query"),
|
||||||
|
category=arguments.get("category"),
|
||||||
|
limit=arguments.get("limit", 20),
|
||||||
|
)
|
||||||
|
elif name == "forget_memory":
|
||||||
|
result = memory.forget_memory(int(arguments["memory_id"]))
|
||||||
|
elif name == "update_profile":
|
||||||
|
updates = {
|
||||||
|
k: arguments[k]
|
||||||
|
for k in ("name", "timezone", "language", "notes")
|
||||||
|
if k in arguments and arguments[k] is not None
|
||||||
|
}
|
||||||
|
result = memory.update_profile(updates)
|
||||||
|
elif name == "update_session_summary":
|
||||||
|
result = memory.update_session_summary(
|
||||||
|
int(arguments["session_id"]),
|
||||||
|
arguments.get("summary", ""),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,9 @@
|
|||||||
Когда спрашивает что делал — get_pomodoro_history.
|
Когда спрашивает что делал — get_pomodoro_history.
|
||||||
|
|
||||||
Не выдумывай данные о таймере — всегда используй инструменты.
|
Не выдумывай данные о таймере — всегда используй инструменты.
|
||||||
|
|
||||||
|
Память:
|
||||||
|
- «Запомни» → remember_fact
|
||||||
|
- «Что ты помнишь» → recall_memories или факты из контекста
|
||||||
|
- Имя, часовой пояс → update_profile
|
||||||
|
- Не выдумывай факты о пользователе
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function noticeLabel(content: string): string {
|
|||||||
if (content.startsWith("⏱")) return "таймер";
|
if (content.startsWith("⏱")) return "таймер";
|
||||||
if (content.startsWith("📋")) return "задачи";
|
if (content.startsWith("📋")) return "задачи";
|
||||||
if (content.startsWith("🔀")) return "git";
|
if (content.startsWith("🔀")) return "git";
|
||||||
|
if (content.startsWith("🧠")) return "память";
|
||||||
return "система";
|
return "система";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user