"""Layered semantic memory and compaction for Space PMFlow AI. The service keeps long-context behavior on a product/runtime contract: thread, user, workspace and project memories are selected by explicit scope anchors and generic relevance, not by business-scene keyword branches. """ from __future__ import annotations import json import re import uuid from datetime import datetime from typing import Any from sqlalchemy import and_, desc, select from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import PROMPT_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR from ..models import PMFlowAIContextMemory, PMFlowAIThread from .ai_function_runtime_profile_service import AIFunctionRuntimeProfileService from .llm_service import llm_service from .prompt_runtime_service import PromptRuntimeRenderResult, PromptRuntimeService from .runtime_audit_context import bind_agent_audit_context PROMPT_KEY_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR = "PROMPT_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR" FUNCTION_NAME_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR = "pmflow_ai_context_memory_compactor" SCENE_KEY_PMFLOW_AI_AGENT_RUNTIME = "space_pmflow_ai_agent_runtime" MEMORY_KEY_CURRENT = "semantic_compaction.current" MEMORY_PACKET_VERSION = "pmflow_ai_layered_memory_pack.v1" _MEMORY_SCOPES = ("thread", "user", "workspace", "project") _ACTIVE_MEMORY_STATUSES = ("active", "candidate") class PMFlowAIContextMemoryService: """Build and update layered semantic memories for PMFlow AI.""" def __init__(self, db: AsyncSession, *, tenant_id: int, generator: Any | None = None) -> None: self.db = db self.tenant_id = int(tenant_id or 1) self.generator = generator or llm_service async def build_memory_context_pack( self, *, thread: PMFlowAIThread | None, current_user: Any | None = None, conversation_context: dict[str, Any] | None = None, tenant_project_context: dict[str, Any] | None = None, user_question: str = "", limit: int = 12, ) -> dict[str, Any]: """Load relevant long-context memory rows for the current turn.""" scope_refs = _scope_refs( thread=thread, current_user=current_user, conversation_context=conversation_context, tenant_project_context=tenant_project_context, ) if not scope_refs or not callable(getattr(self.db, "execute", None)): return {} rows: list[PMFlowAIContextMemory] = [] for scope, refs in scope_refs.items(): if not refs: continue result = await self.db.execute( select(PMFlowAIContextMemory) .where( and_( PMFlowAIContextMemory.tenant_id == self.tenant_id, PMFlowAIContextMemory.memory_scope == scope, PMFlowAIContextMemory.scope_ref.in_(refs[:6]), PMFlowAIContextMemory.status.in_(_ACTIVE_MEMORY_STATUSES), ) ) .order_by( desc(PMFlowAIContextMemory.confidence_score), desc(PMFlowAIContextMemory.last_validated_at), desc(PMFlowAIContextMemory.last_compacted_at), desc(PMFlowAIContextMemory.updated_at), ) .limit(max(1, limit)) ) rows.extend(list(result.scalars().all())) ranked = _rank_memory_rows(question=user_question, rows=rows, limit=limit) now = datetime.utcnow() for row in ranked: row.last_accessed_at = now return _memory_pack_from_rows(ranked, scope_refs=scope_refs) async def compact_and_persist_after_turn( self, *, thread: PMFlowAIThread | None, current_user: Any | None, user_message: str, assistant_answer: str, context_pack: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, run_public_id: str | None = None, user_message_index: int | None = None, assistant_message_index: int | None = None, ) -> dict[str, Any]: """Compact the latest turn into layered long-context memories.""" if thread is None or not getattr(thread, "id", None) or not callable(getattr(self.db, "execute", None)): return {} scope_refs = _scope_refs( thread=thread, current_user=current_user, conversation_context={}, tenant_project_context=tenant_project_context, ) if not scope_refs: return {} previous_pack = await self.build_memory_context_pack( thread=thread, current_user=current_user, conversation_context={}, tenant_project_context=tenant_project_context, user_question=user_message, limit=16, ) compaction = await self._compact_turn( user_message=user_message, assistant_answer=assistant_answer, previous_memory_pack=previous_pack, context_pack=context_pack or {}, user_message_index=user_message_index, assistant_message_index=assistant_message_index, ) memories = _normalize_compaction_memories(compaction) if not memories: memories = _deterministic_compaction_memories( user_message=user_message, assistant_answer=assistant_answer, user_message_index=user_message_index, assistant_message_index=assistant_message_index, ) persisted = [] for scope in _MEMORY_SCOPES: refs = scope_refs.get(scope) or [] if not refs: continue memory = dict(memories.get(scope) or memories.get("thread") or {}) if not _memory_has_content(memory): continue row = await self._upsert_memory( memory_scope=scope, scope_ref=refs[0], memory=memory, thread=thread, run_public_id=run_public_id, user_message_index=user_message_index, assistant_message_index=assistant_message_index, compaction=compaction, ) if row is None: continue persisted.append( { "memory_scope": row.memory_scope, "scope_ref": row.scope_ref, "status": row.status, "confidence_score": float(row.confidence_score or 0.0), "summary": row.summary[:500], "token_estimate": int(row.token_estimate or 0), } ) return { "packet_version": "pmflow_ai_memory_compaction_result.v1", "persisted": persisted, "compaction_source": str(compaction.get("compaction_source") or ""), "scope_count": len(persisted), } async def _compact_turn( self, *, user_message: str, assistant_answer: str, previous_memory_pack: dict[str, Any], context_pack: dict[str, Any], user_message_index: int | None, assistant_message_index: int | None, ) -> dict[str, Any]: generate = getattr(self.generator, "generate", None) if not callable(generate) or not callable(getattr(self.db, "execute", None)): return { "compaction_source": "deterministic_fallback", "memories": _deterministic_compaction_memories( user_message=user_message, assistant_answer=assistant_answer, user_message_index=user_message_index, assistant_message_index=assistant_message_index, ), } try: prompt_result = await self._render_prompt( user_message=user_message, assistant_answer=assistant_answer, previous_memory_pack=previous_memory_pack, context_pack=context_pack, user_message_index=user_message_index, assistant_message_index=assistant_message_index, ) runtime_profile = await AIFunctionRuntimeProfileService(self.db).resolve_profile( function_name=FUNCTION_NAME_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR, scene_key=SCENE_KEY_PMFLOW_AI_AGENT_RUNTIME, prompt_key=PROMPT_KEY_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR, ) with bind_agent_audit_context(agent_step_key="context_memory_compaction"): raw = await generate( prompt_result.prompt, db=self.db, tenant_id=self.tenant_id, function_name=FUNCTION_NAME_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR, use_prompt_profile=False, temperature=float(runtime_profile.temperature), response_mode=runtime_profile.response_mode, max_tokens=int(runtime_profile.max_tokens), routing_meta={ "scene_key": SCENE_KEY_PMFLOW_AI_AGENT_RUNTIME, "runtime": "pmflow_ai_context_memory", "scene_contract": "semantic_memory_compaction", **runtime_profile.routing_meta(), **prompt_result.routing_meta(), "allow_low_to_high_escalation": False, "disable_thinking": True, }, ) parsed = _extract_json_object(raw) parsed["compaction_source"] = "llm_semantic_compaction" parsed["prompt_meta"] = prompt_result.routing_meta() return parsed except Exception as exc: return { "compaction_source": "deterministic_fallback", "compaction_error": exc.__class__.__name__, "memories": _deterministic_compaction_memories( user_message=user_message, assistant_answer=assistant_answer, user_message_index=user_message_index, assistant_message_index=assistant_message_index, ), } async def _render_prompt( self, *, user_message: str, assistant_answer: str, previous_memory_pack: dict[str, Any], context_pack: dict[str, Any], user_message_index: int | None, assistant_message_index: int | None, ) -> PromptRuntimeRenderResult: return await PromptRuntimeService(self.db).render( tenant_id=self.tenant_id, prompt_key=PROMPT_KEY_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR, fallback_template=PROMPT_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR, latest_turn_json=_dump_json( { "user_message_index": user_message_index, "assistant_message_index": assistant_message_index, "user_message": _clean_text(user_message, limit=1600), "assistant_answer": _clean_text(assistant_answer, limit=2400), }, limit=6000, ), previous_memory_pack_json=_dump_json(previous_memory_pack, limit=12000), context_pack_json=_dump_json(context_pack, limit=16000), ) async def _upsert_memory( self, *, memory_scope: str, scope_ref: str, memory: dict[str, Any], thread: PMFlowAIThread, run_public_id: str | None, user_message_index: int | None, assistant_message_index: int | None, compaction: dict[str, Any], ) -> PMFlowAIContextMemory | None: result = await self.db.execute( select(PMFlowAIContextMemory).where( and_( PMFlowAIContextMemory.tenant_id == self.tenant_id, PMFlowAIContextMemory.memory_scope == memory_scope, PMFlowAIContextMemory.scope_ref == scope_ref, PMFlowAIContextMemory.memory_key == MEMORY_KEY_CURRENT, ) ) ) row = result.scalar_one_or_none() now = datetime.utcnow() gate = _memory_write_gate(memory=memory, existing=row) if gate["action"] == "skip": if row is not None: row.last_validated_at = now row.compaction_payload = { **(dict(row.compaction_payload or {}) if isinstance(row.compaction_payload, dict) else {}), "last_gate": gate, } return None if row is None: row = PMFlowAIContextMemory( tenant_id=self.tenant_id, public_memory_id=f"ctxmem_{uuid.uuid4().hex[:16]}", memory_scope=memory_scope, scope_ref=scope_ref, memory_key=MEMORY_KEY_CURRENT, ) self.db.add(row) merged_memory = _merge_memory(existing=row if gate["action"] != "create" else None, incoming=memory) row.source_thread_id = int(thread.id) row.source_run_public_id = str(run_public_id or "")[:80] or None row.status = str(gate["status"])[:32] row.confidence_score = float(gate["confidence_score"]) row.supersedes_memory_id = int(gate["supersedes_memory_id"]) if gate.get("supersedes_memory_id") else None row.invalidation_reason = str(gate.get("invalidation_reason") or "")[:1000] or None row.summary = _clean_text(merged_memory.get("summary") or "", limit=4000) row.durable_facts = _safe_list(merged_memory.get("durable_facts"), limit=24, item_limit=900) row.open_loops = _safe_list(merged_memory.get("open_loops"), limit=16, item_limit=700) row.decisions = _safe_list(merged_memory.get("decisions"), limit=16, item_limit=700) row.preferences = _safe_list(merged_memory.get("preferences"), limit=16, item_limit=700) row.object_refs = _safe_list(merged_memory.get("object_refs"), limit=16, item_limit=500) row.source_message_range = { "start": _safe_int(user_message_index), "end": _safe_int(assistant_message_index), } row.confidence = str(memory.get("confidence") or "medium")[:32] row.token_estimate = _estimated_tokens( { "summary": row.summary, "durable_facts": row.durable_facts, "open_loops": row.open_loops, "decisions": row.decisions, "preferences": row.preferences, } ) row.compaction_payload = { "packet_version": "pmflow_ai_context_memory_compaction.v1", "source": str(compaction.get("compaction_source") or "")[:80], "prompt_meta": compaction.get("prompt_meta") if isinstance(compaction.get("prompt_meta"), dict) else {}, "write_gate": gate, "conflict_resolution": gate.get("conflict_resolution") if isinstance(gate.get("conflict_resolution"), dict) else {}, "raw_memory_keys": sorted([str(key) for key in dict(memory or {}).keys()])[:32], } row.last_compacted_at = now row.last_validated_at = now row.last_accessed_at = now return row def _scope_refs( *, thread: PMFlowAIThread | None, current_user: Any | None, conversation_context: dict[str, Any] | None, tenant_project_context: dict[str, Any] | None, ) -> dict[str, list[str]]: context = conversation_context if isinstance(conversation_context, dict) else {} tenant = tenant_project_context if isinstance(tenant_project_context, dict) else {} refs: dict[str, list[str]] = {scope: [] for scope in _MEMORY_SCOPES} if thread is not None: public_thread_id = str(getattr(thread, "public_thread_id", "") or "").strip() thread_id = str(getattr(thread, "id", "") or "").strip() if public_thread_id: refs["thread"].append(f"thread:{public_thread_id}") elif thread_id: refs["thread"].append(f"thread_id:{thread_id}") user_id = _safe_int(getattr(current_user, "id", None)) or _safe_int(tenant.get("user_id")) if user_id: refs["user"].append(f"user:{user_id}") workspace_scope = str( context.get("conversation_scope") or context.get("workspace_scope") or tenant.get("workspace_scope") or context.get("surface") or "space" ).strip() if workspace_scope: refs["workspace"].append(f"workspace:{workspace_scope[:120]}") project_id = _safe_int(context.get("current_project_id")) or _safe_int(tenant.get("current_project_id")) if project_id: refs["project"].append(f"project:{project_id}") else: for item in _project_refs_from_context(context, tenant): if item not in refs["project"]: refs["project"].append(item) return {scope: values for scope, values in refs.items() if values} def _project_refs_from_context(context: dict[str, Any], tenant: dict[str, Any]) -> list[str]: refs: list[str] = [] roots = [context, tenant.get("surface_context") if isinstance(tenant.get("surface_context"), dict) else {}] for root in roots: for key in ("current_focus_object", "object_ledger"): value = root.get(key) if isinstance(root, dict) else None if isinstance(value, dict): project_id = _safe_int(value.get("project_id") or value.get("id") or value.get("object_id")) object_type = str(value.get("object_type") or value.get("type") or "").strip() if project_id and (not object_type or object_type == "project"): refs.append(f"project:{project_id}") for item in list(root.get("visible_objects") or [])[:12] if isinstance(root, dict) else []: if not isinstance(item, dict): continue project_id = _safe_int(item.get("project_id") or item.get("id") or item.get("object_id")) object_type = str(item.get("object_type") or item.get("type") or "").strip() if project_id and (not object_type or object_type == "project"): refs.append(f"project:{project_id}") out: list[str] = [] for ref in refs: if ref not in out: out.append(ref) return out[:4] def _memory_pack_from_rows(rows: list[PMFlowAIContextMemory], *, scope_refs: dict[str, list[str]]) -> dict[str, Any]: if not rows: return {} return { "packet_version": MEMORY_PACKET_VERSION, "scope_refs": scope_refs, "memories": [_memory_row_packet(row) for row in rows], "context_policy": { "long_term_memory_is_semantic_compaction_not_raw_transcript": True, "same_thread_recent_messages_override_memory_when_conflicting": True, "memory_scope_binding_is_anchor_based_not_scene_keyword_based": True, }, } def _memory_row_packet(row: PMFlowAIContextMemory) -> dict[str, Any]: return { "memory_id": str(row.public_memory_id or "")[:80], "memory_scope": str(row.memory_scope or "")[:40], "scope_ref": str(row.scope_ref or "")[:160], "memory_key": str(row.memory_key or "")[:120], "status": str(getattr(row, "status", "") or "active")[:32], "summary": _clean_text(row.summary, limit=1800), "durable_facts": _safe_list(row.durable_facts, limit=10, item_limit=700), "open_loops": _safe_list(row.open_loops, limit=8, item_limit=500), "decisions": _safe_list(row.decisions, limit=8, item_limit=500), "preferences": _safe_list(row.preferences, limit=8, item_limit=500), "object_refs": _safe_list(row.object_refs, limit=8, item_limit=300), "confidence": str(row.confidence or "")[:32], "confidence_score": float(getattr(row, "confidence_score", 0.0) or 0.0), "token_estimate": int(row.token_estimate or 0), "last_compacted_at": _iso(row.last_compacted_at), } def _rank_memory_rows(*, question: str, rows: list[PMFlowAIContextMemory], limit: int) -> list[PMFlowAIContextMemory]: if not rows: return [] question_terms = _text_terms(question) def score(row: PMFlowAIContextMemory) -> tuple[int, str]: text = " ".join( [ str(row.summary or ""), _dump_json(row.durable_facts, limit=3000), _dump_json(row.open_loops, limit=2000), _dump_json(row.decisions, limit=2000), ] ) terms = _text_terms(text) overlap = len(question_terms & terms) scope_weight = {"thread": 8, "project": 6, "workspace": 4, "user": 3}.get(str(row.memory_scope or ""), 1) confidence_weight = int(float(getattr(row, "confidence_score", 0.0) or 0.0) * 4) status_weight = 2 if str(getattr(row, "status", "") or "active") == "active" else 0 return (overlap + scope_weight + confidence_weight + status_weight, str(row.last_compacted_at or row.updated_at or "")) ranked = sorted(rows, key=score, reverse=True) out: list[PMFlowAIContextMemory] = [] seen: set[str] = set() for row in ranked: key = f"{row.memory_scope}:{row.scope_ref}:{row.memory_key}" if key in seen: continue seen.add(key) out.append(row) if len(out) >= max(1, limit): break return out def _normalize_compaction_memories(value: dict[str, Any]) -> dict[str, dict[str, Any]]: raw = value.get("memories") if isinstance(value, dict) else {} if not isinstance(raw, dict): return {} out: dict[str, dict[str, Any]] = {} for scope in _MEMORY_SCOPES: item = raw.get(scope) if isinstance(item, dict): out[scope] = _sanitize_memory(item) return out def _memory_write_gate(*, memory: dict[str, Any], existing: PMFlowAIContextMemory | None) -> dict[str, Any]: """Decide whether a semantic memory should be written or merged. The gate only uses lifecycle signals, generic text novelty and structured field deltas. It deliberately avoids business-domain keyword rules. """ sanitized = _sanitize_memory(memory) status = _normalize_memory_status(sanitized.get("status")) confidence_score = _confidence_score(sanitized.get("confidence")) incoming_text = _memory_text(sanitized) if not incoming_text: return _gate_result( "skip", "empty_memory", status=status, confidence_score=confidence_score, conflict_resolution=_resolve_memory_conflict(memory=sanitized, existing=existing), ) if status in {"stale", "invalidated", "rejected"}: if existing is None: return _gate_result( "skip", "cannot_invalidate_missing_memory", status=status, confidence_score=confidence_score, conflict_resolution=_resolve_memory_conflict(memory=sanitized, existing=None), ) return _gate_result( "update", "lifecycle_status_update", status=status, confidence_score=max(confidence_score, float(getattr(existing, "confidence_score", 0.0) or 0.0)), supersedes_memory_id=int(existing.id) if getattr(existing, "id", None) is not None else None, invalidation_reason=sanitized.get("invalidation_reason") or sanitized.get("summary") or "", conflict_resolution=_resolve_memory_conflict(memory=sanitized, existing=existing), ) if existing is None: create_status = "candidate" if confidence_score < 0.5 else "active" return _gate_result( "create", "new_scope_memory", status=create_status, confidence_score=confidence_score, conflict_resolution=_resolve_memory_conflict(memory=sanitized, existing=None), ) existing_text = _memory_text(_row_memory_dict(existing)) incoming_terms = _text_terms(incoming_text) existing_terms = _text_terms(existing_text) new_terms = incoming_terms - existing_terms novelty_ratio = len(new_terms) / max(1, len(incoming_terms)) structured_delta = _structured_delta_count(sanitized, _row_memory_dict(existing)) existing_score = float(getattr(existing, "confidence_score", 0.0) or 0.0) supersedes = bool(memory.get("supersedes_current") or memory.get("supersedes_existing")) conflict_resolution = _resolve_memory_conflict( memory={**sanitized, "supersedes_current": supersedes}, existing=existing, novelty_ratio=novelty_ratio, structured_delta=structured_delta, ) if not supersedes and novelty_ratio < 0.08 and structured_delta <= 0 and confidence_score <= existing_score + 0.05: return _gate_result( "skip", "duplicate_or_lower_confidence", status=str(getattr(existing, "status", "") or "active"), confidence_score=existing_score, conflict_resolution=conflict_resolution, ) return _gate_result( "update", "merge_new_semantic_delta", status="active", confidence_score=max(confidence_score, existing_score), supersedes_memory_id=int(existing.id) if supersedes and getattr(existing, "id", None) is not None else None, invalidation_reason=sanitized.get("invalidation_reason") if supersedes else "", novelty_ratio=round(novelty_ratio, 4), structured_delta=structured_delta, conflict_resolution=conflict_resolution, ) def _resolve_memory_conflict( *, memory: dict[str, Any], existing: PMFlowAIContextMemory | None, novelty_ratio: float | None = None, structured_delta: int | None = None, ) -> dict[str, Any]: sanitized = _sanitize_memory(memory) status = _normalize_memory_status(sanitized.get("status")) incoming_score = _confidence_score(sanitized.get("confidence")) if existing is None: return { "packet_version": "pmflow_ai_memory_conflict_resolution.v1", "resolution": "create", "reason": "no_existing_memory", "confidence_movement": {"incoming": incoming_score, "existing": None}, } existing_memory = _row_memory_dict(existing) existing_score = float(getattr(existing, "confidence_score", 0.0) or 0.0) if status in {"stale", "invalidated", "rejected"}: return { "packet_version": "pmflow_ai_memory_conflict_resolution.v1", "resolution": "invalidate", "reason": "incoming_lifecycle_status_invalidates_existing", "old_memory_id": str(getattr(existing, "public_memory_id", "") or "")[:80], "confidence_movement": {"incoming": incoming_score, "existing": existing_score}, } if bool(memory.get("supersedes_current") or memory.get("supersedes_existing")): return { "packet_version": "pmflow_ai_memory_conflict_resolution.v1", "resolution": "supersede", "reason": "incoming_memory_declares_supersession", "old_memory_id": str(getattr(existing, "public_memory_id", "") or "")[:80], "confidence_movement": {"incoming": incoming_score, "existing": existing_score}, } delta = int(structured_delta if structured_delta is not None else _structured_delta_count(sanitized, existing_memory)) novelty = float(novelty_ratio if novelty_ratio is not None else 0.0) if delta > 0 and novelty >= 0.35: resolution = "keep_both" reason = "incoming_adds_distinct_structured_context" elif delta > 0 or novelty >= 0.08: resolution = "merge" reason = "incoming_extends_existing_memory" else: resolution = "no_change" reason = "incoming_memory_duplicates_existing" return { "packet_version": "pmflow_ai_memory_conflict_resolution.v1", "resolution": resolution, "reason": reason, "old_memory_id": str(getattr(existing, "public_memory_id", "") or "")[:80], "novelty_ratio": round(novelty, 4), "structured_delta": delta, "confidence_movement": {"incoming": incoming_score, "existing": existing_score}, } def _gate_result( action: str, reason: str, *, status: str, confidence_score: float, supersedes_memory_id: int | None = None, invalidation_reason: str = "", **extra: Any, ) -> dict[str, Any]: return { "packet_version": "pmflow_ai_memory_write_gate.v1", "action": action, "reason": reason, "status": _normalize_memory_status(status), "confidence_score": max(0.0, min(float(confidence_score or 0.0), 1.0)), "supersedes_memory_id": supersedes_memory_id, "invalidation_reason": _clean_text(invalidation_reason, limit=1000), **extra, } def _merge_memory(*, existing: PMFlowAIContextMemory | None, incoming: dict[str, Any]) -> dict[str, Any]: new_memory = _sanitize_memory(incoming) if existing is None: return new_memory old_memory = _row_memory_dict(existing) old_summary = _clean_text(old_memory.get("summary") or "", limit=3000) new_summary = _clean_text(new_memory.get("summary") or "", limit=3000) if not old_summary: summary = new_summary elif not new_summary or _text_terms(new_summary).issubset(_text_terms(old_summary)): summary = old_summary else: summary = _clean_text(f"{old_summary}\n{new_summary}", limit=4000) return { "summary": summary, "durable_facts": _merge_list_rows(old_memory.get("durable_facts"), new_memory.get("durable_facts"), limit=24), "open_loops": _merge_list_rows(old_memory.get("open_loops"), new_memory.get("open_loops"), limit=16), "decisions": _merge_list_rows(old_memory.get("decisions"), new_memory.get("decisions"), limit=16), "preferences": _merge_list_rows(old_memory.get("preferences"), new_memory.get("preferences"), limit=16), "object_refs": _merge_list_rows(old_memory.get("object_refs"), new_memory.get("object_refs"), limit=16), "confidence": _higher_confidence(old_memory.get("confidence"), new_memory.get("confidence")), "status": new_memory.get("status") or old_memory.get("status") or "active", "invalidation_reason": new_memory.get("invalidation_reason") or old_memory.get("invalidation_reason") or "", } def _sanitize_memory(value: dict[str, Any]) -> dict[str, Any]: summary = _clean_text(value.get("summary") or "", limit=4000) return { "summary": summary, "durable_facts": _safe_list(value.get("durable_facts"), limit=24, item_limit=900), "open_loops": _safe_list(value.get("open_loops"), limit=16, item_limit=700), "decisions": _safe_list(value.get("decisions"), limit=16, item_limit=700), "preferences": _safe_list(value.get("preferences"), limit=16, item_limit=700), "object_refs": _safe_list(value.get("object_refs"), limit=16, item_limit=500), "confidence": str(value.get("confidence") or "medium")[:32], "status": _normalize_memory_status(value.get("status")), "invalidation_reason": _clean_text(value.get("invalidation_reason") or "", limit=1000), } def _deterministic_compaction_memories( *, user_message: str, assistant_answer: str, user_message_index: int | None, assistant_message_index: int | None, ) -> dict[str, dict[str, Any]]: user_text = _clean_text(user_message, limit=900) answer_text = _clean_text(assistant_answer, limit=1400) summary = ";".join(item for item in [f"用户提出:{user_text}" if user_text else "", f"PMFlow AI 回答:{answer_text}" if answer_text else ""] if item) base = { "summary": summary[:3600], "durable_facts": [ { "kind": "turn_summary", "summary": summary[:900], "source_message_indexes": [item for item in [user_message_index, assistant_message_index] if item is not None], } ] if summary else [], "open_loops": [], "decisions": [], "preferences": [], "object_refs": [], "confidence": "medium", } return {scope: dict(base) for scope in _MEMORY_SCOPES} def _row_memory_dict(row: PMFlowAIContextMemory) -> dict[str, Any]: return { "summary": str(getattr(row, "summary", "") or ""), "durable_facts": _safe_list(getattr(row, "durable_facts", None), limit=24, item_limit=900), "open_loops": _safe_list(getattr(row, "open_loops", None), limit=16, item_limit=700), "decisions": _safe_list(getattr(row, "decisions", None), limit=16, item_limit=700), "preferences": _safe_list(getattr(row, "preferences", None), limit=16, item_limit=700), "object_refs": _safe_list(getattr(row, "object_refs", None), limit=16, item_limit=500), "confidence": str(getattr(row, "confidence", "") or "medium")[:32], "status": str(getattr(row, "status", "") or "active")[:32], "invalidation_reason": str(getattr(row, "invalidation_reason", "") or "")[:1000], } def _memory_text(memory: dict[str, Any]) -> str: return " ".join( item for item in [ str(memory.get("summary") or ""), _dump_json(memory.get("durable_facts") or [], limit=5000), _dump_json(memory.get("open_loops") or [], limit=3000), _dump_json(memory.get("decisions") or [], limit=3000), _dump_json(memory.get("preferences") or [], limit=3000), ] if item ) def _merge_list_rows(left: Any, right: Any, *, limit: int) -> list[Any]: out: list[Any] = [] seen: set[str] = set() for item in [*list(left or []), *list(right or [])]: key = _dedupe_key(item) if not key or key in seen: continue seen.add(key) out.append(item) if len(out) >= limit: break return out def _structured_delta_count(left: dict[str, Any], right: dict[str, Any]) -> int: delta = 0 for key in ("durable_facts", "open_loops", "decisions", "preferences", "object_refs"): right_keys = {_dedupe_key(item) for item in list(right.get(key) or [])} for item in list(left.get(key) or []): item_key = _dedupe_key(item) if item_key and item_key not in right_keys: delta += 1 return delta def _dedupe_key(value: Any) -> str: if isinstance(value, dict): text = str(value.get("summary") or value.get("display_name") or value.get("object_id") or value) else: text = str(value or "") return re.sub(r"\s+", "", text.strip().lower())[:240] def _normalize_memory_status(value: Any) -> str: status = str(value or "").strip().lower() if status in {"active", "candidate", "stale", "invalidated", "rejected"}: return status return "active" def _confidence_score(value: Any) -> float: if isinstance(value, (int, float)): return max(0.0, min(float(value), 1.0)) label = str(value or "").strip().lower() if label == "high": return 0.85 if label == "low": return 0.35 return 0.6 def _higher_confidence(left: Any, right: Any) -> str: return str(left or "medium") if _confidence_score(left) >= _confidence_score(right) else str(right or "medium") def _memory_has_content(memory: dict[str, Any]) -> bool: return bool(str(memory.get("summary") or "").strip() or memory.get("durable_facts") or memory.get("open_loops")) def _extract_json_object(raw: Any) -> dict[str, Any]: if isinstance(raw, dict): return raw text = str(raw or "").strip() if not text: return {} try: parsed = json.loads(text) return parsed if isinstance(parsed, dict) else {} except Exception: pass match = re.search(r"\{.*\}", text, flags=re.S) if not match: return {} try: parsed = json.loads(match.group(0)) return parsed if isinstance(parsed, dict) else {} except Exception: return {} def _text_terms(value: Any) -> set[str]: text = str(value or "").lower() alnum = {item for item in re.findall(r"[a-z0-9_]{2,}", text)} cjk = re.findall(r"[\u4e00-\u9fff]", text) cjk_terms = {"".join(cjk[index : index + 2]) for index in range(max(0, len(cjk) - 1))} return {item for item in [*alnum, *cjk_terms] if item} def _safe_list(value: Any, *, limit: int, item_limit: int) -> list[Any]: rows = value if isinstance(value, list) else [] out: list[Any] = [] for item in rows[: max(0, limit)]: if isinstance(item, dict): cleaned = { str(key)[:80]: _clean_text(val, limit=item_limit) if not isinstance(val, (dict, list)) else val for key, val in item.items() if val not in (None, "", [], {}) } if cleaned: out.append(cleaned) continue text = _clean_text(item, limit=item_limit) if text: out.append(text) return out def _dump_json(value: Any, *, limit: int) -> str: try: text = json.dumps(value, ensure_ascii=False, default=str) except Exception: text = str(value or "") return text[: max(0, limit)] def _clean_text(value: Any, *, limit: int) -> str: text = " ".join(str(value or "").replace("\x00", " ").split()) return text[: max(0, limit)] def _safe_int(value: Any) -> int | None: try: if value is None or value == "": return None return int(value) except Exception: return None def _estimated_tokens(value: Any) -> int: text = _dump_json(value, limit=60000) return max(1, int(len(text) / 3.5)) def _iso(value: Any) -> str: if value is None: return "" try: return value.isoformat() except Exception: return str(value or "") __all__ = [ "FUNCTION_NAME_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR", "MEMORY_PACKET_VERSION", "PMFlowAIContextMemoryService", "PROMPT_KEY_PMFLOW_AI_CONTEXT_MEMORY_COMPACTOR", "_resolve_memory_conflict", ]