Files
microdao-daarion/services/memory-service/app/ingestion.py
Apple 5290287058 feat: implement TTS, Document processing, and Memory Service /facts API
- TTS: xtts-v2 integration with voice cloning support
- Document: docling integration for PDF/DOCX/PPTX processing
- Memory Service: added /facts/upsert, /facts/{key}, /facts endpoints
- Added required dependencies (TTS, docling)
2026-01-17 08:16:37 -08:00

699 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Memory Ingestion Pipeline
Автоматичне витягування фактів/пам'яті з діалогів
Етапи:
1. PII Scrubber - виявлення та редакція персональних даних
2. Memory Candidate Extractor - класифікація та витягування
3. Dedup & Merge - дедуплікація схожих пам'ятей
4. Write - збереження в SQL + Vector + Graph
5. Audit Log - запис в аудит
"""
import re
import hashlib
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, timedelta
from uuid import UUID, uuid4
from enum import Enum
import structlog
from pydantic import BaseModel
logger = structlog.get_logger()
class MemoryType(str, Enum):
EPISODIC = "episodic" # Події/факти про взаємодію
SEMANTIC = "semantic" # Стійкі вподобання/профіль
PROCEDURAL = "procedural" # Як робити щось
class MemoryCategory(str, Enum):
PREFERENCE = "preference" # Вподобання користувача
FACT = "fact" # Факт про користувача
TOPIC_INTEREST = "topic_interest" # Інтерес до теми
ROLE = "role" # Роль (інвестор, інженер)
INTERACTION = "interaction" # Тип взаємодії
FEEDBACK = "feedback" # Відгук/оцінка
OPT_OUT = "opt_out" # Заборона збереження
class PIIType(str, Enum):
PHONE = "phone"
EMAIL = "email"
ADDRESS = "address"
PASSPORT = "passport"
CARD_NUMBER = "card_number"
NAME = "name"
LOCATION = "location"
class MemoryCandidate(BaseModel):
"""Кандидат на збереження в пам'ять"""
content: str
summary: str
memory_type: MemoryType
category: MemoryCategory
importance: float # 0.0 - 1.0
confidence: float # 0.0 - 1.0
ttl_days: Optional[int] = None
source_message_ids: List[str] = []
metadata: Dict[str, Any] = {}
class PIIDetection(BaseModel):
"""Результат виявлення PII"""
pii_type: PIIType
start: int
end: int
original: str
redacted: str
# =============================================================================
# 1. PII SCRUBBER
# =============================================================================
class PIIScrubber:
"""Виявлення та редакція персональних даних"""
# Регулярні вирази для PII
PATTERNS = {
PIIType.PHONE: [
r'\+?38?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}', # UA phones
r'\+?\d{1,3}[\s\-]?\(?\d{2,3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}',
],
PIIType.EMAIL: [
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}',
],
PIIType.CARD_NUMBER: [
r'\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b',
],
PIIType.PASSPORT: [
r'\b[A-Z]{2}\d{6}\b', # UA passport
],
}
def detect(self, text: str) -> List[PIIDetection]:
"""Виявити всі PII в тексті"""
detections = []
for pii_type, patterns in self.PATTERNS.items():
for pattern in patterns:
for match in re.finditer(pattern, text, re.IGNORECASE):
detections.append(PIIDetection(
pii_type=pii_type,
start=match.start(),
end=match.end(),
original=match.group(),
redacted=self._redact(pii_type, match.group())
))
return detections
def _redact(self, pii_type: PIIType, value: str) -> str:
"""Редагувати PII значення"""
if pii_type == PIIType.EMAIL:
parts = value.split('@')
return f"{parts[0][:2]}***@{parts[1]}" if len(parts) == 2 else "[EMAIL]"
elif pii_type == PIIType.PHONE:
return f"***{value[-4:]}" if len(value) > 4 else "[PHONE]"
elif pii_type == PIIType.CARD_NUMBER:
return f"****{value[-4:]}"
else:
return f"[{pii_type.value.upper()}]"
def scrub(self, text: str) -> Tuple[str, List[PIIDetection], bool]:
"""
Очистити текст від PII
Returns: (cleaned_text, detections, has_pii)
"""
detections = self.detect(text)
if not detections:
return text, [], False
# Сортувати за позицією (з кінця) для правильної заміни
detections.sort(key=lambda x: x.start, reverse=True)
cleaned = text
for detection in detections:
cleaned = cleaned[:detection.start] + detection.redacted + cleaned[detection.end:]
return cleaned, detections, True
# =============================================================================
# 2. MEMORY CANDIDATE EXTRACTOR
# =============================================================================
class MemoryExtractor:
"""Витягування кандидатів на пам'ять з повідомлень"""
# Ключові фрази для категорій
CATEGORY_PATTERNS = {
MemoryCategory.PREFERENCE: [
r'я (хочу|бажаю|віддаю перевагу|люблю|не люблю)',
r'мені (подобається|не подобається|зручніше)',
r'(краще|гірше) для мене',
],
MemoryCategory.ROLE: [
r'я (інвестор|інженер|розробник|науковець|журналіст|модератор)',
r'працюю (як|в галузі)',
r'моя (роль|посада|професія)',
],
MemoryCategory.TOPIC_INTEREST: [
r'цікавить (мене )?(BioMiner|EcoMiner|токеноміка|governance|стейкінг)',
r'хочу (дізнатися|розібратися) (в|з)',
r'питання (про|щодо|стосовно)',
],
MemoryCategory.OPT_OUT: [
r'(не |НЕ )?(запам[\'ʼ]ятов|запамʼятовуй|запамятовуй)',
r'забудь (мене|це|все)',
r'вимкни (пам[\'ʼ]ять|память)',
],
}
# Важливість за категорією
IMPORTANCE_WEIGHTS = {
MemoryCategory.PREFERENCE: 0.7,
MemoryCategory.ROLE: 0.8,
MemoryCategory.TOPIC_INTEREST: 0.6,
MemoryCategory.FACT: 0.5,
MemoryCategory.OPT_OUT: 1.0, # Найвища важливість
}
def extract(
self,
messages: List[Dict[str, Any]],
context: Optional[Dict[str, Any]] = None
) -> List[MemoryCandidate]:
"""
Витягнути кандидатів на пам'ять з повідомлень
Args:
messages: Список повідомлень [{role, content, message_id, ...}]
context: Додатковий контекст (group_id, user_id, etc.)
Returns:
Список MemoryCandidate
"""
candidates = []
for msg in messages:
if msg.get('role') != 'user':
continue
content = msg.get('content', '')
message_id = msg.get('message_id', str(uuid4()))
# Перевірити opt-out фрази
opt_out = self._check_opt_out(content)
if opt_out:
candidates.append(opt_out)
candidates[-1].source_message_ids = [message_id]
continue
# Шукати інші категорії
for category, patterns in self.CATEGORY_PATTERNS.items():
if category == MemoryCategory.OPT_OUT:
continue
for pattern in patterns:
if re.search(pattern, content, re.IGNORECASE):
candidate = self._create_candidate(
content=content,
category=category,
message_id=message_id,
context=context
)
if candidate:
candidates.append(candidate)
break
return candidates
def _check_opt_out(self, content: str) -> Optional[MemoryCandidate]:
"""Перевірити на opt-out фразу"""
for pattern in self.CATEGORY_PATTERNS[MemoryCategory.OPT_OUT]:
match = re.search(pattern, content, re.IGNORECASE)
if match:
# Визначити тип opt-out
if 'забудь' in content.lower():
action = 'forget'
summary = "Користувач просить видалити пам'ять"
else:
action = 'disable'
summary = "Користувач просить не запам'ятовувати"
return MemoryCandidate(
content=content,
summary=summary,
memory_type=MemoryType.SEMANTIC,
category=MemoryCategory.OPT_OUT,
importance=1.0,
confidence=0.95,
metadata={'action': action}
)
return None
def _create_candidate(
self,
content: str,
category: MemoryCategory,
message_id: str,
context: Optional[Dict[str, Any]] = None
) -> Optional[MemoryCandidate]:
"""Створити кандидата на пам'ять"""
# Визначити тип пам'яті
if category in [MemoryCategory.PREFERENCE, MemoryCategory.ROLE]:
memory_type = MemoryType.SEMANTIC
ttl_days = None # Безстроково
else:
memory_type = MemoryType.EPISODIC
ttl_days = 90 # 3 місяці
# Створити короткий summary
summary = self._generate_summary(content, category)
return MemoryCandidate(
content=content,
summary=summary,
memory_type=memory_type,
category=category,
importance=self.IMPORTANCE_WEIGHTS.get(category, 0.5),
confidence=0.7, # Базова впевненість, можна підвищити через LLM
ttl_days=ttl_days,
source_message_ids=[message_id],
metadata=context or {}
)
def _generate_summary(self, content: str, category: MemoryCategory) -> str:
"""Згенерувати короткий summary"""
# Простий варіант - перші 100 символів
# В production використовувати LLM
summary = content[:100]
if len(content) > 100:
summary += "..."
return f"[{category.value}] {summary}"
# =============================================================================
# 3. DEDUP & MERGE
# =============================================================================
class MemoryDeduplicator:
"""Дедуплікація та об'єднання схожих пам'ятей"""
def __init__(self, similarity_threshold: float = 0.85):
self.similarity_threshold = similarity_threshold
def deduplicate(
self,
new_candidates: List[MemoryCandidate],
existing_memories: List[Dict[str, Any]]
) -> Tuple[List[MemoryCandidate], List[Dict[str, Any]]]:
"""
Дедуплікувати нових кандидатів проти існуючих пам'ятей
Returns:
(candidates_to_create, memories_to_update)
"""
to_create = []
to_update = []
for candidate in new_candidates:
# Шукати схожу пам'ять
similar = self._find_similar(candidate, existing_memories)
if similar:
# Оновити існуючу пам'ять
to_update.append({
'memory_id': similar['memory_id'],
'content': candidate.content,
'summary': candidate.summary,
'importance': max(candidate.importance, similar.get('importance', 0)),
'source_message_ids': list(set(
similar.get('source_message_ids', []) +
candidate.source_message_ids
))
})
else:
to_create.append(candidate)
return to_create, to_update
def _find_similar(
self,
candidate: MemoryCandidate,
existing: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
"""Знайти схожу пам'ять"""
candidate_hash = self._content_hash(candidate.content)
for memory in existing:
# Швидка перевірка за хешем
if self._content_hash(memory.get('content', '')) == candidate_hash:
return memory
# Перевірка за категорією + summary
if (memory.get('category') == candidate.category.value and
self._text_similarity(candidate.summary, memory.get('summary', '')) > self.similarity_threshold):
return memory
return None
def _content_hash(self, content: str) -> str:
"""Обчислити хеш контенту"""
normalized = content.lower().strip()
return hashlib.md5(normalized.encode()).hexdigest()
def _text_similarity(self, text1: str, text2: str) -> float:
"""Проста подібність тексту (Jaccard)"""
if not text1 or not text2:
return 0.0
words1 = set(text1.lower().split())
words2 = set(text2.lower().split())
intersection = len(words1 & words2)
union = len(words1 | words2)
return intersection / union if union > 0 else 0.0
# =============================================================================
# 4. MEMORY INGESTION PIPELINE
# =============================================================================
class MemoryIngestionPipeline:
"""
Повний пайплайн витягування та збереження пам'яті
"""
def __init__(self, db=None, vector_store=None, graph_store=None):
self.db = db
self.vector_store = vector_store
self.graph_store = graph_store
self.pii_scrubber = PIIScrubber()
self.extractor = MemoryExtractor()
self.deduplicator = MemoryDeduplicator()
async def process_conversation(
self,
messages: List[Dict[str, Any]],
user_id: Optional[str] = None,
platform_user_id: Optional[str] = None,
group_id: Optional[str] = None,
conversation_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Обробити розмову та витягнути пам'ять
Returns:
{
"memories_created": int,
"memories_updated": int,
"pii_detected": bool,
"opt_out_requested": bool,
"details": [...]
}
"""
result = {
"memories_created": 0,
"memories_updated": 0,
"pii_detected": False,
"opt_out_requested": False,
"details": []
}
# 1. PII Scrubbing
cleaned_messages = []
for msg in messages:
if msg.get('role') == 'user':
cleaned, detections, has_pii = self.pii_scrubber.scrub(msg.get('content', ''))
if has_pii:
result["pii_detected"] = True
logger.info("pii_detected",
count=len(detections),
types=[d.pii_type.value for d in detections])
cleaned_messages.append({**msg, 'content': cleaned, '_pii_detected': has_pii})
else:
cleaned_messages.append(msg)
# 2. Extract candidates
context = {
'user_id': user_id,
'platform_user_id': platform_user_id,
'group_id': group_id,
'conversation_id': conversation_id
}
candidates = self.extractor.extract(cleaned_messages, context)
# Перевірити opt-out
for candidate in candidates:
if candidate.category == MemoryCategory.OPT_OUT:
result["opt_out_requested"] = True
await self._handle_opt_out(candidate, context)
result["details"].append({
"type": "opt_out",
"action": candidate.metadata.get('action'),
"summary": candidate.summary
})
# Якщо opt-out — не зберігати інші пам'яті
if result["opt_out_requested"]:
return result
# 3. Dedup against existing
existing_memories = []
if self.db:
existing_memories = await self._get_existing_memories(
user_id=user_id,
platform_user_id=platform_user_id,
group_id=group_id
)
to_create, to_update = self.deduplicator.deduplicate(candidates, existing_memories)
# 4. Write to storage
for candidate in to_create:
memory_id = await self._create_memory(candidate, context)
if memory_id:
result["memories_created"] += 1
result["details"].append({
"type": "created",
"memory_id": str(memory_id),
"category": candidate.category.value,
"summary": candidate.summary
})
for update in to_update:
success = await self._update_memory(update)
if success:
result["memories_updated"] += 1
result["details"].append({
"type": "updated",
"memory_id": update['memory_id'],
"summary": update.get('summary')
})
# 5. Audit log
await self._log_ingestion(result, context)
logger.info("ingestion_complete",
created=result["memories_created"],
updated=result["memories_updated"],
pii=result["pii_detected"],
opt_out=result["opt_out_requested"])
return result
async def _handle_opt_out(
self,
candidate: MemoryCandidate,
context: Dict[str, Any]
):
"""Обробити opt-out запит"""
action = candidate.metadata.get('action', 'disable')
group_id = context.get('group_id')
platform_user_id = context.get('platform_user_id')
if not platform_user_id:
return
if self.db:
if action == 'forget' and group_id:
# Повне видалення в групі
await self.db.execute(
"SELECT memory_forget_in_group($1::uuid, $2)",
group_id, platform_user_id
)
else:
# Просто відключити збереження
if group_id:
await self.db.execute("""
UPDATE group_members
SET no_memory_in_group = TRUE
WHERE group_id = $1::uuid AND platform_user_id = $2
""", group_id, platform_user_id)
else:
await self.db.execute("""
UPDATE memory_consent
SET memory_enabled = FALSE, updated_at = NOW()
WHERE platform_user_id = $1
""", platform_user_id)
async def _get_existing_memories(
self,
user_id: Optional[str],
platform_user_id: Optional[str],
group_id: Optional[str]
) -> List[Dict[str, Any]]:
"""Отримати існуючі пам'яті"""
if not self.db:
return []
query = """
SELECT memory_id, content, summary, category, importance, source_message_ids
FROM memories
WHERE is_active = TRUE
"""
params = []
if group_id:
query += " AND group_id = $1::uuid"
params.append(group_id)
if platform_user_id:
query += " AND platform_user_id = $2"
params.append(platform_user_id)
elif user_id:
query += " AND user_id = $1::uuid AND group_id IS NULL"
params.append(user_id)
elif platform_user_id:
query += " AND platform_user_id = $1 AND group_id IS NULL"
params.append(platform_user_id)
else:
return []
rows = await self.db.fetch(query, *params)
return [dict(row) for row in rows]
async def _create_memory(
self,
candidate: MemoryCandidate,
context: Dict[str, Any]
) -> Optional[UUID]:
"""Створити нову пам'ять"""
if not self.db:
return uuid4() # Mock для тестування
memory_id = uuid4()
# Calculate expires_at
expires_at = None
if candidate.ttl_days:
expires_at = datetime.now() + timedelta(days=candidate.ttl_days)
await self.db.execute("""
INSERT INTO memories (
memory_id, user_id, platform_user_id, group_id,
memory_type, category, content, summary,
importance, confidence, ttl_days, expires_at,
source_message_ids, extraction_method, metadata
) VALUES (
$1, $2::uuid, $3, $4::uuid,
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15
)
""",
memory_id,
context.get('user_id'),
context.get('platform_user_id'),
context.get('group_id'),
candidate.memory_type.value,
candidate.category.value,
candidate.content,
candidate.summary,
candidate.importance,
candidate.confidence,
candidate.ttl_days,
expires_at,
candidate.source_message_ids,
'pipeline',
candidate.metadata
)
# Зберегти embedding якщо є vector store
if self.vector_store:
await self._store_embedding(memory_id, candidate, context)
# Зберегти в граф якщо є graph store
if self.graph_store:
await self._store_graph_relation(memory_id, candidate, context)
return memory_id
async def _update_memory(self, update: Dict[str, Any]) -> bool:
"""Оновити існуючу пам'ять"""
if not self.db:
return True
await self.db.execute("""
UPDATE memories
SET content = $2, summary = $3, importance = $4,
source_message_ids = $5, updated_at = NOW()
WHERE memory_id = $1::uuid
""",
update['memory_id'],
update['content'],
update['summary'],
update['importance'],
update['source_message_ids']
)
return True
async def _store_embedding(
self,
memory_id: UUID,
candidate: MemoryCandidate,
context: Dict[str, Any]
):
"""Зберегти embedding в vector store"""
# Реалізація залежить від vector store (Qdrant, pgvector)
pass
async def _store_graph_relation(
self,
memory_id: UUID,
candidate: MemoryCandidate,
context: Dict[str, Any]
):
"""Зберегти зв'язок в graph store"""
# Реалізація для Neo4j
pass
async def _log_ingestion(
self,
result: Dict[str, Any],
context: Dict[str, Any]
):
"""Записати в аудит"""
if not self.db:
return
await self.db.execute("""
INSERT INTO memory_events (
user_id, group_id, action, actor, new_value
) VALUES (
$1::uuid, $2::uuid, 'ingestion', 'pipeline', $3
)
""",
context.get('user_id'),
context.get('group_id'),
result
)