fixed memmory

This commit is contained in:
2026-06-10 08:32:20 +03:00
parent 5a9d26fbf4
commit c56471050c
6 changed files with 125 additions and 26 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ TOOLS_INSTRUCTIONS = """
- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). - list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД).
- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. - create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug.
- Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary. - Память: remember_fact, recall_memories, forget_memory, update_profile, update_session_summary.
- «Запомни» → remember_fact. «Что помнишь» → recall_memories или снимок памяти в контексте. - «Запомни» → remember_fact. «Кто я» / «сколько мне лет» → профиль и факты из блока [Память], не выдумывай.
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools. - Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
- Никогда не пиши «ожидаю ответа от системы». - Никогда не пиши «ожидаю ответа от системы».
""".strip() """.strip()
+11 -1
View File
@@ -11,7 +11,11 @@ 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.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.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
@@ -64,6 +68,12 @@ class ChatService:
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) system_prompt = self._build_system_prompt(session.id)
all_chat = [m for m in session.messages if m.role != "notice"] 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: if len(all_chat) > MAX_HISTORY_MESSAGES:
system_prompt += ( system_prompt += (
f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} " f"\n\n[История чата: в контексте последние {MAX_HISTORY_MESSAGES} "
+31 -6
View File
@@ -4,8 +4,10 @@ from sqlalchemy.orm import Session
from app.memory.service import MemoryService from app.memory.service import MemoryService
from app.memory.parse import is_identity_question
MAX_FACTS_IN_CONTEXT = 25 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]: 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("")
lines.append( lines.append(
"Правила памяти: " "Правила памяти: "
"«запомни» → remember_fact. " "«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
"«что ты помнишь» → recall_memories или ответ из снимка выше. " "«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
"«забудь #N» → forget_memory. " "«забудь #N» → forget_memory. "
"Профиль (имя, timezone) → update_profile. " "Длинный чат — update_session_summary."
"Длинный чат — update_session_summary с краткой сводкой темы. "
"Не выдумывай факты — только то, что в профиле/фактах или сказал пользователь."
) )
return "\n".join(lines) 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)
+40
View File
@@ -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
+39 -17
View File
@@ -2,13 +2,15 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import func, or_, select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import MemoryFact, SessionSummary, UserProfile 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] = { DEFAULT_PROFILE: dict[str, Any] = {
"name": "", "name": "",
"age": "",
"timezone": "", "timezone": "",
"language": "ru", "language": "ru",
"notes": "", "notes": "",
@@ -50,6 +52,20 @@ class MemoryService:
self.db.commit() self.db.commit()
return {"ok": True, "profile": current} 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( def remember_fact(
self, self,
content: str, content: str,
@@ -63,26 +79,28 @@ class MemoryService:
if not text: if not text:
raise ValueError("Пустой факт") raise ValueError("Пустой факт")
existing = self.db.scalar( profile_sync = self._sync_identity_to_profile(text)
select(MemoryFact).where(
MemoryFact.active.is_(True), existing = self._find_similar_fact(text)
func.lower(MemoryFact.content) == text.lower(),
)
)
if existing: if existing:
if len(text) > len(existing.content):
existing.content = text[:2000]
existing.category = category or existing.category existing.category = category or existing.category
existing.importance = max(existing.importance, min(5, max(1, importance))) existing.importance = max(existing.importance, min(5, max(1, importance)))
existing.updated_at = datetime.now(timezone.utc) existing.updated_at = datetime.now(timezone.utc)
if session_id: if session_id:
existing.session_id = session_id existing.session_id = session_id
self.db.commit() self.db.commit()
return { result = {
"ok": True, "ok": True,
"action": "updated", "action": "updated",
"memory_id": existing.id, "memory_id": existing.id,
"content": existing.content, "content": existing.content,
"category": existing.category, "category": existing.category,
} }
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
fact = MemoryFact( fact = MemoryFact(
category=(category or "fact")[:64], category=(category or "fact")[:64],
@@ -94,13 +112,16 @@ class MemoryService:
self.db.add(fact) self.db.add(fact)
self.db.commit() self.db.commit()
self.db.refresh(fact) self.db.refresh(fact)
return { result = {
"ok": True, "ok": True,
"action": "created", "action": "created",
"memory_id": fact.id, "memory_id": fact.id,
"content": fact.content, "content": fact.content,
"category": fact.category, "category": fact.category,
} }
if profile_sync:
result["profile"] = profile_sync.get("profile")
return result
def recall_memories( def recall_memories(
self, self,
@@ -118,15 +139,16 @@ class MemoryService:
stmt = stmt.where(MemoryFact.active.is_(True)) stmt = stmt.where(MemoryFact.active.is_(True))
if category: if category:
stmt = stmt.where(MemoryFact.category == category) stmt = stmt.where(MemoryFact.category == category)
facts = self.db.scalars(stmt.limit(100)).all()
if query: if query:
pattern = f"%{query.strip()}%" qnorm = normalize_text(query)
stmt = stmt.where( facts = [
or_( f
MemoryFact.content.ilike(pattern), for f in facts
MemoryFact.category.ilike(pattern), if qnorm in normalize_text(f.content)
) or qnorm in normalize_text(f.category)
) ]
facts = self.db.scalars(stmt.limit(min(limit, 50))).all() facts = facts[: min(limit, 50)]
return [ return [
{ {
"id": f.id, "id": f.id,
+2 -1
View File
@@ -241,6 +241,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "object", "type": "object",
"properties": { "properties": {
"name": {"type": "string"}, "name": {"type": "string"},
"age": {"type": "string", "description": "Возраст пользователя"},
"timezone": {"type": "string"}, "timezone": {"type": "string"},
"language": {"type": "string"}, "language": {"type": "string"},
"notes": {"type": "string"}, "notes": {"type": "string"},
@@ -360,7 +361,7 @@ async def execute_tool(
elif name == "update_profile": elif name == "update_profile":
updates = { updates = {
k: arguments[k] 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 if k in arguments and arguments[k] is not None
} }
result = memory.update_profile(updates) result = memory.update_profile(updates)