MEMORY_PIPELINE_LOG_ENABLED (default true) turns off the pipeline JSONL log. With the DB trace also off, store_pipeline_log becomes a no-op that does no timing, redaction, background work or database write. The init line states the real on/off state. Lines that carried conversation or memory text at INFO/WARNING now log ids, counts and lengths; the text goes to DEBUG: [add] input, search queries and rewrites, coding tasks and fact statements/subjects, reconcile op detail, extracted info, embedding inputs and unparsable model output. Bump hy-memory to 2.0.8 (not yet published). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2765 lines
122 KiB
Python
2765 lines
122 KiB
Python
"""
|
||
System 2 Agent — 统一认知加工引擎
|
||
|
||
替代旧的 SchemaInductor + IntentionDetector,改为单一 Agent + Tools 架构。
|
||
|
||
流程:
|
||
Phase 1 (硬编码预处理):
|
||
① VDB 取未处理的 L2_FACT + L4_IDENTITY
|
||
② DBSCAN 聚类 → clusters
|
||
③ Graph 正向搜索: 按 cluster 主题 query Graph
|
||
④ Graph 反向查找: VDB node_id → find_referencing_memories
|
||
|
||
Phase 2 (Agent LLM,仅当 clusters_found > 0):
|
||
输入: 聚类结果为主;unprocessed_facts = 未进任一 cluster 的 fresh facts(加菜,有上限)
|
||
无聚类时不触发 Agent(减轻冷启动/散事实场景的 LLM 负担)
|
||
|
||
工具: 8 个 tools — 演化高级认知 (Schema + Intent + 关系)
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
import re
|
||
import uuid
|
||
from typing import Any, Dict, List, Optional
|
||
from types import SimpleNamespace
|
||
|
||
from ..models.memory import MemoryLayer, MemoryNode, MemoryStatus, SourceType
|
||
from ..config import MemoryConfig
|
||
from ..core.embed_service import EmbedService
|
||
from ..data.vector_store_base import VectorStoreBase
|
||
from ..data.graph_store_base import GraphStoreBase
|
||
|
||
from .system2_tools import System2ToolExecutor
|
||
from .specific_supplement import (
|
||
is_internal_profile_chain_custom,
|
||
is_internal_profile_chain_hit,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
def _evoling_env(name: str, default: str) -> str:
|
||
"""Read a System 2 Evoling setting."""
|
||
return os.getenv(f"MEMORY_EVOLING_{name}", default)
|
||
|
||
|
||
_S2_SUMMARY_ENABLED = os.getenv("MEMORY_SUMMARY_ENABLED_IN_SYS2", "false").lower() == "true"
|
||
# 有聚类时,未聚类事实作为加菜;超过此数量截断,避免 1 cluster + 30 条 noise
|
||
_S2_MAX_UNCLUSTERED_FACTS = int(os.getenv("MEMORY_S2_MAX_UNCLUSTERED_FACTS", "15"))
|
||
# Bound the number of candidate facts sent in one System 2 call. A semantic
|
||
# cluster can legitimately span most of a long-lived user's bank; asking the LLM
|
||
# to emit one decision per fact then exhausts its output budget and leaves an
|
||
# unparseable partial JSON array. The writer rescans after every successful batch,
|
||
# so reviewing a deterministic slice is a safe checkpoint and the remaining facts
|
||
# are picked up by the next iteration/cycle.
|
||
_S2_MAX_FACTS_PER_BATCH = int(_evoling_env("MAX_FACTS_PER_BATCH", "24"))
|
||
# 簇内 embedding 余弦相似度 ≥ 此阈值视为重复,只保留一条(防系统重复写入闯关)
|
||
_S2_CLUSTER_DEDUP_COSINE = float(os.getenv("MEMORY_S2_CLUSTER_DEDUP_COSINE", "0.92"))
|
||
_S2_SOURCE_GROUPS_ENABLED = _evoling_env(
|
||
"SOURCE_FACTS_ENABLED", "false",
|
||
).lower() in ("1", "true", "yes", "on")
|
||
_S2_SOURCE_GROUP_MIN_FACTS = int(_evoling_env(
|
||
"SOURCE_GROUP_MIN_FACTS", "1",
|
||
))
|
||
_S2_SOURCE_GROUP_MAX_FACTS = int(_evoling_env(
|
||
"SOURCE_GROUP_MAX_FACTS", "12",
|
||
))
|
||
_S2_SOURCE_RAW_MAX_CHARS = int(_evoling_env(
|
||
"SOURCE_RAW_MAX_CHARS", "24000",
|
||
))
|
||
_S2_FORGET_ENABLED = _evoling_env(
|
||
"FORGET_ENABLED", "false",
|
||
).lower() in ("1", "true", "yes", "on")
|
||
_S2_CANONICAL_STATE_ENABLED = _evoling_env(
|
||
"CANONICAL_STATE_ENABLED", "false",
|
||
).lower() in ("1", "true", "yes", "on")
|
||
_S2_CANONICAL_STATE_REVIEW_VERSION = 3
|
||
_S2_CANONICAL_STATE_VERSION = 2
|
||
_S2_SCHEMA_EVIDENCE_GATE_ENABLED = _evoling_env(
|
||
"SCHEMA_EVIDENCE_GATE_ENABLED", "false",
|
||
).lower() in ("1", "true", "yes", "on")
|
||
_S2_HOLISTIC_THERAPY_ENABLED = _evoling_env(
|
||
"HOLISTIC_THERAPY_ENABLED", "false",
|
||
).lower() in ("1", "true", "yes", "on")
|
||
_S2_HOLISTIC_THERAPY_MAX_FACTS = int(_evoling_env(
|
||
"HOLISTIC_THERAPY_MAX_FACTS", "120",
|
||
))
|
||
_S2_HOLISTIC_THERAPY_MAX_SCHEMAS = int(_evoling_env(
|
||
"HOLISTIC_THERAPY_MAX_SCHEMAS", "5",
|
||
))
|
||
_S2_PROFILE_FACT_PROMOTION_ENABLED = _evoling_env(
|
||
"PROFILE_FACT_PROMOTION_ENABLED", "false",
|
||
).lower() in ("1", "true", "yes", "on")
|
||
_S2_PROFILE_FACT_PROMOTION_MAX_FACTS = int(_evoling_env(
|
||
"PROFILE_FACT_PROMOTION_MAX_FACTS", "120",
|
||
))
|
||
_S2_PROFILE_FACT_PROMOTION_MAX_SELECTED = int(_evoling_env(
|
||
"PROFILE_FACT_PROMOTION_MAX_SELECTED", "12",
|
||
))
|
||
_S2_PROFILE_FACT_SOURCE_CONTEXT_MAX_CHARS = int(_evoling_env(
|
||
"PROFILE_FACT_SOURCE_CONTEXT_MAX_CHARS", "48000",
|
||
))
|
||
_S2_PROFILE_FACT_PROMOTION_REVIEW_VERSION = 2
|
||
|
||
_EQUIPMENT_SPECS = {
|
||
"glasses": {
|
||
"aliases": ("glasses", "eyeglasses", "spectacles", "prescription glasses"),
|
||
"state_key": "assistive_equipment.glasses",
|
||
"state_value": "uses",
|
||
"content": "The user uses glasses for visual detail.",
|
||
"retrieval_text": (
|
||
"Personal eyewear state: the user uses glasses for visual detail. "
|
||
"Relevant contexts: cinema and theater viewing, 3D glasses compatibility, "
|
||
"screen work, reading, eye comfort, and visual accessibility."
|
||
),
|
||
"tags": ["health", "vision", "eyewear"],
|
||
},
|
||
"hearing_aids": {
|
||
"aliases": ("hearing aids", "hearing aid"),
|
||
"state_key": "assistive_equipment.hearing_aids",
|
||
"state_value": "uses",
|
||
"content": "The user uses hearing aids.",
|
||
"retrieval_text": (
|
||
"Hearing support: the user uses hearing aids; relevant to listening "
|
||
"conditions, communication, and noisy environments."
|
||
),
|
||
"tags": ["health", "hearing", "assistive-equipment"],
|
||
},
|
||
"cpap": {
|
||
"aliases": ("cpap", "cpap machine", "cpap device"),
|
||
"state_key": "assistive_equipment.cpap",
|
||
"state_value": "uses",
|
||
"content": "The user uses a CPAP device.",
|
||
"retrieval_text": (
|
||
"Sleep and breathing support: the user uses a CPAP device; relevant "
|
||
"to sleep routines, overnight stays, and travel."
|
||
),
|
||
"tags": ["health", "sleep", "assistive-equipment"],
|
||
},
|
||
"knee_brace": {
|
||
"aliases": ("knee brace",),
|
||
"state_key": "assistive_equipment.knee_brace",
|
||
"state_value": "uses",
|
||
"content": "The user uses a knee brace.",
|
||
"retrieval_text": (
|
||
"Mobility support: the user uses a knee brace; relevant to walking, "
|
||
"hiking, exercise, and physical comfort."
|
||
),
|
||
"tags": ["health", "mobility", "assistive-equipment"],
|
||
},
|
||
"mobility_aid": {
|
||
"aliases": ("wheelchair", "walker", "walking stick", "cane", "crutches"),
|
||
"state_key_template": "assistive_equipment.{equipment}",
|
||
"state_value": "uses",
|
||
"content_template": "The user uses {article}{equipment} as a mobility aid.",
|
||
"retrieval_text_template": (
|
||
"Mobility support: the user uses {article}{equipment}; relevant to "
|
||
"accessibility, walking, travel, and physical activities."
|
||
),
|
||
"tags": ["health", "mobility", "assistive-equipment"],
|
||
},
|
||
}
|
||
|
||
_EQUIPMENT_USE_VERBS = (
|
||
r"wear(?:ing|s)?|use(?:d|s|ing)?|adjust(?:ed|ing|s)?|"
|
||
r"put(?:ting)?\s+on|rely(?:ing|ies|ied)?\s+on|need(?:ed|s|ing)?|"
|
||
r"pack(?:ed|ing|s)?|bring(?:ing|s)?|brought"
|
||
)
|
||
_EQUIPMENT_REJECT_RE = re.compile(
|
||
r"\b(?:borrow(?:ed|ing)?|lend|lent|rented?|trying\s+out|tried\s+on|"
|
||
r"costume|as\s+a\s+joke|for\s+a\s+joke|temporar(?:y|ily)|one[- ]time|"
|
||
r"if\s+i|would\s+i|could\s+i|should\s+i|might\s+i|"
|
||
r"do\s+not|don't|never|no\s+longer|stopped)\b",
|
||
re.I,
|
||
)
|
||
|
||
|
||
def _iter_role_messages(raw_text: str) -> List[Dict[str, str]]:
|
||
"""Parse the stable L1 ``[user]:`` / ``[assistant]:`` envelope."""
|
||
text = str(raw_text or "")
|
||
matches = list(re.finditer(r"(?m)^\[(user|assistant)\]:\s*", text))
|
||
messages: List[Dict[str, str]] = []
|
||
for index, match in enumerate(matches):
|
||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||
messages.append({"role": match.group(1), "text": text[match.end():end].strip()})
|
||
return messages
|
||
|
||
|
||
def _equipment_mentioned(text: str, aliases: tuple[str, ...]) -> Optional[str]:
|
||
lowered = str(text or "").lower()
|
||
for alias in sorted(aliases, key=len, reverse=True):
|
||
if re.search(rf"(?<![a-z]){re.escape(alias)}(?![a-z])", lowered):
|
||
return alias
|
||
return None
|
||
|
||
|
||
def _is_explicit_personal_equipment_use(text: str, alias: str) -> bool:
|
||
"""Require first-person possession/use and reject questions or temporary use."""
|
||
value = " ".join(str(text or "").split())
|
||
lowered = value.lower()
|
||
if not value or _EQUIPMENT_REJECT_RE.search(lowered):
|
||
return False
|
||
if re.search(r"\b(?:please\s+)?forget\s+(?:that\s+)?(?:i\b|i'm\b|i’ve\b|i've\b|my\b|me\b)", lowered):
|
||
return False
|
||
if value.endswith("?") or re.match(
|
||
r"^(?:what|which|who|when|where|why|how|can|could|should|would|do|does|is|are|will)\b",
|
||
lowered,
|
||
):
|
||
return False
|
||
equipment = re.escape(alias.lower())
|
||
direct_patterns = (
|
||
rf"\bi\s+(?:\w+\s+){{0,3}}(?:{_EQUIPMENT_USE_VERBS})\s+(?:my\s+|a\s+|an\s+)?{equipment}\b",
|
||
rf"\b(?:{_EQUIPMENT_USE_VERBS})\s+my\s+{equipment}\b",
|
||
rf"\bmy\s+{equipment}\b.{0,50}\b(?:{_EQUIPMENT_USE_VERBS})\b",
|
||
)
|
||
return any(re.search(pattern, lowered, re.I) for pattern in direct_patterns)
|
||
|
||
|
||
def extract_personal_equipment_states(raw_text: str) -> List[Dict[str, Any]]:
|
||
"""Extract a narrow class of explicit, durable equipment-use states."""
|
||
states: List[Dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for message in _iter_role_messages(raw_text):
|
||
if message["role"] != "user":
|
||
continue
|
||
utterance = message["text"]
|
||
for kind, spec in _EQUIPMENT_SPECS.items():
|
||
alias = _equipment_mentioned(utterance, spec["aliases"])
|
||
if not alias or kind in seen:
|
||
continue
|
||
if not _is_explicit_personal_equipment_use(utterance, alias):
|
||
continue
|
||
article = "an " if alias[:1].lower() in "aeiou" else "a "
|
||
content = spec.get("content") or spec["content_template"].format(
|
||
equipment=alias, article=article,
|
||
)
|
||
retrieval_text = spec.get("retrieval_text") or spec["retrieval_text_template"].format(
|
||
equipment=alias, article=article,
|
||
)
|
||
states.append({
|
||
"equipment_kind": kind,
|
||
"equipment": alias,
|
||
"state_key": spec.get("state_key") or spec["state_key_template"].format(
|
||
equipment=alias.replace(" ", "_"),
|
||
),
|
||
"state_value": spec["state_value"],
|
||
"content": content,
|
||
"retrieval_text": retrieval_text,
|
||
"tags": list(spec["tags"]),
|
||
"evidence_quote": utterance,
|
||
})
|
||
seen.add(kind)
|
||
return states
|
||
|
||
|
||
def build_canonical_state_reviews(
|
||
raw_nodes: List[Any],
|
||
*,
|
||
facts: List[Any],
|
||
active_schemas: List[Any],
|
||
) -> List[Dict[str, Any]]:
|
||
"""Build canonical-state reviews from raw evidence.
|
||
|
||
Ordinary event facts and behavioral Schemas do not count as the canonical
|
||
state record. Exact deduplication happens later by ``state_key``.
|
||
"""
|
||
del facts, active_schemas
|
||
|
||
def _event_time(node: Any) -> float:
|
||
value = getattr(node, "memory_at", None) or getattr(node, "gmt_created", None)
|
||
if hasattr(value, "timestamp"):
|
||
return float(value.timestamp())
|
||
try:
|
||
return float(value or 0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
reviews: List[Dict[str, Any]] = []
|
||
for raw_node in sorted(raw_nodes, key=_event_time):
|
||
custom = dict(getattr(raw_node, "custom", None) or {})
|
||
if int(custom.get("s2_canonical_state_review_version") or 0) >= _S2_CANONICAL_STATE_REVIEW_VERSION:
|
||
continue
|
||
candidates = []
|
||
for state in extract_personal_equipment_states(getattr(raw_node, "content", "")):
|
||
kind = state["equipment_kind"]
|
||
candidates.append({**state, "source_raw_memory_id": str(raw_node.node_id)})
|
||
retire_state_keys = []
|
||
for directive in extract_raw_forget_directives(getattr(raw_node, "content", "")):
|
||
for kind, spec in _EQUIPMENT_SPECS.items():
|
||
if _equipment_mentioned(directive, spec["aliases"]):
|
||
retire_state_keys.append(
|
||
spec.get("state_key") or spec["state_key_template"].format(
|
||
equipment=_equipment_mentioned(directive, spec["aliases"]).replace(" ", "_")
|
||
)
|
||
)
|
||
reviews.append({
|
||
"node_id": str(raw_node.node_id),
|
||
"candidates": candidates,
|
||
"retire_state_keys": list(dict.fromkeys(retire_state_keys)),
|
||
})
|
||
return reviews
|
||
|
||
|
||
def canonical_state_node_id(
|
||
user_id: str,
|
||
agent_id: str,
|
||
state_key: str,
|
||
) -> str:
|
||
"""One stable materialized state record per user, agent, and semantic key."""
|
||
identity = f"canonical_state::{user_id}::{agent_id}::{state_key}"
|
||
return str(uuid.uuid5(uuid.NAMESPACE_URL, identity))
|
||
|
||
|
||
async def run_canonical_state_projection(
|
||
*,
|
||
reviews: List[Dict[str, Any]],
|
||
tool_executor: System2ToolExecutor,
|
||
) -> Dict[str, Any]:
|
||
"""Project explicit raw evidence into versioned, canonical L2 state facts."""
|
||
if not _S2_CANONICAL_STATE_ENABLED or not reviews:
|
||
return {
|
||
"success": True,
|
||
"enabled": _S2_CANONICAL_STATE_ENABLED,
|
||
"reviews": 0,
|
||
"created": 0,
|
||
"reviewed_raw_ids": [],
|
||
"failed_raw_ids": [],
|
||
"tool_calls": [],
|
||
}
|
||
|
||
stored_states = await tool_executor.vector_store.list_by_user(
|
||
user_id=tool_executor.user_id,
|
||
agent_id=tool_executor.agent_id,
|
||
layers=[MemoryLayer.L2_FACT],
|
||
only_latest=False,
|
||
with_vectors=False,
|
||
)
|
||
by_key = {
|
||
str((node.custom or {}).get("state_key") or ""): node
|
||
for node in stored_states
|
||
if (node.custom or {}).get("memory_kind") == "canonical_state"
|
||
and (node.custom or {}).get("state_key")
|
||
}
|
||
|
||
created = 0
|
||
merged = 0
|
||
superseded = 0
|
||
retired = 0
|
||
reviewed_raw_ids: List[str] = []
|
||
failed_raw_ids: List[str] = []
|
||
tool_calls: List[Dict[str, Any]] = []
|
||
for review in reviews:
|
||
raw_id = str(review.get("node_id") or "")
|
||
raw_node = await tool_executor.vector_store.get_by_id(raw_id)
|
||
if (
|
||
raw_node is None
|
||
or raw_node.user_id != tool_executor.user_id
|
||
or raw_node.agent_id != tool_executor.agent_id
|
||
or raw_node.layer != MemoryLayer.L1_RAW
|
||
):
|
||
failed_raw_ids.append(raw_id)
|
||
continue
|
||
raw_succeeded = True
|
||
for state_key in review.get("retire_state_keys") or []:
|
||
current = by_key.get(state_key)
|
||
if current is None or current.status != MemoryStatus.ACTIVE:
|
||
continue
|
||
custom = dict(current.custom or {})
|
||
history = list(custom.get("state_history") or [])
|
||
history.append({
|
||
"state_value": custom.get("state_value"),
|
||
"content": current.content,
|
||
"evidence_raw_ids": list(
|
||
custom.get("evidence_raw_ids") or current.evidence_chain or []
|
||
),
|
||
"valid_until": int(time.time()),
|
||
"retired_by_raw_id": raw_id,
|
||
})
|
||
custom["state_history"] = history
|
||
custom["retired_by_raw_id"] = raw_id
|
||
updated = await tool_executor.vector_store.update_payload(current.node_id, {
|
||
"status": MemoryStatus.ARCHIVED.value,
|
||
"is_latest": False,
|
||
"valid_until": int(time.time()),
|
||
"custom": custom,
|
||
})
|
||
if not updated:
|
||
raw_succeeded = False
|
||
break
|
||
current.status = MemoryStatus.ARCHIVED
|
||
current.custom = custom
|
||
retired += 1
|
||
tool_calls.append({
|
||
"op": "state_retire", "node_id": current.node_id,
|
||
"state_key": state_key, "source_raw_memory_id": raw_id,
|
||
})
|
||
if not raw_succeeded:
|
||
failed_raw_ids.append(raw_id)
|
||
continue
|
||
for candidate in review.get("candidates") or []:
|
||
state_key = candidate["state_key"]
|
||
state_value = candidate["state_value"]
|
||
current = by_key.get(state_key)
|
||
if (
|
||
current is not None
|
||
and current.status == MemoryStatus.ACTIVE
|
||
and str((current.custom or {}).get("state_value")) == state_value
|
||
):
|
||
custom = dict(current.custom or {})
|
||
evidence_ids = list(custom.get("evidence_raw_ids") or current.evidence_chain or [])
|
||
if raw_id not in evidence_ids:
|
||
evidence_ids.append(raw_id)
|
||
custom["evidence_raw_ids"] = evidence_ids
|
||
needs_refresh = int(
|
||
custom.get("state_projection_version") or 0
|
||
) < _S2_CANONICAL_STATE_VERSION
|
||
custom["state_projection_version"] = _S2_CANONICAL_STATE_VERSION
|
||
custom["state_retrieval_text"] = candidate["retrieval_text"]
|
||
updates = {
|
||
"custom": custom,
|
||
"evidence_chain": evidence_ids,
|
||
"evidence_count": len(evidence_ids),
|
||
}
|
||
from ..utils.source_filter import merge_sources
|
||
evidence_nodes = await tool_executor.vector_store.get_by_ids(evidence_ids)
|
||
updates["source"] = merge_sources(
|
||
getattr(evidence_node, "source", None)
|
||
for evidence_node in evidence_nodes
|
||
)
|
||
if needs_refresh:
|
||
updates.update({
|
||
"content": candidate["content"],
|
||
"embedding": await tool_executor.embed_service.embed_queued(
|
||
candidate["retrieval_text"]
|
||
),
|
||
"tags": list(candidate["tags"]),
|
||
"gmt_modified": int(time.time()),
|
||
})
|
||
updated = await tool_executor.vector_store.update_payload(
|
||
current.node_id, updates,
|
||
)
|
||
if not updated:
|
||
raw_succeeded = False
|
||
break
|
||
current.custom = custom
|
||
current.evidence_chain = evidence_ids
|
||
current.evidence_count = len(evidence_ids)
|
||
if needs_refresh:
|
||
current.content = candidate["content"]
|
||
merged += 1
|
||
continue
|
||
|
||
if current is not None:
|
||
old_custom = dict(current.custom or {})
|
||
history = list(old_custom.get("state_history") or [])
|
||
history.append({
|
||
"state_value": old_custom.get("state_value"),
|
||
"content": current.content,
|
||
"evidence_raw_ids": list(
|
||
old_custom.get("evidence_raw_ids") or current.evidence_chain or []
|
||
),
|
||
"valid_until": int(time.time()),
|
||
})
|
||
new_custom = {
|
||
**old_custom,
|
||
"memory_kind": "canonical_state",
|
||
"state_key": state_key,
|
||
"state_value": state_value,
|
||
"state_retrieval_text": candidate["retrieval_text"],
|
||
"evidence_raw_ids": [raw_id],
|
||
"state_evidence_quote": candidate["evidence_quote"],
|
||
"state_history": history,
|
||
"s2_reviewed_count": 1,
|
||
}
|
||
updated = await tool_executor.vector_store.update_payload(current.node_id, {
|
||
"content": candidate["content"],
|
||
"source": getattr(raw_node, "source", "default") or "default",
|
||
"embedding": await tool_executor.embed_service.embed_queued(
|
||
candidate["retrieval_text"]
|
||
),
|
||
"tags": list(candidate["tags"]),
|
||
"memory_at": (
|
||
int(raw_node.memory_at.timestamp())
|
||
if getattr(raw_node, "memory_at", None) else None
|
||
),
|
||
"source_raw_memory_id": raw_id,
|
||
"evidence_chain": [raw_id],
|
||
"evidence_count": 1,
|
||
"status": MemoryStatus.ACTIVE.value,
|
||
"is_latest": True,
|
||
"gmt_modified": int(time.time()),
|
||
"custom": new_custom,
|
||
})
|
||
if not updated:
|
||
raw_succeeded = False
|
||
break
|
||
current.content = candidate["content"]
|
||
current.custom = new_custom
|
||
current.evidence_chain = [raw_id]
|
||
current.evidence_count = 1
|
||
current.status = MemoryStatus.ACTIVE
|
||
superseded += 1
|
||
tool_calls.append({
|
||
"op": "state_update", "node_id": current.node_id,
|
||
"state_key": state_key, "state_value": state_value,
|
||
"source_raw_memory_id": raw_id,
|
||
})
|
||
continue
|
||
|
||
state_custom = {
|
||
"memory_kind": "canonical_state",
|
||
"state_key": state_key,
|
||
"state_value": state_value,
|
||
"state_retrieval_text": candidate["retrieval_text"],
|
||
"evidence_raw_ids": [raw_id],
|
||
"state_evidence_quote": candidate["evidence_quote"],
|
||
"state_projection_version": _S2_CANONICAL_STATE_VERSION,
|
||
"s2_reviewed_count": 1,
|
||
}
|
||
node = MemoryNode(
|
||
node_id=canonical_state_node_id(
|
||
tool_executor.user_id,
|
||
tool_executor.agent_id,
|
||
state_key,
|
||
),
|
||
user_id=tool_executor.user_id,
|
||
agent_id=tool_executor.agent_id,
|
||
session_id=getattr(raw_node, "session_id", "") or "default_session",
|
||
source=getattr(raw_node, "source", "default") or "default",
|
||
layer=MemoryLayer.L2_FACT,
|
||
content=candidate["content"],
|
||
owner="user",
|
||
source_type=SourceType.EXPLICIT,
|
||
status=MemoryStatus.ACTIVE,
|
||
confidence=0.98,
|
||
evidence_count=1,
|
||
memory_at=getattr(raw_node, "memory_at", None),
|
||
source_raw_memory_id=raw_id,
|
||
evidence_chain=[raw_id],
|
||
tags=list(candidate["tags"]),
|
||
custom=state_custom,
|
||
embedding=await tool_executor.embed_service.embed_queued(
|
||
candidate["retrieval_text"]
|
||
),
|
||
)
|
||
try:
|
||
await tool_executor.vector_store.upsert(node)
|
||
except Exception as exc:
|
||
tool_calls.append({"op": "state_create", "state_key": state_key, "error": str(exc)})
|
||
raw_succeeded = False
|
||
break
|
||
by_key[state_key] = node
|
||
tool_calls.append({
|
||
"op": "state_create",
|
||
"node_id": node.node_id,
|
||
"state_key": state_key,
|
||
"state_value": state_value,
|
||
"source_raw_memory_id": raw_id,
|
||
})
|
||
created += 1
|
||
if not raw_succeeded:
|
||
failed_raw_ids.append(raw_id)
|
||
continue
|
||
custom = dict(raw_node.custom or {})
|
||
custom["s2_canonical_state_review_version"] = _S2_CANONICAL_STATE_REVIEW_VERSION
|
||
try:
|
||
await tool_executor.vector_store.update_payload(raw_id, {"custom": custom})
|
||
reviewed_raw_ids.append(raw_id)
|
||
except Exception:
|
||
failed_raw_ids.append(raw_id)
|
||
|
||
return {
|
||
"success": not failed_raw_ids,
|
||
"enabled": True,
|
||
"reviews": len(reviews),
|
||
"created": created,
|
||
"merged": merged,
|
||
"superseded": superseded,
|
||
"retired": retired,
|
||
"reviewed_raw_ids": reviewed_raw_ids,
|
||
"failed_raw_ids": failed_raw_ids,
|
||
"tool_calls": tool_calls,
|
||
}
|
||
|
||
_FORGET_EN_RE = re.compile(
|
||
r"\b(?:asked|told|requested)\b.{0,80}\bforget\s+"
|
||
r"(?:that\s+(?:they|the user)\b|(?:their|the user's)\b)",
|
||
re.I,
|
||
)
|
||
_FORGET_ZH_RE = re.compile(r"用户.{0,30}(?:请|要求|让).{0,20}忘记.{0,10}(?:自己|其|他们|她|他)")
|
||
|
||
|
||
def is_explicit_forget_marker(text: str) -> bool:
|
||
"""Conservative detector for user-scoped, explicit forgetting facts."""
|
||
value = str(text or "").strip()
|
||
if not value:
|
||
return False
|
||
lowered = value.lower()
|
||
if not (lowered.startswith("the user ") or value.startswith("用户")):
|
||
return False
|
||
return bool(_FORGET_EN_RE.search(value) or _FORGET_ZH_RE.search(value))
|
||
|
||
|
||
def extract_raw_forget_directives(raw_text: str) -> List[str]:
|
||
"""Extract explicit self-directed forgetting requests from L1 raw text.
|
||
|
||
Only user turns are considered. The object must be first-person (I/my/me or
|
||
Chinese self-reference); requests about another named person are excluded.
|
||
"""
|
||
text = str(raw_text or "")
|
||
messages = list(re.finditer(r"(?m)^\[(user|assistant)\]:\s*", text))
|
||
directives: List[str] = []
|
||
for index, match in enumerate(messages):
|
||
if match.group(1) != "user":
|
||
continue
|
||
end = messages[index + 1].start() if index + 1 < len(messages) else len(text)
|
||
utterance = text[match.end():end].strip()
|
||
lowered = utterance.lower()
|
||
english = re.search(
|
||
r"\b(?:please\s+)?forget\s+(?:that\s+)?(?:i\b|i'm\b|i’ve\b|i've\b|my\b|me\b)",
|
||
lowered,
|
||
)
|
||
chinese = re.search(r"(?:请)?忘记.{0,12}(?:我|我的|本人)", utterance)
|
||
if english or chinese:
|
||
directives.append(utterance)
|
||
return directives
|
||
|
||
|
||
def _text_terms(text: str) -> set[str]:
|
||
return {
|
||
token for token in re.findall(r"[a-z0-9][a-z0-9_-]+", (text or "").lower())
|
||
if len(token) >= 3
|
||
}
|
||
|
||
|
||
def select_relevant_raw_excerpt(candidate_text: str, raw_text: str, max_chars: int = 6000) -> str:
|
||
"""Select the most relevant message plus an adjacent dialogue turn.
|
||
|
||
L1 writes can contain many unrelated conversations. Passing the entire write
|
||
caused the model to overlook one candidate or infer a pattern from a different
|
||
turn. Lexical overlap is deterministic and only selects context; it does not
|
||
decide whether a Schema should be created.
|
||
"""
|
||
text = str(raw_text or "").strip()
|
||
if not text:
|
||
return ""
|
||
matches = list(re.finditer(r"(?m)^\[(user|assistant)\]:\s*", text))
|
||
if not matches:
|
||
return text[:max_chars]
|
||
messages = []
|
||
for index, match in enumerate(matches):
|
||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||
messages.append({
|
||
"role": match.group(1),
|
||
"text": text[match.end():end].strip(),
|
||
})
|
||
candidate_terms = _text_terms(candidate_text)
|
||
best_index = max(
|
||
range(len(messages)),
|
||
key=lambda idx: len(candidate_terms & _text_terms(messages[idx]["text"])),
|
||
)
|
||
selected = {best_index}
|
||
if messages[best_index]["role"] == "assistant" and best_index > 0:
|
||
selected.add(best_index - 1)
|
||
elif messages[best_index]["role"] == "user" and best_index + 1 < len(messages):
|
||
selected.add(best_index + 1)
|
||
excerpt = "\n".join(
|
||
f"[{messages[index]['role']}]: {messages[index]['text']}"
|
||
for index in sorted(selected)
|
||
)
|
||
return excerpt[:max_chars]
|
||
|
||
|
||
def build_evoling_batches(
|
||
materials: Dict[str, Any],
|
||
*,
|
||
max_batches: int,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Split one preprocessing snapshot into bounded, one-cluster LLM batches.
|
||
|
||
The previous implementation asked one response to describe every cluster and
|
||
regularly hit the output-token ceiling. A batch deliberately contains at most
|
||
``MEMORY_EVOLING_MAX_FACTS_PER_BATCH`` facts from one cluster and no unrelated
|
||
unclustered supplement. Existing graph context is retained so the model can
|
||
attach evidence instead of creating duplicates. Fact order is preserved so a
|
||
successful slice can be checkpointed and the next rescan deterministically
|
||
advances to the remaining facts.
|
||
"""
|
||
clusters = list(materials.get("clusters") or [])
|
||
limit = max(0, int(max_batches))
|
||
max_facts = max(1, _S2_MAX_FACTS_PER_BATCH)
|
||
planned: List[Dict[str, Any]] = []
|
||
for cluster in clusters:
|
||
facts = list(cluster.get("facts") or [])
|
||
slices = [facts[i:i + max_facts] for i in range(0, len(facts), max_facts)] or [[]]
|
||
for fact_slice in slices:
|
||
selected_ids = {
|
||
str(fact.get("node_id") or "") for fact in fact_slice
|
||
if fact.get("node_id")
|
||
}
|
||
batch_cluster = {
|
||
**cluster,
|
||
"ids": [
|
||
node_id for node_id in (cluster.get("ids") or [])
|
||
if str(node_id) in selected_ids
|
||
],
|
||
"facts": fact_slice,
|
||
}
|
||
if cluster.get("raw_contexts"):
|
||
batch_cluster["raw_contexts"] = [
|
||
item for item in cluster.get("raw_contexts") or []
|
||
if str(item.get("node_id") or "") in selected_ids
|
||
]
|
||
stats = dict(materials.get("stats") or {})
|
||
stats.update({
|
||
"clusters_found": 1,
|
||
"clustered_facts_count": len(fact_slice),
|
||
"unclustered_facts_count": 0,
|
||
"evoling_batch_index": len(planned),
|
||
})
|
||
planned.append({
|
||
**materials,
|
||
"clusters": [batch_cluster],
|
||
"graph_forward": cluster.get(
|
||
"graph_forward", materials.get("graph_forward", [])
|
||
),
|
||
"unprocessed_facts": [],
|
||
"stats": stats,
|
||
})
|
||
batches = planned[:limit]
|
||
for batch in batches:
|
||
batch["stats"]["evoling_batch_count"] = len(batches)
|
||
return batches
|
||
|
||
|
||
def cluster_fingerprint(cluster: Dict[str, Any]) -> str:
|
||
"""Stable identity for continuation/no-progress accounting."""
|
||
return "|".join(sorted(
|
||
str(fact.get("node_id") or "")
|
||
for fact in cluster.get("facts") or []
|
||
if fact.get("node_id")
|
||
))
|
||
|
||
|
||
def fact_source_keys(node: Any) -> set[str]:
|
||
"""Return the independent raw/session sources represented by one fact."""
|
||
custom = getattr(node, "custom", None) or {}
|
||
keys = {
|
||
str(value) for value in custom.get("source_raw_memory_ids") or [] if str(value)
|
||
}
|
||
legacy_source = str(getattr(node, "source_raw_memory_id", "") or "")
|
||
if legacy_source:
|
||
keys.add(legacy_source)
|
||
if not keys:
|
||
session_id = str(getattr(node, "session_id", "") or "")
|
||
if session_id:
|
||
keys.add(f"session:{session_id}")
|
||
return keys
|
||
|
||
|
||
def fact_has_unreviewed_sources(node: Any) -> bool:
|
||
"""Whether an L2 fact contains provenance added after its last S2 review."""
|
||
custom = getattr(node, "custom", None) or {}
|
||
evidence_count = int(custom.get("s2_evidence_count") or 0)
|
||
reviewed_count = int(custom.get("s2_reviewed_count") or 0)
|
||
if bool(custom.get("s2_forget_reviewed")):
|
||
return False
|
||
source_count = len(fact_source_keys(node))
|
||
reviewed_source_count = custom.get("s2_reviewed_source_count")
|
||
if reviewed_source_count is None:
|
||
# Legacy reviewed nodes did not save a checkpoint. Treat the sources
|
||
# currently present as reviewed; writer UPDATE records this count before
|
||
# appending a future source, so that future source reopens the node.
|
||
reviewed_source_count = source_count if (evidence_count or reviewed_count) else 0
|
||
if source_count > int(reviewed_source_count or 0):
|
||
return True
|
||
return evidence_count == 0 and reviewed_count == 0
|
||
|
||
|
||
def fact_has_unreviewed_holistic_sources(node: Any) -> bool:
|
||
"""Whether a fact has raw provenance unseen by holistic therapy review."""
|
||
custom = getattr(node, "custom", None) or {}
|
||
return len(fact_source_keys(node)) > int(
|
||
custom.get("s2_holistic_therapy_reviewed_source_count") or 0
|
||
)
|
||
|
||
|
||
def fact_has_unreviewed_profile_sources(node: Any) -> bool:
|
||
"""Whether a fact has raw provenance unseen by profile-fact review."""
|
||
custom = getattr(node, "custom", None) or {}
|
||
reviewed_version = int(custom.get("s2_profile_review_version") or 0)
|
||
reviewed_sources = int(custom.get("s2_profile_reviewed_source_count") or 0)
|
||
return (
|
||
reviewed_version < _S2_PROFILE_FACT_PROMOTION_REVIEW_VERSION
|
||
or len(fact_source_keys(node)) > reviewed_sources
|
||
)
|
||
|
||
|
||
def profile_fact_promotion_enabled() -> bool:
|
||
return _S2_PROFILE_FACT_PROMOTION_ENABLED
|
||
|
||
|
||
def is_user_subject_fact(node: Any) -> bool:
|
||
"""Conservatively accept evidence that is explicitly about the user."""
|
||
owner = str(getattr(node, "owner", "") or "").strip().lower()
|
||
if owner in ("agent", "assistant"):
|
||
return False
|
||
content = str(getattr(node, "content", "") or "").strip().lower()
|
||
return bool(re.match(r"^(?:the\s+user|user)(?:\b|\s|[,:])|^\u7528\u6237", content))
|
||
|
||
|
||
def validate_new_schema_evidence(
|
||
op: Dict[str, Any],
|
||
candidate_nodes: Dict[str, Any],
|
||
) -> Optional[str]:
|
||
"""Require a user-backed, cross-event pattern before creating L6."""
|
||
if not _S2_SCHEMA_EVIDENCE_GATE_ENABLED:
|
||
return None
|
||
if str(op.get("schema_kind") or "behavior_pattern") != "behavior_pattern":
|
||
return "explicit_state_belongs_in_canonical_l2"
|
||
evidence_ids = list(dict.fromkeys(
|
||
str(value) for value in op.get("evidence_list") or [] if str(value)
|
||
))
|
||
evidence = [candidate_nodes.get(node_id) for node_id in evidence_ids]
|
||
if not evidence_ids or any(node is None for node in evidence):
|
||
return "unknown_evidence"
|
||
user_evidence = [
|
||
node for node in evidence
|
||
if is_user_subject_fact(node)
|
||
]
|
||
if not user_evidence:
|
||
return "needs_user_evidence"
|
||
source_keys = set().union(*(fact_source_keys(node) for node in user_evidence))
|
||
if len(source_keys) < 2:
|
||
return "needs_two_independent_sources"
|
||
return None
|
||
|
||
|
||
def validate_profile_fact_promotion(
|
||
op: Dict[str, Any],
|
||
candidate_nodes: Dict[str, Any],
|
||
) -> Optional[str]:
|
||
"""Only a current user-owned L2 candidate may enter the Profile channel."""
|
||
node_id = str(op.get("node_id") or "")
|
||
node = candidate_nodes.get(node_id)
|
||
if node is None:
|
||
return "unknown_fact"
|
||
if not is_user_subject_fact(node):
|
||
return "needs_user_fact"
|
||
if is_explicit_forget_marker(str(getattr(node, "content", "") or "")):
|
||
return "forget_marker_not_profile"
|
||
return None
|
||
|
||
|
||
def build_source_context_groups(
|
||
facts: List[Any],
|
||
*,
|
||
excluded_node_ids: set[str],
|
||
embeddings_by_id: Optional[Dict[str, Any]] = None,
|
||
min_facts: int,
|
||
max_facts: int,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Group remaining facts by the raw write that produced them.
|
||
|
||
Sharing a raw write is context rather than evidence of one semantic pattern.
|
||
The Agent must explicitly account for each candidate fact as used or skipped;
|
||
the raw write is attached only to verify subject and recover omitted details.
|
||
"""
|
||
grouped: Dict[str, List[Any]] = {}
|
||
for fact in facts:
|
||
if fact.node_id in excluded_node_ids:
|
||
continue
|
||
source_id = str(getattr(fact, "source_raw_memory_id", "") or "")
|
||
if not source_id:
|
||
continue
|
||
grouped.setdefault(source_id, []).append(fact)
|
||
|
||
output: List[Dict[str, Any]] = []
|
||
for source_id, members in grouped.items():
|
||
if len(members) < max(1, min_facts):
|
||
continue
|
||
members = members[:max(1, max_facts)]
|
||
member_vectors = [
|
||
(embeddings_by_id or {}).get(fact.node_id)
|
||
for fact in members
|
||
if (embeddings_by_id or {}).get(fact.node_id) is not None
|
||
]
|
||
if member_vectors:
|
||
import numpy as np
|
||
centroid = np.mean(np.asarray(member_vectors), axis=0).tolist()
|
||
else:
|
||
centroid = None
|
||
output.append({
|
||
"group_type": "source_context_review",
|
||
"source_raw_memory_id": source_id,
|
||
"source_group_size": len(members),
|
||
"ids": [fact.node_id for fact in members],
|
||
"centroid_text": f"facts from raw memory {source_id}",
|
||
"centroid_embedding": centroid,
|
||
"facts": [
|
||
{
|
||
"node_id": fact.node_id,
|
||
"content": fact.content,
|
||
"layer": fact.layer.value,
|
||
"owner": getattr(fact, "owner", None),
|
||
"source_raw_memory_id": source_id,
|
||
"source_raw_memory_ids": list(
|
||
(getattr(fact, "custom", None) or {}).get("source_raw_memory_ids") or []
|
||
),
|
||
"session_id": getattr(fact, "session_id", "") or "",
|
||
}
|
||
for fact in members
|
||
],
|
||
})
|
||
output.sort(key=lambda group: (-group["source_group_size"], group["source_raw_memory_id"]))
|
||
return output
|
||
|
||
|
||
def _cosine_sim_vec(a, b) -> float:
|
||
import numpy as np
|
||
na = float(np.linalg.norm(a))
|
||
nb = float(np.linalg.norm(b))
|
||
if na < 1e-9 or nb < 1e-9:
|
||
return 0.0
|
||
return float(np.dot(a, b) / (na * nb))
|
||
|
||
|
||
def _dedupe_group_indices(
|
||
group_indices: List[int],
|
||
X,
|
||
valid_facts: List,
|
||
sim_threshold: float,
|
||
) -> tuple:
|
||
"""
|
||
簇内去重:与已保留条目的 embedding 相似度 ≥ threshold 则丢弃。
|
||
Returns:
|
||
(deduped_indices, removed_count)
|
||
"""
|
||
if len(group_indices) <= 1:
|
||
return list(group_indices), 0
|
||
|
||
kept: List[int] = []
|
||
removed = 0
|
||
for idx in group_indices:
|
||
if not kept:
|
||
kept.append(idx)
|
||
continue
|
||
dup_of = None
|
||
max_sim = 0.0
|
||
for k in kept:
|
||
sim = _cosine_sim_vec(X[idx], X[k])
|
||
if sim > max_sim:
|
||
max_sim = sim
|
||
dup_of = k
|
||
if max_sim >= sim_threshold:
|
||
removed += 1
|
||
logger.info(
|
||
f"[S2-preprocess] cluster dedup: drop {valid_facts[idx].node_id} "
|
||
f"(sim={max_sim:.3f} ≈ {valid_facts[dup_of].node_id})"
|
||
)
|
||
else:
|
||
kept.append(idx)
|
||
return kept, removed
|
||
|
||
|
||
def s2_agent_skip_reason(materials: Dict[str, Any]) -> Optional[str]:
|
||
"""
|
||
返回跳过 S2 Agent 的原因;None 表示应运行 Agent。
|
||
|
||
- no_facts: 聚类池为空(全已被 schema 引用或 VDB 无 fact)
|
||
- no_clusters: DBSCAN 未形成任何 cluster(散事实,不触发 Agent)
|
||
"""
|
||
stats = materials.get("stats") or {}
|
||
if stats.get("total_facts_pool", 0) == 0:
|
||
return "no_facts"
|
||
if stats.get("clusters_found", 0) == 0:
|
||
return "no_clusters"
|
||
return None
|
||
|
||
# ====================================================================
|
||
# System Prompt (dual language)
|
||
# ====================================================================
|
||
|
||
SYSTEM2_AGENT_PROMPT_ZH = """你是一个认知加工 Agent,负责从用户的记忆数据中演化高层认知结构。
|
||
|
||
## 记忆分层架构
|
||
|
||
用户的记忆存储在两个系统中:
|
||
|
||
### VDB(向量数据库)— 你只能读取,不能修改
|
||
| 层级 | 说明 |
|
||
|------|------|
|
||
| L0 BASIC_INFO | 用户基础属性(姓名、年龄、职业等)|
|
||
| L1 RAW | 原始对话记录 |
|
||
| L2 FACT | 原子事实(从对话中提取的客观事实)|
|
||
| L3 SUMMARY | 会话摘要 |
|
||
| L4 IDENTITY | 身份画像(性格特征、偏好、习惯)|
|
||
| L5 KNOWLEDGE | 领域知识 |
|
||
|
||
### Graph(知识图谱)— 你是唯一的写入者
|
||
| 层级 | 说明 |
|
||
|------|------|
|
||
| L6 SCHEMA | 行为模式 — 域内、原子化、创建后不可变 |
|
||
|
||
## L6 Schema 是什么?
|
||
|
||
Schema 是从多次独立经历归纳出的长期行为模式,包含以下三个要素:
|
||
|
||
- **Circumstance(场景)**:该模式发生的领域/话题/场景。Schema 必须限定在其场景内,不要跨域泛化。
|
||
- **Pattern(模式)**:用户在该场景下的惯常行为、思维方式或行动倾向。
|
||
- **Insight(洞察)**:底层心理驱动力或心智模型。
|
||
|
||
### 内容格式
|
||
行为模式用一句话组合三要素:
|
||
"当[场景]时,用户[模式]——反映了[洞察]。"
|
||
|
||
对与情绪、治疗、压力、哀伤或创伤有关的长期模式,不要把具体人生背景压缩成
|
||
空泛人格词。优先保留这三部分:
|
||
"Trigger/background(持久触发背景) → emotional response(反复情绪反应) → observed coping resource(用户已实际使用的应对资源)。"
|
||
只能写入 user-owned 证据中出现的触发背景、反应和应对方式;助手建议不是用户已观察到的应对资源。
|
||
|
||
### 规则
|
||
- **原子化**:一个 Schema 只包含一个模式。两个不同模式 → 两个 Schema。
|
||
- **不可变**:Schema 创建后内容永远不变。不要修改已有 Schema 的内容。
|
||
- **累积证据**:新事实只是在支持已有表述 → 调 `add_evidence`。如果新证据会让旧画像的当前状态产生实质遗漏、错误或误导,使用 `revise_schema` 创建新版本,让旧版本保留审计但退出 active 检索。
|
||
- **域内约束**:不要跨域。跨域抽象由系统的其他流程处理。
|
||
- **事实与模式分层**:单条明确身份、健康状态、设备使用、隶属关系、项目或偏好属于 L2 状态,不创建 L6。L6 必须由至少两个独立 raw/session 中的 user-owned 证据支持。
|
||
- **主体安全**:同一原始写入可能包含他人资料或示例。只有材料明确归因于当前用户时才创建用户 Schema;否则对该材料不执行任何操作。
|
||
- **候选覆盖**:source-context 审查时,每条候选事实必须二选一:用于 `create_schema`/`add_evidence` 的 evidence,或用 `skip_fact` 明确跳过。原始上下文只用于确认主体或补回该事实遗漏的细节,不得据此创建无关 Schema。
|
||
- **证据门槛**:创建 L6 必须有 user-owned evidence,且这些证据合计代表至少两个独立 raw/session。
|
||
一个被 S1 合并的 L2 事实可以代表多次独立写入,应检查它的 `source_raw_memory_ids`,不要只数节点。
|
||
助手建议不能作为用户模式的独立证据。
|
||
|
||
## 工作流程
|
||
|
||
输入材料分两部分(仅当已形成聚类时才会调用你):
|
||
- **聚类结果(Cluster Results)**:主输入,按语义相似度归组的事实
|
||
- **未聚类事实(补充)**:未进入任一聚类的事实,优先级低于聚类结果,酌情审视即可
|
||
|
||
对于每组事实(无论聚类还是未聚类):
|
||
1. **搜索已有 Schema**:如果已有 Schema 已覆盖该模式,使用 `add_evidence`。只有加入新证据后旧表述会产生实质遗漏、错误或误导时才使用 `revise_schema`。
|
||
2. **创建新 Schema**:仅当没有已有 Schema 覆盖时。一句话,三要素。
|
||
3. **建立关系**:两个 Schema 主题相关 → 用 `add_edge`(RELATED_TO)。
|
||
|
||
## 原则
|
||
|
||
- 所有 Graph 操作必须通过 tools 执行
|
||
- Schema 内容创建后不可变——绝不重新创建已存在的 Schema
|
||
- 优先 `add_evidence` 而非创建重复节点
|
||
- 宁可不创建,也不要创建低质量节点
|
||
- 一个 Schema = 一个域内原子模式
|
||
- 打标签时优先使用已有标签列表中的标签"""
|
||
|
||
|
||
SYSTEM2_AGENT_PROMPT_EN = """You are a cognitive processing Agent. Your job is to evolve higher-order cognitive structures from a user's raw memory data.
|
||
|
||
## Memory Architecture
|
||
|
||
### VDB (Vector Database) — read-only
|
||
| Layer | Description |
|
||
|-------|-------------|
|
||
| L0 BASIC_INFO | User attributes (name, age, occupation) |
|
||
| L1 RAW | Original conversation transcripts |
|
||
| L2 FACT | Atomic facts extracted from conversations |
|
||
| L3 SUMMARY | Session summaries |
|
||
| L4 IDENTITY | Identity traits, preferences, habits |
|
||
| L5 KNOWLEDGE | Domain knowledge |
|
||
|
||
### Graph (Knowledge Graph) — you are the sole writer
|
||
| Layer | Description |
|
||
|-------|-------------|
|
||
| L6 SCHEMA | Behavioral patterns — domain-bound, atomic, immutable once created |
|
||
|
||
## What is an L6 Schema?
|
||
|
||
A Schema is one cross-event behavioral pattern in the long-term user model and
|
||
contains the following three components:
|
||
|
||
|
||
- **Circumstance**: The domain/topic/situation where this pattern is observed. A Schema MUST stay within its circumstance — do NOT generalize across domains. Cross-domain abstraction is handled separately by the system.
|
||
- **Pattern**: The user's habitual behavior, thinking style, or action tendency in this circumstance.
|
||
- **Insight**: The underlying psychological driver or mental model.
|
||
|
||
### Content format
|
||
Use a single sentence combining all three:
|
||
"When [circumstance], the user [pattern] — reflecting [insight]."
|
||
|
||
For durable emotional, therapy, stress, grief, or trauma-related patterns, do
|
||
not erase the specific autobiographical background into a generic personality
|
||
label. Prefer an anchored structure that preserves:
|
||
"Trigger/background -> recurring emotional response -> observed coping resource."
|
||
Only include triggers, responses, and coping resources present in user-owned
|
||
evidence. Assistant advice is not an observed user coping resource.
|
||
|
||
### Rules
|
||
- **Atomic**: One pattern per Schema. Two distinct patterns → two Schemas.
|
||
- **Immutable**: Once created, content NEVER changes.
|
||
- **Evidence only**: New facts that merely support existing wording → call
|
||
`add_evidence`. If new evidence materially changes, corrects, or narrows the
|
||
current durable state, use `revise_schema` so the old version is retained for
|
||
audit but removed from active retrieval.
|
||
- **Domain-bound**: Stay within the observed domain. Cross-domain abstraction is handled by a separate system process.
|
||
- **Separate facts from patterns**: A single explicit identity, health state,
|
||
equipment use, affiliation, project, or preference belongs in L2 state memory,
|
||
not L6. L6 requires user-owned evidence representing at least two independent
|
||
raw writes or sessions. One reconciled L2 fact may represent multiple writes;
|
||
inspect its source_raw_memory_ids rather than counting fact nodes.
|
||
- **Subject safety**: Facts sharing a source chunk may describe other people or
|
||
examples. Create a user Schema only when the material explicitly attributes
|
||
the pattern to the user. Otherwise output no operation for that material.
|
||
- **Candidate coverage**: In a source-context review, account for every listed
|
||
candidate fact: either use it as evidence in `create_schema`/`add_evidence`,
|
||
or emit `skip_fact`. Raw context may confirm its subject or restore omitted
|
||
detail, but must not seed unrelated Schemas.
|
||
- **Evidence threshold**: Creating L6 requires one or more user-owned evidence
|
||
facts representing at least two independent raw writes or sessions.
|
||
Assistant advice does not establish a user pattern by itself.
|
||
|
||
### Good vs Bad examples
|
||
✅ "When cooking, the user strictly follows recipe steps and precisely measures ingredients — reflecting a need for external structure to manage uncertainty."
|
||
✅ "When gaming, the user always checks walkthroughs to achieve 100% completion — driven by low tolerance for incompleteness."
|
||
✅ "After an unexpected mentor loss, mortality reminders can bring grief back suddenly; the user pauses to reflect and takes quiet walks to regain steadiness."
|
||
❌ "The user approaches music as a medium for creative synthesis and cultural bridge-building, consistently blending diverse musical traditions..." (multiple patterns merged, too broad)
|
||
❌ "The user is passionate about many things and values quality." (no circumstance, too vague)
|
||
❌ "The user is meaning-driven and resilient." (erases the durable trigger and observed coping evidence)
|
||
|
||
## Your Workflow
|
||
|
||
Input materials (you are only invoked when at least one cluster exists):
|
||
- **Cluster Results**: Primary input — facts grouped by semantic similarity
|
||
- **Unclustered Facts (supplemental)**: Facts not in any cluster; lower priority than cluster results
|
||
|
||
For each group of facts (clustered or unclustered):
|
||
1. **Search existing Schemas**: If an existing Schema already captures this pattern, call `add_evidence`. Use `revise_schema` only when the old wording would become materially incomplete or misleading after adding the new evidence.
|
||
2. **Create new Schema**: Only if no existing Schema covers this pattern. One sentence, three components.
|
||
3. **Build relationships**: If two Schemas are thematically related, use `add_edge` (RELATED_TO).
|
||
|
||
## Principles
|
||
|
||
- All Graph operations MUST go through tools
|
||
- Schema content is IMMUTABLE — never recreate what already exists
|
||
- Prefer `add_evidence` over creating duplicates
|
||
- Prefer not creating over creating low-quality nodes
|
||
- One Schema = one atomic pattern in one domain
|
||
- When tagging, prefer reusing tags from the existing tags list"""
|
||
|
||
# backward compat alias
|
||
SYSTEM2_AGENT_PROMPT = SYSTEM2_AGENT_PROMPT_ZH
|
||
|
||
|
||
# ====================================================================
|
||
# Preprocessor: materials preparation (hardcoded, no LLM)
|
||
# ====================================================================
|
||
|
||
async def prepare_materials(
|
||
user_id: str,
|
||
agent_id: str,
|
||
vector_store: VectorStoreBase,
|
||
graph_store: GraphStoreBase,
|
||
embed_service: EmbedService,
|
||
config: MemoryConfig,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Phase 1: 硬编码预处理 — 聚类 + Graph 正反向搜索 → materials
|
||
|
||
Returns:
|
||
{
|
||
"clusters": [{ids, centroid_text, facts: [{node_id, content, layer}]}],
|
||
"graph_forward": [{node_id, content, layer, confidence, evidence_count}],
|
||
"graph_reverse": [{node_id, content, layer, confidence, evidence_vdb_id}],
|
||
"unprocessed_facts": [{node_id, content, layer}],
|
||
"stats": {total_facts, clusters_found, ...}
|
||
}
|
||
"""
|
||
# Clustering config(min_samples=4)
|
||
_MIN_FACTS_FOR_INDUCTION = int(os.getenv(
|
||
"MEMORY_S2_MIN_CLUSTER_SIZE",
|
||
"2" if _S2_SCHEMA_EVIDENCE_GATE_ENABLED else "4",
|
||
))
|
||
_CLUSTERING_THRESHOLD = float(os.getenv("MEMORY_S2_CLUSTER_THRESHOLD", "0.55"))
|
||
# Stage 2:大簇(>12)内再切;阈值比 Stage1 略严即可,0.75 过严难拆出合法子簇
|
||
_REFINE_THRESHOLD = float(os.getenv("MEMORY_S2_REFINE_CLUSTER_THRESHOLD", "0.65"))
|
||
_MAX_CLUSTER_SIZE = 12 # 超过此 size 的 cluster 触发二阶段细分
|
||
_REFINE_MIN_SAMPLES = _MIN_FACTS_FOR_INDUCTION
|
||
|
||
# Graph 正向搜索 config
|
||
_GRAPH_SEARCH_TOPK_PER_CLUSTER = 8 # 每个 cluster centroid 召回的 Graph 节点数
|
||
|
||
isolation_key = MemoryNode.build_isolation_key(user_id, agent_id or "default")
|
||
|
||
# ① VDB: 取 L2_FACT + L4_IDENTITY(可选 L3_SUMMARY)
|
||
_s2_layers = [MemoryLayer.L2_FACT, MemoryLayer.L4_IDENTITY]
|
||
if _S2_SUMMARY_ENABLED:
|
||
_s2_layers.append(MemoryLayer.L3_SUMMARY)
|
||
all_facts = await vector_store.list_by_user(
|
||
user_id=user_id,
|
||
agent_id=agent_id,
|
||
status_filter=[MemoryStatus.ACTIVE],
|
||
layers=_s2_layers,
|
||
)
|
||
|
||
raw_forget_nodes = []
|
||
raw_equipment_nodes = []
|
||
if _S2_FORGET_ENABLED or _S2_CANONICAL_STATE_ENABLED:
|
||
try:
|
||
raw_nodes = await vector_store.list_by_user(
|
||
user_id=user_id,
|
||
agent_id=agent_id,
|
||
status_filter=[MemoryStatus.ACTIVE, MemoryStatus.SHADOW],
|
||
layers=[MemoryLayer.L1_RAW],
|
||
allow_raw_layer=True,
|
||
)
|
||
for raw_node in raw_nodes:
|
||
if _S2_FORGET_ENABLED and not bool(
|
||
(raw_node.custom or {}).get("s2_forget_reviewed")
|
||
):
|
||
directives = extract_raw_forget_directives(raw_node.content)
|
||
if directives:
|
||
raw_forget_nodes.append((raw_node, directives))
|
||
if _S2_CANONICAL_STATE_ENABLED:
|
||
raw_equipment_nodes.append(raw_node)
|
||
except Exception as exc:
|
||
logger.warning("[S2-preprocess] raw lifecycle scan failed: %s", exc)
|
||
|
||
# ①b 按已审查的 raw provenance 过滤。S1 可能把多次近义事件
|
||
# 合并到同一 L2 节点;节点已被 Schema 引用不代表后续新来源也已审查。
|
||
fresh_facts = [] # evidence_count/review_count = 0 → 待处理
|
||
evidenced_facts = [] # evidence_count >= 1 → 已被引用,排除
|
||
for fact in all_facts:
|
||
if fact_has_unreviewed_sources(fact):
|
||
fresh_facts.append(fact)
|
||
else:
|
||
evidenced_facts.append(fact)
|
||
|
||
forget_markers = [
|
||
fact for fact in all_facts
|
||
if _S2_FORGET_ENABLED and is_explicit_forget_marker(fact.content)
|
||
and not bool((fact.custom or {}).get("s2_forget_reviewed"))
|
||
]
|
||
l2_source_ids = {
|
||
str(getattr(fact, "source_raw_memory_id", "") or "")
|
||
for fact in forget_markers
|
||
}
|
||
for raw_node, directives in raw_forget_nodes:
|
||
if raw_node.node_id in l2_source_ids:
|
||
continue
|
||
forget_markers.append(SimpleNamespace(
|
||
node_id=raw_node.node_id,
|
||
content="\n".join(directives),
|
||
layer=MemoryLayer.L1_RAW,
|
||
custom=raw_node.custom,
|
||
marker_source="l1_raw",
|
||
))
|
||
forget_marker_ids = {fact.node_id for fact in forget_markers}
|
||
|
||
# 聚类池 = 仅全新 facts
|
||
clustering_pool = [
|
||
fact for fact in fresh_facts if fact.node_id not in forget_marker_ids
|
||
]
|
||
|
||
logger.info(
|
||
f"[S2-preprocess] Facts triage: fresh={len(fresh_facts)} "
|
||
f"evidenced_excluded={len(evidenced_facts)} "
|
||
f"(pool={len(clustering_pool)})"
|
||
)
|
||
|
||
# ② 两阶段聚类(只对 clustering_pool 做聚类,排除已充分消化的 facts)
|
||
import numpy as np
|
||
clusters = []
|
||
valid_facts = []
|
||
embeddings_by_id: Dict[str, Any] = {}
|
||
try:
|
||
from sklearn.cluster import DBSCAN
|
||
from sklearn.metrics.pairwise import cosine_distances
|
||
|
||
# Collect embeddings from clustering_pool (not all_facts)
|
||
embeddings = []
|
||
for fact in clustering_pool:
|
||
emb = fact.embedding
|
||
if isinstance(emb, dict):
|
||
emb = list(emb.values())[0] if emb else None
|
||
if emb and isinstance(emb, (list, np.ndarray)) and len(emb) > 0:
|
||
embeddings.append(emb)
|
||
valid_facts.append(fact)
|
||
embeddings_by_id[fact.node_id] = emb
|
||
else:
|
||
try:
|
||
emb = await embed_service.embed_queued(fact.content)
|
||
embeddings.append(emb)
|
||
valid_facts.append(fact)
|
||
embeddings_by_id[fact.node_id] = emb
|
||
except Exception:
|
||
pass
|
||
|
||
if len(embeddings) >= _MIN_FACTS_FOR_INDUCTION:
|
||
X = np.array(embeddings)
|
||
|
||
# --- Stage 1: 粗分 ---
|
||
dist_matrix = cosine_distances(X)
|
||
db = DBSCAN(eps=1.0 - _CLUSTERING_THRESHOLD, min_samples=_MIN_FACTS_FOR_INDUCTION, metric="precomputed")
|
||
labels = db.fit_predict(dist_matrix)
|
||
|
||
stage1_clusters = {} # label → [indices]
|
||
for i, lbl in enumerate(labels):
|
||
if lbl == -1:
|
||
continue
|
||
stage1_clusters.setdefault(lbl, []).append(i)
|
||
|
||
# --- Stage 2: 对大 cluster 细分 ---
|
||
final_index_groups = []
|
||
for lbl, indices in stage1_clusters.items():
|
||
if len(indices) <= _MAX_CLUSTER_SIZE:
|
||
final_index_groups.append(indices)
|
||
else:
|
||
# 子矩阵上用更高 threshold 再跑一次 DBSCAN
|
||
sub_X = X[indices]
|
||
sub_dist = cosine_distances(sub_X)
|
||
sub_db = DBSCAN(
|
||
eps=1.0 - _REFINE_THRESHOLD,
|
||
min_samples=_REFINE_MIN_SAMPLES,
|
||
metric="precomputed",
|
||
)
|
||
sub_labels = sub_db.fit_predict(sub_dist)
|
||
|
||
sub_groups = {}
|
||
noise_indices = []
|
||
for si, sl in enumerate(sub_labels):
|
||
if sl == -1:
|
||
noise_indices.append(indices[si])
|
||
else:
|
||
sub_groups.setdefault(sl, []).append(indices[si])
|
||
|
||
for sg in sub_groups.values():
|
||
final_index_groups.append(sg)
|
||
|
||
# noise 归入最近的子 cluster(或保留为独立 cluster)
|
||
if noise_indices and sub_groups:
|
||
# 计算每个 noise 到各子 cluster centroid 的距离,归入最近的
|
||
sub_centroids = {
|
||
sl: np.mean(X[[indices[si] for si, sll in enumerate(sub_labels) if sll == sl]], axis=0)
|
||
for sl in sub_groups
|
||
}
|
||
for ni in noise_indices:
|
||
best_sl = min(sub_centroids, key=lambda sl: float(cosine_distances(
|
||
X[ni:ni+1], sub_centroids[sl].reshape(1, -1)
|
||
)[0, 0]))
|
||
# 找到对应的 final group 并 append
|
||
for fg in final_index_groups:
|
||
if any(indices[si] in fg for si, sl in enumerate(sub_labels) if sl == best_sl):
|
||
fg.append(ni)
|
||
break
|
||
elif noise_indices:
|
||
final_index_groups.append(noise_indices)
|
||
|
||
logger.info(
|
||
f"[S2-preprocess] Refined cluster (size={len(indices)}) "
|
||
f"→ {len(sub_groups)} sub-clusters + {len(noise_indices)} noise"
|
||
)
|
||
|
||
# --- 构建 cluster 输出(簇内去重 + 最小 N 条,N=_MIN_FACTS_FOR_INDUCTION)---
|
||
dedup_removed_total = 0
|
||
for group_indices in final_index_groups:
|
||
if len(group_indices) < _REFINE_MIN_SAMPLES:
|
||
continue
|
||
group_indices, n_rm = _dedupe_group_indices(
|
||
group_indices, X, valid_facts, _S2_CLUSTER_DEDUP_COSINE,
|
||
)
|
||
dedup_removed_total += n_rm
|
||
if len(group_indices) < _MIN_FACTS_FOR_INDUCTION:
|
||
logger.info(
|
||
f"[S2-preprocess] cluster dropped after dedup: "
|
||
f"size={len(group_indices)} < {_MIN_FACTS_FOR_INDUCTION}"
|
||
)
|
||
continue
|
||
cluster_facts = [valid_facts[i] for i in group_indices]
|
||
centroid = np.mean(X[group_indices], axis=0)
|
||
dists_to_centroid = [np.linalg.norm(X[i] - centroid) for i in group_indices]
|
||
rep_idx = group_indices[np.argmin(dists_to_centroid)]
|
||
|
||
clusters.append({
|
||
"group_type": "semantic_cluster",
|
||
"ids": [f.node_id for f in cluster_facts],
|
||
"centroid_text": valid_facts[rep_idx].content[:100],
|
||
"centroid_embedding": centroid.tolist(),
|
||
"facts": [
|
||
{
|
||
"node_id": f.node_id,
|
||
"content": f.content,
|
||
"layer": f.layer.value,
|
||
"owner": getattr(f, "owner", None),
|
||
"source_raw_memory_id": getattr(f, "source_raw_memory_id", "") or "",
|
||
"source_raw_memory_ids": list(
|
||
(getattr(f, "custom", None) or {}).get("source_raw_memory_ids") or []
|
||
),
|
||
"session_id": getattr(f, "session_id", "") or "",
|
||
}
|
||
for f in cluster_facts
|
||
],
|
||
})
|
||
|
||
logger.info(
|
||
f"[S2-preprocess] Clustering: {len(valid_facts)} facts → "
|
||
f"{len(stage1_clusters)} stage1 → {len(clusters)} final clusters "
|
||
f"(dedup_removed={dedup_removed_total})"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[S2-preprocess] clustering failed: {e}")
|
||
|
||
semantic_clustered_node_ids = {
|
||
fact["node_id"]
|
||
for cluster in clusters
|
||
for fact in cluster.get("facts", [])
|
||
}
|
||
source_groups: List[Dict[str, Any]] = []
|
||
if _S2_SOURCE_GROUPS_ENABLED:
|
||
source_groups = build_source_context_groups(
|
||
valid_facts,
|
||
excluded_node_ids=semantic_clustered_node_ids,
|
||
embeddings_by_id=embeddings_by_id,
|
||
min_facts=_S2_SOURCE_GROUP_MIN_FACTS,
|
||
max_facts=_S2_SOURCE_GROUP_MAX_FACTS,
|
||
)
|
||
for group in source_groups:
|
||
fact_sources = []
|
||
for fact in group.get("facts") or []:
|
||
source_ids = list(fact.get("source_raw_memory_ids") or [])
|
||
legacy_source = str(fact.get("source_raw_memory_id") or "")
|
||
if legacy_source and legacy_source not in source_ids:
|
||
source_ids.append(legacy_source)
|
||
for source_id in source_ids:
|
||
pair = (fact, str(source_id))
|
||
if source_id and pair not in fact_sources:
|
||
fact_sources.append(pair)
|
||
excerpts = []
|
||
for fact, source_id in fact_sources:
|
||
try:
|
||
raw_node = await vector_store.get_by_id(source_id)
|
||
raw_content = str(getattr(raw_node, "content", "") or "")
|
||
if not raw_content:
|
||
continue
|
||
excerpt = select_relevant_raw_excerpt(
|
||
fact.get("content", ""), raw_content,
|
||
max_chars=max(512, _S2_SOURCE_RAW_MAX_CHARS // max(1, len(fact_sources))),
|
||
)
|
||
excerpts.append({
|
||
"node_id": fact.get("node_id"),
|
||
"source_raw_memory_id": source_id,
|
||
"excerpt": excerpt,
|
||
})
|
||
except Exception as exc:
|
||
logger.debug("[S2-preprocess] source raw lookup failed id=%s: %s", source_id, exc)
|
||
if excerpts:
|
||
group["raw_contexts"] = excerpts
|
||
clusters.extend(source_groups)
|
||
|
||
# ③ Graph 正向搜索: 用 cluster centroid 向量召回相关 Schema
|
||
# 不再全列所有 Graph 节点 — 只召回与当前 clusters 语义相关的
|
||
graph_forward = []
|
||
seen_node_ids = set()
|
||
try:
|
||
if clusters and graph_store:
|
||
for c in clusters:
|
||
centroid_emb = c.get("centroid_embedding")
|
||
if not centroid_emb:
|
||
continue
|
||
# 搜索 L6_SCHEMA
|
||
hits = await graph_store.vector_search(
|
||
query_embedding=centroid_emb,
|
||
isolation_key=isolation_key,
|
||
layers=["l6_schema"],
|
||
limit=_GRAPH_SEARCH_TOPK_PER_CLUSTER,
|
||
score_threshold=0.3,
|
||
)
|
||
local_graph = []
|
||
for h in hits:
|
||
if is_internal_profile_chain_hit(h):
|
||
continue
|
||
nid = h.get("node_id", "")
|
||
evidence_refs = await graph_store.get_evidence_vdbrefs(nid)
|
||
item = {
|
||
"node_id": nid,
|
||
"content": h.get("content", ""),
|
||
"layer": h.get("layer", ""),
|
||
"confidence": h.get("confidence", 0),
|
||
"evidence_count": len(evidence_refs),
|
||
"similarity": round(h.get("score", 0), 4),
|
||
}
|
||
local_graph.append(item)
|
||
if nid not in seen_node_ids:
|
||
seen_node_ids.add(nid)
|
||
graph_forward.append(item)
|
||
c["graph_forward"] = local_graph
|
||
logger.info(f"[S2-preprocess] Graph forward: {len(graph_forward)} nodes via centroid search")
|
||
except Exception as e:
|
||
logger.warning(f"[S2-preprocess] graph forward search failed: {e}")
|
||
|
||
# ③b 查 Graph 真实节点总数(不依赖 vector_search 结果判断 Graph 是否为空)
|
||
graph_total_schemas = 0
|
||
all_active_schemas: List[Any] = []
|
||
try:
|
||
if graph_store:
|
||
_all_schemas = await graph_store.get_all_nodes(
|
||
isolation_key=isolation_key,
|
||
layer=MemoryLayer.L6_SCHEMA,
|
||
status=MemoryStatus.ACTIVE,
|
||
limit=1000,
|
||
)
|
||
all_active_schemas = [
|
||
node for node in _all_schemas
|
||
if not is_internal_profile_chain_custom(node.custom)
|
||
]
|
||
graph_total_schemas = len(all_active_schemas)
|
||
logger.info(
|
||
f"[S2-preprocess] Graph actual count: "
|
||
f"schemas={graph_total_schemas} "
|
||
f"(isolation_key={isolation_key})"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[S2-preprocess] graph count query failed: {e}")
|
||
|
||
canonical_state_reviews = build_canonical_state_reviews(
|
||
raw_equipment_nodes,
|
||
facts=all_facts,
|
||
active_schemas=all_active_schemas,
|
||
) if _S2_CANONICAL_STATE_ENABLED else []
|
||
|
||
# ④ Graph 反向查找: 从 VDB node_id 找到引用它的 Graph 节点
|
||
graph_reverse = []
|
||
try:
|
||
vdb_ids = [f.node_id for f in valid_facts[:100]]
|
||
graph_reverse = [
|
||
row for row in await graph_store.find_referencing_memories(vdb_ids, limit=50)
|
||
if not is_internal_profile_chain_hit(row)
|
||
]
|
||
except Exception as e:
|
||
logger.warning(f"[S2-preprocess] graph reverse search failed: {e}")
|
||
|
||
# ⑤ 已有 Topic 列表(供 Agent 复用 tag,减少发散)
|
||
existing_tags = []
|
||
try:
|
||
from ..models.memory import MemoryNode as _MN
|
||
|
||
topics = await graph_store.get_all_topics(_MN.topic_scope_of(isolation_key))
|
||
existing_tags = [t["name"] for t in topics if t.get("name")]
|
||
except Exception as e:
|
||
logger.debug(f"[S2-preprocess] get_all_topics failed: {e}")
|
||
|
||
clustered_node_ids: set = set()
|
||
for c in clusters:
|
||
for f in c.get("facts", []):
|
||
clustered_node_ids.add(f["node_id"])
|
||
|
||
unprocessed: List[Dict[str, Any]] = []
|
||
if clusters:
|
||
unprocessed = [
|
||
{"node_id": f.node_id, "content": f.content, "layer": f.layer.value}
|
||
for f in valid_facts
|
||
if f.node_id not in clustered_node_ids
|
||
]
|
||
if len(unprocessed) > _S2_MAX_UNCLUSTERED_FACTS:
|
||
logger.info(
|
||
f"[S2-preprocess] unclustered facts capped: {len(unprocessed)} → "
|
||
f"{_S2_MAX_UNCLUSTERED_FACTS} (clustered={len(clustered_node_ids)})"
|
||
)
|
||
unprocessed = unprocessed[:_S2_MAX_UNCLUSTERED_FACTS]
|
||
|
||
if clusters:
|
||
logger.info(
|
||
f"[S2-preprocess] clusters={len(clusters)} clustered_facts={len(clustered_node_ids)} "
|
||
f"source_groups={len(source_groups)} unclustered_supplement={len(unprocessed)}"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"[S2-preprocess] no clusters → skip S2 agent "
|
||
f"(fresh_pool={len(valid_facts)}, will not send unprocessed_facts to LLM)"
|
||
)
|
||
|
||
return {
|
||
"clusters": clusters,
|
||
"graph_forward": graph_forward,
|
||
"graph_reverse": graph_reverse,
|
||
"existing_tags": existing_tags,
|
||
"unprocessed_facts": unprocessed,
|
||
"forget_markers": [
|
||
{"node_id": fact.node_id, "content": fact.content, "layer": fact.layer.value,
|
||
"marker_source": getattr(fact, "marker_source", "l2_fact")}
|
||
for fact in forget_markers
|
||
],
|
||
"canonical_state_reviews": canonical_state_reviews,
|
||
"stats": {
|
||
"total_facts_all": len(all_facts),
|
||
"total_facts_pool": len(valid_facts),
|
||
"fresh_facts": len(fresh_facts),
|
||
"evidenced_excluded": len(evidenced_facts),
|
||
"clusters_found": len(clusters),
|
||
"semantic_clusters_found": len(clusters) - len(source_groups),
|
||
"source_groups_found": len(source_groups),
|
||
"clustered_facts_count": len(clustered_node_ids),
|
||
"unclustered_facts_count": len(unprocessed),
|
||
"graph_schemas_recalled": len([g for g in graph_forward if g["layer"] == "l6_schema"]),
|
||
"graph_total_schemas": graph_total_schemas,
|
||
"graph_reverse_hits": len(graph_reverse),
|
||
"existing_tags_count": len(existing_tags),
|
||
"forget_markers_found": len(forget_markers),
|
||
"canonical_state_reviews_found": len(canonical_state_reviews),
|
||
"canonical_state_candidates_found": sum(
|
||
len(review.get("candidates") or []) for review in canonical_state_reviews
|
||
),
|
||
},
|
||
}
|
||
|
||
|
||
async def prepare_holistic_therapy_materials(
|
||
*,
|
||
user_id: str,
|
||
agent_id: str,
|
||
vector_store: VectorStoreBase,
|
||
graph_store: GraphStoreBase,
|
||
) -> Dict[str, Any]:
|
||
"""Build one bounded nightly view over user-owned L2 facts across sessions."""
|
||
if not _S2_HOLISTIC_THERAPY_ENABLED:
|
||
return {"enabled": False, "pending": False, "clusters": [], "stats": {}}
|
||
all_facts = await vector_store.list_by_user(
|
||
user_id=user_id,
|
||
agent_id=agent_id,
|
||
status_filter=[MemoryStatus.ACTIVE],
|
||
layers=[MemoryLayer.L2_FACT],
|
||
)
|
||
eligible = [
|
||
fact for fact in all_facts
|
||
if is_user_subject_fact(fact)
|
||
and (getattr(fact, "custom", None) or {}).get("memory_kind") != "canonical_state"
|
||
]
|
||
pending = [fact for fact in eligible if fact_has_unreviewed_holistic_sources(fact)]
|
||
if not pending:
|
||
return {
|
||
"enabled": True, "pending": False, "clusters": [],
|
||
"stats": {"eligible_facts": len(eligible), "pending_facts": 0},
|
||
}
|
||
|
||
def _sort_key(fact):
|
||
value = getattr(fact, "memory_at", None) or getattr(fact, "gmt_created", None)
|
||
return value.timestamp() if hasattr(value, "timestamp") else 0
|
||
|
||
pending_ids = {fact.node_id for fact in pending}
|
||
ordered = sorted(eligible, key=lambda fact: (fact.node_id not in pending_ids, -_sort_key(fact)))
|
||
selected = ordered[:max(1, _S2_HOLISTIC_THERAPY_MAX_FACTS)]
|
||
active_schemas = await graph_store.get_all_nodes(
|
||
isolation_key=MemoryNode.build_isolation_key(user_id, agent_id or "default"),
|
||
layer=MemoryLayer.L6_SCHEMA,
|
||
status=MemoryStatus.ACTIVE,
|
||
limit=1000,
|
||
) if graph_store else []
|
||
active_schemas = [
|
||
node for node in active_schemas
|
||
if not is_internal_profile_chain_custom(node.custom)
|
||
]
|
||
facts = [{
|
||
"node_id": fact.node_id,
|
||
"content": fact.content,
|
||
"layer": fact.layer.value,
|
||
"owner": getattr(fact, "owner", None),
|
||
"source_raw_memory_id": getattr(fact, "source_raw_memory_id", "") or "",
|
||
"source_raw_memory_ids": list(
|
||
(getattr(fact, "custom", None) or {}).get("source_raw_memory_ids") or []
|
||
),
|
||
"session_id": getattr(fact, "session_id", "") or "",
|
||
} for fact in selected]
|
||
companion_hints = {}
|
||
for fact in selected:
|
||
if fact.node_id not in pending_ids:
|
||
continue
|
||
anchor_sources = fact_source_keys(fact)
|
||
candidates = []
|
||
for other in selected:
|
||
if other.node_id == fact.node_id:
|
||
continue
|
||
other_sources = fact_source_keys(other)
|
||
combined = anchor_sources | other_sources
|
||
if len(combined) < 2 or not (other_sources - anchor_sources):
|
||
continue
|
||
score = 0.0
|
||
try:
|
||
left = fact.embedding
|
||
right = other.embedding
|
||
if isinstance(left, dict):
|
||
left = list(left.values())[0] if left else None
|
||
if isinstance(right, dict):
|
||
right = list(right.values())[0] if right else None
|
||
if left is not None and right is not None:
|
||
score = _cosine_sim_vec(left, right)
|
||
except Exception:
|
||
score = 0.0
|
||
candidates.append({
|
||
"node_id": other.node_id,
|
||
"similarity": round(float(score), 4),
|
||
"added_raw_source_ids": sorted(other_sources - anchor_sources),
|
||
"combined_raw_source_count": len(combined),
|
||
})
|
||
candidates.sort(key=lambda item: (-item["similarity"], item["node_id"]))
|
||
if candidates:
|
||
companion_hints[fact.node_id] = candidates[:3]
|
||
return {
|
||
"enabled": True,
|
||
"pending": True,
|
||
"holistic_therapy_review": True,
|
||
"clusters": [{
|
||
"group_type": "holistic_therapy_review",
|
||
"centroid_text": "nightly cross-session emotional and therapy consolidation",
|
||
"facts": facts,
|
||
}],
|
||
"graph_forward": [{
|
||
"node_id": node.node_id,
|
||
"content": node.content,
|
||
"layer": node.layer.value,
|
||
"confidence": node.confidence,
|
||
"evidence_count": len(await graph_store.get_evidence_vdbrefs(node.node_id)),
|
||
"similarity": 1.0,
|
||
} for node in active_schemas],
|
||
"graph_reverse": [],
|
||
"existing_tags": [],
|
||
"unprocessed_facts": [],
|
||
"independent_source_companions": companion_hints,
|
||
"stats": {
|
||
"eligible_facts": len(eligible),
|
||
"pending_facts": len(pending),
|
||
"total_facts_all": len(eligible),
|
||
"total_facts_pool": len(selected),
|
||
"fresh_facts": len(pending),
|
||
"evidenced_excluded": len(eligible) - len(pending),
|
||
"clusters_found": 1,
|
||
"clustered_facts_count": len(selected),
|
||
"unclustered_facts_count": 0,
|
||
"graph_total_schemas": len(active_schemas),
|
||
"graph_schemas_recalled": len(active_schemas),
|
||
"holistic_selected_facts": len(selected),
|
||
"holistic_pending_facts": len(pending),
|
||
"holistic_remaining_pending_facts": max(0, len(pending) - len(selected)),
|
||
},
|
||
}
|
||
|
||
|
||
async def run_profile_fact_promotion(
|
||
*,
|
||
user_id: str,
|
||
agent_id: str,
|
||
vector_store: VectorStoreBase,
|
||
config: MemoryConfig,
|
||
forget_markers: Optional[List[Dict[str, Any]]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Select durable atomic L2 facts for Profile without copying them into L6."""
|
||
if not _S2_PROFILE_FACT_PROMOTION_ENABLED:
|
||
return {"success": True, "enabled": False, "selected": [], "reviewed": 0,
|
||
"prompt_tokens": 0, "completion_tokens": 0}
|
||
if not hasattr(vector_store, "list_by_user"):
|
||
return {"success": True, "enabled": True, "skipped": True,
|
||
"reason": "vector_store_list_unavailable", "selected": [],
|
||
"reviewed": 0, "remaining": 0, "prompt_tokens": 0,
|
||
"completion_tokens": 0}
|
||
facts = await vector_store.list_by_user(
|
||
user_id=user_id,
|
||
agent_id=agent_id,
|
||
status_filter=[MemoryStatus.ACTIVE],
|
||
layers=[MemoryLayer.L2_FACT],
|
||
)
|
||
eligible = [
|
||
fact for fact in facts
|
||
if is_user_subject_fact(fact)
|
||
and (getattr(fact, "custom", None) or {}).get("memory_kind") != "canonical_state"
|
||
]
|
||
forget_markers = list(forget_markers or [])
|
||
pending = [fact for fact in eligible if fact_has_unreviewed_profile_sources(fact)]
|
||
if forget_markers:
|
||
# A forget request must inspect every currently promoted fact first,
|
||
# including facts whose source count also changed in this cycle.
|
||
promoted = [
|
||
fact for fact in eligible
|
||
if bool((getattr(fact, "custom", None) or {}).get("s2_profile_eligible"))
|
||
]
|
||
promoted_ids = {fact.node_id for fact in promoted}
|
||
pending = promoted + [fact for fact in pending if fact.node_id not in promoted_ids]
|
||
selected_pool = pending[:max(1, _S2_PROFILE_FACT_PROMOTION_MAX_FACTS)]
|
||
if not selected_pool:
|
||
return {"success": True, "enabled": True, "selected": [], "reviewed": 0,
|
||
"remaining": 0, "prompt_tokens": 0, "completion_tokens": 0}
|
||
|
||
from ..agent.llm_provider import LLMProvider
|
||
provider = LLMProvider(config)
|
||
candidates = []
|
||
raw_cache: Dict[str, str] = {}
|
||
per_context_chars = min(1600, max(
|
||
512,
|
||
_S2_PROFILE_FACT_SOURCE_CONTEXT_MAX_CHARS
|
||
// max(1, len(selected_pool) * 2),
|
||
))
|
||
context_chars_used = 0
|
||
for fact in selected_pool:
|
||
candidate = {"node_id": fact.node_id, "content": fact.content}
|
||
raw_contexts = []
|
||
for source_id in sorted(fact_source_keys(fact))[:3]:
|
||
if source_id.startswith("session:"):
|
||
continue
|
||
if context_chars_used >= _S2_PROFILE_FACT_SOURCE_CONTEXT_MAX_CHARS:
|
||
break
|
||
try:
|
||
if source_id not in raw_cache:
|
||
raw_node = await vector_store.get_by_id(source_id)
|
||
raw_cache[source_id] = str(getattr(raw_node, "content", "") or "")
|
||
raw_content = raw_cache[source_id]
|
||
if not raw_content:
|
||
continue
|
||
excerpt = select_relevant_raw_excerpt(
|
||
fact.content, raw_content,
|
||
max_chars=min(
|
||
per_context_chars,
|
||
_S2_PROFILE_FACT_SOURCE_CONTEXT_MAX_CHARS - context_chars_used,
|
||
),
|
||
)
|
||
if excerpt:
|
||
raw_contexts.append({
|
||
"source_raw_memory_id": source_id,
|
||
"excerpt": excerpt,
|
||
})
|
||
context_chars_used += len(excerpt)
|
||
except Exception as exc:
|
||
logger.debug(
|
||
"[S2-profile] source raw lookup failed id=%s: %s", source_id, exc,
|
||
)
|
||
if raw_contexts:
|
||
candidate["source_context"] = raw_contexts
|
||
candidates.append(candidate)
|
||
system = (
|
||
"You select a very small set of existing atomic memories for a user's "
|
||
"long-term emotional Profile channel. "
|
||
"Do not summarize, rewrite, combine, or infer facts. Select an exact user-owned "
|
||
"L2 fact only when its exact content would materially change future supportive "
|
||
"or therapy-style advice: a durable autobiographical background with ongoing "
|
||
"emotional impact, a recurring burden or fear, an observed coping behavior, or a "
|
||
"value conflict/decision constraint. Do not select identity or occupation facts, "
|
||
"ordinary hobbies or preferences, medical states without an emotional/behavioral "
|
||
"consequence, assistant advice, facts about another person, explicit forget "
|
||
"requests, credentials or sensitive identifiers, one-time logistics, generic "
|
||
"questions, or trivia. Prefer fewer items; "
|
||
"selection is exceptional rather than a summary of the user. Return at most "
|
||
f"{max(0, _S2_PROFILE_FACT_PROMOTION_MAX_SELECTED)} items as JSON only: "
|
||
"A candidate may include `source_context` from the same raw write. Use it only "
|
||
"to recover the meaning and emotional consequence surrounding the exact L2 fact; "
|
||
"it may justify selecting that existing node ID, but must never be treated as a "
|
||
"new fact, rewritten memory, cross-event pattern, or permission to select another ID. "
|
||
"When forget directives are supplied, return any currently promoted fact "
|
||
"that is explicitly targeted in `demote`, and never return it in `selected`. "
|
||
'{"selected":[{"node_id":"...","reason":"..."}],"demote":[{"node_id":"...","reason":"..."}]}'
|
||
)
|
||
try:
|
||
response = await provider.complete_messages(
|
||
messages=[
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": json.dumps({
|
||
"facts": candidates,
|
||
"forget_directives": [marker.get("content", "") for marker in forget_markers],
|
||
}, ensure_ascii=False)},
|
||
],
|
||
max_tokens=max(512, int(_evoling_env(
|
||
"MAX_TOKENS", "4096",
|
||
))),
|
||
)
|
||
parsed = _parse_json_object(response.content or "")
|
||
if parsed is None:
|
||
return {
|
||
"success": False, "error": "invalid profile promotion JSON",
|
||
"selected": [], "reviewed": 0, "remaining": len(pending),
|
||
"prompt_tokens": response.prompt_tokens or 0,
|
||
"completion_tokens": response.completion_tokens or 0,
|
||
}
|
||
by_id = {fact.node_id: fact for fact in selected_pool}
|
||
requested = list(parsed.get("selected") or [])[:max(0, _S2_PROFILE_FACT_PROMOTION_MAX_SELECTED)]
|
||
selected = []
|
||
for item in requested:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
op = {"node_id": str(item.get("node_id") or ""), "reason": item.get("reason", "")}
|
||
if validate_profile_fact_promotion(op, by_id):
|
||
continue
|
||
selected.append(op)
|
||
selected_by_id = {item["node_id"]: item for item in selected}
|
||
demote = []
|
||
for item in list(parsed.get("demote") or []):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
node_id = str(item.get("node_id") or "")
|
||
node = by_id.get(node_id)
|
||
if node is None or not bool((getattr(node, "custom", None) or {}).get("s2_profile_eligible")):
|
||
continue
|
||
demote.append({"node_id": node_id, "reason": str(item.get("reason") or "")})
|
||
demote_by_id = {item["node_id"]: item for item in demote}
|
||
updated = 0
|
||
failed_node_ids = []
|
||
for fact in selected_pool:
|
||
custom = dict(getattr(fact, "custom", None) or {})
|
||
source_count = len(fact_source_keys(fact))
|
||
reviewed_source_count = int(custom.get("s2_profile_reviewed_source_count") or 0)
|
||
custom["s2_profile_review_version"] = _S2_PROFILE_FACT_PROMOTION_REVIEW_VERSION
|
||
custom["s2_profile_reviewed_source_count"] = source_count
|
||
if fact.node_id in selected_by_id:
|
||
custom.update({
|
||
"s2_profile_eligible": True,
|
||
"s2_profile_reason": str(selected_by_id[fact.node_id].get("reason") or ""),
|
||
"s2_profile_version": 1,
|
||
})
|
||
elif source_count > reviewed_source_count:
|
||
# Re-evaluation is authoritative for a changed fact. If the
|
||
# selector no longer considers it durable Profile material,
|
||
# remove its previous eligibility instead of leaving stale data.
|
||
custom["s2_profile_eligible"] = False
|
||
custom["s2_profile_demote_reason"] = "not_selected_after_source_update"
|
||
if fact.node_id in demote_by_id:
|
||
custom["s2_profile_eligible"] = False
|
||
custom["s2_profile_demote_reason"] = demote_by_id[fact.node_id]["reason"]
|
||
if await vector_store.update_payload(fact.node_id, {"custom": custom}):
|
||
updated += 1
|
||
else:
|
||
failed_node_ids.append(fact.node_id)
|
||
remaining = max(0, len(pending) - len(selected_pool))
|
||
return {
|
||
"success": not failed_node_ids, "enabled": True,
|
||
"selected": selected, "demoted": demote,
|
||
"reviewed": updated, "remaining": remaining,
|
||
"failed_node_ids": failed_node_ids,
|
||
"prompt_tokens": response.prompt_tokens or 0,
|
||
"completion_tokens": response.completion_tokens or 0,
|
||
}
|
||
except Exception as exc:
|
||
return {"success": False, "error": str(exc), "selected": [], "reviewed": 0,
|
||
"remaining": len(pending), "prompt_tokens": 0, "completion_tokens": 0}
|
||
|
||
|
||
def _parse_json_object(text: str) -> Optional[Dict[str, Any]]:
|
||
value = str(text or "").strip()
|
||
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", value, re.S | re.I)
|
||
payload = match.group(1) if match else value
|
||
try:
|
||
result = json.loads(payload)
|
||
return result if isinstance(result, dict) else None
|
||
except json.JSONDecodeError:
|
||
try:
|
||
from json_repair import repair_json
|
||
result = repair_json(payload, return_objects=True)
|
||
return result if isinstance(result, dict) else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
async def run_forget_retirement(
|
||
*,
|
||
forget_markers: List[Dict[str, Any]],
|
||
graph_store: GraphStoreBase,
|
||
tool_executor: System2ToolExecutor,
|
||
config: MemoryConfig,
|
||
isolation_key: str,
|
||
) -> Dict[str, Any]:
|
||
"""Retire active Schemas targeted by explicit user forgetting markers.
|
||
|
||
The LLM can only choose IDs from the current user's active Schema whitelist.
|
||
Tool execution revalidates user scope, active status, and marker semantics.
|
||
"""
|
||
if not _S2_FORGET_ENABLED or not forget_markers:
|
||
return {"success": True, "skipped": True, "markers": 0, "tool_calls": [],
|
||
"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||
from ..agent.llm_provider import LLMProvider
|
||
provider = LLMProvider(config)
|
||
system = """You match ONE explicit user forgetting directive to active user Schemas.
|
||
Return JSON only: {"retire_schema_ids":["..."],"reason_by_id":{"...":"..."}}.
|
||
Retire only a Schema that stores the same USER fact or preference explicitly targeted by the directive.
|
||
A broader Schema may be retired only when its defining meaning substantially depends on that forgotten item.
|
||
Do not retire a broader commitment because one episode, setback, recommendation, or subtopic was forgotten.
|
||
Use only supplied Schema IDs. When uncertain, return an empty list."""
|
||
tool_calls = []
|
||
failures = []
|
||
marker_results = []
|
||
prompt_tokens = completion_tokens = 0
|
||
for marker in forget_markers:
|
||
marker_id = str(marker.get("node_id") or "")
|
||
marker_node = await tool_executor.vector_store.get_by_id(marker_id)
|
||
marker_is_explicit = (
|
||
marker.get("marker_source") == "l1_raw"
|
||
or is_explicit_forget_marker(marker.get("content", ""))
|
||
)
|
||
if marker_node is None or not marker_is_explicit:
|
||
marker_results.append({"marker_id": marker_id, "resolved": True,
|
||
"reason": "not_user_scoped_explicit_forget"})
|
||
continue
|
||
embedding = marker_node.embedding
|
||
if isinstance(embedding, dict):
|
||
embedding = list(embedding.values())[0] if embedding else None
|
||
if not embedding:
|
||
embedding = await tool_executor.embed_service.embed_queued(marker.get("content", ""))
|
||
hits = await graph_store.vector_search(
|
||
query_embedding=embedding,
|
||
isolation_key=isolation_key,
|
||
layers=[MemoryLayer.L6_SCHEMA.value],
|
||
limit=8,
|
||
score_threshold=0.0,
|
||
)
|
||
candidates = [
|
||
{"node_id": str(hit.get("node_id") or ""),
|
||
"content": str(hit.get("content") or ""),
|
||
"score": float(hit.get("score") or 0.0)}
|
||
for hit in hits if hit.get("node_id")
|
||
]
|
||
allowed = {item["node_id"] for item in candidates}
|
||
if not candidates:
|
||
marker_results.append({"marker_id": marker_id, "resolved": True,
|
||
"reason": "no_active_schema_candidates"})
|
||
continue
|
||
try:
|
||
response = await provider.complete_messages(
|
||
messages=[{"role": "system", "content": system},
|
||
{"role": "user", "content": json.dumps({
|
||
"forget_marker": marker,
|
||
"candidate_active_schemas": candidates,
|
||
}, ensure_ascii=False)}],
|
||
max_tokens=1024,
|
||
)
|
||
except Exception as exc:
|
||
failures.append(f"{marker_id}: LLM failed: {exc}")
|
||
marker_results.append({"marker_id": marker_id, "resolved": False,
|
||
"reason": "llm_failed"})
|
||
continue
|
||
prompt_tokens += int(response.prompt_tokens or 0)
|
||
completion_tokens += int(response.completion_tokens or 0)
|
||
parsed = _parse_json_object(response.content or "")
|
||
if parsed is None:
|
||
failures.append(f"{marker_id}: invalid JSON")
|
||
marker_results.append({"marker_id": marker_id, "resolved": False,
|
||
"reason": "invalid_json", "response": response.content or ""})
|
||
continue
|
||
selected = []
|
||
for node_id in parsed.get("retire_schema_ids") or []:
|
||
node_id = str(node_id)
|
||
if node_id in allowed and node_id not in selected:
|
||
selected.append(node_id)
|
||
retired = []
|
||
marker_failed = False
|
||
for node_id in selected:
|
||
operation = {
|
||
"node_id": node_id,
|
||
"forget_fact_id": marker_id,
|
||
"reason": str((parsed.get("reason_by_id") or {}).get(node_id)
|
||
or "explicit user forgetting directive"),
|
||
}
|
||
result = await tool_executor.execute("retire_schema", operation)
|
||
call = {"tool": "retire_schema", "args": operation, "result": result}
|
||
tool_calls.append(call)
|
||
try:
|
||
value = json.loads(result) if isinstance(result, str) else result
|
||
except Exception:
|
||
value = {"error": str(result)}
|
||
if value.get("retired"):
|
||
retired.append(node_id)
|
||
else:
|
||
marker_failed = True
|
||
failures.append(value.get("error") or f"{marker_id}: retirement failed")
|
||
marker_results.append({
|
||
"marker_id": marker_id,
|
||
"resolved": not marker_failed,
|
||
"selected": selected,
|
||
"retired": retired,
|
||
"candidate_schemas": candidates,
|
||
"response": response.content or "",
|
||
"reason": "retired" if retired else "no_match",
|
||
})
|
||
unresolved = [item["marker_id"] for item in marker_results if not item.get("resolved")]
|
||
return {
|
||
"success": not failures,
|
||
"markers": len(forget_markers),
|
||
"tool_calls": tool_calls,
|
||
"failures": failures,
|
||
"marker_results": marker_results,
|
||
"resolved_marker_ids": [item["marker_id"] for item in marker_results if item.get("resolved")],
|
||
"unresolved_marker_ids": unresolved,
|
||
"prompt_tokens": prompt_tokens,
|
||
"completion_tokens": completion_tokens,
|
||
"total_tokens": prompt_tokens + completion_tokens,
|
||
}
|
||
|
||
|
||
# ====================================================================
|
||
# System 2 Agent: LLM + tools
|
||
# ====================================================================
|
||
|
||
async def run_system2_agent(
|
||
materials: Dict[str, Any],
|
||
tool_executor: System2ToolExecutor,
|
||
config: MemoryConfig,
|
||
max_iterations: int = 10, # kept for backward compat, unused in single-call mode
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Phase 2: System 2 Agent — Single LLM call + JSON output + post-process execution.
|
||
|
||
Instead of multi-turn tool-calling loop, this version:
|
||
1. Puts all materials (facts + existing graph) into the prompt
|
||
2. Asks LLM to output a JSON array of operations
|
||
3. Parses and executes operations via tool_executor
|
||
|
||
Returns:
|
||
{
|
||
"success": bool,
|
||
"tool_calls": [...],
|
||
"agent_reasoning": str,
|
||
"total_tokens": int,
|
||
"elapsed_ms": float,
|
||
}
|
||
"""
|
||
from ..agent.llm_provider import LLMProvider
|
||
|
||
start = time.time()
|
||
|
||
# Build LLMProvider from config
|
||
llm_provider = LLMProvider(config)
|
||
|
||
# Detect language from materials content
|
||
lang = _detect_language(materials)
|
||
system_prompt = _build_single_call_system_prompt(lang)
|
||
user_msg = _build_materials_message(materials, lang=lang)
|
||
|
||
messages = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_msg},
|
||
]
|
||
|
||
# Single LLM call (no tools, just JSON output)
|
||
max_tokens = max(512, int(_evoling_env(
|
||
"MAX_TOKENS", str(config.llm.agent_max_tokens or 4000),
|
||
)))
|
||
try:
|
||
response = await llm_provider.complete_messages(
|
||
messages=messages,
|
||
max_tokens=max_tokens,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"[S2-agent] LLM call failed: {e}")
|
||
return {
|
||
"success": False,
|
||
"error": f"LLM call failed: {e}",
|
||
"tool_calls": [],
|
||
"elapsed_ms": (time.time() - start) * 1000,
|
||
}
|
||
|
||
total_prompt_tokens = response.prompt_tokens or 0
|
||
total_completion_tokens = response.completion_tokens or 0
|
||
agent_reasoning = response.content or ""
|
||
|
||
# Parse JSON operations from response
|
||
operations = _parse_operations_json(agent_reasoning)
|
||
if operations is None:
|
||
logger.warning("[S2-agent] Failed to parse operations JSON from response")
|
||
return {
|
||
"success": False,
|
||
"error_code": 502,
|
||
"failure_kind": "invalid_operations_json",
|
||
"error_message": "System2 response was not a complete JSON operation array",
|
||
"error": "System2 response was not a complete JSON operation array",
|
||
"finish_reason": getattr(response, "finish_reason", "") or "",
|
||
"truncated": (
|
||
(getattr(response, "finish_reason", "") or "") == "length"
|
||
or total_completion_tokens >= max_tokens
|
||
),
|
||
"tool_calls": [],
|
||
"tool_call_log": [],
|
||
"agent_reasoning": agent_reasoning,
|
||
"messages": messages + [{"role": "assistant", "content": agent_reasoning}],
|
||
"total_prompt_tokens": total_prompt_tokens,
|
||
"total_completion_tokens": total_completion_tokens,
|
||
"total_tokens": total_prompt_tokens + total_completion_tokens,
|
||
"elapsed_ms": (time.time() - start) * 1000,
|
||
"iterations": 1,
|
||
"operations_count": 0,
|
||
}
|
||
|
||
source_review = any(
|
||
cluster.get("group_type") == "source_context_review"
|
||
for cluster in materials.get("clusters") or []
|
||
)
|
||
candidate_ids = {
|
||
str(fact.get("node_id") or "")
|
||
for cluster in materials.get("clusters") or []
|
||
for fact in cluster.get("facts") or []
|
||
if fact.get("node_id")
|
||
}
|
||
candidate_nodes = {
|
||
str(fact.get("node_id") or ""): SimpleNamespace(
|
||
node_id=str(fact.get("node_id") or ""),
|
||
content=fact.get("content", ""),
|
||
owner=fact.get("owner"),
|
||
source_raw_memory_id=fact.get("source_raw_memory_id", ""),
|
||
custom={"source_raw_memory_ids": list(fact.get("source_raw_memory_ids") or [])},
|
||
session_id=fact.get("session_id", ""),
|
||
)
|
||
for cluster in materials.get("clusters") or []
|
||
for fact in cluster.get("facts") or []
|
||
if fact.get("node_id")
|
||
}
|
||
handled_candidate_ids = set()
|
||
if materials.get("holistic_therapy_review"):
|
||
allowed = []
|
||
creates = 0
|
||
for op in operations:
|
||
op_type = op.get("op", "")
|
||
if op_type not in ("create_schema", "add_evidence", "skip_fact"):
|
||
continue
|
||
if op_type == "create_schema":
|
||
if creates >= max(0, _S2_HOLISTIC_THERAPY_MAX_SCHEMAS):
|
||
continue
|
||
creates += 1
|
||
op["schema_kind"] = "behavior_pattern"
|
||
allowed.append(op)
|
||
operations = allowed
|
||
if source_review:
|
||
# The prompt explicitly permits [] when the evidence does not support a
|
||
# durable pattern. In that case the whole one-group review is a valid
|
||
# no-op decision; checkpoint every candidate so the same batch is not
|
||
# retried forever. A non-empty response must still account for each
|
||
# candidate individually, which catches partial omissions.
|
||
if not operations:
|
||
handled_candidate_ids.update(candidate_ids)
|
||
for op in operations:
|
||
op_type = op.get("op", "")
|
||
if op_type in ("create_schema", "add_evidence", "revise_schema"):
|
||
handled_candidate_ids.update(
|
||
str(node_id) for node_id in op.get("evidence_list") or []
|
||
if str(node_id) in candidate_ids
|
||
)
|
||
elif op_type == "skip_fact":
|
||
node_id = str(op.get("node_id") or "")
|
||
if node_id in candidate_ids:
|
||
handled_candidate_ids.add(node_id)
|
||
missing = candidate_ids - handled_candidate_ids
|
||
if missing:
|
||
return {
|
||
"success": False,
|
||
"error_code": 502,
|
||
"failure_kind": "candidate_coverage_incomplete",
|
||
"error_message": f"System2 omitted {len(missing)} candidate fact decisions",
|
||
"error": f"System2 omitted candidate fact decisions: {sorted(missing)}",
|
||
"finish_reason": getattr(response, "finish_reason", "") or "",
|
||
"truncated": False,
|
||
"tool_calls": [],
|
||
"tool_call_log": [],
|
||
"agent_reasoning": agent_reasoning,
|
||
"messages": messages + [{"role": "assistant", "content": agent_reasoning}],
|
||
"total_prompt_tokens": total_prompt_tokens,
|
||
"total_completion_tokens": total_completion_tokens,
|
||
"total_tokens": total_prompt_tokens + total_completion_tokens,
|
||
"elapsed_ms": (time.time() - start) * 1000,
|
||
"iterations": 1,
|
||
"operations_count": len(operations),
|
||
"handled_candidate_ids": sorted(handled_candidate_ids),
|
||
"missing_candidate_ids": sorted(missing),
|
||
}
|
||
|
||
# Execute operations via tool_executor
|
||
all_tool_calls = []
|
||
state_change_count = 0
|
||
operation_failures = 0
|
||
rejected_candidate_ids = set()
|
||
for op in operations:
|
||
op_type = op.get("op", "")
|
||
try:
|
||
if op_type == "create_schema":
|
||
rejection = validate_new_schema_evidence(op, candidate_nodes)
|
||
if rejection:
|
||
rejected_candidate_ids.update(
|
||
str(node_id) for node_id in op.get("evidence_list") or []
|
||
if str(node_id) in candidate_ids
|
||
)
|
||
all_tool_calls.append({
|
||
"tool": "create_graph_node",
|
||
"args": op,
|
||
"result": {"created": False, "rejected": True, "reason": rejection},
|
||
})
|
||
continue
|
||
result = await tool_executor.execute("create_graph_node", {
|
||
"layer": "l6_schema",
|
||
"content": op.get("content", ""),
|
||
"evidence_list": op.get("evidence_list", []),
|
||
"tags": op.get("tags", []),
|
||
"confidence": op.get("confidence", 0.8),
|
||
"schema_kind": op.get("schema_kind", "behavior_pattern"),
|
||
"retrieval_text": op.get("retrieval_text", ""),
|
||
})
|
||
all_tool_calls.append({"tool": "create_graph_node", "args": op, "result": result})
|
||
parsed_result = json.loads(result) if isinstance(result, str) else result
|
||
if isinstance(parsed_result, dict) and parsed_result.get("created"):
|
||
state_change_count += 1
|
||
elif (
|
||
isinstance(parsed_result, dict)
|
||
and parsed_result.get("deduplicated")
|
||
and int(parsed_result.get("evidence_added") or 0) > 0
|
||
):
|
||
state_change_count += 1
|
||
elif isinstance(parsed_result, dict) and parsed_result.get("error"):
|
||
operation_failures += 1
|
||
|
||
elif op_type == "add_evidence":
|
||
result = await tool_executor.execute("add_evidence", {
|
||
"node_id": op.get("node_id", ""),
|
||
"evidence_list": op.get("evidence_list", []),
|
||
})
|
||
all_tool_calls.append({"tool": "add_evidence", "args": op, "result": result})
|
||
parsed_result = json.loads(result) if isinstance(result, str) else result
|
||
if isinstance(parsed_result, dict) and int(parsed_result.get("evidence_added") or 0) > 0:
|
||
state_change_count += 1
|
||
elif isinstance(parsed_result, dict) and parsed_result.get("error"):
|
||
operation_failures += 1
|
||
|
||
elif op_type == "revise_schema":
|
||
result = await tool_executor.execute("revise_schema", {
|
||
"node_id": op.get("node_id", ""),
|
||
"content": op.get("content", ""),
|
||
"retrieval_text": op.get("retrieval_text", ""),
|
||
"evidence_list": op.get("evidence_list", []),
|
||
"tags": op.get("tags", []),
|
||
"confidence": op.get("confidence", 0.8),
|
||
"schema_kind": op.get("schema_kind", ""),
|
||
"reason": op.get("reason", ""),
|
||
})
|
||
all_tool_calls.append({"tool": "revise_schema", "args": op, "result": result})
|
||
parsed_result = json.loads(result) if isinstance(result, str) else result
|
||
if isinstance(parsed_result, dict) and parsed_result.get("revised"):
|
||
state_change_count += 1
|
||
elif isinstance(parsed_result, dict) and parsed_result.get("error"):
|
||
operation_failures += 1
|
||
|
||
elif op_type == "add_edge":
|
||
result = await tool_executor.execute("add_edge", {
|
||
"source_id": op.get("source_id", ""),
|
||
"target_id": op.get("target_id", ""),
|
||
"reason": op.get("reason", ""),
|
||
})
|
||
all_tool_calls.append({"tool": "add_edge", "args": op, "result": result})
|
||
parsed_result = json.loads(result) if isinstance(result, str) else result
|
||
if isinstance(parsed_result, dict) and parsed_result.get("error"):
|
||
operation_failures += 1
|
||
|
||
elif op_type == "skip_fact":
|
||
all_tool_calls.append({
|
||
"tool": "skip_fact",
|
||
"args": op,
|
||
"result": {"skipped": True},
|
||
})
|
||
|
||
else:
|
||
logger.warning(f"[S2-agent] Unknown operation type: {op_type}")
|
||
all_tool_calls.append({
|
||
"tool": op_type or "unknown",
|
||
"args": op,
|
||
"error": "unknown operation type",
|
||
})
|
||
operation_failures += 1
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[S2-agent] Failed to execute op {op_type}: {e}")
|
||
all_tool_calls.append({"tool": op_type, "args": op, "error": str(e)})
|
||
operation_failures += 1
|
||
|
||
elapsed_ms = (time.time() - start) * 1000
|
||
|
||
logger.info(
|
||
f"[S2-agent] Single-call done: {len(operations)} ops parsed, "
|
||
f"{len(all_tool_calls)} executed, elapsed={elapsed_ms:.0f}ms"
|
||
)
|
||
|
||
return {
|
||
"success": operation_failures == 0,
|
||
"tool_calls": all_tool_calls,
|
||
"tool_call_log": tool_executor.tool_call_log,
|
||
"agent_reasoning": agent_reasoning.strip(),
|
||
"messages": messages + [{"role": "assistant", "content": agent_reasoning}],
|
||
"total_prompt_tokens": total_prompt_tokens,
|
||
"total_completion_tokens": total_completion_tokens,
|
||
"total_tokens": total_prompt_tokens + total_completion_tokens,
|
||
"elapsed_ms": elapsed_ms,
|
||
"iterations": 1,
|
||
"operations_count": len(operations),
|
||
"state_change_count": state_change_count,
|
||
"operation_failures": operation_failures,
|
||
"handled_candidate_ids": sorted(handled_candidate_ids) if source_review else None,
|
||
"rejected_candidate_ids": sorted(rejected_candidate_ids),
|
||
"finish_reason": getattr(response, "finish_reason", "") or "",
|
||
"truncated": False,
|
||
}
|
||
|
||
|
||
def _build_single_call_system_prompt(lang: str) -> str:
|
||
"""Build system prompt for single-call mode (includes output format instructions)."""
|
||
base = SYSTEM2_AGENT_PROMPT_ZH if lang == "zh" else SYSTEM2_AGENT_PROMPT_EN
|
||
|
||
if lang == "zh":
|
||
output_instructions = """
|
||
|
||
## 输出格式
|
||
|
||
不要调用 tools。直接输出一个 JSON 数组,包含你决定执行的操作。用 ```json 代码块包裹。
|
||
|
||
可用操作:
|
||
|
||
1. **create_schema** — 创建新的 L6 行为 Schema
|
||
```{"op": "create_schema", "schema_kind": "behavior_pattern", "content": "供用户阅读的简洁画像", "retrieval_text": "用于检索的行为与适用场景", "evidence_list": ["fact_node_id_1", "fact_node_id_2"], "tags": ["tag1"]}```
|
||
|
||
2. **add_evidence** — 给已有 Schema 追加证据(不修改内容)
|
||
```{"op": "add_evidence", "node_id": "existing_schema_node_id", "evidence_list": ["new_fact_id_1"]}```
|
||
|
||
3. **revise_schema** — 新证据使已有 Schema 的当前状态发生实质变化时,创建新版本并让旧版本退出默认检索。不要用于可共存的细节补充。
|
||
```{"op": "revise_schema", "node_id": "old_schema_id", "schema_kind": "explicit_state", "content": "更新后的当前画像", "retrieval_text": "更新后的检索文本", "evidence_list": ["new_fact_id_1"], "tags": ["tag1"], "reason": "明确说明旧画像为何会误导"}```
|
||
|
||
4. **add_edge** — 建立两个 Schema 之间的关系
|
||
```{"op": "add_edge", "source_id": "schema_id_1", "target_id": "schema_id_2", "reason": "关系原因"}```
|
||
|
||
5. **skip_fact** — 明确标记某条候选事实已审查但不创建 Schema。仅用于他人事实、临时事件、不能证明用户特征的助手建议,或已被现有 Schema 完整覆盖的事实。
|
||
```{"op": "skip_fact", "node_id": "candidate_fact_id", "reason": "不是稳定的用户模式"}```
|
||
|
||
## 输出约定
|
||
|
||
1. 输出必须是 JSON 数组,用 ```json 代码块包裹。
|
||
2. 如果数据不足以得出可靠结论,输出 `[]`。在 source-context review 中,
|
||
`[]` 表示已审查该组全部候选,且都不足以支持稳定 Schema。
|
||
3. 代码块外不要有任何其他文字。
|
||
4. evidence_list 中的 id 必须来自材料中提供的 fact node_id。
|
||
5. add_evidence / revise_schema 的 node_id 必须来自已有 Graph 节点的 id。
|
||
6. 对 source-context 审查,必须让每条候选事实出现在 evidence_list 或 skip_fact 中。"""
|
||
else:
|
||
output_instructions = """
|
||
|
||
## Output Format
|
||
|
||
Do NOT call tools. Output a JSON array of operations directly, wrapped in a ```json code block.
|
||
|
||
Available operations:
|
||
|
||
1. **create_schema** — Create a new L6 behavioral Schema
|
||
```{"op": "create_schema", "schema_kind": "behavior_pattern", "content": "concise user-facing pattern", "retrieval_text": "behavior plus relevant situations", "evidence_list": ["fact_node_id_1", "fact_node_id_2"], "tags": ["tag1"]}```
|
||
|
||
2. **add_evidence** — Add evidence to an existing Schema (content stays immutable)
|
||
```{"op": "add_evidence", "node_id": "existing_schema_node_id", "evidence_list": ["new_fact_id_1"]}```
|
||
|
||
3. **revise_schema** — Create a new version when new evidence materially changes the current state and retire the old version from active retrieval. Do not use it for compatible detail.
|
||
```{"op": "revise_schema", "node_id": "old_schema_id", "schema_kind": "explicit_state", "content": "updated current profile", "retrieval_text": "updated retrieval text", "evidence_list": ["new_fact_id_1"], "tags": ["tag1"], "reason": "why the old wording would mislead"}```
|
||
|
||
4. **add_edge** — Create a relationship between two Schemas
|
||
```{"op": "add_edge", "source_id": "schema_id_1", "target_id": "schema_id_2", "reason": "relationship reason"}```
|
||
|
||
5. **skip_fact** — Explicitly mark a candidate fact as reviewed without creating
|
||
a Schema. Use only for facts about another person, transient events, assistant
|
||
advice that does not establish a user trait, or facts already fully covered.
|
||
```{"op": "skip_fact", "node_id": "candidate_fact_id", "reason": "not a durable user pattern"}```
|
||
|
||
## Output Contract
|
||
|
||
1. Output MUST be a JSON array wrapped in a ```json code block.
|
||
2. If data is insufficient for reliable conclusions, output `[]`. In a
|
||
source-context review, `[]` means every candidate in that review was examined
|
||
and none supports a durable Schema.
|
||
3. NO text outside the code block.
|
||
4. evidence_list IDs must come from the fact node_ids provided in the materials.
|
||
5. add_evidence / revise_schema node_id must come from existing Graph node IDs.
|
||
6. For a source-context review, every candidate fact must appear in an evidence_list or skip_fact."""
|
||
|
||
if _S2_SCHEMA_EVIDENCE_GATE_ENABLED:
|
||
if lang == "zh":
|
||
output_instructions += """
|
||
|
||
7. L6 只表示跨事件行为模式。create_schema 必须使用 user-owned 证据,且证据合计代表至少两个独立 raw/session。当 `source_raw_memory_ids` 包含多个独立写入时,一个合并后的 L2 事实也可以满足门槛。
|
||
8. 单条明确身份、健康状态、设备使用、隶属关系、项目或偏好不要创建 L6;这些由 canonical L2 状态通道维护。"""
|
||
else:
|
||
output_instructions += """
|
||
|
||
7. L6 represents cross-event behavioral patterns only. create_schema must use user-owned evidence representing at least two independent raw writes or sessions. A single reconciled fact can qualify when source_raw_memory_ids contains multiple independent writes.
|
||
8. Do not create L6 for a single explicit identity, health state, equipment use, affiliation, project, or preference. Canonical L2 state projection owns those facts."""
|
||
|
||
# Remove the old "All Graph operations MUST go through tools" principle
|
||
base = base.replace("- 所有 Graph 操作必须通过 tools 执行\n", "")
|
||
base = base.replace("- All Graph operations MUST go through tools\n", "")
|
||
# Remove old tool-calling instructions from workflow
|
||
base = base.replace("调 `add_evidence` 添加证据", "使用 add_evidence 操作")
|
||
base = base.replace("call `add_evidence`", "use add_evidence operation")
|
||
base = base.replace("调 `add_evidence`", "使用 add_evidence")
|
||
base = base.replace("用 `add_edge`(RELATED_TO)", "使用 add_edge 操作")
|
||
base = base.replace("use `add_edge` (RELATED_TO)", "use add_edge operation")
|
||
|
||
return base + output_instructions
|
||
|
||
|
||
def _parse_operations_json(text: str) -> Optional[List[Dict[str, Any]]]:
|
||
"""Parse JSON array of operations from LLM response text."""
|
||
import re
|
||
|
||
# Try to find ```json ... ``` block
|
||
match = re.search(r'```json\s*([\s\S]*?)\s*```', text)
|
||
if match:
|
||
json_str = match.group(1).strip()
|
||
else:
|
||
# Try raw JSON array
|
||
match = re.search(r'\[\s*\{[\s\S]*\}\s*\]', text)
|
||
if match:
|
||
json_str = match.group(0)
|
||
elif '[]' in text:
|
||
return []
|
||
else:
|
||
return None
|
||
|
||
try:
|
||
result = json.loads(json_str)
|
||
if isinstance(result, list):
|
||
return result
|
||
return None
|
||
except json.JSONDecodeError:
|
||
logger.warning(f"[S2-agent] JSON parse failed ({len(json_str)} chars)")
|
||
logger.debug(f"[S2-agent] unparsable output: {json_str[:200]!r}")
|
||
return None
|
||
|
||
|
||
def _detect_language(materials: Dict[str, Any]) -> str:
|
||
"""
|
||
从 materials 的 fact content 检测语言。
|
||
|
||
扫描所有聚类中的 fact content,计算中文字符占比。
|
||
中文比例 >= 70% → "zh",否则 → "en"。
|
||
"""
|
||
import re
|
||
all_text = ""
|
||
for cluster in materials.get("clusters", []):
|
||
for f in cluster.get("facts", []):
|
||
all_text += f.get("content", "") + " "
|
||
for f in materials.get("unprocessed_facts", []):
|
||
all_text += f.get("content", "") + " "
|
||
|
||
if not all_text.strip():
|
||
return "en"
|
||
|
||
# 统计中文字符(CJK Unified Ideographs)
|
||
zh_chars = len(re.findall(r'[\u4e00-\u9fff]', all_text))
|
||
# 统计所有非空白字符
|
||
total_chars = len(re.findall(r'\S', all_text))
|
||
|
||
if total_chars == 0:
|
||
return "en"
|
||
|
||
zh_ratio = zh_chars / total_chars
|
||
lang = "zh" if zh_ratio >= 0.7 else "en"
|
||
logger.debug(f"[S2-agent] language detection: zh_chars={zh_chars} total={total_chars} ratio={zh_ratio:.2f} → {lang}")
|
||
return lang
|
||
|
||
|
||
def _build_materials_message(materials: Dict[str, Any], lang: str = "zh") -> str:
|
||
"""将预处理好的 materials 格式化为 Agent 能理解的 user message"""
|
||
is_zh = (lang == "zh")
|
||
|
||
parts = [("## 预处理材料\n" if is_zh else "## Preprocessed Materials\n")]
|
||
|
||
stats = materials.get("stats", {})
|
||
fresh = stats.get("fresh_facts", 0)
|
||
evidenced = stats.get("evidenced_excluded", 0)
|
||
# 用真实 Graph 节点数判断是否为空(不依赖 vector_search 召回结果)
|
||
graph_total = stats.get("graph_total_schemas", 0)
|
||
recalled_schemas = stats.get("graph_schemas_recalled", 0)
|
||
|
||
if is_zh:
|
||
parts.append(f"### 统计\n- VDB 事实总数: {stats.get('total_facts_all', 0)}")
|
||
parts.append(f"- 新鲜事实(待处理): {fresh}(已被引用排除={evidenced})")
|
||
parts.append(f"- 聚类数: {stats.get('clusters_found', 0)}")
|
||
parts.append(
|
||
f"- 聚类内事实: {stats.get('clustered_facts_count', 0)},"
|
||
f"未聚类补充: {stats.get('unclustered_facts_count', 0)}"
|
||
)
|
||
parts.append(f"- Graph Schema 总数: {stats.get('graph_total_schemas', 0)}(本次召回={recalled_schemas})")
|
||
if graph_total == 0:
|
||
parts.append("- Graph 状态: **空**(首次认知加工)\n")
|
||
else:
|
||
parts.append("")
|
||
else:
|
||
parts.append(f"### Statistics\n- Total VDB facts: {stats.get('total_facts_all', 0)}")
|
||
parts.append(f"- Fresh facts (to process): {fresh} (evidenced_excluded={evidenced})")
|
||
parts.append(f"- Clusters found: {stats.get('clusters_found', 0)}")
|
||
parts.append(
|
||
f"- Facts in clusters: {stats.get('clustered_facts_count', 0)}, "
|
||
f"unclustered supplement: {stats.get('unclustered_facts_count', 0)}"
|
||
)
|
||
parts.append(f"- Graph Schemas total: {stats.get('graph_total_schemas', 0)} (recalled={recalled_schemas})")
|
||
if graph_total == 0:
|
||
parts.append("- Graph status: **EMPTY** (first cognitive processing run)\n")
|
||
else:
|
||
parts.append("")
|
||
|
||
# Clusters
|
||
clusters = materials.get("clusters", [])
|
||
clustered_ids = set()
|
||
if clusters:
|
||
parts.append(("### 聚类结果\n" if is_zh else "### Cluster Results\n"))
|
||
for i, c in enumerate(clusters):
|
||
n_facts = len(c['facts'])
|
||
group_type = c.get("group_type", "semantic_cluster")
|
||
if group_type == "holistic_therapy_review":
|
||
label = (
|
||
f"{n_facts} 条跨会话用户事实;只归纳有证据的长期情绪/治疗模式"
|
||
if is_zh else
|
||
f"{n_facts} cross-session user facts; only evidence-backed durable emotional/therapy patterns"
|
||
)
|
||
elif group_type == "source_context_review":
|
||
label = (
|
||
f"{n_facts} 条候选事实;原始写入仅用于主体与细节核验"
|
||
if is_zh else
|
||
f"{n_facts} candidate facts; raw write is only for subject/detail validation"
|
||
)
|
||
else:
|
||
label = f"{n_facts} 条,主题" if is_zh else f"{n_facts} facts, topic"
|
||
parts.append(f"**Cluster {i}** ({label}: {c['centroid_text']})")
|
||
for f in c["facts"]:
|
||
source_ids = list(f.get("source_raw_memory_ids") or [])
|
||
legacy_source = str(f.get("source_raw_memory_id") or "")
|
||
if legacy_source and legacy_source not in source_ids:
|
||
source_ids.append(legacy_source)
|
||
source_note = f", raw_sources={source_ids}" if source_ids else ""
|
||
parts.append(
|
||
f" - [{f['layer']}] {f['content']} "
|
||
f"(id={f['node_id']}{source_note})"
|
||
)
|
||
clustered_ids.add(f['node_id'])
|
||
if group_type == "source_context_review" and c.get("raw_contexts"):
|
||
raw_label = "候选事实对应的原始片段" if is_zh else "Relevant raw excerpt per candidate"
|
||
parts.append(f"\n**{raw_label}:**")
|
||
for item in c["raw_contexts"]:
|
||
parts.append(
|
||
f"- candidate_id={item.get('node_id')}, "
|
||
f"raw_source={item.get('source_raw_memory_id', '')}\n"
|
||
f"{item.get('excerpt', '')}"
|
||
)
|
||
parts.append("")
|
||
if materials.get("holistic_therapy_review"):
|
||
parts.append(
|
||
(
|
||
"这是一次有边界的夜间全局整理。最多创建 "
|
||
f"{_S2_HOLISTIC_THERAPY_MAX_SCHEMAS} 条 anchored therapy Schema。"
|
||
"优先联合不同 raw/session 中的持久 trigger/background、"
|
||
"反复 emotional response 和用户已实际使用的 coping resource/decision constraint。"
|
||
"证据不足时输出 []。\n"
|
||
if is_zh else
|
||
"This is one bounded nightly holistic consolidation pass. Create at most "
|
||
f"{_S2_HOLISTIC_THERAPY_MAX_SCHEMAS} anchored therapy Schemas. "
|
||
"Prefer joining durable trigger/background, recurring emotional response, "
|
||
"and observed coping resource or decision constraint across different raw writes/sessions. "
|
||
"Before create_schema, union raw_sources for its evidence IDs and require at least two "
|
||
"distinct raw source IDs; two facts from one raw write are one event. "
|
||
"Output [] when evidence is insufficient.\n"
|
||
)
|
||
)
|
||
companion_hints = materials.get("independent_source_companions") or {}
|
||
if companion_hints:
|
||
parts.append(
|
||
"**独立来源 companion 提示(只是候选,不是结论):**"
|
||
if is_zh else
|
||
"**Independent-source companion hints (candidates, not conclusions):**"
|
||
)
|
||
for node_id, companions in companion_hints.items():
|
||
parts.append(
|
||
f"- {node_id} -> "
|
||
+ ", ".join(
|
||
f"{item['node_id']} (sim={item['similarity']}, "
|
||
f"combined_raw_sources={item['combined_raw_source_count']})"
|
||
for item in companions
|
||
)
|
||
)
|
||
parts.append("")
|
||
# 无聚类时不应进入 Agent;此处不输出「未聚类」大块(避免误导读成主输入)
|
||
# Unclustered facts — 仅在有聚类时作为加菜
|
||
all_facts = materials.get("unprocessed_facts", [])
|
||
unclustered = [f for f in all_facts if f["node_id"] not in clustered_ids]
|
||
if clusters and unclustered:
|
||
header = "### 未聚类事实(补充,次要)\n" if is_zh else "### Unclustered Facts (supplemental)\n"
|
||
hint = (
|
||
"以下事实未进入上述任一聚类,可在完成聚类主题加工后酌情审视;优先级低于聚类结果。\n"
|
||
if is_zh
|
||
else "Facts below were not grouped into any cluster above. Review after cluster themes; lower priority than cluster results.\n"
|
||
)
|
||
parts.append(header)
|
||
parts.append(hint)
|
||
for f in unclustered:
|
||
parts.append(f" - [{f['layer']}] {f['content']} (id={f['node_id']})")
|
||
parts.append("")
|
||
|
||
# Existing graph nodes
|
||
forward = materials.get("graph_forward", [])
|
||
if forward:
|
||
if is_zh:
|
||
parts.append(f"### 相关 Graph 节点(通过 cluster centroid 向量召回,共 {len(forward)} 条)\n")
|
||
else:
|
||
parts.append(f"### Related Graph Nodes (retrieved via cluster centroid search, {len(forward)} hits)\n")
|
||
for g in forward:
|
||
sim = g.get("similarity", "")
|
||
sim_str = f", sim={sim}" if sim else ""
|
||
parts.append(
|
||
f" - [{g['layer']}] {g['content']} "
|
||
f"(id={g['node_id']}, evidence={g['evidence_count']}{sim_str})"
|
||
)
|
||
parts.append("")
|
||
elif graph_total == 0:
|
||
if is_zh:
|
||
parts.append("### 已有 Graph 节点\n当前 Graph 为空,这是首次认知加工。无需搜索 Graph,直接根据聚类结果创建 Schema。\n")
|
||
else:
|
||
parts.append("### Existing Graph Nodes\nThe Graph is currently EMPTY — this is the first cognitive processing run. Do NOT search the Graph (it will return nothing). Proceed directly to create Schemas from the cluster results.\n")
|
||
else:
|
||
# Graph 有节点但 centroid search 没召回(可能 threshold 太高或语义距离远)
|
||
if is_zh:
|
||
parts.append(
|
||
f"### 已有 Graph 节点\n"
|
||
f"Graph 中共有 {stats.get('graph_total_schemas', 0)} 个 Schema,"
|
||
f"但 centroid 向量召回未命中相关节点。请根据聚类结果判断是否需要创建新 Schema。\n"
|
||
)
|
||
else:
|
||
parts.append(
|
||
f"### Existing Graph Nodes\n"
|
||
f"The Graph has {stats.get('graph_total_schemas', 0)} Schemas, "
|
||
f"but centroid-based vector recall found no matches. Decide based on cluster results whether new Schemas are needed.\n"
|
||
)
|
||
|
||
# Reverse hits
|
||
reverse = materials.get("graph_reverse", [])
|
||
if reverse:
|
||
parts.append(("### Graph 反向引用(已有 Schema 引用的 VDB 事实)\n" if is_zh
|
||
else "### Graph Reverse References (VDB facts referenced by existing Schemas)\n"))
|
||
for r in reverse:
|
||
parts.append(
|
||
f" - [{r['layer']}] {r['content']} "
|
||
f"(schema_id={r['node_id']}, refs vdb={r.get('evidence_vdb_id', '')})"
|
||
)
|
||
parts.append("")
|
||
|
||
# Existing tags(指导 Agent 复用已有 tag,减少发散)
|
||
existing_tags = materials.get("existing_tags", [])
|
||
if existing_tags:
|
||
if is_zh:
|
||
parts.append("### 已有标签(请优先从中选择,避免创建同义标签)\n")
|
||
parts.append(f" {', '.join(existing_tags)}\n")
|
||
else:
|
||
parts.append("### Existing Tags (prefer reusing these over creating synonyms)\n")
|
||
parts.append(f" {', '.join(existing_tags)}\n")
|
||
|
||
if is_zh:
|
||
parts.append(
|
||
"请分析以上材料,输出操作 JSON 数组。"
|
||
"决定是否需要创建新的 Schema,或为已有 Schema 追加 evidence。"
|
||
"如果数据不足以得出可靠结论,输出空数组 `[]`。"
|
||
"打标签时请优先使用已有标签列表中的标签。"
|
||
)
|
||
else:
|
||
parts.append(
|
||
"Analyze the materials above and output a JSON array of operations. "
|
||
"Decide whether to create new Schemas, or add evidence to existing ones. "
|
||
"If the data is insufficient for reliable conclusions, output an empty array `[]`. "
|
||
"When tagging, prefer reusing tags from the existing tags list above."
|
||
)
|
||
|
||
return "\n".join(parts)
|