added RAG, Multiuser, TG bot
This commit is contained in:
@@ -1,83 +1,89 @@
|
||||
from typing import Any
|
||||
|
||||
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", "age", "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 (имя/возраст также пишутся в профиль). "
|
||||
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
|
||||
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
|
||||
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
|
||||
"«забудь #N» → forget_memory. "
|
||||
"Длинный чат — 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)
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.memory.service import MemoryService
|
||||
|
||||
from app.memory.parse import is_identity_question
|
||||
|
||||
PROFILE_KEYS = ("name", "age", "timezone", "language", "notes")
|
||||
|
||||
|
||||
def get_memory_snapshot(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
session_id: int | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return MemoryService(db, user_id).snapshot(session_id, query=query)
|
||||
|
||||
|
||||
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))}):")
|
||||
limit = get_settings().memory_facts_in_context
|
||||
for fact in facts[:limit]:
|
||||
lines.append(
|
||||
f"- [{fact.get('category')}] #{fact.get('id')} {fact.get('content')}"
|
||||
)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("Запомненные факты: пока нет.")
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Правила памяти: "
|
||||
"«запомни» → remember_fact (имя/возраст также пишутся в профиль). "
|
||||
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
|
||||
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
|
||||
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
|
||||
"«забудь #N» → forget_memory. "
|
||||
"Длинный чат — 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)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from typing import Any
|
||||
|
||||
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", "age", "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 (имя/возраст также пишутся в профиль). "
|
||||
"«кто я» / «сколько мне лет» → ответь из профиля и фактов выше, БЕЗ выдумок. "
|
||||
"Роль персонажа (сын, мать и т.п.) — стиль общения, НЕ биография пользователя. "
|
||||
"Если профиль и факты пусты — честно скажи «не помню» и предложи запомнить. "
|
||||
"«забудь #N» → forget_memory. "
|
||||
"Длинный чат — 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)
|
||||
+153
-152
@@ -1,152 +1,153 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.llm.client import LLMClient
|
||||
from app.memory.service import MemoryService
|
||||
from app.projects.structuring import strip_markdown_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SKIP_USER_PATTERN = re.compile(
|
||||
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
EXTRACTION_PROMPT = """
|
||||
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
|
||||
Ответь ТОЛЬКО JSON без markdown.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"facts": [
|
||||
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
|
||||
],
|
||||
"profile": {"name": "", "age": "", "timezone": "", "notes": ""}
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
|
||||
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
|
||||
- profile — только поля с новыми значениями (пустые строки не включай).
|
||||
- facts — короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут …»).
|
||||
- Если нечего сохранять — {"facts": [], "profile": {}}.
|
||||
- Не дублируй уже известное (см. текущий профиль и факты ниже).
|
||||
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _should_skip_extraction(user_text: str) -> bool:
|
||||
text = user_text.strip()
|
||||
if len(text) < 4:
|
||||
return True
|
||||
if SKIP_USER_PATTERN.match(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _call_extractor(
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
snapshot: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
profile = snapshot.get("profile") or {}
|
||||
facts = snapshot.get("facts") or []
|
||||
known = [
|
||||
f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
|
||||
"Факты:",
|
||||
*[f"- {f.get('content')}" for f in facts[:30]],
|
||||
]
|
||||
|
||||
settings = get_settings()
|
||||
extract_model = settings.memory_extract_model.strip() or None
|
||||
|
||||
llm = LLMClient()
|
||||
result = await llm.complete(
|
||||
[
|
||||
{"role": "system", "content": EXTRACTION_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"\n".join(known)
|
||||
+ "\n\n---\nДиалог:\nПользователь: "
|
||||
+ user_text
|
||||
+ "\nАссистент: "
|
||||
+ assistant_text[:1500]
|
||||
),
|
||||
},
|
||||
],
|
||||
temperature=0.2,
|
||||
model=extract_model,
|
||||
for_extraction=True,
|
||||
)
|
||||
raw = strip_markdown_json(result.get("content") or "")
|
||||
if not raw:
|
||||
return {"facts": [], "profile": {}}
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
return {"facts": [], "profile": {}}
|
||||
return parsed
|
||||
|
||||
|
||||
async def extract_after_turn(
|
||||
db: Session,
|
||||
session_id: int,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not force and _should_skip_extraction(user_text):
|
||||
return {"ok": True, "skipped": "short_message", "saved": []}
|
||||
|
||||
if not (assistant_text or "").strip():
|
||||
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
|
||||
|
||||
memory = MemoryService(db)
|
||||
snapshot = memory.snapshot(session_id)
|
||||
|
||||
try:
|
||||
parsed = await _call_extractor(user_text, assistant_text, snapshot)
|
||||
except (json.JSONDecodeError, Exception) as exc:
|
||||
logger.warning("Memory extraction failed: %s", exc)
|
||||
return {"ok": False, "error": str(exc), "saved": []}
|
||||
|
||||
saved: list[dict[str, Any]] = []
|
||||
|
||||
profile_updates = parsed.get("profile") or {}
|
||||
if isinstance(profile_updates, dict):
|
||||
filtered = {
|
||||
k: str(v).strip()
|
||||
for k, v in profile_updates.items()
|
||||
if v and str(v).strip()
|
||||
}
|
||||
if filtered:
|
||||
memory.update_profile(filtered)
|
||||
saved.append({"type": "profile", "updates": filtered})
|
||||
|
||||
facts = parsed.get("facts") or []
|
||||
if isinstance(facts, list):
|
||||
for item in facts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
content = (item.get("content") or "").strip()
|
||||
if not content or len(content) < 3:
|
||||
continue
|
||||
try:
|
||||
result = memory.remember_fact(
|
||||
content,
|
||||
category=str(item.get("category") or "fact")[:64],
|
||||
importance=int(item.get("importance") or 3),
|
||||
session_id=session_id,
|
||||
source="auto",
|
||||
)
|
||||
saved.append({"type": "fact", **result})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return {"ok": True, "saved": saved, "count": len(saved)}
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.llm.client import LLMClient
|
||||
from app.memory.service import MemoryService
|
||||
from app.projects.structuring import strip_markdown_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SKIP_USER_PATTERN = re.compile(
|
||||
r"^(ок|ok|да|нет|спасибо|thanks|\.{1,3}|👍|\+1)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
EXTRACTION_PROMPT = """
|
||||
Ты извлекаешь долгосрочные факты о пользователе из фрагмента диалога.
|
||||
Ответь ТОЛЬКО JSON без markdown.
|
||||
|
||||
Схема:
|
||||
{
|
||||
"facts": [
|
||||
{"content": "текст факта", "category": "preference|person|habit|project|fact", "importance": 1}
|
||||
],
|
||||
"profile": {"name": "", "age": "", "timezone": "", "notes": ""}
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Сохраняй устойчивое: имя, возраст, предпочтения, привычки, проекты, семья, работа.
|
||||
- НЕ сохраняй: статус помидоро, погоду, разовые команды, ролевую игру, выдумки ассистента.
|
||||
- profile — только поля с новыми значениями (пустые строки не включай).
|
||||
- facts — короткие утверждения от первого лица пользователя («люблю кофе», «меня зовут …»).
|
||||
- Если нечего сохранять — {"facts": [], "profile": {}}.
|
||||
- Не дублируй уже известное (см. текущий профиль и факты ниже).
|
||||
- importance: 5 критично (имя), 4 важно, 3 обычно, 2 мелочь.
|
||||
""".strip()
|
||||
|
||||
|
||||
def _should_skip_extraction(user_text: str) -> bool:
|
||||
text = user_text.strip()
|
||||
if len(text) < 4:
|
||||
return True
|
||||
if SKIP_USER_PATTERN.match(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _call_extractor(
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
snapshot: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
profile = snapshot.get("profile") or {}
|
||||
facts = snapshot.get("facts") or []
|
||||
known = [
|
||||
f"Профиль: {json.dumps(profile, ensure_ascii=False)}",
|
||||
"Факты:",
|
||||
*[f"- {f.get('content')}" for f in facts[:30]],
|
||||
]
|
||||
|
||||
settings = get_settings()
|
||||
extract_model = settings.memory_extract_model.strip() or None
|
||||
|
||||
llm = LLMClient()
|
||||
result = await llm.complete(
|
||||
[
|
||||
{"role": "system", "content": EXTRACTION_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"\n".join(known)
|
||||
+ "\n\n---\nДиалог:\nПользователь: "
|
||||
+ user_text
|
||||
+ "\nАссистент: "
|
||||
+ assistant_text[:1500]
|
||||
),
|
||||
},
|
||||
],
|
||||
temperature=0.2,
|
||||
model=extract_model,
|
||||
for_extraction=True,
|
||||
)
|
||||
raw = strip_markdown_json(result.get("content") or "")
|
||||
if not raw:
|
||||
return {"facts": [], "profile": {}}
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
return {"facts": [], "profile": {}}
|
||||
return parsed
|
||||
|
||||
|
||||
async def extract_after_turn(
|
||||
db: Session,
|
||||
session_id: int,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
*,
|
||||
user_id: int,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not force and _should_skip_extraction(user_text):
|
||||
return {"ok": True, "skipped": "short_message", "saved": []}
|
||||
|
||||
if not (assistant_text or "").strip():
|
||||
return {"ok": True, "skipped": "no_assistant_reply", "saved": []}
|
||||
|
||||
memory = MemoryService(db, user_id)
|
||||
snapshot = memory.snapshot(session_id)
|
||||
|
||||
try:
|
||||
parsed = await _call_extractor(user_text, assistant_text, snapshot)
|
||||
except (json.JSONDecodeError, Exception) as exc:
|
||||
logger.warning("Memory extraction failed: %s", exc)
|
||||
return {"ok": False, "error": str(exc), "saved": []}
|
||||
|
||||
saved: list[dict[str, Any]] = []
|
||||
|
||||
profile_updates = parsed.get("profile") or {}
|
||||
if isinstance(profile_updates, dict):
|
||||
filtered = {
|
||||
k: str(v).strip()
|
||||
for k, v in profile_updates.items()
|
||||
if v and str(v).strip()
|
||||
}
|
||||
if filtered:
|
||||
memory.update_profile(filtered)
|
||||
saved.append({"type": "profile", "updates": filtered})
|
||||
|
||||
facts = parsed.get("facts") or []
|
||||
if isinstance(facts, list):
|
||||
for item in facts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
content = (item.get("content") or "").strip()
|
||||
if not content or len(content) < 3:
|
||||
continue
|
||||
try:
|
||||
result = memory.remember_fact(
|
||||
content,
|
||||
category=str(item.get("category") or "fact")[:64],
|
||||
importance=int(item.get("importance") or 3),
|
||||
session_id=session_id,
|
||||
source="auto",
|
||||
)
|
||||
saved.append({"type": "fact", **result})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return {"ok": True, "saved": saved, "count": len(saved)}
|
||||
|
||||
+300
-228
@@ -1,228 +1,300 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
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": "",
|
||||
}
|
||||
|
||||
|
||||
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 _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,
|
||||
*,
|
||||
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("Пустой факт")
|
||||
|
||||
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()
|
||||
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],
|
||||
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)
|
||||
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,
|
||||
*,
|
||||
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)
|
||||
facts = self.db.scalars(stmt.limit(100)).all()
|
||||
if query:
|
||||
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,
|
||||
"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,
|
||||
"source": f.source,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in facts
|
||||
],
|
||||
"session_summary": summary_row.summary if summary_row else "",
|
||||
"total_facts": len(facts),
|
||||
}
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
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": "",
|
||||
}
|
||||
|
||||
|
||||
class MemoryService:
|
||||
def __init__(self, db: Session, user_id: int):
|
||||
self.db = db
|
||||
self.user_id = user_id
|
||||
|
||||
@staticmethod
|
||||
def _schedule_rag(coro) -> None:
|
||||
def runner() -> None:
|
||||
asyncio.run(coro)
|
||||
|
||||
threading.Thread(target=runner, daemon=True).start()
|
||||
|
||||
def get_profile(self) -> dict[str, Any]:
|
||||
row = self.db.scalar(select(UserProfile).where(UserProfile.user_id == self.user_id).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).where(UserProfile.user_id == self.user_id).limit(1))
|
||||
if not row:
|
||||
row = UserProfile(user_id=self.user_id, 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 _find_similar_fact(self, text: str) -> MemoryFact | None:
|
||||
for fact in self.db.scalars(
|
||||
select(MemoryFact).where(MemoryFact.user_id == self.user_id, 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,
|
||||
*,
|
||||
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("Пустой факт")
|
||||
|
||||
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()
|
||||
from app.rag.ingest import index_memory_fact
|
||||
|
||||
self._schedule_rag(index_memory_fact(existing))
|
||||
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(
|
||||
user_id=self.user_id,
|
||||
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)
|
||||
from app.rag.ingest import index_memory_fact
|
||||
|
||||
self._schedule_rag(index_memory_fact(fact))
|
||||
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,
|
||||
*,
|
||||
query: str | None = None,
|
||||
category: str | None = None,
|
||||
limit: int = 20,
|
||||
active_only: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
stmt = select(MemoryFact).where(MemoryFact.user_id == self.user_id).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)
|
||||
facts = self.db.scalars(stmt.limit(100)).all()
|
||||
if query:
|
||||
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,
|
||||
"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 or fact.user_id != self.user_id:
|
||||
raise ValueError(f"Память #{memory_id} не найдена")
|
||||
fact.active = False
|
||||
fact.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
from app.rag.ingest import deactivate_memory_fact
|
||||
|
||||
self._schedule_rag(deactivate_memory_fact(memory_id))
|
||||
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.user_id == self.user_id, 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:
|
||||
from app.db.models import ChatSession
|
||||
|
||||
session = self.db.get(ChatSession, session_id)
|
||||
if not session or session.user_id != self.user_id:
|
||||
return 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("Пустая сводка")
|
||||
|
||||
from app.db.models import ChatSession
|
||||
|
||||
session = self.db.get(ChatSession, session_id)
|
||||
if not session or session.user_id != self.user_id:
|
||||
raise ValueError("Session not found")
|
||||
|
||||
row = self.db.scalar(
|
||||
select(SessionSummary).where(SessionSummary.session_id == 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()
|
||||
from app.rag.ingest import index_session_summary
|
||||
|
||||
self._schedule_rag(index_session_summary(session_id, row.summary))
|
||||
return {"ok": True, "session_id": session_id, "summary": row.summary}
|
||||
|
||||
def snapshot(self, session_id: int | None = None, query: str | None = None) -> dict[str, Any]:
|
||||
from app.config import get_settings
|
||||
from app.settings.service import SettingsService
|
||||
|
||||
settings = get_settings()
|
||||
svc = SettingsService(self.db)
|
||||
rag_on = bool(svc.get_effective("rag_enabled")) and settings.rag_enabled
|
||||
facts_payload: list[dict[str, Any]]
|
||||
total_facts = len(self.get_active_facts(limit=500))
|
||||
if rag_on and (query or "").strip():
|
||||
async def _load() -> list[dict[str, Any]]:
|
||||
from app.rag.retriever import retrieve_memory_facts
|
||||
|
||||
top_k = int(svc.get_effective("rag_top_k"))
|
||||
return await retrieve_memory_facts(query or "", user_id=self.user_id, top_k=top_k)
|
||||
|
||||
try:
|
||||
rag_facts = asyncio.run(_load())
|
||||
except Exception:
|
||||
rag_facts = []
|
||||
if rag_facts:
|
||||
facts_payload = rag_facts
|
||||
else:
|
||||
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
|
||||
facts_payload = [
|
||||
{
|
||||
"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
|
||||
]
|
||||
else:
|
||||
facts = self.get_active_facts(limit=settings.memory_facts_in_context)
|
||||
facts_payload = [
|
||||
{
|
||||
"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
|
||||
]
|
||||
summary_row = self.get_session_summary(session_id) if session_id else None
|
||||
return {
|
||||
"profile": self.get_profile(),
|
||||
"facts": facts_payload,
|
||||
"session_summary": summary_row.summary if summary_row else "",
|
||||
"total_facts": total_facts,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
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": "",
|
||||
}
|
||||
|
||||
|
||||
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 _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,
|
||||
*,
|
||||
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("Пустой факт")
|
||||
|
||||
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()
|
||||
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],
|
||||
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)
|
||||
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,
|
||||
*,
|
||||
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)
|
||||
facts = self.db.scalars(stmt.limit(100)).all()
|
||||
if query:
|
||||
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,
|
||||
"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,
|
||||
"source": f.source,
|
||||
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
for f in facts
|
||||
],
|
||||
"session_summary": summary_row.summary if summary_row else "",
|
||||
"total_facts": len(facts),
|
||||
}
|
||||
Reference in New Issue
Block a user