diff --git a/.env.example b/.env.example index 48c2a35..bb5a4ee 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 DATABASE_URL=sqlite:///./data/assistant.db CORS_ORIGINS=http://localhost:5173,http://localhost:8080,http://localhost:3080 SYSTEM_PROMPT_PATH=./prompts/assistant.md +MEMORY_AUTO_EXTRACT=true # Taiga (on host :9000, nginx → taiga.grigowashere.ru) TAIGA_BASE_URL=http://host.docker.internal:9000 diff --git a/README.md b/README.md index c66306a..bd3201a 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,11 @@ data/ SQLite БД (создаётся автоматически) В system prompt на каждый ответ: персонаж → **память** → помидоро → проекты. История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`. +**Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет +устойчивые факты (`source=auto`). Отключить: `MEMORY_AUTO_EXTRACT=false`. + +**UI:** вкладка `/memory` — профиль, факты, JSON-снимок для отладки. + ### Tools - `remember_fact` — «запомни, что…» @@ -198,8 +203,8 @@ data/ SQLite БД (создаётся автоматически) ## Следующие фазы -- Qdrant: семантический поиск по фактам и документам -- RAG: загрузка файлов, `search_documents` +- Фаза 4: инструменты с обращением к внешним API +- Фаза 5: RAG по файлам, Qdrant (если понадобится семантика) - Проактивные чаты по расписанию - Фитнес-трекер diff --git a/backend/app/api/routes/memory.py b/backend/app/api/routes/memory.py index 5277d05..e87adf2 100644 --- a/backend/app/api/routes/memory.py +++ b/backend/app/api/routes/memory.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.db.base import get_db +from app.db.models import ChatSession +from app.memory.extract import extract_after_turn from app.memory.service import MemoryService router = APIRouter() @@ -26,6 +28,13 @@ class SessionSummaryUpdate(BaseModel): message_count: int = 0 +class ExtractRequest(BaseModel): + session_id: int + user_text: str = Field(min_length=1) + assistant_text: str = "" + force: bool = False + + @router.get("/memory") def get_memory_snapshot( session_id: int | None = None, @@ -85,6 +94,23 @@ def forget_fact(memory_id: int, db: Session = Depends(get_db)) -> dict[str, Any] raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.post("/memory/extract") +async def extract_memories( + payload: ExtractRequest, + db: Session = Depends(get_db), +) -> dict: + session = db.get(ChatSession, payload.session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return await extract_after_turn( + db, + payload.session_id, + payload.user_text, + payload.assistant_text, + force=payload.force, + ) + + @router.put("/memory/sessions/{session_id}/summary") def update_session_summary( session_id: int, diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 5d44b24..b3a0914 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -5,6 +5,7 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session +from app.config import get_settings from app.character.service import CharacterService from app.chat.notices import ( POMODORO_TOOL_NAMES, @@ -16,6 +17,7 @@ from app.memory.context import ( format_memory_context, get_memory_snapshot, ) +from app.memory.extract import extract_after_turn from app.projects.context import format_projects_context, get_projects_snapshot from app.db.models import ChatSession, Message from app.llm.client import LLMClient @@ -184,7 +186,20 @@ class ChatService: if final_content: self._save_message(session_id, "assistant", final_content) - yield self._sse("done", {}) + memory_meta: dict[str, Any] = {} + if get_settings().memory_auto_extract: + extraction = await extract_after_turn( + self.db, + session_id, + user_text, + final_content, + ) + memory_meta = { + "memory_extracted": extraction.get("count", 0), + "memory_saved": extraction.get("saved", []), + } + + yield self._sse("done", memory_meta) return yield self._sse("error", {"message": "Too many tool call rounds"}) diff --git a/backend/app/config.py b/backend/app/config.py index d9ee859..52a3734 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): database_url: str = "sqlite:///./data/assistant.db" cors_origins: str = "http://localhost:5173,http://localhost:8080,http://localhost:3000" system_prompt_path: str = "./prompts/assistant.md" + memory_auto_extract: bool = True # Taiga/Gitea on host (not in Docker) — use host.docker.internal from container taiga_base_url: str = "http://host.docker.internal:9000" diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 3be0b6f..b839672 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -70,11 +70,13 @@ class LLMClient: self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, + *, + temperature: float = 0.7, ) -> dict[str, Any]: kwargs: dict[str, Any] = { "model": self.model, "messages": messages, - "temperature": 0.7, + "temperature": temperature, } if tools: kwargs["tools"] = tools diff --git a/backend/app/memory/extract.py b/backend/app/memory/extract.py new file mode 100644 index 0000000..9117319 --- /dev/null +++ b/backend/app/memory/extract.py @@ -0,0 +1,143 @@ +import json +import logging +import re +from typing import Any + +from sqlalchemy.orm import Session + +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]], + ] + + 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] if assistant_text else "(нет ответа)") + ), + }, + ], + temperature=0.2, + ) + 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": []} + + 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)} diff --git a/backend/app/memory/service.py b/backend/app/memory/service.py index cc2cbc6..f222542 100644 --- a/backend/app/memory/service.py +++ b/backend/app/memory/service.py @@ -218,6 +218,8 @@ class MemoryService: "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 ], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67652ac..52016f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import PomodoroWidget from "./components/PomodoroWidget"; import { PomodoroProvider } from "./context/PomodoroContext"; import Character from "./pages/Character"; import Chat from "./pages/Chat"; +import Memory from "./pages/Memory"; import Pomodoro from "./pages/Pomodoro"; import "./App.css"; @@ -18,6 +19,7 @@ export default function App() { Помидоро Персонаж + Память @@ -26,6 +28,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6353da6..29415f8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -64,6 +64,30 @@ export interface CharacterCardV2 { data: CharacterCardData; } +export interface UserProfile { + name?: string; + age?: string; + timezone?: string; + language?: string; + notes?: string; +} + +export interface MemoryFact { + id: number; + category: string; + content: string; + importance: number; + source?: string; + updated_at?: string | null; +} + +export interface MemorySnapshot { + profile: UserProfile; + facts: MemoryFact[]; + session_summary?: string; + total_facts: number; +} + export interface PomodoroHistoryItem { id: number; status: string; @@ -194,4 +218,31 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(card), }), + + getMemorySnapshot: (sessionId?: number) => + request( + `/api/v1/memory${sessionId ? `?session_id=${sessionId}` : ""}` + ), + + updateProfile: (updates: UserProfile) => + request<{ ok: boolean; profile: UserProfile }>("/api/v1/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }), + + createMemoryFact: (payload: { + content: string; + category?: string; + importance?: number; + session_id?: number; + }) => + request<{ ok: boolean; memory_id: number }>("/api/v1/memory/facts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + forgetMemoryFact: (id: number) => + request<{ ok: boolean }>(`/api/v1/memory/facts/${id}`, { method: "DELETE" }), }; diff --git a/frontend/src/pages/Memory.css b/frontend/src/pages/Memory.css new file mode 100644 index 0000000..4e6df87 --- /dev/null +++ b/frontend/src/pages/Memory.css @@ -0,0 +1,160 @@ +.memory-page { + max-width: 900px; + margin: 0 auto; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.memory-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; +} + +.memory-header h2 { + margin: 0 0 0.25rem; +} + +.memory-header p { + margin: 0; + color: #8b95a8; + font-size: 0.9rem; +} + +.memory-header-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.memory-session-input { + width: 140px; +} + +.memory-message { + padding: 0.6rem 0.9rem; + background: #1a2a1f; + border: 1px solid #2d5a3d; + border-radius: 8px; + font-size: 0.9rem; +} + +.memory-section { + background: #151922; + border: 1px solid #2a2f3a; + border-radius: 10px; + padding: 1rem 1.25rem; +} + +.memory-section h3 { + margin: 0 0 0.75rem; + font-size: 1rem; +} + +.memory-profile-form { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.memory-profile-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: #8b95a8; +} + +.memory-profile-form input { + padding: 0.45rem 0.6rem; + border-radius: 6px; + border: 1px solid #2a2f3a; + background: #0f1218; + color: #e8ecf1; +} + +.memory-profile-form button { + grid-column: 1 / -1; + justify-self: start; +} + +.memory-summary { + margin: 0; + white-space: pre-wrap; + color: #c5cdd8; +} + +.memory-add-fact { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.memory-add-fact input { + flex: 1; + min-width: 200px; +} + +.memory-facts-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.memory-facts-list li { + padding: 0.75rem; + background: #0f1218; + border: 1px solid #2a2f3a; + border-radius: 8px; +} + +.memory-fact-meta { + display: flex; + gap: 0.6rem; + font-size: 0.78rem; + color: #8b95a8; + margin-bottom: 0.35rem; + flex-wrap: wrap; +} + +.memory-source-auto { + color: #7eb8da; +} + +.memory-source-user, +.memory-source-tool { + color: #9ed49e; +} + +.memory-source-api { + color: #d4b87a; +} + +.memory-fact-content { + margin-bottom: 0.5rem; + line-height: 1.4; +} + +.memory-empty { + color: #8b95a8; + font-style: italic; +} + +.memory-raw { + background: #0f1218; + border: 1px solid #2a2f3a; + border-radius: 10px; + padding: 1rem; + overflow: auto; + font-size: 0.82rem; + max-height: 70vh; +} diff --git a/frontend/src/pages/Memory.tsx b/frontend/src/pages/Memory.tsx new file mode 100644 index 0000000..21d2fbf --- /dev/null +++ b/frontend/src/pages/Memory.tsx @@ -0,0 +1,183 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { api, MemoryFact, MemorySnapshot, UserProfile } from "../api/client"; +import "./Memory.css"; + +const PROFILE_FIELDS: (keyof UserProfile)[] = [ + "name", + "age", + "timezone", + "language", + "notes", +]; + +export default function Memory() { + const [snapshot, setSnapshot] = useState(null); + const [profile, setProfile] = useState({}); + const [facts, setFacts] = useState([]); + const [sessionId, setSessionId] = useState(""); + const [newFact, setNewFact] = useState(""); + const [newCategory, setNewCategory] = useState("fact"); + const [message, setMessage] = useState(""); + const [showRaw, setShowRaw] = useState(false); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setMessage(""); + try { + const sid = sessionId.trim() ? Number(sessionId) : undefined; + const data = await api.getMemorySnapshot(sid); + setSnapshot(data); + setProfile(data.profile || {}); + setFacts(data.facts || []); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка загрузки"); + } finally { + setLoading(false); + } + }, [sessionId]); + + useEffect(() => { + load().catch(console.error); + }, [load]); + + const handleProfileSave = async (e: FormEvent) => { + e.preventDefault(); + try { + await api.updateProfile(profile); + setMessage("Профиль сохранён"); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleAddFact = async (e: FormEvent) => { + e.preventDefault(); + if (!newFact.trim()) return; + try { + await api.createMemoryFact({ + content: newFact.trim(), + category: newCategory, + session_id: sessionId.trim() ? Number(sessionId) : undefined, + }); + setNewFact(""); + setMessage("Факт добавлен"); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleForget = async (id: number) => { + try { + await api.forgetMemoryFact(id); + setMessage(`Факт #${id} удалён`); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + return ( +
+
+
+

Память

+

Отладка профиля, фактов и автоизвлечения

+
+
+ setSessionId(e.target.value)} + className="memory-session-input" + /> + + +
+
+ + {message &&
{message}
} + + {showRaw ? ( +
{JSON.stringify(snapshot, null, 2)}
+ ) : ( + <> +
+

Профиль

+
+ {PROFILE_FIELDS.map((key) => ( + + ))} + +
+
+ + {snapshot?.session_summary && ( +
+

Сводка сессии

+

{snapshot.session_summary}

+
+ )} + +
+

Факты ({facts.length})

+
+ setNewFact(e.target.value)} + placeholder="Новый факт вручную" + /> + + +
+
    + {facts.map((fact) => ( +
  • +
    + #{fact.id} + + {fact.source} + + [{fact.category}] + imp {fact.importance} +
    +
    {fact.content}
    + +
  • + ))} + {facts.length === 0 && ( +
  • Фактов пока нет
  • + )} +
+
+ + )} +
+ ); +}