diff --git a/packages/everalgo-user-memory/CHANGELOG.md b/packages/everalgo-user-memory/CHANGELOG.md index 2e0d342..7eb60e0 100644 --- a/packages/everalgo-user-memory/CHANGELOG.md +++ b/packages/everalgo-user-memory/CHANGELOG.md @@ -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 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..1fccd6e 100644 --- a/packages/everalgo-user-memory/src/everalgo/user_memory/profile.py +++ b/packages/everalgo-user-memory/src/everalgo/user_memory/profile.py @@ -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"] @@ -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) @@ -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)") 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..8553b00 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 @@ -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} @@ -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. @@ -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. @@ -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: 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..ab811d4 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 @@ -9,6 +9,9 @@ # Incremental Update Prompt PROFILE_UPDATE_PROMPT = """你是用户画像更新员。根据对话记录,判断需要对用户画像做哪些操作。 +**目标用户:user_id={target_user}** +只处理 id 为 {target_user} 的用户的信息。每行对话都带有 `(user_id:...)` 标记;请把每条事实归属到实际陈述它的发言者。其他参与者以及 AI 助手都只是上下文,绝不是目标用户。 + 【当前用户画像】(每条都有 index 编号) {current_profile} @@ -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(显式信息)**:可以直接从对话中提取的用户事实。 @@ -97,6 +113,9 @@ # Initial Extraction Prompt PROFILE_INITIAL_EXTRACTION_PROMPT = """你是一个"用户画像分析师"。请阅读下面的对话,构建用户画像。 +**目标用户:user_id={target_user}** +只为这一位用户构建画像。对话中可能有多个发言者——其他参与者以及 AI 助手。每行都带有 `(user_id:...)` 标记;只把信息归属给 id 为 {target_user} 的用户。其余所有人(包括助手)都只是上下文,绝不是本画像的对象。 + 【第一部分:显式信息 (explicit_info)】 用户的客观事实和当前状态,如身高体重、喜好、疾病等。 @@ -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,格式如下: 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..d09b528 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 @@ -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 # ==========================================================================