Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/everalgo-user-memory/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ follows [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Target-aware profile extraction (multi-speaker attribution).** `ProfileExtractor` now threads the `sender_id` it is already given into both the INIT and UPDATE prompts as `target_user`. The prompts instruct the model to attribute each fact to the speaker who stated it (every conversation line is tagged `(user_id:...)`) and to treat other participants — and the AI assistant — as context, never as the profile's subject. This prevents cross-speaker contamination in group conversations (e.g. another member's facts, or the assistant's own persona, leaking into a user's profile). Applied symmetrically to `prompts/en/profile.py` and `prompts/zh/profile.py`.

### Changed

- **Anti-bloat "durable abstraction" rule for profile descriptions.** Both INIT and UPDATE prompts (en + zh) now carry a HARD RULE requiring each `description` to be a timeless generalization: no date/weekday/clock-time in `description` (coarse time belongs in `evidence`), and repeated instances of a pattern must be folded into one existing item via `update` rather than appended as new dated clauses. Includes WRONG/RIGHT examples and a pre-output self-check. Reduces the accumulation of dated, event-log-style profile entries over many updates.
- **`_render_conversation` coarsens per-message timestamps to date granularity** (`YYYY-MM-DD`). Profile extraction needs no intraday precision; dropping the clock component removes second-level noise, avoids UTC/local intraday misreads, and reinforces the anti-bloat "no clock time" rule by keeping only a coarse date in the rendered transcript.

## [0.3.1] - 2026-06-24

### Fixed
Expand Down
14 changes: 12 additions & 2 deletions packages/everalgo-user-memory/src/everalgo/user_memory/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ async def _init_extract(
prompt: str | None,
) -> Profile:
conversation_text = _render_conversation(memcells)
rendered = render_prompt(PROFILE_INITIAL_EXTRACTION_PROMPT, prompt, conversation_text=conversation_text)
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"]
Expand Down Expand Up @@ -127,6 +132,7 @@ async def _update_extract(
prompt,
current_profile=current_profile_text,
conversations=conversation_text,
target_user=sender_id,
)

data = await _call_llm_for_profile_update(self._llm, rendered)
Expand Down Expand Up @@ -247,7 +253,11 @@ def _render_conversation(memcells: Sequence[MemCell]) -> str:
continue
speaker = m.sender_name or m.sender_id
user_id = m.sender_id or ""
time_str = format_message_timestamp(m.timestamp)
# Coarsen to date granularity: profile extraction needs no intraday precision,
# and dropping the clock component removes second-level noise while avoiding
# UTC/local intraday misreads. Reinforces the anti-bloat "no clock time in
# description" rule below by keeping only a coarse date in the transcript.
time_str = format_message_timestamp(m.timestamp)[:10]
lines.append(f"[{time_str}] {speaker}(user_id:{user_id}): {text}")
if not lines:
lines.append("(no prior MemCells in the cluster)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@

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}

Expand All @@ -39,10 +42,23 @@

【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
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.

【Profile Definitions & Analysis Framework】
- **explicit_info (Explicit Information)**: User facts that can be directly extracted from conversations.
Expand Down Expand Up @@ -115,6 +131,9 @@

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.

Expand All @@ -123,9 +142,14 @@
*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
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. Describe each piece of information in one natural sentence, easy to understand
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.

【Output Format】
Output JSON directly in the following format:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# Incremental Update Prompt
PROFILE_UPDATE_PROMPT = """你是用户画像更新员。根据对话记录,判断需要对用户画像做哪些操作。

**目标用户:user_id={target_user}**
只处理 id 为 {target_user} 的用户的信息。每行对话都带有 `(user_id:...)` 标记;请把每条事实归属到实际陈述它的发言者。其他参与者以及 AI 助手都只是上下文,绝不是目标用户。

【当前用户画像】(每条都有 index 编号)
{current_profile}

Expand All @@ -32,10 +35,23 @@

【重要规则】
1. **挖掘标签**:隐式特征必须包含【性格标签】,例如:[风险厌恶型]、[社交驱动型]、[数据考据党]。
2. 只提取用户信息,不要把 AI 助手的建议当成用户特征
2. **发言者归属**:只提取目标用户(user_id={target_user})的信息。如果另一位参与者——或 AI 助手——陈述了某个事实,那属于**他们**,不属于目标用户;绝不要把助手自身的身份或人设当成用户特征。
3. evidence 要包含时间信息 - 如"2024年10月用户提到..."
4. explicit_info 和 implicit_traits 的 index 是独立编号的
5. **去重**:在使用 "add" 前,仔细检查所有已有条目。如果类似的特征/信息已存在(即使措辞不同),请用 "update" 来补充而非重复添加。只有确实全新的信息才用 "add"。
6. **恒久抽象——硬性规则(防膨胀)**:`description` 必须是**不带时间的**概括,其中**绝不能**出现日期、星期或具体时刻(确有必要的粗粒度时间只放进 `evidence`,绝不放进 `description`)。
如果新对话只是某条已有条目所涵盖模式的又一次实例(又一顿饭、又一次听歌、又一笔消费、又一次情绪波动等),你**必须**:
- 对该已有条目用 action="update"(绝不为它用 action="add"),并且
- 把它的 `description` **重写**成一句不带时间的概括,将新实例融入既有模式——**不要**把新实例当作单独的带日期从句追加上去。
示例:
错误(追加带日期的实例):"偏好午饭后小睡。2026-07-01 午饭后又睡了一觉,醒来说很精神。"
正确(重新概括):"习惯在午饭后小睡,通常醒来后感觉精神;将其视为日常作息的一部分。"
如果某条已有 `description` 本身已是罗列式/带日期的流水账,请在本次 update 中一并把它重写为概括。
如果某条已有 `description` **已经**混合了多个子话题且/或已经带有日期(如下例),你**必须**把整条 `description` 完全重写为干净、无日期、融合后的表述——不要只把新实例补在已带日期的描述末尾。
修复已膨胀条目的示例:
错误(已有条目已带 2 个日期,又追加第 3 个):"倾向熬夜,最近在改善。2026-06-29 熬夜去表白。有时下午小睡。2026-07-01 又睡了一觉,醒来没做梦。"
正确(把已有条目重写为无日期、融入新信息):"倾向熬夜,但在温和的监督下持续改善,偶尔遇到具体的事会商量着破例;下午有时也会小睡,醒来后通常反馈一切正常。"
在最终输出前:逐条重读你即将输出的每个 `description`,检查其中是否含有日期/星期/时刻。若混入了,现在就重写该条——不要带着日期提交。

【画像定义与分析框架】
- **explicit_info(显式信息)**:可以直接从对话中提取的用户事实。
Expand Down Expand Up @@ -97,6 +113,9 @@
# Initial Extraction Prompt
PROFILE_INITIAL_EXTRACTION_PROMPT = """你是一个"用户画像分析师"。请阅读下面的对话,构建用户画像。

