NoteStack-Test / backend /tests /evals /test_chat_recall.py
HumeraR's picture
feat: add MemoryNotes component to Settings page and enhance UI styles
b74c6ae
Raw History Blame Contribute Delete
7.56 kB
"""Chat memory: does the model file a chat's rounds under the right topics?
The static tests pin the prompts and the labeled scenarios and always run. The live tests (opt-in) run the real model on
scripted chats, the one that motivated the feature first: a chat about evals and benchmarks, a detour about Obama's
birthday, then a follow up that only makes sense against the evals topic.
LIVE_EVALS=1 pytest tests/evals/test_chat_recall.py -k live
It uses LLM_API_KEY from the app's .env files and makes about 4 calls on the fast model. Run it after touching
OrganizeChat in app/llm/signatures.py."""
import os
import dspy
import pytest
from app.chat_memory import files
from app.chat_memory.context import JEV_MEMORY, MEMORY_LEVELS
from app.config import settings
from app.llm import provider
from app.llm.signatures import OrganizeChat, ResearchArchiveWithMemory, TriageWithMemory
from tests.evals.live import env_value
PROMPT = " ".join(OrganizeChat.__doc__.split())
def test_the_organizer_prompt_files_by_topic_and_keeps_the_old_details():
assert "Continue the most recent topic unless the round clearly starts something else" in PROMPT
assert "Reopen an older topic when the round belongs to it" in PROMPT
assert "a one off question (for example the date of someone's birthday) is also a topic of its own" in PROMPT
assert "Use SKIP only for greetings and thanks" in PROMPT
assert "extend it and keep what still matters, so earlier details survive" in PROMPT
def test_the_organizer_prompt_keeps_summaries_honest_and_synonyms_in():
assert "State who said what" in PROMPT and "name the post paths the assistant used" in PROMPT
assert "the summary says what was discussed, not what is true" in PROMPT
assert "the other words a writer might later use for the same thing (synonyms, related terms)" in PROMPT
assert "Use only labels that appear in the rounds" in PROMPT
assert "Never use em dashes" in PROMPT
assert set(OrganizeChat.input_fields) == {"topics", "rounds"}
assert set(OrganizeChat.output_fields) == {"assignments", "updates"}
def test_the_read_side_prompts_treat_chat_notes_as_context_not_evidence():
agent = " ".join(ResearchArchiveWithMemory.__doc__.split())
assert "never as evidence about the posts" in agent and "cite only text you read from the posts" in agent
assert "each such place is a citation" in agent and "exact=true for a literal phrase" in agent
triage = " ".join(TriageWithMemory.__doc__.split())
assert all(level in triage for level in MEMORY_LEVELS)
assert set(ResearchArchiveWithMemory.input_fields) >= {"question", "chat_memory"}
def test_every_memory_level_has_examples_and_no_example_is_shared():
assert set(JEV_MEMORY) == set(MEMORY_LEVELS)
seen = [e for c in JEV_MEMORY.values() for e in c["examples"]]
assert len(seen) == len(set(seen)) and all(c["examples"] for c in JEV_MEMORY.values())
# The scripted chats. Each round is (question, answer).
EVALS = [
("What have I written about evals and benchmarks?",
"You argued MMLU is saturated and that benchmark scores hide failure modes. Your post 'Benchmarks are broken' "
"says to build your own eval set. [1]"),
("Which benchmarks are best for small models?",
"The best benchmarks for small models are 1. GSM8K, 2. ARC and 3. HellaSwag, because they are cheap to run [1]."),
]
OBAMA = [
("When is Obama's birthday?", "Barack Obama was born on August 4, 1961."),
("And how old is he now?", "He is 65 as of 2026."),
]
PRICING = [
("What did I say about raising prices?",
"You moved the paid tier from $5 to $8 a month and churn stayed flat [1]."),
]
def render(rounds, start=1):
"""The prompt text for scripted rounds, labeled the way the job labels them."""
out, n = [], 0
for i, (q, a) in enumerate(rounds, start=start):
out += [f"r{i}", f"m{n + 1} Writer: {q}", f"m{n + 2} Assistant: {a}", ""]
n += 2
return "\n".join(out).strip()
def organize(topics: str, rounds_text: str) -> dict:
lm = dspy.LM(settings.llm_fast_model, api_base=settings.llm_api_base, api_key=env_value("LLM_API_KEY"),
temperature=settings.llm_temperature, max_tokens=8000, cache=False, **provider._extra_kwargs("low"))
with dspy.context(lm=lm, adapter=dspy.JSONAdapter()): # the adapter the app uses
out = dspy.Predict(OrganizeChat)(topics=topics, rounds=rounds_text)
return {"assignments": {a.round: a.topic for a in out.assignments},
"updates": {u.topic: u for u in out.updates}}
def test_the_scripted_chats_are_intact():
assert len(EVALS) == 2 and len(OBAMA) == 2 and len(PRICING) == 1
assert "r1" in render(EVALS) and "m4 Assistant" in render(EVALS)
@pytest.mark.skipif(not os.environ.get("LIVE_EVALS"), reason="opt in with LIVE_EVALS=1 (calls the real model)")
@pytest.mark.skipif(not env_value("LLM_API_KEY"), reason="no LLM_API_KEY found in .env")
class TestLiveOrganizer:
def test_a_detour_becomes_its_own_topic(self):
out = organize("(none)", render(EVALS + OBAMA))
# The model may write the same new topic as "NEW:Evals" in one round and "Evals" in the next. The job reads both
# as one topic (it compares slugs), so the eval does too.
a = {r: files.topic_slug(t.removeprefix("NEW:").removeprefix("new:")) for r, t in out["assignments"].items()}
assert a["r1"] == a["r2"], out["assignments"] # both evals rounds together
assert a["r3"] == a["r4"] and a["r3"] != a["r1"], out["assignments"] # the detour is a separate topic
by_slug = {files.topic_slug(t.removeprefix("NEW:").removeprefix("new:")): u for t, u in out["updates"].items()}
evals, obama = by_slug[a["r1"]], by_slug[a["r3"]]
assert "obama" not in evals.summary.lower() and "benchmark" in evals.summary.lower()
assert "obama" in obama.summary.lower() or "birthday" in obama.summary.lower()
def test_coming_back_after_a_detour_reopens_the_old_topic(self):
topics = ("evals-and-benchmarks | Evals and benchmarks | Research on evals | eval, benchmark, mmlu\n"
"obama-birthday | Obama birthday | A one off date question | obama, birthday\n\n"
"Summary of evals-and-benchmarks: The writer asked about benchmarks. The assistant said MMLU is "
"saturated and ranked GSM8K, ARC and HellaSwag best for small models.\n"
"Summary of obama-birthday: The writer asked when Obama was born and his age.")
rounds = [("Which of those benchmarks did you say was best?", "I said GSM8K was the first choice.")]
out = organize(topics, render(rounds))
assert out["assignments"]["r1"] == "evals-and-benchmarks", out["assignments"]
assert "gsm8k" in out["updates"]["evals-and-benchmarks"].summary.lower() # earlier details survive
def test_keywords_carry_synonyms_so_a_related_word_can_find_the_topic(self):
out = organize("(none)", render(PRICING))
update = next(iter(out["updates"].values()))
words = " ".join(update.keywords).lower()
assert any(w in words for w in ("fee", "cost", "charge", "subscription")), update.keywords
def test_the_assistants_key_answer_is_chosen_for_keeping(self):
out = organize("(none)", render(EVALS))
update = next(iter(out["updates"].values()))
assert "m4" in update.keep, update.keep # the ranking is the answer worth saving word for word
assert all(k in {"m1", "m2", "m3", "m4"} for k in update.keep)