fixed memmory
This commit is contained in:
@@ -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,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} "
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user