**目标用户:user_id={target_user}**
只为这一位用户构建画像。对话中可能有多个发言者——其他参与者以及 AI 助手。每行都带有 `(user_id:...)` 标记;只把信息归属给 id 为 {target_user} 的用户。其余所有人(包括助手)都只是上下文,绝不是本画像的对象。

【第一部分:显式信息 (explicit_info)】
用户的客观事实和当前状态,如身高体重、喜好、疾病等。

Expand All @@ -106,9 +125,14 @@
*命名规范*:Trait 字段必须简练精准,推荐“[形容词] [名词]”格式,严禁过度堆砌形容词。

【提取原则】
1. 只提取用户本人的信息,不要把助手的建议当成用户特征
1. 只提取目标用户(user_id={target_user})本人的信息。绝不要把其他参与者或 AI 助手说的话归到目标用户身上——包括助手自身的名字、人设、角色或第一人称自述。助手描述的是它自己,绝不是用户。
2. 隐式特征必须有多个证据支撑:同一条隐式特征的 evidence 必须来自多个信号;证据可来自【当前对话】与/或【已有画像 current_profile 的 evidence】(更新时可用),不能仅凭单条新对话臆断
3. 每条信息用一句自然语言描述,通俗易懂
3. **恒久抽象——硬性规则**:每条信息都用**一句不带时间的**概括来描述——是稳定的一般化表述,**绝不是**带日期的流水账或实例罗列。`description` 中**绝不能**出现日期、星期或具体时刻(粗粒度时间只放进 `evidence`,绝不放进 `description`)。
如果对话中同一行为/偏好出现多次(多顿饭、多次听歌、多笔消费、多种情绪),你**必须**把所有实例融合成**一句**概括——**不要**把它们逐条列成带日期的事件。
示例:
错误:"06-05 吃了拉面,06-07 吃了意面,06-10 又吃了拉面。"
正确:"经常吃面食,且乐于在不同店家间尝鲜。"
最终输出前:逐条检查每个 `description` 是否含日期/星期/时刻;若有,重写该条。

【输出格式】
请直接输出 JSON,格式如下:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,68 @@ def test_render_conversation_uses_sentinel_when_all_inputs_empty() -> None:
assert rendered == "(no prior MemCells in the cluster)"


# ==========================================================================
# Target-aware extraction (multi-speaker attribution)
# ==========================================================================


def test_render_conversation_tags_each_speaker_with_own_user_id() -> None:
"""Multi-speaker cell: every line carries its speaker's own ``(user_id:...)`` tag.

Attribution must key on ``user_id``, not the display name — so the id, not the
(possibly ambiguous) ``sender_name``, is what the target-aware prompt matches on.
"""
cell = MemCell(
items=[
ChatMessage(
id="m1", role="user", content="I ship Rust daily.",
timestamp=_TS, sender_id="u_alice", sender_name="Alice",
),
ChatMessage(
id="m2", role="user", content="I only write Go.",
timestamp=_TS, sender_id="u_bob", sender_name="Bob",
),
],
timestamp=_TS,
)
rendered = _render_conversation([cell])
assert "(user_id:u_alice): I ship Rust daily." in rendered
assert "(user_id:u_bob): I only write Go." in rendered


async def test_aextract_threads_target_user_id_into_prompt() -> None:
"""The target user's ``user_id`` (not the display name) is injected into the prompt,
so the LLM is told to attribute facts to that id in a multi-speaker transcript."""
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)

multi_speaker = MemCell(
items=[
_msg("I ship Rust daily.", sender="u_alice", sender_name="Alice"),
_msg("I only write Go.", sender="u_bob", sender_name="Bob"),
],
timestamp=_TS,
)

await ProfileExtractor(llm=fake).aextract([multi_speaker], sender_id="u_alice")

prompt = captured["prompt"]
# Target is identified by user_id, and both speakers' lines are present for the
# model to discriminate — the other speaker (u_bob) is context, never the subject.
assert "user_id=u_alice" in prompt
assert "(user_id:u_bob): I only write Go." in prompt


# ==========================================================================
# _build_summary defensive branches
# ==========================================================================
Expand Down