From c56471050cfbeffef2a18963a5e7a5546be2efef Mon Sep 17 00:00:00 2001 From: grigo Date: Wed, 10 Jun 2026 08:32:20 +0300 Subject: [PATCH] fixed memmory --- backend/app/character/card.py | 3 +- backend/app/chat/service.py | 12 +++++++- backend/app/memory/context.py | 37 +++++++++++++++++++---- backend/app/memory/parse.py | 40 +++++++++++++++++++++++++ backend/app/memory/service.py | 56 ++++++++++++++++++++++++----------- backend/app/tools/registry.py | 3 +- 6 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 backend/app/memory/parse.py diff --git a/backend/app/character/card.py b/backend/app/character/card.py index 5397d73..684b66e 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -13,7 +13,8 @@ TOOLS_INSTRUCTIONS = """ - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. -- «Запомни» → remember_fact. «Что помнишь» → recall_memories или снимок памяти в контексте. +- «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай. +- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе. - Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. - Никогда не пиши «ожидаю ответа от системы». """.strip() diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index de73c9a..5d44b24 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -11,7 +11,11 @@ from app.chat.notices import ( format_pomodoro_context, format_tool_notice, ) -from app.memory.context import format_memory_context, get_memory_snapshot +from app.memory.context import ( + format_identity_hint, + format_memory_context, + get_memory_snapshot, +) from app.projects.context import format_projects_context, get_projects_snapshot from app.db.models import ChatSession, Message from app.llm.client import LLMClient @@ -64,6 +68,12 @@ class ChatService: 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"] + last_user = next((m.content for m in reversed(all_chat) if m.role == "user"), "") + if last_user: + memory_snapshot = get_memory_snapshot(self.db, session.id) + identity_hint = format_identity_hint(memory_snapshot, last_user) + if identity_hint: + system_prompt += f"\n\n{identity_hint}" if len(all_chat) > MAX_HISTORY_MESSAGES: system_prompt += ( f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} " diff --git a/backend/app/memory/context.py b/backend/app/memory/context.py index 08310aa..c950e00 100644 --- a/backend/app/memory/context.py +++ b/backend/app/memory/context.py @@ -4,8 +4,10 @@ from sqlalchemy.orm import Session from app.memory.service import MemoryService +from app.memory.parse import is_identity_question + MAX_FACTS_IN_CONTEXT = 25 -PROFILE_KEYS = ("name", "timezone", "language", "notes") +PROFILE_KEYS = ("name", "age", "timezone", "language", "notes") def get_memory_snapshot(db: Session, session_id: int | None = None) -> dict[str, Any]: @@ -48,11 +50,34 @@ def format_memory_context(snapshot: dict[str, Any]) -> str: lines.append("") lines.append( "Правила памяти: " - "«запомни» → remember_fact. " - "«что ты помнишь» → recall_memories или ответ из снимка выше. " + "«запомни» → remember_fact (имя/возраст также пишутся в профиль). " + "«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. " + "Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. " + "Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. " "«забудь #N» → forget_memory. " - "Профиль (имя, timezone) → update_profile. " - "Длинный чат — update_session_summary с краткой сводкой темы. " - "Не выдумывай факты — только то, что в профиле/фактах или сказал пользователь." + "Длинный чат — update_session_summary." ) return "\n".join(lines) + + +def format_identity_hint(snapshot: dict[str, Any], user_text: str) -> str: + if not is_identity_question(user_text): + return "" + + profile = snapshot.get("profile") or {} + facts = snapshot.get("facts") or [] + lines = [ + "[Вопрос об идентичности пользователя]", + "Ответь ТОЛЬКО из данных ниже. Не придумывай роли из сценария персонажа.", + ] + name = (profile.get("name") or "").strip() + age = (profile.get("age") or "").strip() + if name: + lines.append(f"Имя: {name}") + if age: + lines.append(f"Возраст: {age} лет") + for fact in facts: + lines.append(f"Факт: {fact.get('content')}") + if not name and not age and not facts: + lines.append("Данных нет — скажи, что не помнишь.") + return "\n".join(lines) diff --git a/backend/app/memory/parse.py b/backend/app/memory/parse.py new file mode 100644 index 0000000..12f8c84 --- /dev/null +++ b/backend/app/memory/parse.py @@ -0,0 +1,40 @@ +import re + +IDENTITY_QUESTION = re.compile( + r"(кто\s+я|как\s+меня\s+зовут|сколько\s+мне\s+лет|" + r"что\s+ты\s+(помнишь|знаешь)\s+(обо\s+мне|про\s+меня)|" + r"напомни\s+(кто\s+я|про\s+меня))", + re.IGNORECASE, +) + +NAME_PATTERN = re.compile( + r"(?:меня\s+зовут|имя[:\s]+|зовут)\s+([A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё\-]*)", + re.IGNORECASE, +) +AGE_PATTERN = re.compile(r"(?:мне\s+(\d{1,3})\s+лет|возраст[:\s]+(\d{1,3}))", re.IGNORECASE) + + +def normalize_text(text: str) -> str: + return " ".join(text.casefold().split()) + + +def is_identity_question(text: str) -> bool: + return bool(IDENTITY_QUESTION.search(text)) + + +def parse_identity(text: str) -> dict[str, str]: + result: dict[str, str] = {} + name_match = NAME_PATTERN.search(text) + if name_match: + result["name"] = name_match.group(1) + age_match = AGE_PATTERN.search(text) + if age_match: + result["age"] = age_match.group(1) or age_match.group(2) + return result + + +def texts_are_similar(a: str, b: str) -> bool: + na, nb = normalize_text(a), normalize_text(b) + if na == nb: + return True + return na in nb or nb in na diff --git a/backend/app/memory/service.py b/backend/app/memory/service.py index ec06657..cc2cbc6 100644 --- a/backend/app/memory/service.py +++ b/backend/app/memory/service.py @@ -2,13 +2,15 @@ import json from datetime import datetime, timezone from typing import Any -from sqlalchemy import func, or_, select +from sqlalchemy import select from sqlalchemy.orm import Session from app.db.models import MemoryFact, SessionSummary, UserProfile +from app.memory.parse import normalize_text, parse_identity, texts_are_similar DEFAULT_PROFILE: dict[str, Any] = { "name": "", + "age": "", "timezone": "", "language": "ru", "notes": "", @@ -50,6 +52,20 @@ class MemoryService: self.db.commit() return {"ok": True, "profile": current} + def _find_similar_fact(self, text: str) -> MemoryFact | None: + for fact in self.db.scalars( + select(MemoryFact).where(MemoryFact.active.is_(True)) + ): + if texts_are_similar(fact.content, text): + return fact + return None + + def _sync_identity_to_profile(self, text: str) -> dict[str, Any] | None: + parsed = parse_identity(text) + if not parsed: + return None + return self.update_profile(parsed) + def remember_fact( self, content: str, @@ -63,26 +79,28 @@ class MemoryService: if not text: raise ValueError("Пустой факт") - existing = self.db.scalar( - select(MemoryFact).where( - MemoryFact.active.is_(True), - func.lower(MemoryFact.content) == text.lower(), - ) - ) + profile_sync = self._sync_identity_to_profile(text) + + existing = self._find_similar_fact(text) if existing: + if len(text) > len(existing.content): + existing.content = text[:2000] 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 { + result = { "ok": True, "action": "updated", "memory_id": existing.id, "content": existing.content, "category": existing.category, } + if profile_sync: + result["profile"] = profile_sync.get("profile") + return result fact = MemoryFact( category=(category or "fact")[:64], @@ -94,13 +112,16 @@ class MemoryService: self.db.add(fact) self.db.commit() self.db.refresh(fact) - return { + result = { "ok": True, "action": "created", "memory_id": fact.id, "content": fact.content, "category": fact.category, } + if profile_sync: + result["profile"] = profile_sync.get("profile") + return result def recall_memories( self, @@ -118,15 +139,16 @@ class MemoryService: stmt = stmt.where(MemoryFact.active.is_(True)) if category: stmt = stmt.where(MemoryFact.category == category) + facts = self.db.scalars(stmt.limit(100)).all() 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() + qnorm = normalize_text(query) + facts = [ + f + for f in facts + if qnorm in normalize_text(f.content) + or qnorm in normalize_text(f.category) + ] + facts = facts[: min(limit, 50)] return [ { "id": f.id, diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 796ef72..65e69fe 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -241,6 +241,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "object", "properties": { "name": {"type": "string"}, + "age": {"type": "string", "description": "Возраст пользователя"}, "timezone": {"type": "string"}, "language": {"type": "string"}, "notes": {"type": "string"}, @@ -360,7 +361,7 @@ async def execute_tool( elif name == "update_profile": updates = { k: arguments[k] - for k in ("name", "timezone", "language", "notes") + for k in ("name", "age", "timezone", "language", "notes") if k in arguments and arguments[k] is not None } result = memory.update_profile(updates)