diff --git a/packages/everalgo-user-memory/CHANGELOG.md b/packages/everalgo-user-memory/CHANGELOG.md index 2e0d342..c7a45e4 100644 --- a/packages/everalgo-user-memory/CHANGELOG.md +++ b/packages/everalgo-user-memory/CHANGELOG.md @@ -6,6 +6,10 @@ follows [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- `ProfileExtractor` now produces a holistic `Profile.summary`. The initial-extraction, update, and compact prompts (en + zh) each request a one-to-two sentence holistic summary, and the extractor uses it when the model returns one. When the model omits it — or a custom `prompt=` override predates the field, or the value is blank / non-string — the summary falls back to an empty string instead of copying the first available description. Downstream synthesis layers are responsible for producing a true holistic portrait. Previously `summary` was always a verbatim copy of the first `explicit_info` description. + ## [0.3.1] - 2026-06-24 ### Fixed diff --git a/packages/everalgo-user-memory/src/everalgo/user_memory/profile.py b/packages/everalgo-user-memory/src/everalgo/user_memory/profile.py index b9d7616..de1b11b 100644 --- a/packages/everalgo-user-memory/src/everalgo/user_memory/profile.py +++ b/packages/everalgo-user-memory/src/everalgo/user_memory/profile.py @@ -1,329 +1,346 @@ -"""Synthesize a user Profile from a chronological sequence of MemCells.""" - -from __future__ import annotations - -import json -import logging -from typing import TYPE_CHECKING, Any, cast - -from asgiref.sync import async_to_sync - -from everalgo.llm.format import format_message_timestamp -from everalgo.llm.types import ChatMessage as LLMChatMessage -from everalgo.prompts import render_prompt -from everalgo.types import MemCell, Profile -from everalgo.user_memory._render import chat_messages, render_content -from everalgo.user_memory.prompts.en.profile import ( - PROFILE_COMPACT_PROMPT, - PROFILE_INITIAL_EXTRACTION_PROMPT, - PROFILE_UPDATE_PROMPT, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - - from everalgo.llm.protocols import LLMClient - -logger = logging.getLogger(__name__) - - -_PROFILE_MAX_ITEMS = 30 -_PROFILE_COMPACT_THRESHOLD = int(_PROFILE_MAX_ITEMS * 1.5) - - -class ProfileExtractor: - """Synthesize a Profile from a chronologically ordered sequence of MemCells. - - Non-ChatMessage items are silently skipped (agent → user-memory contract). - ``memcells`` must be ordered chronologically; the last element is the most recent and - its timestamp becomes ``Profile.timestamp``. - """ - - def __init__(self, *, llm: LLMClient) -> None: - self._llm = llm - - async def aextract( - self, - memcells: Sequence[MemCell], - *, - sender_id: str, - old_profile: Profile | None = None, - prompt: str | None = None, - ) -> Profile: - """Extract one Profile for ``sender_id`` from ``memcells``. - - Two extraction modes: - INIT (old_profile is None): full extraction from memcells. - UPDATE (old_profile present): LLM emits add/update/delete ops on top of old_profile; - when post-merge explicit_info+implicit_traits item count - exceeds the internal compact threshold, a second LLM pass - runs to re-summarise (compact strategy, caller-transparent). - Returns the final Profile in both modes. - - Args: - memcells: Must be non-empty and ordered chronologically; last element is the most recent. - sender_id: Must be one of the memcells' chat senders; not inferred. - old_profile: Existing profile for UPDATE mode; None triggers INIT mode. - prompt: Prompt override; None uses the bundled default for the selected mode. - - Raises: - ValueError: If ``memcells`` is empty or the LLM response is malformed. - LLMError: From the LLM call. - json.JSONDecodeError: On unparseable response. - """ - if not memcells: - raise ValueError("memcells must contain at least one MemCell") - - if old_profile is None: - return await self._init_extract(memcells, sender_id=sender_id, prompt=prompt) - return await self._update_extract(memcells, sender_id=sender_id, old_profile=old_profile, prompt=prompt) - - extract = async_to_sync(aextract) - - # ------------------------------------------------------------------ - # Private: INIT path - # ------------------------------------------------------------------ - - async def _init_extract( - self, - memcells: Sequence[MemCell], - *, - sender_id: str, - prompt: str | None, - ) -> Profile: - conversation_text = _render_conversation(memcells) - rendered = render_prompt(PROFILE_INITIAL_EXTRACTION_PROMPT, prompt, conversation_text=conversation_text) - - data = await _call_llm_for_profile_init(self._llm, rendered) - explicit_info = data["explicit_info"] - implicit_traits = data["implicit_traits"] - summary = _build_summary(explicit_info, implicit_traits) - return Profile.model_validate( - { - "owner_id": sender_id, - "summary": summary, - "timestamp": memcells[-1].timestamp, - "explicit_info": explicit_info, - "implicit_traits": implicit_traits, - } - ) - - # ------------------------------------------------------------------ - # Private: UPDATE path - # ------------------------------------------------------------------ - - async def _update_extract( - self, - memcells: Sequence[MemCell], - *, - sender_id: str, - old_profile: Profile, - prompt: str | None, - ) -> Profile: - current_profile_text = _render_profile_for_update(old_profile) - conversation_text = _render_conversation(memcells) - rendered = render_prompt( - PROFILE_UPDATE_PROMPT, - prompt, - current_profile=current_profile_text, - conversations=conversation_text, - ) - - data = await _call_llm_for_profile_update(self._llm, rendered) - ops_payload = data["operations"] - merged_profile = _apply_ops(old_profile, ops_payload, timestamp=memcells[-1].timestamp) - - explicit_info: list[Any] = list(getattr(merged_profile, "explicit_info", []) or []) - implicit_traits: list[Any] = list(getattr(merged_profile, "implicit_traits", []) or []) - total_items = len(explicit_info) + len(implicit_traits) - if total_items > _PROFILE_COMPACT_THRESHOLD: - return await self._compact(merged_profile) - return merged_profile - - async def _compact(self, profile: Profile) -> Profile: - explicit_info: list[Any] = list(getattr(profile, "explicit_info", []) or []) - implicit_traits: list[Any] = list(getattr(profile, "implicit_traits", []) or []) - total_items = len(explicit_info) + len(implicit_traits) - profile_text = json.dumps( - {"explicit_info": explicit_info, "implicit_traits": implicit_traits}, - ensure_ascii=False, - indent=2, - ) - rendered = render_prompt( - PROFILE_COMPACT_PROMPT, - None, - total_items=total_items, - max_items=_PROFILE_MAX_ITEMS, - profile_text=profile_text, - ) - - data = await _call_llm_for_profile_compact(self._llm, rendered) - new_explicit = data["explicit_info"] - new_implicit = data["implicit_traits"] - summary = _build_summary(new_explicit, new_implicit) - return Profile.model_validate( - { - "owner_id": profile.owner_id, - "summary": summary, - "timestamp": profile.timestamp, - "explicit_info": new_explicit, - "implicit_traits": new_implicit, - } - ) - - -# --------------------------------------------------------------------------- -# LLM callsites — brace-balanced JSON extraction + 5-retry (mirror b150b32). -# --------------------------------------------------------------------------- - - -async def _call_llm_for_profile_init(llm: LLMClient, rendered: str) -> dict[str, Any]: - """Call LLM for profile initial extraction; return validated dict with explicit_info + implicit_traits lists.""" - response = await llm.chat(messages=[LLMChatMessage(role="user", content=rendered)]) - text = response.content - json_str = _extract_json_object(text) - data: dict[str, Any] = json.loads(json_str) - if "explicit_info" not in data or "implicit_traits" not in data: - raise ValueError(f"Profile init response missing required keys: {list(data.keys())!r}") - if not isinstance(data["explicit_info"], list) or not isinstance(data["implicit_traits"], list): - raise ValueError(f"explicit_info and implicit_traits must be lists: {data!r}") # noqa: TRY004 - return data - - -async def _call_llm_for_profile_update(llm: LLMClient, rendered: str) -> dict[str, Any]: - """Call LLM for profile update; return validated dict with operations list.""" - response = await llm.chat(messages=[LLMChatMessage(role="user", content=rendered)]) - text = response.content - json_str = _extract_json_object(text) - data: dict[str, Any] = json.loads(json_str) - if "operations" not in data: - raise ValueError(f"Profile update response missing 'operations' key: {list(data.keys())!r}") - if not isinstance(data["operations"], list): - raise ValueError(f"operations must be a list: {data!r}") # noqa: TRY004 - return data - - -async def _call_llm_for_profile_compact(llm: LLMClient, rendered: str) -> dict[str, Any]: - """Call LLM for profile compact; return validated dict with explicit_info + implicit_traits lists.""" - response = await llm.chat(messages=[LLMChatMessage(role="user", content=rendered)]) - text = response.content - json_str = _extract_json_object(text) - data: dict[str, Any] = json.loads(json_str) - if "explicit_info" not in data or "implicit_traits" not in data: - raise ValueError(f"Profile compact response missing required keys: {list(data.keys())!r}") - if not isinstance(data["explicit_info"], list) or not isinstance(data["implicit_traits"], list): - raise ValueError(f"explicit_info and implicit_traits must be lists: {data!r}") # noqa: TRY004 - return data - - -def _extract_json_object(text: str) -> str: - """First balanced {{...}} block in text (brace-balanced parser for nested JSON).""" - start = text.find("{") - if start < 0: - raise ValueError(f"No JSON object found in profile LLM response: {text[:200]!r}") - depth = 0 - for i in range(start, len(text)): - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - if depth == 0: - return text[start : i + 1] - raise ValueError(f"Unbalanced JSON in profile LLM response: {text[:200]!r}") - - -# --------------------------------------------------------------------------- -# Module-level helpers. -# --------------------------------------------------------------------------- - - -def _render_conversation(memcells: Sequence[MemCell]) -> str: - """Render ChatMessage items as ``[ISO-ts] speaker(user_id:xxx): content`` lines; tool items are skipped.""" - lines: list[str] = [] - for cell in memcells: - for m in chat_messages(cell): - text = render_content(m.content) - if not text: - continue - speaker = m.sender_name or m.sender_id - user_id = m.sender_id or "" - time_str = format_message_timestamp(m.timestamp) - lines.append(f"[{time_str}] {speaker}(user_id:{user_id}): {text}") - if not lines: - lines.append("(no prior MemCells in the cluster)") - return "\n".join(lines) - - -def _render_profile_for_update(profile: Profile) -> str: - """Render existing profile fields as indexed JSON for the UPDATE prompt.""" - explicit_info: list[Any] = list(getattr(profile, "explicit_info", []) or []) - implicit_traits: list[Any] = list(getattr(profile, "implicit_traits", []) or []) - parts: list[str] = ["=== explicit_info ==="] - for i, item in enumerate(explicit_info): - parts.append(f"[{i}] {json.dumps(item, ensure_ascii=False)}") - parts.append("=== implicit_traits ===") - for i, item in enumerate(implicit_traits): - parts.append(f"[{i}] {json.dumps(item, ensure_ascii=False)}") - return "\n".join(parts) - - -def _to_list(value: object) -> list[Any]: # pyright: ignore[reportUnusedFunction] - """Coerce to list[Any]; returns [] for non-list values.""" - return cast("list[Any]", value) if isinstance(value, list) else [] # type: ignore[redundant-cast] - - -def _apply_ops(old_profile: Profile, ops: list[dict[str, Any]], *, timestamp: int) -> Profile: - """Apply add/update/delete ops to old_profile and return a new Profile.""" - explicit_info: list[Any] = list(getattr(old_profile, "explicit_info", []) or []) - implicit_traits: list[Any] = list(getattr(old_profile, "implicit_traits", []) or []) - - for op in ops: - action = op.get("action") - if action == "none": - continue - op_type = op.get("type") - target = explicit_info if op_type == "explicit_info" else implicit_traits - - if action == "add": - data = op.get("data") - if isinstance(data, dict): - target.append(data) - elif action == "update": - idx = op.get("index") - data = op.get("data") - if isinstance(idx, int) and isinstance(data, dict) and 0 <= idx < len(target): - merged = dict(target[idx]) - merged.update(cast("dict[str, Any]", data)) - target[idx] = merged - elif action == "delete": - idx = op.get("index") - if isinstance(idx, int) and 0 <= idx < len(target): - target.pop(idx) - - summary = _build_summary(explicit_info, implicit_traits) - return Profile.model_validate( - { - "owner_id": old_profile.owner_id, - "summary": summary, - "timestamp": timestamp, - "explicit_info": explicit_info, - "implicit_traits": implicit_traits, - } - ) - - -def _build_summary(explicit_info: list[Any], implicit_traits: list[Any]) -> str: - """Return the first ``description`` from explicit_info, then implicit_traits; sentinel ``"(no summary)"`` if both empty.""" - for item in explicit_info: - if not isinstance(item, dict): - continue - desc = item.get("description") # type: ignore[reportUnknownVariableType,reportUnknownMemberType] - if isinstance(desc, str) and desc.strip(): - return desc.strip() - for item in implicit_traits: - if not isinstance(item, dict): - continue - desc = item.get("description") or item.get("trait") # type: ignore[reportUnknownVariableType,reportUnknownMemberType] - if isinstance(desc, str) and desc.strip(): - return desc.strip() - return "(no summary)" +"""Synthesize a user Profile from a chronological sequence of MemCells.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any, cast + +from asgiref.sync import async_to_sync + +from everalgo.llm.format import format_message_timestamp +from everalgo.llm.types import ChatMessage as LLMChatMessage +from everalgo.prompts import render_prompt +from everalgo.types import MemCell, Profile +from everalgo.user_memory._render import chat_messages, render_content +from everalgo.user_memory.prompts.en.profile import ( + PROFILE_COMPACT_PROMPT, + PROFILE_INITIAL_EXTRACTION_PROMPT, + PROFILE_UPDATE_PROMPT, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from everalgo.llm.protocols import LLMClient + +logger = logging.getLogger(__name__) + + +_PROFILE_MAX_ITEMS = 30 +_PROFILE_COMPACT_THRESHOLD = int(_PROFILE_MAX_ITEMS * 1.5) + + +class ProfileExtractor: + """Synthesize a Profile from a chronologically ordered sequence of MemCells. + + Non-ChatMessage items are silently skipped (agent → user-memory contract). + ``memcells`` must be ordered chronologically; the last element is the most recent and + its timestamp becomes ``Profile.timestamp``. + """ + + def __init__(self, *, llm: LLMClient) -> None: + self._llm = llm + + async def aextract( + self, + memcells: Sequence[MemCell], + *, + sender_id: str, + old_profile: Profile | None = None, + prompt: str | None = None, + ) -> Profile: + """Extract one Profile for ``sender_id`` from ``memcells``. + + Two extraction modes: + INIT (old_profile is None): full extraction from memcells. + UPDATE (old_profile present): LLM emits add/update/delete ops on top of old_profile; + when post-merge explicit_info+implicit_traits item count + exceeds the internal compact threshold, a second LLM pass + runs to re-summarise (compact strategy, caller-transparent). + Returns the final Profile in both modes. + + Args: + memcells: Must be non-empty and ordered chronologically; last element is the most recent. + sender_id: Must be one of the memcells' chat senders; not inferred. + old_profile: Existing profile for UPDATE mode; None triggers INIT mode. + prompt: Prompt override; None uses the bundled default for the selected mode. + + Raises: + ValueError: If ``memcells`` is empty or the LLM response is malformed. + LLMError: From the LLM call. + json.JSONDecodeError: On unparseable response. + """ + if not memcells: + raise ValueError("memcells must contain at least one MemCell") + + if old_profile is None: + return await self._init_extract(memcells, sender_id=sender_id, prompt=prompt) + return await self._update_extract(memcells, sender_id=sender_id, old_profile=old_profile, prompt=prompt) + + extract = async_to_sync(aextract) + + # ------------------------------------------------------------------ + # Private: INIT path + # ------------------------------------------------------------------ + + async def _init_extract( + self, + memcells: Sequence[MemCell], + *, + sender_id: str, + prompt: str | None, + ) -> Profile: + conversation_text = _render_conversation(memcells) + rendered = render_prompt( + PROFILE_INITIAL_EXTRACTION_PROMPT, + prompt, + conversation_text=conversation_text, + target_user=sender_id, + ) + + data = await _call_llm_for_profile_init(self._llm, rendered) + explicit_info = data["explicit_info"] + implicit_traits = data["implicit_traits"] + summary = _resolve_summary(data.get("summary"), explicit_info, implicit_traits) + return Profile.model_validate( + { + "owner_id": sender_id, + "summary": summary, + "timestamp": memcells[-1].timestamp, + "explicit_info": explicit_info, + "implicit_traits": implicit_traits, + } + ) + + # ------------------------------------------------------------------ + # Private: UPDATE path + # ------------------------------------------------------------------ + + async def _update_extract( + self, + memcells: Sequence[MemCell], + *, + sender_id: str, + old_profile: Profile, + prompt: str | None, + ) -> Profile: + current_profile_text = _render_profile_for_update(old_profile) + conversation_text = _render_conversation(memcells) + rendered = render_prompt( + PROFILE_UPDATE_PROMPT, + prompt, + current_profile=current_profile_text, + conversations=conversation_text, + target_user=sender_id, + ) + + data = await _call_llm_for_profile_update(self._llm, rendered) + ops_payload = data["operations"] + merged_profile = _apply_ops(old_profile, ops_payload, timestamp=memcells[-1].timestamp) + + explicit_info: list[Any] = list(getattr(merged_profile, "explicit_info", []) or []) + implicit_traits: list[Any] = list(getattr(merged_profile, "implicit_traits", []) or []) + total_items = len(explicit_info) + len(implicit_traits) + if total_items > _PROFILE_COMPACT_THRESHOLD: + return await self._compact(merged_profile) + return merged_profile + + async def _compact(self, profile: Profile) -> Profile: + explicit_info: list[Any] = list(getattr(profile, "explicit_info", []) or []) + implicit_traits: list[Any] = list(getattr(profile, "implicit_traits", []) or []) + total_items = len(explicit_info) + len(implicit_traits) + profile_text = json.dumps( + {"explicit_info": explicit_info, "implicit_traits": implicit_traits}, + ensure_ascii=False, + indent=2, + ) + rendered = render_prompt( + PROFILE_COMPACT_PROMPT, + None, + total_items=total_items, + max_items=_PROFILE_MAX_ITEMS, + profile_text=profile_text, + ) + + data = await _call_llm_for_profile_compact(self._llm, rendered) + new_explicit = data["explicit_info"] + new_implicit = data["implicit_traits"] + summary = _resolve_summary(data.get("summary"), new_explicit, new_implicit) + return Profile.model_validate( + { + "owner_id": profile.owner_id, + "summary": summary, + "timestamp": profile.timestamp, + "explicit_info": new_explicit, + "implicit_traits": new_implicit, + } + ) + + +# --------------------------------------------------------------------------- +# LLM callsites — brace-balanced JSON extraction + 5-retry (mirror b150b32). +# --------------------------------------------------------------------------- + + +async def _call_llm_for_profile_init(llm: LLMClient, rendered: str) -> dict[str, Any]: + """Call LLM for profile initial extraction; return validated dict with explicit_info + implicit_traits lists.""" + response = await llm.chat(messages=[LLMChatMessage(role="user", content=rendered)]) + text = response.content + json_str = _extract_json_object(text) + data: dict[str, Any] = json.loads(json_str) + if "explicit_info" not in data or "implicit_traits" not in data: + raise ValueError(f"Profile init response missing required keys: {list(data.keys())!r}") + if not isinstance(data["explicit_info"], list) or not isinstance(data["implicit_traits"], list): + raise ValueError(f"explicit_info and implicit_traits must be lists: {data!r}") # noqa: TRY004 + return data + + +async def _call_llm_for_profile_update(llm: LLMClient, rendered: str) -> dict[str, Any]: + """Call LLM for profile update; return validated dict with operations list.""" + response = await llm.chat(messages=[LLMChatMessage(role="user", content=rendered)]) + text = response.content + json_str = _extract_json_object(text) + data: dict[str, Any] = json.loads(json_str) + if "operations" not in data: + raise ValueError(f"Profile update response missing 'operations' key: {list(data.keys())!r}") + if not isinstance(data["operations"], list): + raise ValueError(f"operations must be a list: {data!r}") # noqa: TRY004 + return data + + +async def _call_llm_for_profile_compact(llm: LLMClient, rendered: str) -> dict[str, Any]: + """Call LLM for profile compact; return validated dict with explicit_info + implicit_traits lists.""" + response = await llm.chat(messages=[LLMChatMessage(role="user", content=rendered)]) + text = response.content + json_str = _extract_json_object(text) + data: dict[str, Any] = json.loads(json_str) + if "explicit_info" not in data or "implicit_traits" not in data: + raise ValueError(f"Profile compact response missing required keys: {list(data.keys())!r}") + if not isinstance(data["explicit_info"], list) or not isinstance(data["implicit_traits"], list): + raise ValueError(f"explicit_info and implicit_traits must be lists: {data!r}") # noqa: TRY004 + return data + + +def _extract_json_object(text: str) -> str: + """First balanced {{...}} block in text (brace-balanced parser for nested JSON).""" + start = text.find("{") + if start < 0: + raise ValueError(f"No JSON object found in profile LLM response: {text[:200]!r}") + depth = 0 + for i in range(start, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + raise ValueError(f"Unbalanced JSON in profile LLM response: {text[:200]!r}") + + +# --------------------------------------------------------------------------- +# Module-level helpers. +# --------------------------------------------------------------------------- + + +def _render_conversation(memcells: Sequence[MemCell]) -> str: + """Render ChatMessage items as ``[ISO-ts] speaker(user_id:xxx): content`` lines; tool items are skipped.""" + lines: list[str] = [] + for cell in memcells: + for m in chat_messages(cell): + text = render_content(m.content) + if not text: + continue + speaker = m.sender_name or m.sender_id + user_id = m.sender_id or "" + time_str = format_message_timestamp(m.timestamp)[:10] # 粗化到日期:去秒级噪声、回避 UTC 时区误读(画像抽取无需日内精度) + lines.append(f"[{time_str}] {speaker}(user_id:{user_id}): {text}") + if not lines: + lines.append("(no prior MemCells in the cluster)") + return "\n".join(lines) + + +def _render_profile_for_update(profile: Profile) -> str: + """Render existing profile fields as indexed JSON for the UPDATE prompt.""" + explicit_info: list[Any] = list(getattr(profile, "explicit_info", []) or []) + implicit_traits: list[Any] = list(getattr(profile, "implicit_traits", []) or []) + parts: list[str] = ["=== explicit_info ==="] + for i, item in enumerate(explicit_info): + parts.append(f"[{i}] {json.dumps(item, ensure_ascii=False)}") + parts.append("=== implicit_traits ===") + for i, item in enumerate(implicit_traits): + parts.append(f"[{i}] {json.dumps(item, ensure_ascii=False)}") + return "\n".join(parts) + + +def _to_list(value: object) -> list[Any]: # pyright: ignore[reportUnusedFunction] + """Coerce to list[Any]; returns [] for non-list values.""" + return cast("list[Any]", value) if isinstance(value, list) else [] # type: ignore[redundant-cast] + + +def _apply_ops(old_profile: Profile, ops: list[dict[str, Any]], *, timestamp: int) -> Profile: + """Apply add/update/delete ops to old_profile and return a new Profile.""" + explicit_info: list[Any] = list(getattr(old_profile, "explicit_info", []) or []) + implicit_traits: list[Any] = list(getattr(old_profile, "implicit_traits", []) or []) + + for op in ops: + action = op.get("action") + if action == "none": + continue + op_type = op.get("type") + target = explicit_info if op_type == "explicit_info" else implicit_traits + + if action == "add": + data = op.get("data") + if isinstance(data, dict): + target.append(data) + elif action == "update": + idx = op.get("index") + data = op.get("data") + if isinstance(idx, int) and isinstance(data, dict) and 0 <= idx < len(target): + merged = dict(target[idx]) + merged.update(cast("dict[str, Any]", data)) + target[idx] = merged + elif action == "delete": + idx = op.get("index") + if isinstance(idx, int) and 0 <= idx < len(target): + target.pop(idx) + + summary = _resolve_summary(llm_summary, explicit_info, implicit_traits) + return Profile.model_validate( + { + "owner_id": old_profile.owner_id, + "summary": summary, + "timestamp": timestamp, + "explicit_info": explicit_info, + "implicit_traits": implicit_traits, + } + ) + + +def _resolve_summary(candidate: object, explicit_info: list[Any], implicit_traits: list[Any]) -> str: + """Prefer the model's holistic ``summary``; fall back to an empty string. + + The extraction prompts ask the model for a short paragraph holistic summary alongside + ``explicit_info`` / ``implicit_traits``. When the model omits it -- an older ``prompt=`` override + that predates this field, a non-string value, or a blank string -- returns ``""`` so + ``Profile.summary`` is never a misleading verbatim copy of a single fact. Downstream synthesis + layers (e.g. the caller's own overall-impression module) are responsible for producing a true + holistic portrait. + """ + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + return _build_summary(explicit_info, implicit_traits) + + +def _build_summary(explicit_info: list[Any], implicit_traits: list[Any]) -> str: + """Return an empty string; holistic summaries are produced downstream by the caller's synthesis layer. + + Previously this function returned the first ``description`` from explicit_info or + implicit_traits, which caused ``Profile.summary`` to be a verbatim copy of a single + item -- misleading downstream consumers that reasonably expect a holistic portrait. + The extraction prompts still request a ``summary`` from the model, and + :func:`_resolve_summary` prefers it when present; this function is now the + intentional empty fallback. + """ + return "" diff --git a/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/en/profile.py b/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/en/profile.py index 536a086..af20c66 100644 --- a/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/en/profile.py +++ b/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/en/profile.py @@ -1,217 +1,247 @@ -"""English prompts for ProfileExtractor. - -``PROFILE_INITIAL_EXTRACTION_PROMPT`` is the active prompt used by :class:`ProfileExtractor`; it -replaces the prior 2-stage ``CONVERSATION_PROFILE_PART1 + PART2`` flow with a single call returning -``{explicit_info, implicit_traits}``. The other prompts exported here (``PROFILE_UPDATE_PROMPT`` / -``PROFILE_COMPACT_PROMPT`` / ``TEAM_PROFILE_UPDATE_PROMPT``) cover maintenance operations not yet -consumed by :class:`ProfileExtractor` — kept for future minor extractor releases. - -Placeholders & rendering: all four templates use single-brace placeholders that survive -:py:meth:`str.format` because their JSON examples already escape literal braces as ``{{ }}``. -""" - -PROFILE_UPDATE_PROMPT = """ -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. - -You are a user profile updater. Based on conversation records, determine what operations to perform on the user profile. - -【Current User Profile】(Each item has an index number) -{current_profile} - -【Conversation Records】(Multiple conversations from the same topic) -{conversations} - -【Task】 -Analyze conversations and output a list of operations (can have multiple). Available action types: -- **update**: Modify existing items (specify by index) -- **add**: Add profile items -- **delete**: Delete existing items -- **none**: No operation needed (use when conversation contains no user info) - -【Operation Guide】 -- **update**: Existing item has updates, supplements, or corrections -- **add**: Discovered completely new user information (unrelated to existing items) -- **delete**: Should delete in these cases: - - User explicitly negates (e.g., "I'm no longer vegetarian") - - Info is outdated (e.g., "traveling next week" but it's already passed) - - Too trivial/useless (e.g., "want pizza today") - - Directly contradicts new info - -【Important Rules】 -1. **Tag Mining**: Implicit traits must include [Personality Tags], e.g., [Risk-Averse], [Socially-Driven], [Data-Oriented]. -2. Only extract user info, don't treat AI assistant suggestions as user traits -3. evidence should include time info - e.g., "In Oct 2024 user mentioned..." -4. Index numbers for explicit_info and implicit_traits are independent -5. **Deduplication**: Before using "add", carefully check ALL existing items. If a similar trait/info already exists (even with different wording), use "update" to enrich it instead of adding a duplicate. Only use "add" for genuinely NEW information not covered by any existing item. - -【Profile Definitions & Analysis Framework】 -- **explicit_info (Explicit Information)**: User facts that can be directly extracted from conversations. - - *Content*: Basic info, health status, skills, clear preferences. - -- **implicit_traits (Implicit Traits)**: Psychological profile, personality tags, and decision styles inferred from behavior. - - *Extraction Requirement*: Freely analyze from dimensions like decision patterns, social preferences, and life philosophy. - - *Naming Convention*: - 1. Keep tags short, readable, and reusable for retrieval/comparison (prefer 2–6 words). - 2. Avoid stitching multiple dimensions into one long label; if multiple dimensions exist, split into multiple implicit traits. - 3. Tags should describe stable behavioral/psychological tendencies, not one-off events or short-term states. - - Make reasonable inferences to extract the user's deep traits - -【Output Format】 -No operations: -```json -{{"operations": [{{"action": "none"}}], "update_note": "conversation contains no user info"}} -``` - -With operations (can combine multiple add/update/delete): -```json -{{ - "operations": [ - {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, - {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, - {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, - {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} - ], - "update_note": "added 2 explicit info and 1 implicit trait, updated 1, deleted 1" -}} -``` - -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. -""" - -PROFILE_COMPACT_PROMPT = """ -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. - -The current user profile has {total_items} items (explicit_info + implicit_traits combined), exceeding the limit of {max_items}. - -Please compact the profile to **{max_items} items TOTAL** (explicit_info + implicit_traits combined, NOT {max_items} each). - -Compaction strategies: -1. **Merge Similar Items**: Combine multiple records of the same dimension into one "Current State + Trend" description. -2. **Refine Tags**: Implicit traits should be summarized as personality tags (e.g., [Risk-Averse]), removing repetitive or shallow descriptions. -3. Delete unimportant, outdated, or short-term statuses. -4. Preserve item fields (especially evidence). - -Current Profile: -{profile_text} - -**IMPORTANT**: Output must have explicit_info + implicit_traits ≤ {max_items} items TOTAL. -```json -{{ - "explicit_info": [ - {{"category": "...", "description": "...", "evidence": "..."}} - ], - "implicit_traits": [ - {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}} - ], - "compact_note": "Explain what was deleted/merged" -}} -``` - -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. -""" - -PROFILE_INITIAL_EXTRACTION_PROMPT = """ -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. - -You are a "User Profile Analyst". Please read the conversation below and build a user profile. - -【Part 1: Explicit Information (explicit_info)】 -Objective facts and current status. - -【Part 2: Implicit Traits (implicit_traits)】 -Psychological profile, personality tags, and decision styles inferred from behavior. -*Extraction Requirement*: Freely analyze decision making, social patterns, and values. Trait field must be a highly summarized [Adjective/Noun Phrase Tag]. - -【Extraction Principles】 -1. Only extract information about the user themselves, not assistant suggestions -2. Implicit traits must be supported by multiple evidence: each implicit trait must have evidence corroborated by multiple signals from the conversations and/or existing profile; do not infer from a single data point alone -3. Describe each piece of information in one natural sentence, easy to understand - -【Output Format】 -Output JSON directly in the following format: -```json -{{ - "explicit_info": [ - {{ - "category": "category name", - "description": "one sentence description", - "evidence": "one-sentence evidence grounded in the conversations" - }} - ], - "implicit_traits": [ - {{ - "trait": "trait name", - "description": "one sentence description of this trait", - "basis": "inferred from which behaviors/conversations", - "evidence": "one-sentence evidence grounded in the conversations" - }} - ] -}} -``` - -LANGUAGE RULE: Detect the language of the input conversation and respond in the SAME language. If the conversation is in Chinese, output in Chinese. If in English, output in English. - -【Original Conversation】 -{conversation_text}""" - - -TEAM_PROFILE_UPDATE_PROMPT = """ -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. - -You are a user profile updater for **group conversations**. Your task is to extract and update the profile for ONE specific user from a multi-person conversation. - -**TARGET USER: {target_user}** -You MUST only extract information about **{target_user}**. Carefully attribute each piece of information to the correct speaker. Do NOT mix up information from different participants. - -【Current Profile for {target_user}】(Each item has an index number) -{current_profile} - -【Group Conversation Records】(Multiple participants - only extract info about {target_user}) -{conversations} - -【Task】 -Analyze the conversations and output operations ONLY for information about **{target_user}**. Available action types: -- **update**: Modify existing items (specify by index) -- **add**: Add profile items -- **delete**: Delete existing items -- **none**: No operation needed (use when conversation contains no info about {target_user}) - -【Operation Guide】 -- **update**: Existing item has updates, supplements, or corrections -- **add**: Discovered completely new information about {target_user} (unrelated to existing items) -- **delete**: Should delete in these cases: - - {target_user} explicitly negates something (e.g., "I'm no longer vegetarian") - - Info is outdated or directly contradicts new info - -【Important Rules】 -1. **Speaker Attribution**: This is a GROUP conversation with multiple speakers. ONLY extract what **{target_user}** said or what is explicitly about {target_user}. If another participant mentions a fact, it belongs to THAT participant's profile, NOT {target_user}'s. -2. **Tag Mining**: Implicit traits must include [Personality Tags], e.g., [Risk-Averse], [Socially-Driven], [Data-Oriented]. -3. evidence should include time info and speaker - e.g., "In Oct 2024 {target_user} stated..." -4. Index numbers for explicit_info and implicit_traits are independent -5. **Deduplication**: Before using "add", check ALL existing items. If a similar trait/info already exists, use "update" instead. Only "add" genuinely NEW information. - -【Profile Definitions】 -- **explicit_info**: Facts directly stated by or about {target_user} (skills, background, preferences, location, etc.) -- **implicit_traits**: Personality traits and behavioral patterns inferred from {target_user}'s statements and behavior in the conversation. - -【Output Format】 -No operations: -```json -{{"operations": [{{"action": "none"}}], "update_note": "conversation contains no info about {target_user}"}} -``` - -With operations: -```json -{{ - "operations": [ - {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, - {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, - {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, - {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} - ], - "update_note": "..." -}} -``` - -**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. -""" +"""English prompts for ProfileExtractor. + +``PROFILE_INITIAL_EXTRACTION_PROMPT`` is the active prompt used by :class:`ProfileExtractor`; it +replaces the prior 2-stage ``CONVERSATION_PROFILE_PART1 + PART2`` flow with a single call returning +``{explicit_info, implicit_traits}``. The other prompts exported here (``PROFILE_UPDATE_PROMPT`` / +``PROFILE_COMPACT_PROMPT`` / ``TEAM_PROFILE_UPDATE_PROMPT``) cover maintenance operations not yet +consumed by :class:`ProfileExtractor` — kept for future minor extractor releases. + +Placeholders & rendering: all four templates use single-brace placeholders that survive +:py:meth:`str.format` because their JSON examples already escape literal braces as ``{{ }}``. +""" + +PROFILE_UPDATE_PROMPT = """ +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. + +You are a user profile updater. Based on conversation records, determine what operations to perform on the user profile. + +**TARGET USER: user_id={target_user}** +Operate ONLY on information about the user whose id is {target_user}. Each conversation line is tagged `(user_id:...)`; attribute every fact to the speaker who stated it. Other participants and the AI assistant are context, never the target. + +【Current User Profile】(Each item has an index number) +{current_profile} + +【Conversation Records】(Multiple conversations from the same topic) +{conversations} + +【Task】 +Analyze conversations and output a list of operations (can have multiple). Available action types: +- **update**: Modify existing items (specify by index) +- **add**: Add profile items +- **delete**: Delete existing items +- **none**: No operation needed (use when conversation contains no user info) + +【Operation Guide】 +- **update**: Existing item has updates, supplements, or corrections +- **add**: Discovered completely new user information (unrelated to existing items) +- **delete**: Should delete in these cases: + - User explicitly negates (e.g., "I'm no longer vegetarian") + - Info is outdated (e.g., "traveling next week" but it's already passed) + - Too trivial/useless (e.g., "want pizza today") + - Directly contradicts new info + +【Important Rules】 +1. **Tag Mining**: Implicit traits must include [Personality Tags], e.g., [Risk-Averse], [Socially-Driven], [Data-Oriented]. +2. **Speaker attribution**: extract information about the target user (user_id={target_user}) ONLY. If another participant — or the AI assistant — states a fact, it belongs to THEM, not the target; never let the assistant's own identity or persona become a user trait. +3. evidence should include time info - e.g., "In Oct 2024 user mentioned..." +4. Index numbers for explicit_info and implicit_traits are independent +5. **Deduplication**: Before using "add", carefully check ALL existing items. If a similar trait/info already exists (even with different wording), use "update" to enrich it instead of adding a duplicate. Only use "add" for genuinely NEW information not covered by any existing item. +6. **Durable abstraction — HARD RULE (anti-bloat)**: `description` is a TIMELESS generalization. It must NEVER contain a date, weekday, or clock time (coarse time, if truly needed, goes only in `evidence`, never in `description`). + IF the new conversation is another instance of a pattern already covered by an existing item (another meal, listening session, purchase, mood episode, etc.) THEN you MUST: + - use action="update" on that existing item (never action="add" for it), AND + - REWRITE its `description` into ONE timeless sentence that folds the new instance into the existing pattern — do NOT append the new instance as a separate dated clause. + Example: + WRONG (appended dated instance): "Prefers napping after lunch. On 2026-07-01 napped again after lunch and reported waking up refreshed." + RIGHT (re-synthesized): "Regularly naps after lunch, typically waking refreshed; treats it as a normal part of the daily routine." + If an existing `description` is already an enumerated/dated log, rewrite it into a generalization as part of this same update. + IF an existing `description` ALREADY mixes multiple sub-topics and/or already contains dates (like the example below), you MUST fully rewrite the ENTIRE description into clean, dateless, merged prose — do not just patch the new instance onto the end of an already-dated description. + Example of fixing an already-bloated item: + WRONG (existing item, already has 2 dates, and a 3rd is appended): "Tends to stay up late, improving lately. On 2026-06-29 stayed up late to confess something. Also naps some afternoons. On 2026-07-01 napped again, waking with no dreams." + RIGHT (existing item rewritten dateless, new info folded in): "Tends to stay up late but has been improving under gentle accountability, occasionally negotiating exceptions when something specific comes up; also naps some afternoons, usually reporting back after waking with no issues." + Before finalizing your response: re-read every `description` you are about to output and check it contains no date/weekday/clock-time token. If one slipped in, rewrite that description now — do not submit it with a date still present. +7. **summary**: always include a top-level "summary" field — a short paragraph that synthesises the defining facts and traits; do not copy a single item. + +【Profile Definitions & Analysis Framework】 +- **explicit_info (Explicit Information)**: User facts that can be directly extracted from conversations. + - *Content*: Basic info, health status, skills, clear preferences. + +- **implicit_traits (Implicit Traits)**: Psychological profile, personality tags, and decision styles inferred from behavior. + - *Extraction Requirement*: Freely analyze from dimensions like decision patterns, social preferences, and life philosophy. + - *Naming Convention*: + 1. Keep tags short, readable, and reusable for retrieval/comparison (prefer 2–6 words). + 2. Avoid stitching multiple dimensions into one long label; if multiple dimensions exist, split into multiple implicit traits. + 3. Tags should describe stable behavioral/psychological tendencies, not one-off events or short-term states. + - Make reasonable inferences to extract the user's deep traits + +【Output Format】 +No operations: +```json +{{"operations": [{{"action": "none"}}], "update_note": "conversation contains no user info", "summary": "a short paragraph synthesising the user's current profile"}} +``` + +With operations (can combine multiple add/update/delete): +```json +{{ + "operations": [ + {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, + {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, + {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, + {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} + ], + "update_note": "added 2 explicit info and 1 implicit trait, updated 1, deleted 1", + "summary": "a short paragraph synthesising the user after applying these operations" +}} +``` + +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. +""" + +PROFILE_COMPACT_PROMPT = """ +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. + +The current user profile has {total_items} items (explicit_info + implicit_traits combined), exceeding the limit of {max_items}. + +Please compact the profile to **{max_items} items TOTAL** (explicit_info + implicit_traits combined, NOT {max_items} each). + +Compaction strategies: +1. **Merge Similar Items**: Combine multiple records of the same dimension into one "Current State + Trend" description. +2. **Refine Tags**: Implicit traits should be summarized as personality tags (e.g., [Risk-Averse]), removing repetitive or shallow descriptions. +3. Delete unimportant, outdated, or short-term statuses. +4. Preserve item fields (especially evidence). +5. Also emit a top-level "summary": a short paragraph that synthesises the compacted profile. + +Current Profile: +{profile_text} + +**IMPORTANT**: Output must have explicit_info + implicit_traits ≤ {max_items} items TOTAL. +```json +{{ + "explicit_info": [ + {{"category": "...", "description": "...", "evidence": "..."}} + ], + "implicit_traits": [ + {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}} + ], + "compact_note": "Explain what was deleted/merged", + "summary": "a short paragraph synthesising the compacted profile" +}} +``` + +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. +""" + +PROFILE_INITIAL_EXTRACTION_PROMPT = """ +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. + +You are a "User Profile Analyst". Please read the conversation below and build a user profile. + +**TARGET USER: user_id={target_user}** +Build the profile for THIS user only. The conversation may include several speakers — other participants and the AI assistant. Each line is tagged `(user_id:...)`; attribute information ONLY to the user whose id is {target_user}. Everyone else, the assistant included, is context — never the subject of this profile. + +【Part 1: Explicit Information (explicit_info)】 +Objective facts and current status. + +【Part 2: Implicit Traits (implicit_traits)】 +Psychological profile, personality tags, and decision styles inferred from behavior. +*Extraction Requirement*: Freely analyze decision making, social patterns, and values. Trait field must be a highly summarized [Adjective/Noun Phrase Tag]. + +【Extraction Principles】 +1. Extract information about the target user (user_id={target_user}) ONLY. Never attribute to the target anything said by another participant or by the AI assistant — including the assistant's own name, persona, role, or first-person self-description. The assistant describes itself, never the user. +2. Implicit traits must be supported by multiple evidence: each implicit trait must have evidence corroborated by multiple signals from the conversations and/or existing profile; do not infer from a single data point alone +3. **Durable abstraction — HARD RULE**: describe each item as ONE concise, timeless sentence — a stable generalization, NEVER a dated log or list of instances. `description` must NEVER contain a date, weekday, or clock time (put coarse timing only in `evidence`, never in `description`). + IF the conversation shows the same behavior/preference multiple times (meals, listening sessions, purchases, moods) THEN you MUST fold all instances into ONE generalized sentence — do NOT enumerate them as separate dated events. + Example: + WRONG: "Ate ramen on 06-05, pasta on 06-07, and ramen again on 06-10." + RIGHT: "Frequently eats noodle-based dishes; enjoys variety across visits." + Before finalizing: check every `description` for date/weekday/clock-time tokens; rewrite any that contain one. +4. **summary**: after listing explicit_info and implicit_traits, write a short paragraph that synthesises the most defining facts and traits; do not merely repeat the first item. + +【Output Format】 +Output JSON directly in the following format: +```json +{{ + "explicit_info": [ + {{ + "category": "category name", + "description": "one sentence description", + "evidence": "one-sentence evidence grounded in the conversations" + }} + ], + "implicit_traits": [ + {{ + "trait": "trait name", + "description": "one sentence description of this trait", + "basis": "inferred from which behaviors/conversations", + "evidence": "one-sentence evidence grounded in the conversations" + }} + ], + "summary": "a short paragraph synthesising the user from the items above" +}} +``` + +LANGUAGE RULE: Detect the language of the input conversation and respond in the SAME language. If the conversation is in Chinese, output in Chinese. If in English, output in English. + +【Original Conversation】 +{conversation_text}""" + + +TEAM_PROFILE_UPDATE_PROMPT = """ +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. + +You are a user profile updater for **group conversations**. Your task is to extract and update the profile for ONE specific user from a multi-person conversation. + +**TARGET USER: {target_user}** +You MUST only extract information about **{target_user}**. Carefully attribute each piece of information to the correct speaker. Do NOT mix up information from different participants. + +【Current Profile for {target_user}】(Each item has an index number) +{current_profile} + +【Group Conversation Records】(Multiple participants - only extract info about {target_user}) +{conversations} + +【Task】 +Analyze the conversations and output operations ONLY for information about **{target_user}**. Available action types: +- **update**: Modify existing items (specify by index) +- **add**: Add profile items +- **delete**: Delete existing items +- **none**: No operation needed (use when conversation contains no info about {target_user}) + +【Operation Guide】 +- **update**: Existing item has updates, supplements, or corrections +- **add**: Discovered completely new information about {target_user} (unrelated to existing items) +- **delete**: Should delete in these cases: + - {target_user} explicitly negates something (e.g., "I'm no longer vegetarian") + - Info is outdated or directly contradicts new info + +【Important Rules】 +1. **Speaker Attribution**: This is a GROUP conversation with multiple speakers. ONLY extract what **{target_user}** said or what is explicitly about {target_user}. If another participant mentions a fact, it belongs to THAT participant's profile, NOT {target_user}'s. +2. **Tag Mining**: Implicit traits must include [Personality Tags], e.g., [Risk-Averse], [Socially-Driven], [Data-Oriented]. +3. evidence should include time info and speaker - e.g., "In Oct 2024 {target_user} stated..." +4. Index numbers for explicit_info and implicit_traits are independent +5. **Deduplication**: Before using "add", check ALL existing items. If a similar trait/info already exists, use "update" instead. Only "add" genuinely NEW information. + +【Profile Definitions】 +- **explicit_info**: Facts directly stated by or about {target_user} (skills, background, preferences, location, etc.) +- **implicit_traits**: Personality traits and behavioral patterns inferred from {target_user}'s statements and behavior in the conversation. + +【Output Format】 +No operations: +```json +{{"operations": [{{"action": "none"}}], "update_note": "conversation contains no info about {target_user}"}} +``` + +With operations: +```json +{{ + "operations": [ + {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, + {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, + {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, + {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} + ], + "update_note": "..." +}} +``` + +**CRITICAL LANGUAGE RULE**: You MUST output in the SAME language as the input conversation content. If the conversation content is in Chinese, ALL output MUST be in Chinese. If in English, output in English. This is mandatory. +""" \ No newline at end of file diff --git a/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/zh/profile.py b/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/zh/profile.py index 1729327..0bb5a5c 100644 --- a/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/zh/profile.py +++ b/packages/everalgo-user-memory/src/everalgo/user_memory/prompts/zh/profile.py @@ -1,196 +1,208 @@ -"""Chinese prompts for ProfileExtractor. - -``PROFILE_INITIAL_EXTRACTION_PROMPT`` is the active prompt used by :class:`ProfileExtractor`; it -replaces the prior 2-stage ``CONVERSATION_PROFILE_PART1 + PART2`` flow with a single call returning -``{explicit_info, implicit_traits}``. The other prompts cover maintenance operations not yet consumed -by :class:`ProfileExtractor` — kept for future minor extractor releases. -""" - -# Incremental Update Prompt -PROFILE_UPDATE_PROMPT = """你是用户画像更新员。根据对话记录,判断需要对用户画像做哪些操作。 - -【当前用户画像】(每条都有 index 编号) -{current_profile} - -【对话记录】(来自同一主题的多轮对话) -{conversations} - -【任务】 -分析对话,输出需要执行的操作列表(可以有多条操作)。可选操作类型: -- **update**: 修改现有条目(通过 index 指定) -- **add**: 新增画像条目 -- **delete**: 删除现有条目 -- **none**: 无需任何操作(当对话不包含任何用户信息时使用) - -【操作选择指南】 -- **update**: 现有条目有信息更新、补充、修改 -- **add**: 发现全新的用户信息(与现有条目无关) -- **delete**: 以下情况应该删除: - - 用户明确否定(如"我不再吃素了") - - 信息已过时(如"下周要出差"但已经过了) - - 与新信息直接矛盾 - -【重要规则】 -1. **挖掘标签**:隐式特征必须包含【性格标签】,例如:[风险厌恶型]、[社交驱动型]、[数据考据党]。 -2. 只提取用户信息,不要把 AI 助手的建议当成用户特征 -3. evidence 要包含时间信息 - 如"2024年10月用户提到..." -4. explicit_info 和 implicit_traits 的 index 是独立编号的 -5. **去重**:在使用 "add" 前,仔细检查所有已有条目。如果类似的特征/信息已存在(即使措辞不同),请用 "update" 来补充而非重复添加。只有确实全新的信息才用 "add"。 - -【画像定义与分析框架】 -- **explicit_info(显式信息)**:可以直接从对话中提取的用户事实。 - - *包含内容*:基本资料、健康状况、能力技能、明确偏好等。 - -- **implicit_traits(隐式特征)**:基于行为推断的心理画像、性格标签和决策风格。 - - *提取要求*:请结合对话上下文,从决策模式、社交偏好、生活哲学等维度进行自由分析和概括。 - - *命名规范*: - 1. 标签必须简练、可读、可复用(便于检索/对比),尽量控制在 2-6 个字。 - 2. 避免把多个维度硬拼成一个长标签;如果信息包含多个维度,请拆成多条隐式特征分别表达。 - 3. 标签应描述“稳定的行为/心理倾向”,不要写成一次性的事件或短期状态。 - - 请做合理推理,提取出用户的深层特征 -【输出格式】 -无操作时: -```json -{{"operations": [{{"action": "none"}}], "update_note": "对话不包含用户信息"}} -``` - -有操作时(可以组合多条 add/update/delete): -```json -{{ - "operations": [ - {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, - {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, - {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, - {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} - ], - "update_note": "新增2条显式信息和1条隐式特征,更新1条,删除1条" -}} -```""" - -# Compacting Prompt -PROFILE_COMPACT_PROMPT = """当前用户画像有 {total_items} 条记录(explicit_info + implicit_traits 合计),超过了上限 {max_items} 条。 - -请精简画像至 **合计 {max_items} 条**(explicit_info + implicit_traits 两类加起来,不是每类 {max_items} 条)。 - -精简原则: -1. **合并同类项**:将同一维度的多条记录(如多次体重记录)合并为一条"当前状态+趋势"的描述。 -2. **提炼标签**:隐式特征应归纳为性格标签(如[风险厌恶型]),删除重复或浅层的描述。 -3. 删除不重要、已过时或短期状态。 -4. 保留每条条目的字段完整(尤其是 evidence)。 - -当前画像: -{profile_text} - -**重要**:输出的 explicit_info + implicit_traits 合计必须 ≤ {max_items} 条。 -```json -{{ - "explicit_info": [ - {{"category": "...", "description": "...", "evidence": "..."}} - ], - "implicit_traits": [ - {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}} - ], - "compact_note": "说明删除/合并了哪些内容" -}} -```""" - -# Initial Extraction Prompt -PROFILE_INITIAL_EXTRACTION_PROMPT = """你是一个"用户画像分析师"。请阅读下面的对话,构建用户画像。 - -【第一部分:显式信息 (explicit_info)】 -用户的客观事实和当前状态,如身高体重、喜好、疾病等。 - -【第二部分:隐式特征 (implicit_traits)】 -基于行为推断的心理画像、性格标签和决策风格。 -*提取要求*:从决策、社交、生活观念等维度进行深度挖掘。 -*命名规范*:Trait 字段必须简练精准,推荐“[形容词] [名词]”格式,严禁过度堆砌形容词。 - -【提取原则】 -1. 只提取用户本人的信息,不要把助手的建议当成用户特征 -2. 隐式特征必须有多个证据支撑:同一条隐式特征的 evidence 必须来自多个信号;证据可来自【当前对话】与/或【已有画像 current_profile 的 evidence】(更新时可用),不能仅凭单条新对话臆断 -3. 每条信息用一句自然语言描述,通俗易懂 - -【输出格式】 -请直接输出 JSON,格式如下: -```json -{{ - "explicit_info": [ - {{ - "category": "分类名", - "description": "一句话描述", - "evidence": "一句话证据(来自对话内容)" - }} - ], - "implicit_traits": [ - {{ - "trait": "特征名称", - "description": "一句话描述这个特征", - "basis": "从哪些行为/对话推断出来的", - "evidence": "一句话证据(来自对话内容)" - }} - ] -}} -``` - -【对话原文】 -{conversation_text}""" - - -# ============================================================================ -# TEAM-specific prompts (multi-user group conversation) -# ============================================================================ - -TEAM_PROFILE_UPDATE_PROMPT = """你是**群聊场景**的用户画像更新员。你的任务是从多人对话中,仅提取和更新**一个特定用户**的画像。 - -**目标用户:{target_user}** -你必须只提取关于 **{target_user}** 的信息。仔细区分每条信息的发言者,不要把其他参与者的信息混入 {target_user} 的画像。 - -【{target_user} 的当前画像】(每条都有 index 编号) -{current_profile} - -【群聊对话记录】(多个参与者 - 只提取 {target_user} 的信息) -{conversations} - -【任务】 -分析对话,仅输出关于 **{target_user}** 的操作列表。可选操作类型: -- **update**: 修改现有条目(通过 index 指定) -- **add**: 新增画像条目 -- **delete**: 删除现有条目 -- **none**: 无需任何操作(当对话不包含 {target_user} 的信息时使用) - -【操作选择指南】 -- **update**: 现有条目有信息更新、补充、修改 -- **add**: 发现 {target_user} 的全新信息(与现有条目无关) -- **delete**: 以下情况应该删除: - - {target_user} 明确否定(如"我不再吃素了") - - 信息已过时或与新信息直接矛盾 - -【重要规则】 -1. **发言者归属**:这是一个多人群聊。只提取 **{target_user}** 说的话或明确关于 {target_user} 的信息。如果其他参与者提到某个事实,那属于其他人的画像,不是 {target_user} 的。 -2. **挖掘标签**:隐式特征必须包含【性格标签】,例如:[风险厌恶型]、[社交驱动型]、[数据考据党]。 -3. evidence 要包含时间和发言者信息 - 如"2024年10月 {target_user} 提到..." -4. explicit_info 和 implicit_traits 的 index 是独立编号的 -5. **去重**:在使用 "add" 前,检查所有已有条目。如果类似信息已存在,用 "update" 补充。只有确实全新的信息才用 "add"。 - -【画像定义】 -- **explicit_info(显式信息)**:{target_user} 直接陈述的或关于其本人的事实(技能、背景、偏好、所在地等) -- **implicit_traits(隐式特征)**:从 {target_user} 在对话中的表述和行为推断出的性格特征和行为模式 - -【输出格式】 -无操作时: -```json -{{"operations": [{{"action": "none"}}], "update_note": "对话不包含 {target_user} 的信息"}} -``` - -有操作时: -```json -{{ - "operations": [ - {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, - {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, - {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, - {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} - ], - "update_note": "..." -}} -```""" +"""Chinese prompts for ProfileExtractor. + +``PROFILE_INITIAL_EXTRACTION_PROMPT`` is the active prompt used by :class:`ProfileExtractor`; it +replaces the prior 2-stage ``CONVERSATION_PROFILE_PART1 + PART2`` flow with a single call returning +``{explicit_info, implicit_traits}``. The other prompts cover maintenance operations not yet consumed +by :class:`ProfileExtractor` — kept for future minor extractor releases. +""" + +# Incremental Update Prompt +PROFILE_UPDATE_PROMPT = """你是用户画像更新员。根据对话记录,判断需要对用户画像做哪些操作。 + +**目标用户:user_id={target_user}** +只对 user_id 为 {target_user} 的用户的信息执行操作。每行对话都带 `(user_id:...)` 标签;把每个事实归属给真正陈述它的发言者。其他参与者和 AI 助手都只是上下文,绝不是目标用户。 + +【当前用户画像】(每条都有 index 编号) +{current_profile} + +【对话记录】(来自同一主题的多轮对话) +{conversations} + +【任务】 +分析对话,输出需要执行的操作列表(可以有多条操作)。可选操作类型: +- **update**: 修改现有条目(通过 index 指定) +- **add**: 新增画像条目 +- **delete**: 删除现有条目 +- **none**: 无需任何操作(当对话不包含任何用户信息时使用) + +【操作选择指南】 +- **update**: 现有条目有信息更新、补充、修改 +- **add**: 发现全新的用户信息(与现有条目无关) +- **delete**: 以下情况应该删除: + - 用户明确否定(如"我不再吃素了") + - 信息已过时(如"下周要出差"但已经过了) + - 与新信息直接矛盾 + +【重要规则】 +1. **挖掘标签**:隐式特征必须包含【性格标签】,例如:[风险厌恶型]、[社交驱动型]、[数据考据党]。 +2. **发言者归属**:只提取目标用户(user_id={target_user})的信息。如果其他参与者或 AI 助手陈述了某个事实,那属于他们,不属于目标用户;绝不要让助手自身的身份或人设变成用户特征。 +3. evidence 要包含时间信息 - 如"2024年10月用户提到..." +4. explicit_info 和 implicit_traits 的 index 是独立编号的 +5. **去重**:在使用 "add" 前,仔细检查所有已有条目。如果类似的特征/信息已存在(即使措辞不同),请用 "update" 来补充而非重复添加。只有确实全新的信息才用 "add"。 +6. **summary**:始终输出顶层 "summary" 字段——一段简短文字,综合提炼用户的核心特征与基调;不要复制单条。 + +【画像定义与分析框架】 +- **explicit_info(显式信息)**:可以直接从对话中提取的用户事实。 + - *包含内容*:基本资料、健康状况、能力技能、明确偏好等。 + +- **implicit_traits(隐式特征)**:基于行为推断的心理画像、性格标签和决策风格。 + - *提取要求*:请结合对话上下文,从决策模式、社交偏好、生活哲学等维度进行自由分析和概括。 + - *命名规范*: + 1. 标签必须简练、可读、可复用(便于检索/对比),尽量控制在 2-6 个字。 + 2. 避免把多个维度硬拼成一个长标签;如果信息包含多个维度,请拆成多条隐式特征分别表达。 + 3. 标签应描述“稳定的行为/心理倾向”,不要写成一次性的事件或短期状态。 + - 请做合理推理,提取出用户的深层特征 +【输出格式】 +无操作时: +```json +{{"operations": [{{"action": "none"}}], "update_note": "对话不包含用户信息", "summary": "一段简短文字,综合概括用户当前画像"}} +``` + +有操作时(可以组合多条 add/update/delete): +```json +{{ + "operations": [ + {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, + {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, + {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, + {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} + ], + "update_note": "新增2条显式信息和1条隐式特征,更新1条,删除1条", + "summary": "一段简短文字,综合概括应用操作后的用户画像" +}} +```""" + +# Compacting Prompt +PROFILE_COMPACT_PROMPT = """当前用户画像有 {total_items} 条记录(explicit_info + implicit_traits 合计),超过了上限 {max_items} 条。 + +请精简画像至 **合计 {max_items} 条**(explicit_info + implicit_traits 两类加起来,不是每类 {max_items} 条)。 + +精简原则: +1. **合并同类项**:将同一维度的多条记录(如多次体重记录)合并为一条"当前状态+趋势"的描述。 +2. **提炼标签**:隐式特征应归纳为性格标签(如[风险厌恶型]),删除重复或浅层的描述。 +3. 删除不重要、已过时或短期状态。 +4. 保留每条条目的字段完整(尤其是 evidence)。 +5. 同时输出顶层 "summary":一段简短文字,综合概括精简后的画像。 + +当前画像: +{profile_text} + +**重要**:输出的 explicit_info + implicit_traits 合计必须 ≤ {max_items} 条。 +```json +{{ + "explicit_info": [ + {{"category": "...", "description": "...", "evidence": "..."}} + ], + "implicit_traits": [ + {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}} + ], + "compact_note": "说明删除/合并了哪些内容", + "summary": "一段简短文字,综合概括精简后的画像" +}} +```""" + +# Initial Extraction Prompt +PROFILE_INITIAL_EXTRACTION_PROMPT = """你是一个"用户画像分析师"。请阅读下面的对话,构建用户画像。 + +**目标用户:user_id={target_user}** +只为该用户构建画像。对话中可能有多个发言者——其他参与者以及 AI 助手。每行都带 `(user_id:...)` 标签;只把信息归属给 user_id 为 {target_user} 的用户。其他所有人(包括助手)都只是上下文,绝不是本画像的主体。 + +【第一部分:显式信息 (explicit_info)】 +用户的客观事实和当前状态,如身高体重、喜好、疾病等。 + +【第二部分:隐式特征 (implicit_traits)】 +基于行为推断的心理画像、性格标签和决策风格。 +*提取要求*:从决策、社交、生活观念等维度进行深度挖掘。 +*命名规范*:Trait 字段必须简练精准,推荐“[形容词] [名词]”格式,严禁过度堆砌形容词。 + +【提取原则】 +1. 只提取目标用户(user_id={target_user})的信息。绝不要把其他参与者或 AI 助手说的话归到目标用户头上——包括助手自身的名字、人设、角色或第一人称自述。助手描述的是它自己,永远不是用户。 +2. 隐式特征必须有多个证据支撑:同一条隐式特征的 evidence 必须来自多个信号;证据可来自【当前对话】与/或【已有画像 current_profile 的 evidence】(更新时可用),不能仅凭单条新对话臆断 +3. 每条信息用一句自然语言描述,通俗易懂 +4. summary:在列出 explicit_info 和 implicit_traits 之后,写一段简短文字,综合最具代表性的事实与特征,而非简单复制第一条 + +【输出格式】 +请直接输出 JSON,格式如下: +```json +{{ + "explicit_info": [ + {{ + "category": "分类名", + "description": "一句话描述", + "evidence": "一句话证据(来自对话内容)" + }} + ], + "implicit_traits": [ + {{ + "trait": "特征名称", + "description": "一句话描述这个特征", + "basis": "从哪些行为/对话推断出来的", + "evidence": "一句话证据(来自对话内容)" + }} + ], + "summary": "一段简短文字,综合上述信息提炼" +}} +``` + +【对话原文】 +{conversation_text}""" + + +# ============================================================================ +# TEAM-specific prompts (multi-user group conversation) +# ============================================================================ + +TEAM_PROFILE_UPDATE_PROMPT = """你是**群聊场景**的用户画像更新员。你的任务是从多人对话中,仅提取和更新**一个特定用户**的画像。 + +**目标用户:{target_user}** +你必须只提取关于 **{target_user}** 的信息。仔细区分每条信息的发言者,不要把其他参与者的信息混入 {target_user} 的画像。 + +【{target_user} 的当前画像】(每条都有 index 编号) +{current_profile} + +【群聊对话记录】(多个参与者 - 只提取 {target_user} 的信息) +{conversations} + +【任务】 +分析对话,仅输出关于 **{target_user}** 的操作列表。可选操作类型: +- **update**: 修改现有条目(通过 index 指定) +- **add**: 新增画像条目 +- **delete**: 删除现有条目 +- **none**: 无需任何操作(当对话不包含 {target_user} 的信息时使用) + +【操作选择指南】 +- **update**: 现有条目有信息更新、补充、修改 +- **add**: 发现 {target_user} 的全新信息(与现有条目无关) +- **delete**: 以下情况应该删除: + - {target_user} 明确否定(如"我不再吃素了") + - 信息已过时或与新信息直接矛盾 + +【重要规则】 +1. **发言者归属**:这是一个多人群聊。只提取 **{target_user}** 说的话或明确关于 {target_user} 的信息。如果其他参与者提到某个事实,那属于其他人的画像,不是 {target_user} 的。 +2. **挖掘标签**:隐式特征必须包含【性格标签】,例如:[风险厌恶型]、[社交驱动型]、[数据考据党]。 +3. evidence 要包含时间和发言者信息 - 如"2024年10月 {target_user} 提到..." +4. explicit_info 和 implicit_traits 的 index 是独立编号的 +5. **去重**:在使用 "add" 前,检查所有已有条目。如果类似信息已存在,用 "update" 补充。只有确实全新的信息才用 "add"。 + +【画像定义】 +- **explicit_info(显式信息)**:{target_user} 直接陈述的或关于其本人的事实(技能、背景、偏好、所在地等) +- **implicit_traits(隐式特征)**:从 {target_user} 在对话中的表述和行为推断出的性格特征和行为模式 + +【输出格式】 +无操作时: +```json +{{"operations": [{{"action": "none"}}], "update_note": "对话不包含 {target_user} 的信息"}} +``` + +有操作时: +```json +{{ + "operations": [ + {{"action": "add", "type": "explicit_info", "data": {{"category": "...", "description": "...", "evidence": "..."}}}}, + {{"action": "add", "type": "implicit_traits", "data": {{"trait": "...", "description": "...", "basis": "...", "evidence": "..."}}}}, + {{"action": "update", "type": "explicit_info", "index": 0, "data": {{"description": "..."}}}}, + {{"action": "delete", "type": "implicit_traits", "index": 1, "reason": "..."}} + ], + "update_note": "..." +}} +```""" diff --git a/packages/everalgo-user-memory/tests/user_memory/test_user_memory_profile.py b/packages/everalgo-user-memory/tests/user_memory/test_user_memory_profile.py index 82f51bd..9491754 100644 --- a/packages/everalgo-user-memory/tests/user_memory/test_user_memory_profile.py +++ b/packages/everalgo-user-memory/tests/user_memory/test_user_memory_profile.py @@ -1,498 +1,638 @@ -"""Tests for everalgo.user_memory.profile — ProfileExtractor. - -Uses a single LLM call against ``PROFILE_INITIAL_EXTRACTION_PROMPT`` returning -``{explicit_info, implicit_traits}``. No internal retry — exceptions propagate directly to the caller. - -UPDATE-mode tests cover: -- update no compact: ops applied, LLM called once, result has merged items. -- update triggers compact: post-merge item count > threshold, second LLM call runs. -- update preserves new_explicit_info merge correctness: add + update + delete ops each applied correctly. -""" - -from __future__ import annotations - -import json -from typing import Any, cast - -import pytest - -from everalgo.llm.types import ChatMessage as LLMChatMessage -from everalgo.llm.types import ChatResponse -from everalgo.testing.fake_llm import FakeLLMClient -from everalgo.types import ChatMessage, MemCell, Profile, ToolCall, ToolCallFunction, ToolCallRequest, ToolCallResult -from everalgo.user_memory.profile import ( - _PROFILE_COMPACT_THRESHOLD, - ProfileExtractor, - _build_summary, - _render_conversation, -) - -_TS = 1700000010000 -_TS_OLD_1 = 1690000000000 -_TS_OLD_2 = 1695000000000 - -_msg_id = 0 - - -def _msg( - text: str, - role: str = "user", - ts: int = _TS, - sender: str = "u_alice", - sender_name: str | None = "Alice", - msg_id: str | None = None, -) -> ChatMessage: - global _msg_id - _msg_id += 1 - return ChatMessage( - id=msg_id if msg_id is not None else f"m{_msg_id}", - role=role, # type: ignore[arg-type] - content=text, - timestamp=ts, - sender_id=sender, - sender_name=sender_name, - ) - - -def _memcell(ts: int = _TS, content: str = "Default content") -> MemCell: - return MemCell( - items=[_msg(content, ts=ts)], - timestamp=ts, - ) - - -def _cluster() -> list[MemCell]: - return [ - MemCell( - items=[_msg("User asked about Python async patterns.", ts=_TS_OLD_1)], - timestamp=_TS_OLD_1, - ), - MemCell( - items=[_msg("User mentioned they prefer ruff over black.", ts=_TS_OLD_2)], - timestamp=_TS_OLD_2, - ), - ] - - -def _payload(explicit_info: list[dict[str, Any]], implicit_traits: list[dict[str, Any]]) -> str: - return json.dumps({"explicit_info": explicit_info, "implicit_traits": implicit_traits}) - - -def _implicit_trait( - trait: str = "[Test]", description: str = "Test trait", basis: str = "test basis", evidence: str = "test evidence" -) -> dict[str, Any]: - """Helper to create a complete implicit_traits item with all required fields.""" - return {"trait": trait, "description": description, "basis": basis, "evidence": evidence} - - -async def test_aextract_builds_profile_from_explicit_info() -> None: - """``summary`` derives from first explicit_info description; lists preserved as extras.""" - payload = _payload( - explicit_info=[ - { - "category": "Technical Skills", - "description": "Alice is a Python developer focusing on async patterns.", - "evidence": "Alice asked about async patterns.", - }, - ], - implicit_traits=[ - _implicit_trait( - trait="[Pragmatic]", - description="Prefers tooling that minimises ceremony.", - basis="Repeated preference for ruff over black.", - evidence="Alice mentioned ruff preference.", - ), - ], - ) - fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) - - profile = await ProfileExtractor(llm=fake).aextract([*_cluster(), _memcell()], sender_id="u_alice") - - assert profile.owner_id == "u_alice" - assert "Python developer" in profile.summary - # Extras preserved via Profile.model_config extra="allow" - assert profile.explicit_info[0]["category"] == "Technical Skills" # type: ignore[attr-defined] - assert profile.implicit_traits[0]["trait"] == "[Pragmatic]" # type: ignore[attr-defined] - - -async def test_aextract_summary_falls_back_to_implicit_trait_when_explicit_empty() -> None: - payload = _payload( - explicit_info=[], - implicit_traits=[ - _implicit_trait(trait="[Pragmatic]", description="Prefers minimal-ceremony tooling."), - ], - ) - fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) - - profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") - - assert profile.summary == "Prefers minimal-ceremony tooling." - - -async def test_aextract_raises_on_payload_missing_required_keys() -> None: - """Payload without both explicit_info and implicit_traits → ValueError on first attempt (no internal retry).""" - bad_responses: list[str | ChatResponse] = [ChatResponse(content="{}", model="fake")] - fake = FakeLLMClient(responses=bad_responses) - - with pytest.raises(ValueError): - await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") - - assert fake.call_count == 1 - - -async def test_aextract_raises_on_bad_json() -> None: - """Unparseable JSON → ValueError on first attempt (no internal retry).""" - bad_responses: list[str | ChatResponse] = [ChatResponse(content="not json", model="fake")] - fake = FakeLLMClient(responses=bad_responses) - - with pytest.raises(ValueError): - await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") - - assert fake.call_count == 1 - - -async def test_aextract_owner_id_equals_sender_id() -> None: - """``Profile.owner_id`` must equal the ``sender_id`` argument.""" - payload = _payload( - explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], - implicit_traits=[], - ) - fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) - - profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_custom") - - assert profile.owner_id == "u_custom" - - -async def test_aextract_renders_cluster_into_conversation_text() -> None: - """Cluster ChatMemCells are stitched into the {conversation_text} placeholder.""" - captured: dict[str, str] = {} - payload = _payload( - explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], - implicit_traits=[], - ) - - def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: - assert isinstance(messages[0].content, str) # narrow for test - captured["prompt"] = messages[0].content - return ChatResponse(content=payload, model="fake") - - fake = FakeLLMClient(handler=handler) - - await ProfileExtractor(llm=fake).aextract([*_cluster(), _memcell()], sender_id="u_alice") - - assert "Python async patterns" in captured["prompt"] - - -async def test_aextract_per_call_prompt_overrides_default() -> None: - captured: dict[str, str] = {} - payload = _payload( - explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], - implicit_traits=[], - ) - - def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: - assert isinstance(messages[0].content, str) # narrow for test - captured["prompt"] = messages[0].content - return ChatResponse(content=payload, model="fake") - - fake = FakeLLMClient(handler=handler) - custom = "CUSTOM PROFILE conv={conversation_text}" - - await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice", prompt=custom) - - assert captured["prompt"].startswith("CUSTOM PROFILE conv=") - assert "Default content" in captured["prompt"] - - -# ========================================================================== -# _render_conversation helper -# ========================================================================== - - -def test_render_conversation_skips_empty_content() -> None: - """Messages with empty content are silently dropped.""" - cell = MemCell( - items=[ - ChatMessage( - id="m1", role="user", content="hello", timestamp=1700000000000, sender_id="u_alice", sender_name="Alice" - ), - ChatMessage( - id="m2", role="user", content="", timestamp=1700000001000, sender_id="u_bob", sender_name="Bob" - ), - ], - timestamp=1700000001000, - ) - rendered = _render_conversation([cell]) - assert "Alice" in rendered - assert "Bob" not in rendered - - -def test_render_conversation_uses_sentinel_when_all_inputs_empty() -> None: - """Empty cluster + empty messages → sentinel line.""" - empty_cell = MemCell( - items=[], - timestamp=1700000000000, - ) - rendered = _render_conversation([empty_cell]) - assert rendered == "(no prior MemCells in the cluster)" - - -# ========================================================================== -# _build_summary defensive branches -# ========================================================================== - - -def test_build_summary_skips_non_dict_items_in_explicit_info() -> None: - """Non-dict entries inside explicit_info are skipped.""" - summary = _build_summary( - explicit_info=["not a dict", 42, {"description": "real one"}], - implicit_traits=[], - ) - assert summary == "real one" - - -def test_build_summary_skips_non_dict_items_in_implicit_traits() -> None: - """Non-dict entries inside implicit_traits are skipped.""" - summary = _build_summary( - explicit_info=[], - implicit_traits=["not a dict", {"description": "trait desc"}], - ) - assert summary == "trait desc" - - -def test_build_summary_returns_sentinel_when_no_usable_description() -> None: - """Empty / whitespace-only / non-string descriptions → sentinel.""" - summary = _build_summary( - explicit_info=[{"description": ""}, {"description": " "}, {"description": 42}], - implicit_traits=[{"description": None}], - ) - assert summary == "(no summary)" - - -# ========================================================================== -# Empty-memcells guard -# ========================================================================== - - -async def test_aextract_raises_on_empty_memcells() -> None: - """Passing an empty sequence raises ValueError before any LLM call.""" - fake = FakeLLMClient(handler=lambda *_a, **_kw: ChatResponse(content="{}", model="fake")) - with pytest.raises(ValueError, match="at least one"): - await ProfileExtractor(llm=fake).aextract([], sender_id="u_alice") - - -# ========================================================================== -# Silent-skip contract — agent → user-memory pipeline -# ========================================================================== - - -async def test_aextract_silently_skips_non_chat_items() -> None: - """ProfileExtractor must silently skip ToolCallRequest / ToolCallResult items. - - Locks the agent → user-memory pipeline contract: a sequence of MemCells with mixed items - must render only the ChatMessage text into the profile prompt. - """ - payload = json.dumps( - { - "explicit_info": [{"category": "Skills", "description": "Alice writes Python.", "evidence": "x"}], - "implicit_traits": [], - } - ) - captured: dict[str, str] = {} - - def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: - assert isinstance(messages[0].content, str) # narrow for test - captured["prompt"] = messages[0].content - return ChatResponse(content=payload, model="fake") - - fake = FakeLLMClient(handler=handler) - - mixed_cell = MemCell( - items=[ - ChatMessage( - id="c1", - role="user", - content="I write Python for data pipelines.", - timestamp=1700000000000, - sender_id="u_alice", - sender_name="Alice", - ), - ToolCallRequest( - tool_calls=[ - ToolCall(id="tc1", function=ToolCallFunction(name="search.docs", arguments='{"q": "python"}')) - ], - timestamp=1700000001000, - sender_id="assistant", - ), - ToolCallResult( - tool_call_id="tc1", - content="Python docs returned.", - timestamp=1700000002000, - ), - ], - timestamp=1700000002000, - ) - - profile = await ProfileExtractor(llm=fake).aextract([mixed_cell], sender_id="u_alice") - - assert profile.owner_id == "u_alice" - # ChatMessage content must be present in the rendered prompt - assert "I write Python for data pipelines" in captured["prompt"] - # Tool call content must NOT appear — ToolCallRequest / ToolCallResult were silently skipped - assert "search.docs" not in captured["prompt"] - assert "Python docs returned" not in captured["prompt"] - - -# ========================================================================== -# UPDATE mode -# ========================================================================== - - -def _old_profile( - explicit_info: list[dict[str, Any]] | None = None, - implicit_traits: list[dict[str, Any]] | None = None, -) -> Profile: - ei: list[dict[str, Any]] = explicit_info or [ - {"category": "Skills", "description": "Alice writes Python.", "evidence": "x"} - ] - it: list[dict[str, Any]] = implicit_traits or [ - _implicit_trait(trait="[Pragmatic]", description="Minimal ceremony.", basis="test basis", evidence="y") - ] - return Profile.model_validate( - { - "owner_id": "u_alice", - "summary": "Alice writes Python.", - "timestamp": _TS_OLD_1, - "explicit_info": ei, - "implicit_traits": it, - } - ) - - -async def test_update_no_compact_applies_ops_and_returns_merged_profile() -> None: - """UPDATE mode: ops applied, single LLM call, item count stays below compact threshold.""" - ops_json = json.dumps( - { - "operations": [ - { - "action": "add", - "type": "explicit_info", - "data": {"category": "Location", "description": "Lives in Berlin.", "evidence": "z"}, - }, - ], - "update_note": "added 1", - } - ) - fake = FakeLLMClient(responses=[ChatResponse(content=ops_json, model="fake")]) - - profile = await ProfileExtractor(llm=fake).aextract( - [_memcell()], - sender_id="u_alice", - old_profile=_old_profile(), - ) - - assert fake.call_count == 1 - assert profile.owner_id == "u_alice" - ei = cast("list[dict[str, Any]]", profile.explicit_info) # type: ignore[attr-defined] - assert len(ei) == 2 - assert ei[1]["category"] == "Location" - assert ei[1]["description"] == "Lives in Berlin." - - -async def test_update_triggers_compact_when_item_count_exceeds_threshold() -> None: - """UPDATE mode: post-merge item count > _PROFILE_COMPACT_THRESHOLD triggers second LLM compact call.""" - # Build an old profile with enough items to push total over the threshold after one add. - n = _PROFILE_COMPACT_THRESHOLD # one add brings total to threshold + 1 - ei_items = [{"category": f"Cat{i}", "description": f"Desc{i}.", "evidence": "x"} for i in range(n)] - old_p = _old_profile(explicit_info=ei_items, implicit_traits=[]) - - ops_json = json.dumps( - { - "operations": [ - { - "action": "add", - "type": "explicit_info", - "data": {"category": "New", "description": "One more.", "evidence": "z"}, - } - ], - "update_note": "added 1", - } - ) - compact_json = json.dumps( - { - "explicit_info": [{"category": "Merged", "description": "Compacted profile.", "evidence": "all"}], - "implicit_traits": [], - "compact_note": "merged many into one", - } - ) - fake = FakeLLMClient( - responses=[ - ChatResponse(content=ops_json, model="fake"), - ChatResponse(content=compact_json, model="fake"), - ] - ) - - profile = await ProfileExtractor(llm=fake).aextract( - [_memcell()], - sender_id="u_alice", - old_profile=old_p, - ) - - assert fake.call_count == 2 # update call + compact call - ei = cast("list[dict[str, Any]]", profile.explicit_info) # type: ignore[attr-defined] - assert len(ei) == 1 - assert ei[0]["category"] == "Merged" - - -async def test_update_merge_correctness_add_update_delete() -> None: - """UPDATE mode: add / update / delete ops each produce the correct merged result.""" - old_p = _old_profile( - explicit_info=[ - {"category": "Skills", "description": "Writes Python.", "evidence": "x"}, - {"category": "Location", "description": "In Berlin.", "evidence": "y"}, - ], - implicit_traits=[ - _implicit_trait(trait="[Pragmatic]", description="Minimal ceremony.", basis="test", evidence="z"), - ], - ) - ops_json = json.dumps( - { - "operations": [ - { - "action": "add", - "type": "explicit_info", - "data": {"category": "Hobby", "description": "Plays chess.", "evidence": "new"}, - }, - { - "action": "update", - "type": "explicit_info", - "index": 0, - "data": {"description": "Writes Python and Go."}, - }, - {"action": "delete", "type": "implicit_traits", "index": 0}, - ], - "update_note": "add, update, delete", - } - ) - fake = FakeLLMClient(responses=[ChatResponse(content=ops_json, model="fake")]) - - profile = await ProfileExtractor(llm=fake).aextract( - [_memcell()], - sender_id="u_alice", - old_profile=old_p, - ) - - ei = cast("list[dict[str, Any]]", profile.explicit_info) # type: ignore[attr-defined] - it = cast("list[dict[str, Any]]", profile.implicit_traits) # type: ignore[attr-defined] - # add: 3rd explicit_info item added - assert len(ei) == 3 - assert ei[2]["category"] == "Hobby" - # update: first item's description overwritten - assert ei[0]["description"] == "Writes Python and Go." - # existing field preserved by update merge - assert ei[0]["category"] == "Skills" - # Location (index 1) untouched - assert ei[1]["category"] == "Location" - # delete: implicit_traits[0] removed - assert len(it) == 0 +"""Tests for everalgo.user_memory.profile — ProfileExtractor. + +Uses a single LLM call against ``PROFILE_INITIAL_EXTRACTION_PROMPT`` returning +``{explicit_info, implicit_traits}``. No internal retry — exceptions propagate directly to the caller. + +UPDATE-mode tests cover: +- update no compact: ops applied, LLM called once, result has merged items. +- update triggers compact: post-merge item count > threshold, second LLM call runs. +- update preserves new_explicit_info merge correctness: add + update + delete ops each applied correctly. +""" + +from __future__ import annotations + +import json +from typing import Any, cast + +import pytest + +from everalgo.llm.types import ChatMessage as LLMChatMessage +from everalgo.llm.types import ChatResponse +from everalgo.testing.fake_llm import FakeLLMClient +from everalgo.types import ChatMessage, MemCell, Profile, ToolCall, ToolCallFunction, ToolCallRequest, ToolCallResult +from everalgo.user_memory.profile import ( + _PROFILE_COMPACT_THRESHOLD, + ProfileExtractor, + _build_summary, + _render_conversation, +) + +_TS = 1700000010000 +_TS_OLD_1 = 1690000000000 +_TS_OLD_2 = 1695000000000 + +_msg_id = 0 + + +def _msg( + text: str, + role: str = "user", + ts: int = _TS, + sender: str = "u_alice", + sender_name: str | None = "Alice", + msg_id: str | None = None, +) -> ChatMessage: + global _msg_id + _msg_id += 1 + return ChatMessage( + id=msg_id if msg_id is not None else f"m{_msg_id}", + role=role, # type: ignore[arg-type] + content=text, + timestamp=ts, + sender_id=sender, + sender_name=sender_name, + ) + + +def _memcell(ts: int = _TS, content: str = "Default content") -> MemCell: + return MemCell( + items=[_msg(content, ts=ts)], + timestamp=ts, + ) + + +def _cluster() -> list[MemCell]: + return [ + MemCell( + items=[_msg("User asked about Python async patterns.", ts=_TS_OLD_1)], + timestamp=_TS_OLD_1, + ), + MemCell( + items=[_msg("User mentioned they prefer ruff over black.", ts=_TS_OLD_2)], + timestamp=_TS_OLD_2, + ), + ] + + +def _payload(explicit_info: list[dict[str, Any]], implicit_traits: list[dict[str, Any]]) -> str: + return json.dumps({"explicit_info": explicit_info, "implicit_traits": implicit_traits}) + + +def _implicit_trait( + trait: str = "[Test]", description: str = "Test trait", basis: str = "test basis", evidence: str = "test evidence" +) -> dict[str, Any]: + """Helper to create a complete implicit_traits item with all required fields.""" + return {"trait": trait, "description": description, "basis": basis, "evidence": evidence} + + +async def test_aextract_builds_profile_from_explicit_info() -> None: + """``summary`` derives from first explicit_info description; lists preserved as extras.""" + payload = _payload( + explicit_info=[ + { + "category": "Technical Skills", + "description": "Alice is a Python developer focusing on async patterns.", + "evidence": "Alice asked about async patterns.", + }, + ], + implicit_traits=[ + _implicit_trait( + trait="[Pragmatic]", + description="Prefers tooling that minimises ceremony.", + basis="Repeated preference for ruff over black.", + evidence="Alice mentioned ruff preference.", + ), + ], + ) + fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([*_cluster(), _memcell()], sender_id="u_alice") + + assert profile.owner_id == "u_alice" + assert "Python developer" in profile.summary + # Extras preserved via Profile.model_config extra="allow" + assert profile.explicit_info[0]["category"] == "Technical Skills" # type: ignore[attr-defined] + assert profile.implicit_traits[0]["trait"] == "[Pragmatic]" # type: ignore[attr-defined] + + +async def test_aextract_summary_falls_back_to_implicit_trait_when_explicit_empty() -> None: + payload = _payload( + explicit_info=[], + implicit_traits=[ + _implicit_trait(trait="[Pragmatic]", description="Prefers minimal-ceremony tooling."), + ], + ) + fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") + + assert profile.summary == "Prefers minimal-ceremony tooling." + + +async def test_aextract_raises_on_payload_missing_required_keys() -> None: + """Payload without both explicit_info and implicit_traits → ValueError on first attempt (no internal retry).""" + bad_responses: list[str | ChatResponse] = [ChatResponse(content="{}", model="fake")] + fake = FakeLLMClient(responses=bad_responses) + + with pytest.raises(ValueError): + await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") + + assert fake.call_count == 1 + + +async def test_aextract_raises_on_bad_json() -> None: + """Unparseable JSON → ValueError on first attempt (no internal retry).""" + bad_responses: list[str | ChatResponse] = [ChatResponse(content="not json", model="fake")] + fake = FakeLLMClient(responses=bad_responses) + + with pytest.raises(ValueError): + await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") + + assert fake.call_count == 1 + + +async def test_aextract_owner_id_equals_sender_id() -> None: + """``Profile.owner_id`` must equal the ``sender_id`` argument.""" + payload = _payload( + explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], + implicit_traits=[], + ) + fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_custom") + + assert profile.owner_id == "u_custom" + + +async def test_aextract_renders_cluster_into_conversation_text() -> None: + """Cluster ChatMemCells are stitched into the {conversation_text} placeholder.""" + captured: dict[str, str] = {} + payload = _payload( + explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], + implicit_traits=[], + ) + + def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: + assert isinstance(messages[0].content, str) # narrow for test + captured["prompt"] = messages[0].content + return ChatResponse(content=payload, model="fake") + + fake = FakeLLMClient(handler=handler) + + await ProfileExtractor(llm=fake).aextract([*_cluster(), _memcell()], sender_id="u_alice") + + assert "Python async patterns" in captured["prompt"] + + +async def test_aextract_per_call_prompt_overrides_default() -> None: + captured: dict[str, str] = {} + payload = _payload( + explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], + implicit_traits=[], + ) + + def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: + assert isinstance(messages[0].content, str) # narrow for test + captured["prompt"] = messages[0].content + return ChatResponse(content=payload, model="fake") + + fake = FakeLLMClient(handler=handler) + custom = "CUSTOM PROFILE conv={conversation_text}" + + await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice", prompt=custom) + + assert captured["prompt"].startswith("CUSTOM PROFILE conv=") + assert "Default content" in captured["prompt"] + + +async def test_aextract_threads_target_user_into_init_prompt() -> None: + """INIT renders the target ``sender_id`` into the prompt so the LLM attributes facts to one speaker.""" + captured: dict[str, str] = {} + payload = _payload( + explicit_info=[{"category": "x", "description": "y", "evidence": "z"}], + implicit_traits=[], + ) + + def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: + assert isinstance(messages[0].content, str) # narrow for test + captured["prompt"] = messages[0].content + return ChatResponse(content=payload, model="fake") + + fake = FakeLLMClient(handler=handler) + + await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") + + assert "TARGET USER: user_id=u_alice" in captured["prompt"] + + +async def test_aextract_threads_target_user_into_update_prompt() -> None: + """UPDATE renders the target ``sender_id`` into the prompt (same attribution discipline as INIT).""" + captured: dict[str, str] = {} + + def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: + assert isinstance(messages[0].content, str) # narrow for test + captured["prompt"] = messages[0].content + return ChatResponse(content=json.dumps({"operations": [{"action": "none"}]}), model="fake") + + fake = FakeLLMClient(handler=handler) + + await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice", old_profile=_old_profile()) + + assert "TARGET USER: user_id=u_alice" in captured["prompt"] + + +# ========================================================================== +# _render_conversation helper +# ========================================================================== + + +def test_render_conversation_skips_empty_content() -> None: + """Messages with empty content are silently dropped.""" + cell = MemCell( + items=[ + ChatMessage( + id="m1", role="user", content="hello", timestamp=1700000000000, sender_id="u_alice", sender_name="Alice" + ), + ChatMessage( + id="m2", role="user", content="", timestamp=1700000001000, sender_id="u_bob", sender_name="Bob" + ), + ], + timestamp=1700000001000, + ) + rendered = _render_conversation([cell]) + assert "Alice" in rendered + assert "Bob" not in rendered + + +def test_render_conversation_uses_sentinel_when_all_inputs_empty() -> None: + """Empty cluster + empty messages → sentinel line.""" + empty_cell = MemCell( + items=[], + timestamp=1700000000000, + ) + rendered = _render_conversation([empty_cell]) + assert rendered == "(no prior MemCells in the cluster)" + + +# ========================================================================== + +# ========================================================================== +# Empty-memcells guard +# ========================================================================== + + +async def test_aextract_raises_on_empty_memcells() -> None: + """Passing an empty sequence raises ValueError before any LLM call.""" + fake = FakeLLMClient(handler=lambda *_a, **_kw: ChatResponse(content="{}", model="fake")) + with pytest.raises(ValueError, match="at least one"): + await ProfileExtractor(llm=fake).aextract([], sender_id="u_alice") + + +# ========================================================================== +# Silent-skip contract — agent → user-memory pipeline +# ========================================================================== + + +async def test_aextract_silently_skips_non_chat_items() -> None: + """ProfileExtractor must silently skip ToolCallRequest / ToolCallResult items. + + Locks the agent → user-memory pipeline contract: a sequence of MemCells with mixed items + must render only the ChatMessage text into the profile prompt. + """ + payload = json.dumps( + { + "explicit_info": [{"category": "Skills", "description": "Alice writes Python.", "evidence": "x"}], + "implicit_traits": [], + } + ) + captured: dict[str, str] = {} + + def handler(messages: list[LLMChatMessage], **kwargs: Any) -> ChatResponse: + assert isinstance(messages[0].content, str) # narrow for test + captured["prompt"] = messages[0].content + return ChatResponse(content=payload, model="fake") + + fake = FakeLLMClient(handler=handler) + + mixed_cell = MemCell( + items=[ + ChatMessage( + id="c1", + role="user", + content="I write Python for data pipelines.", + timestamp=1700000000000, + sender_id="u_alice", + sender_name="Alice", + ), + ToolCallRequest( + tool_calls=[ + ToolCall(id="tc1", function=ToolCallFunction(name="search.docs", arguments='{"q": "python"}')) + ], + timestamp=1700000001000, + sender_id="assistant", + ), + ToolCallResult( + tool_call_id="tc1", + content="Python docs returned.", + timestamp=1700000002000, + ), + ], + timestamp=1700000002000, + ) + + profile = await ProfileExtractor(llm=fake).aextract([mixed_cell], sender_id="u_alice") + + assert profile.owner_id == "u_alice" + # ChatMessage content must be present in the rendered prompt + assert "I write Python for data pipelines" in captured["prompt"] + # Tool call content must NOT appear — ToolCallRequest / ToolCallResult were silently skipped + assert "search.docs" not in captured["prompt"] + assert "Python docs returned" not in captured["prompt"] + + +# ========================================================================== +# UPDATE mode +# ========================================================================== + + +def _old_profile( + explicit_info: list[dict[str, Any]] | None = None, + implicit_traits: list[dict[str, Any]] | None = None, +) -> Profile: + ei: list[dict[str, Any]] = explicit_info or [ + {"category": "Skills", "description": "Alice writes Python.", "evidence": "x"} + ] + it: list[dict[str, Any]] = implicit_traits or [ + _implicit_trait(trait="[Pragmatic]", description="Minimal ceremony.", basis="test basis", evidence="y") + ] + return Profile.model_validate( + { + "owner_id": "u_alice", + "summary": "Alice writes Python.", + "timestamp": _TS_OLD_1, + "explicit_info": ei, + "implicit_traits": it, + } + ) + + +async def test_update_no_compact_applies_ops_and_returns_merged_profile() -> None: + """UPDATE mode: ops applied, single LLM call, item count stays below compact threshold.""" + ops_json = json.dumps( + { + "operations": [ + { + "action": "add", + "type": "explicit_info", + "data": {"category": "Location", "description": "Lives in Berlin.", "evidence": "z"}, + }, + ], + "update_note": "added 1", + } + ) + fake = FakeLLMClient(responses=[ChatResponse(content=ops_json, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract( + [_memcell()], + sender_id="u_alice", + old_profile=_old_profile(), + ) + + assert fake.call_count == 1 + assert profile.owner_id == "u_alice" + ei = cast("list[dict[str, Any]]", profile.explicit_info) # type: ignore[attr-defined] + assert len(ei) == 2 + assert ei[1]["category"] == "Location" + assert ei[1]["description"] == "Lives in Berlin." + + +async def test_update_triggers_compact_when_item_count_exceeds_threshold() -> None: + """UPDATE mode: post-merge item count > _PROFILE_COMPACT_THRESHOLD triggers second LLM compact call.""" + # Build an old profile with enough items to push total over the threshold after one add. + n = _PROFILE_COMPACT_THRESHOLD # one add brings total to threshold + 1 + ei_items = [{"category": f"Cat{i}", "description": f"Desc{i}.", "evidence": "x"} for i in range(n)] + old_p = _old_profile(explicit_info=ei_items, implicit_traits=[]) + + ops_json = json.dumps( + { + "operations": [ + { + "action": "add", + "type": "explicit_info", + "data": {"category": "New", "description": "One more.", "evidence": "z"}, + } + ], + "update_note": "added 1", + } + ) + compact_json = json.dumps( + { + "explicit_info": [{"category": "Merged", "description": "Compacted profile.", "evidence": "all"}], + "implicit_traits": [], + "compact_note": "merged many into one", + } + ) + fake = FakeLLMClient( + responses=[ + ChatResponse(content=ops_json, model="fake"), + ChatResponse(content=compact_json, model="fake"), + ] + ) + + profile = await ProfileExtractor(llm=fake).aextract( + [_memcell()], + sender_id="u_alice", + old_profile=old_p, + ) + + assert fake.call_count == 2 # update call + compact call + ei = cast("list[dict[str, Any]]", profile.explicit_info) # type: ignore[attr-defined] + assert len(ei) == 1 + assert ei[0]["category"] == "Merged" + + +async def test_update_merge_correctness_add_update_delete() -> None: + """UPDATE mode: add / update / delete ops each produce the correct merged result.""" + old_p = _old_profile( + explicit_info=[ + {"category": "Skills", "description": "Writes Python.", "evidence": "x"}, + {"category": "Location", "description": "In Berlin.", "evidence": "y"}, + ], + implicit_traits=[ + _implicit_trait(trait="[Pragmatic]", description="Minimal ceremony.", basis="test", evidence="z"), + ], + ) + ops_json = json.dumps( + { + "operations": [ + { + "action": "add", + "type": "explicit_info", + "data": {"category": "Hobby", "description": "Plays chess.", "evidence": "new"}, + }, + { + "action": "update", + "type": "explicit_info", + "index": 0, + "data": {"description": "Writes Python and Go."}, + }, + {"action": "delete", "type": "implicit_traits", "index": 0}, + ], + "update_note": "add, update, delete", + } + ) + fake = FakeLLMClient(responses=[ChatResponse(content=ops_json, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract( + [_memcell()], + sender_id="u_alice", + old_profile=old_p, + ) + + ei = cast("list[dict[str, Any]]", profile.explicit_info) # type: ignore[attr-defined] + it = cast("list[dict[str, Any]]", profile.implicit_traits) # type: ignore[attr-defined] + # add: 3rd explicit_info item added + assert len(ei) == 3 + assert ei[2]["category"] == "Hobby" + # update: first item's description overwritten + assert ei[0]["description"] == "Writes Python and Go." + # existing field preserved by update merge + assert ei[0]["category"] == "Skills" + # Location (index 1) untouched + assert ei[1]["category"] == "Location" + # delete: implicit_traits[0] removed + assert len(it) == 0 + +# ========================================================================== +# _build_summary — always returns ""; downstream synthesis handles summaries +# ========================================================================== + + +def test_build_summary_returns_empty_string_regardless_of_input() -> None: + """_build_summary always returns '' — it no longer copies the first description.""" + assert _build_summary([{"description": "real one"}], []) == "" + assert _build_summary([], [{"description": "trait desc"}]) == "" + assert _build_summary([{"description": ""}], [{"description": None}]) == "" + assert _build_summary(["not a dict", 42], []) == "" + assert _build_summary([], []) == "" + + +# ========================================================================== +# Holistic summary — LLM-provided summary preferred, _build_summary fallback +# ========================================================================== + + +async def test_init_prefers_llm_summary_when_present() -> None: + """INIT mode: a top-level ``summary`` from the model becomes ``Profile.summary`` verbatim.""" + payload = json.dumps( + { + "explicit_info": [{"category": "Skills", "description": "Alice writes Python.", "evidence": "x"}], + "implicit_traits": [], + "summary": "A pragmatic backend engineer who values minimal-ceremony tooling.", + } + ) + fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") + + assert profile.summary == "A pragmatic backend engineer who values minimal-ceremony tooling." + + +async def test_aextract_falls_back_to_empty_when_llm_summary_blank() -> None: + """INIT mode: a blank / whitespace-only ``summary`` is ignored; summary falls back to ``""`` (downstream synthesis handles it).""" + payload = json.dumps( + { + "explicit_info": [{"category": "Skills", "description": "Alice writes Python.", "evidence": "x"}], + "implicit_traits": [], + "summary": " ", + } + ) + fake = FakeLLMClient(responses=[ChatResponse(content=payload, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice") + + assert profile.summary == "" + + +async def test_update_prefers_llm_summary_when_present() -> None: + """UPDATE mode: a top-level ``summary`` in the ops payload becomes ``Profile.summary``.""" + ops_json = json.dumps( + { + "operations": [ + { + "action": "add", + "type": "explicit_info", + "data": {"category": "Location", "description": "Lives in Berlin.", "evidence": "z"}, + }, + ], + "update_note": "added 1", + "summary": "A Berlin-based Python developer.", + } + ) + fake = FakeLLMClient(responses=[ChatResponse(content=ops_json, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice", old_profile=_old_profile()) + + assert profile.summary == "A Berlin-based Python developer." + + +async def test_update_falls_back_to_empty_when_summary_absent() -> None: + """UPDATE mode: with no ``summary`` in the payload, summary is ``""``.""" + ops_json = json.dumps({"operations": [{"action": "none"}], "update_note": "no change"}) + fake = FakeLLMClient(responses=[ChatResponse(content=ops_json, model="fake")]) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice", old_profile=_old_profile()) + + assert profile.summary == "" + + +async def test_compact_prefers_llm_summary_when_present() -> None: + """UPDATE → compact: the compact payload's ``summary`` becomes ``Profile.summary``.""" + n = _PROFILE_COMPACT_THRESHOLD + ei_items = [{"category": f"Cat{i}", "description": f"Desc{i}.", "evidence": "x"} for i in range(n)] + old_p = _old_profile(explicit_info=ei_items, implicit_traits=[]) + + ops_json = json.dumps( + { + "operations": [ + { + "action": "add", + "type": "explicit_info", + "data": {"category": "New", "description": "One more.", "evidence": "z"}, + } + ], + "update_note": "added 1", + } + ) + compact_json = json.dumps( + { + "explicit_info": [{"category": "Merged", "description": "Compacted profile.", "evidence": "all"}], + "implicit_traits": [], + "compact_note": "merged many into one", + "summary": "A long-tenured user with a broad, now-condensed profile.", + } + ) + fake = FakeLLMClient( + responses=[ + ChatResponse(content=ops_json, model="fake"), + ChatResponse(content=compact_json, model="fake"), + ] + ) + + profile = await ProfileExtractor(llm=fake).aextract([_memcell()], sender_id="u_alice", old_profile=old_p) + + assert fake.call_count == 2 + assert profile.summary == "A long-tenured user with a broad, now-condensed profile." + + +def test_resolve_summary_prefers_non_blank_string() -> None: + """A non-blank string candidate is returned stripped, ignoring the fallback inputs.""" + assert _resolve_summary(" A concise portrait. ", [{"description": "first"}], []) == "A concise portrait." + + +def test_resolve_summary_falls_back_on_blank_or_non_string() -> None: + """Blank, None, or non-string candidates fall back to ``""``.""" + assert _resolve_summary(" ", [{"description": "first"}], []) == "" + assert _resolve_summary(None, [{"description": "first"}], []) == "" + assert _resolve_summary(123, [], [{"description": "trait desc"}]) == "" + assert _resolve_summary(None, [], []) == ""