"""
Fact extraction and summary generation for the memory system.
Flow (triggered when a session is considered complete):
1. Format session.messages as plain text
2. Ask LLM to extract stable facts about the user → upsert into memory_facts
3. If new facts were found → regenerate summary from all facts
"""
import json
import structlog
from navi.llm.base import LLMBackend, Message
from .store import MemoryStore
log = structlog.get_logger()
_EXTRACT_SYSTEM = """\
You extract stable facts about the user from a conversation transcript.
Extract ONLY facts that are:
- Persistent characteristics: name, age, location, occupation, family situation
- Technical environment: OS, tools, languages, home servers, devices
- Preferences: communication style, coding habits, things they like or dislike
- Ongoing projects or goals
- Any other stable, reusable facts about this specific person
Do NOT extract:
- Topics that were discussed or questions that were asked
- Temporary states ("was tired", "was busy today")
- Information about third parties that isn't about the user
- Facts you already know from earlier turns (avoid duplicates)
Return a JSON array — empty [] if nothing new to extract:
[
{"category": "profile", "key": "name", "value": "Eugene"},
{"category": "technical", "key": "primary_os", "value": "Arch Linux"},
{"category": "preferences", "key": "response_language", "value": "Russian"}
]
Valid categories: profile, preferences, technical, projects, other"""
_SUMMARY_SYSTEM = """\
You are writing a memory summary for an AI assistant about its user.
Summarize the facts below in 2-4 short paragraphs (max 400 words).
Write from the assistant's perspective: what you know about the user.
Be specific and concrete. Cover the most important identifying details first,
then preferences and ongoing context."""
async def extract_and_update(
session,
llm: LLMBackend,
model: str,
memory_store: MemoryStore,
) -> None:
"""
Extract facts from a session and update the memory summary.
Safe to call multiple times — already-extracted sessions produce no duplicates.
"""
facts_added = await _extract_facts(session, llm, model, memory_store)
log.info("memory.extracted", session_id=session.id, facts_added=facts_added)
await memory_store.mark_session_extracted(session.id)
if facts_added > 0:
await _regenerate_summary(llm, model, memory_store)
async def _extract_facts(session, llm: LLMBackend, model: str, store: MemoryStore) -> int:
lines: list[str] = []
for msg in session.messages:
if msg.role == "user" and msg.content:
lines.append(f"User: {msg.content}")
elif msg.role == "assistant" and msg.content:
lines.append(f"Assistant: {msg.content}")
if not lines:
return 0
prompt = [
Message(role="system", content=_EXTRACT_SYSTEM),
Message(role="user", content="\n".join(lines)),
]
try:
response = await llm.complete(prompt, tools=None, temperature=0.1, model=model)
raw = (response.content or "").strip()
except Exception:
log.warning("memory.extract_llm_error", session_id=session.id, exc_info=True)
return 0
# Find JSON array in response (model may add surrounding text)
start = raw.find("[")
end = raw.rfind("]") + 1
if start == -1 or end == 0:
return 0
try:
facts = json.loads(raw[start:end])
except json.JSONDecodeError:
log.warning("memory.extract_parse_error", session_id=session.id, raw=raw[:300])
return 0
count = 0
for fact in facts:
if not isinstance(fact, dict):
continue
category = str(fact.get("category", "other")).strip().lower()
key = str(fact.get("key", "")).strip()
value = str(fact.get("value", "")).strip()
if key and value:
await store.upsert_fact(category, key, value, session.id)
count += 1
return count
async def _regenerate_summary(llm: LLMBackend, model: str, store: MemoryStore) -> None:
facts = await store.get_all_facts()
if not facts:
return
# Group by category, sort by recency within each
by_cat: dict[str, list] = {}
for f in facts:
by_cat.setdefault(f["category"], []).append(f)
lines: list[str] = []
for cat in sorted(by_cat):
lines.append(f"[{cat}]")
for f in by_cat[cat]:
lines.append(f" {f['key']}: {f['value']}")
prompt = [
Message(role="system", content=_SUMMARY_SYSTEM),
Message(role="user", content="\n".join(lines)),
]
try:
response = await llm.complete(prompt, tools=None, temperature=0.3, model=model)
summary = (response.content or "").strip()
except Exception:
log.warning("memory.summary_llm_error", exc_info=True)
return
if summary:
await store.set_summary(summary)
log.info("memory.summary_updated", fact_count=len(facts))