From f5b91f1e9e6b2812190e93400b356d809d8a2821 Mon Sep 17 00:00:00 2001 From: Mazen Tamer Salah <78306991+mazen-salah@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:03:45 +0300 Subject: [PATCH] fix(tasks): read Memory.text in classify_events personal context (#3640) The classify_events task pulled user memories to give the LLM personal context, but read `m.content`, which the Memory ORM does not have (the column is `text`). That raised AttributeError on the first row; the surrounding except swallowed it and logged at debug, so the personal-context block was silently always empty and events were classified without it. Extract the rendering into `_memory_context_lines` (reads `text`, robust via getattr, keeps the 200-char and 40-line caps) and raise the swallowed-exception log to warning so a future schema mismatch is visible. Adds tests/test_classify_events_memory_text.py for the field, truncation, blank skipping, missing-attr robustness, and the line cap. --- src/builtin_actions.py | 31 ++++++++++++++------- tests/test_classify_events_memory_text.py | 33 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 tests/test_classify_events_memory_text.py diff --git a/src/builtin_actions.py b/src/builtin_actions.py index b48ed94fa..1ea7cd8a4 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -579,6 +579,24 @@ def _classify_event_heuristic(summary: str) -> tuple: return etype, None +def _memory_context_lines(mems, limit: int = 40) -> list: + """Render Memory rows into short personal-context bullets for event classify. + + Reads the Memory ORM `text` column. The previous inline code read a + non-existent `content` attribute, so it raised AttributeError on the first + row, the surrounding except swallowed it, and the classifier ran with no + personal context at all. getattr keeps it robust to future schema drift. + """ + lines: list = [] + for m in mems: + c = (getattr(m, "text", "") or "").strip() + if c: + lines.append(f"- {c[:200]}") + if len(lines) >= limit: + break + return lines + + async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: """Hybrid classification of upcoming calendar events: fast heuristic for obvious cases, LLM fallback for ambiguous ones. Assigns event_type + @@ -614,16 +632,11 @@ async def action_classify_events(owner: str, **kwargs) -> Tuple[str, bool]: try: from core.database import Memory as _Mem _mems = db.query(_Mem).filter(_Mem.owner == owner).limit(60).all() if owner else [] - if _mems: - _lines = [] - for m in _mems: - c = (m.content or "").strip() - if c: - _lines.append(f"- {c[:200]}") - if _lines: - _memory_context = "USER CONTEXT (relationships, work, life):\n" + "\n".join(_lines[:40]) + "\n\n" + _lines = _memory_context_lines(_mems) + if _lines: + _memory_context = "USER CONTEXT (relationships, work, life):\n" + "\n".join(_lines) + "\n\n" except Exception as _me: - logger.debug(f"Could not load memory for classify: {_me}") + logger.warning(f"Could not load memory for classify: {_me}") classified_h = 0 classified_llm = 0 diff --git a/tests/test_classify_events_memory_text.py b/tests/test_classify_events_memory_text.py new file mode 100644 index 000000000..328929115 --- /dev/null +++ b/tests/test_classify_events_memory_text.py @@ -0,0 +1,33 @@ +"""classify_events must read the Memory `text` column, not a non-existent +`content` attribute. + +The previous inline loop did `m.content`, which raised AttributeError on the +first Memory row; the surrounding except swallowed it, so the personal-context +block the LLM relies on was always empty. The logic now lives in +`_memory_context_lines`, which reads `text`. +""" +from src.builtin_actions import _memory_context_lines + + +class _Mem: + def __init__(self, text): + self.text = text + + +def test_uses_text_and_truncates_and_skips_blank(): + lines = _memory_context_lines([_Mem("Alice is my spouse"), _Mem(" "), _Mem("y" * 250)]) + assert lines[0] == "- Alice is my spouse" + assert len(lines) == 2 # the blank row is skipped + assert lines[1] == "- " + "y" * 200 # truncated to 200 chars + + +def test_skips_rows_without_text_attribute(): + class _Bad: # mimics a schema where the attribute is absent + pass + + assert _memory_context_lines([_Bad(), _Mem("ok")]) == ["- ok"] + + +def test_respects_limit(): + mems = [_Mem(f"memory {i}") for i in range(50)] + assert len(_memory_context_lines(mems, limit=40)) == 40