From f6d368ca09f2b1a3ce6686c275748e5a14a8cb8d Mon Sep 17 00:00:00 2001 From: Leonardo Araujo dos Santos Date: Sun, 27 Sep 2026 09:30:05 -0300 Subject: [PATCH] Recognise loose fact phrasings and reuse the wallet the asker just typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four misses from one production DM: - "Guarde na sua memoria que eu quero os meus valores em reais brasileiro" saved nothing. Accept remember wrappers (guarde/grave/anote/lembre/salve na sua memória que, remember that), "quero (ver) os (meus) valores em X", and the real in every number and gender. - "Oque vide sabe sobre mim ?" went to the corpus. "o que sabe sobre mim" / "what do know about me" now shows the asker's facts when the word is "você" in any spelling or a short lowercase typo of it; names and other pronouns are excluded. - "Você sabe qual é a moeda do meu país ?" went to the corpus. It and its variants show the preferred currency; with none saved the reply says how to save one. - A DeFi follow-up asked "Qual carteira?" right after the asker typed one, and "A carteira que eu acabei de passar" was searched for. A chain route with no typed, carried or saved address now uses the latest address the asker typed in their own remembered turns at this location (logged as asker_typed), and a bare pointer back at a wallet re-asks the latest chain question with it. Alerts get the same fallback. The chain call budget is keyed per question as well as per address so that follow-up is not refused as the earlier run calling again. --- README.md | 19 +- .../proposal.md | 47 ++++ .../specs/defi-positions/spec.md | 26 +++ .../specs/personal-facts/spec.md | 39 ++++ .../fix-facts-and-wallet-follow-up/tasks.md | 19 ++ src/chatmemory/adapters/chain/clearance.py | 10 +- src/chatmemory/app/alert_intent.py | 10 +- src/chatmemory/app/alert_requests.py | 5 +- src/chatmemory/app/fact_replies.py | 13 ++ src/chatmemory/app/reasoning/service.py | 36 ++- src/chatmemory/app/routing.py | 107 ++++++++- src/chatmemory/app/routing_crypto.py | 32 +++ src/chatmemory/domain/currency.py | 6 +- tests/e2e/test_facts_and_wallet_follow_up.py | 178 +++++++++++++++ tests/unit/test_defi_positions.py | 22 ++ tests/unit/test_facts_and_wallet_follow_up.py | 214 ++++++++++++++++++ tests/unit/test_suggestion_intent.py | 1 + 17 files changed, 765 insertions(+), 19 deletions(-) create mode 100644 openspec/changes/fix-facts-and-wallet-follow-up/proposal.md create mode 100644 openspec/changes/fix-facts-and-wallet-follow-up/specs/defi-positions/spec.md create mode 100644 openspec/changes/fix-facts-and-wallet-follow-up/specs/personal-facts/spec.md create mode 100644 openspec/changes/fix-facts-and-wallet-follow-up/tasks.md create mode 100644 tests/e2e/test_facts_and_wallet_follow_up.py create mode 100644 tests/unit/test_facts_and_wallet_follow_up.py diff --git a/README.md b/README.md index 0c73168..b335699 100644 --- a/README.md +++ b/README.md @@ -219,11 +219,16 @@ chamar de Leo, nasci em 21/06/1981, meu telefone é …, moro em …, minha cart age is not kept: it follows from the birth date. "My name is" with one word is the name you're called by; with more, it is your full name. Common typos ("walet", "morro") and a missing "é"/"is" before an email, phone or wallet are -understood. +understood, and so is a request to remember wrapped around any of them — +*"guarde na sua memória que eu quero os meus valores em reais brasileiro"*, +*"lembre que …"*, *"remember that …"*. -`what do you know about me?` (or `o que você sabe sobre mim?`) shows them, -`what's my phone?` / `qual o meu telefone?` shows one, and `forget my email` -deletes one. Replies come in the language you wrote in. +`what do you know about me?` (or `o que você sabe sobre mim?`, also typed as +`oq vc sabe sobre mim` or with a typo for "você") shows them, `what's my +phone?` / `qual o meu telefone?` / `você sabe qual é o meu email?` shows one, +and `forget my email` deletes one. `qual a moeda do meu país?` / `what's my +currency?` shows the preferred currency, or says how to save one. Replies +come in the language you wrote in. Saving another wallet adds it rather than replacing the first. `forget my wallet 0x…` (or `…45e0`) / `esqueça minha carteira 0x…` removes that one; @@ -231,7 +236,11 @@ with several saved, `forget my wallet` alone asks which. `forget my wallets` / `esqueça minhas carteiras` removes them all, Ethereum and Bitcoin. `what's my portfolio?` sums every saved wallet; a balance, DeFi, activity or alert question reads one, so with several saved it asks which (by their last four characters) unless you name it — -`what's my wallet balance …45e0?`. +`what's my wallet balance …45e0?`. With none saved and none typed, such a +question uses the latest address you typed earlier in the same conversation (the +same DM or channel — never one somebody else wrote or one found in the +messages), and `a carteira que eu acabei de passar` / `essa carteira` / `the +wallet I just gave you` asks your last wallet question again with it. With a preferred currency saved, every dollar figure the assistant reads for you is followed by the same figure in that currency, converted in code at the diff --git a/openspec/changes/fix-facts-and-wallet-follow-up/proposal.md b/openspec/changes/fix-facts-and-wallet-follow-up/proposal.md new file mode 100644 index 0000000..d5911cd --- /dev/null +++ b/openspec/changes/fix-facts-and-wallet-follow-up/proposal.md @@ -0,0 +1,47 @@ +## Why + +One Portuguese DM in production hit four misses in a row: + +- "Guarde na sua memoria que eu quero os meus valores em reais brasileiro" + saved nothing: the "guarde (na sua memória) que" wrapper, "os meus valores" + and "reais brasileiro" were each unrecognised, so it went to the corpus. +- "Oque vide sabe sobre mim ?" ("vide" for "você") went to the corpus. +- "Você sabe qual é a moeda do meu país ?" went to the corpus. +- After "verifique as pools de liquidez nesta wallet 0xB26B…45e0" was answered, + a follow-up about the value in the asker's currency was answered "Qual + carteira?", and "A carteira que eu acabei de passar" was searched for in the + corpus. + +## What Changes + +- **Remember wrappers.** "guarde / grave / anote / lembre / salve / registre + (na sua memória) que …" and "remember that …" before any fact statement. +- **Currency phrasing.** "(eu) quero (ver) os (meus) valores em X", "I want to + see my prices in X"; the real in every number and gender ("reais + brasileiro", "real brasileiros", "brazilian reals"). +- **Show.** "o que / oque / oq sabe sobre mim" and "what do know about me" when the word is "você" in any spelling or a short + lowercase typo of it; a capitalised name or another pronoun is not the + assistant. "Você sabe qual …" / "do you know what …" before a fact asked + for by name. +- **The currency asked for.** "qual a moeda do meu país", "qual a minha moeda + de base", "what's the currency of my country", "which currency do I use" + show the preferred currency; with none saved the reply says so and how to + save one ("minha moeda é o real"). +- **The wallet just given.** A chain question (balance, positions, portfolio, + activity, alert) that needs an address and has none typed, carried or saved + reads the latest address the asker typed in this conversation -- their own + remembered questions, kept per person and location -- and logs its source + as `asker_typed`. A message that only points back at a wallet ("a carteira + que eu acabei de passar", "essa carteira", "the wallet I just gave you") + asks the latest chain question again with that address. The chain call + budget is charged per question as well as per address, so that follow-up + is not refused as the earlier run calling again. + +## Impact + +- `app/routing.py`, `app/routing_crypto.py`, `app/reasoning/service.py`, + `app/alert_intent.py`, `app/alert_requests.py`, `app/fact_replies.py`, + `domain/currency.py`, `adapters/chain/clearance.py`. +- No schema change. Egress rooting is unchanged: the address is spelled into + the question, and it is the asker's own words. diff --git a/openspec/changes/fix-facts-and-wallet-follow-up/specs/defi-positions/spec.md b/openspec/changes/fix-facts-and-wallet-follow-up/specs/defi-positions/spec.md new file mode 100644 index 0000000..5aadb80 --- /dev/null +++ b/openspec/changes/fix-facts-and-wallet-follow-up/specs/defi-positions/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: A chain question without an address uses the one the asker just gave + +The system SHALL, when a chain question (balance, positions, portfolio, +activity or alert) needs an address and none is typed, carried or saved, use +the latest address the asker typed in their own remembered questions in the +same conversation location, and SHALL never take one from another person's +message, from retrieved content or from another location. A message that only +points back at a wallet SHALL ask the asker's latest chain question again with +that address. + +#### Scenario: A DM follow-up +- WHEN, in a DM, "verifique as pools de liquidez nesta wallet 0x…" was answered + and the asker then writes "Porque você não respondeu o valor das pools na + minha moeda de base?" or "A carteira que eu acabei de passar" +- THEN that address SHALL be read, and the reply SHALL NOT ask which wallet + +#### Scenario: A channel follow-up +- WHEN the asker typed the address in the same channel conversation +- THEN a follow-up there SHALL read it + +#### Scenario: Somebody else's address +- WHEN an address appears only in another person's message, in retrieved + content, or in the asker's DM rather than this channel +- THEN it SHALL NOT be read, and the reply SHALL ask for an address diff --git a/openspec/changes/fix-facts-and-wallet-follow-up/specs/personal-facts/spec.md b/openspec/changes/fix-facts-and-wallet-follow-up/specs/personal-facts/spec.md new file mode 100644 index 0000000..e5c55d1 --- /dev/null +++ b/openspec/changes/fix-facts-and-wallet-follow-up/specs/personal-facts/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Facts are recognised as people phrase them + +The system SHALL recognise a fact statement behind a request to remember it, +the currency preference in its common phrasings, and a request to show the +asker's facts with the assistant named in any common spelling or typo, without +reading a question about money or about another person as either. + +#### Scenario: A currency kept in memory +- WHEN someone writes "Guarde na sua memoria que eu quero os meus valores em + reais brasileiro" +- THEN BRL SHALL be stored as their preferred currency + +#### Scenario: Money talk is not a preference +- WHEN someone writes "quanto é 100 reais em dólar?" or "quero trocar 100 + reais em dólar" +- THEN no preferred currency SHALL be stored + +#### Scenario: "você" typed as people type it +- WHEN someone writes "Oque vide sabe sobre mim ?" +- THEN their facts SHALL be shown, as for "o que você sabe sobre mim?" + +#### Scenario: Somebody else is not the assistant +- WHEN someone writes "o que o João sabe sobre mim?" or "o que ele sabe sobre + mim?" +- THEN their facts SHALL NOT be shown for it + +### Requirement: The preferred currency can be asked for + +The system SHALL answer "qual a moeda do meu país", "qual a minha moeda", +"what's my currency" and their variants with the preferred currency, and with +none saved SHALL say so and how to save one, in the asker's language. + +#### Scenario: None saved +- WHEN someone with no preferred currency writes "Você sabe qual é a moeda do + meu país ?" +- THEN the reply SHALL say no preferred currency is saved and suggest "minha + moeda é o real", without searching the corpus diff --git a/openspec/changes/fix-facts-and-wallet-follow-up/tasks.md b/openspec/changes/fix-facts-and-wallet-follow-up/tasks.md new file mode 100644 index 0000000..f25f6d3 --- /dev/null +++ b/openspec/changes/fix-facts-and-wallet-follow-up/tasks.md @@ -0,0 +1,19 @@ +## 1. Facts + +- [x] 1.1 Remember wrappers ("guarde na sua memória que", "lembre que", "anote que", "remember that") +- [x] 1.2 "(eu) quero (ver) os (meus) valores em X"; the real in every number and gender +- [x] 1.3 "o que sabe sobre mim" / "what do know about me"; names and pronouns excluded +- [x] 1.4 "qual a moeda do meu país" and variants show the currency; none saved says how to save one + +## 2. The wallet just given + +- [x] 2.1 A chain route with no address uses the latest one the asker typed in this conversation +- [x] 2.2 "a carteira que eu acabei de passar" / "essa carteira" / "the wallet I just gave you" re-asks the latest chain question +- [x] 2.3 Alerts: the asker's earlier-typed address when nothing is typed, carried or saved +- [x] 2.4 Chain call budget charged per question and address + +## 3. Tests and docs + +- [x] 3.1 Unit: the production sentences, false-positive guards, follow-up routing, budget +- [x] 3.2 End to end: DM transcript, channel follow-up, another person's and the corpus's addresses never used +- [x] 3.3 README facts and wallet sections diff --git a/src/chatmemory/adapters/chain/clearance.py b/src/chatmemory/adapters/chain/clearance.py index 209c917..e68f2e5 100644 --- a/src/chatmemory/adapters/chain/clearance.py +++ b/src/chatmemory/adapters/chain/clearance.py @@ -144,8 +144,14 @@ async def _admit( key: str, cleared: Cleared, ) -> Cleared | ToolResult: - """The per-question budget, then the rate limit.""" - if not budget.spend(key): + """The per-question budget, then the rate limit. + + Charged per question as well as per address: a follow-up that reads the + wallet the asker just gave ("a carteira que eu acabei de passar") is a new + question, not the same run calling again. The rate limit still spaces + every call to the provider, whatever the question. + """ + if not budget.spend(f"{cleared.question}\n{key}"): log.warning("chain.budget_exhausted", tool=tool) return refusal(server, tool, "calls_per_run_exhausted") if not await limiter.acquire(): diff --git a/src/chatmemory/app/alert_intent.py b/src/chatmemory/app/alert_intent.py index 66c2f1f..ca7acb7 100644 --- a/src/chatmemory/app/alert_intent.py +++ b/src/chatmemory/app/alert_intent.py @@ -65,7 +65,11 @@ from dataclasses import dataclass from decimal import Decimal, InvalidOperation -from chatmemory.app.routing import CONVERSATION_VERBS, recent_chain_address +from chatmemory.app.routing import ( + CONVERSATION_VERBS, + asker_typed_address, + recent_chain_address, +) from chatmemory.domain.chain import find_addresses from chatmemory.ports.alerts import DEFAULT_EDGE_PERCENT, AlertKind, PriceDirection @@ -267,6 +271,9 @@ class AlertIntent: asset: str | None = None direction: PriceDirection | None = None level: Decimal | None = None + #: With no address written or carried: the latest one the asker typed + #: earlier in this conversation, used only when no wallet is saved. + earlier_address: str | None = None def alert_intent(text: str, previous_questions: Sequence[str] = ()) -> AlertIntent | None: @@ -289,6 +296,7 @@ def alert_intent(text: str, previous_questions: Sequence[str] = ()) -> AlertInte threshold=_threshold(text) if kind is AlertKind.AAVE_HEALTH else None, chain=_chain(text), address=address, + earlier_address=None if address else asker_typed_address(previous_questions), token_id=_token_id(text) if kind is AlertKind.LP_RANGE else None, edge_percent=_edge_percent(request) if kind is AlertKind.LP_RANGE else None, ) diff --git a/src/chatmemory/app/alert_requests.py b/src/chatmemory/app/alert_requests.py index 0802f44..3ea6764 100644 --- a/src/chatmemory/app/alert_requests.py +++ b/src/chatmemory/app/alert_requests.py @@ -472,6 +472,9 @@ async def propose( if address is None and saved_wallets: wallets = suffix_list(tuple(saved_wallets)) return AlertReply(text("which_wallet", language, wallets=wallets)) + saved = address is not None and intent.address is None + # Typed by the asker earlier here, with nothing saved: theirs as well. + address = address or intent.earlier_address if address is None: return AlertReply(text("no_address", language)) refused = _limit_refusal(intent, language) @@ -482,7 +485,7 @@ async def propose( # An edge distance on a position already watched needs no new slot. if room <= 0 and intent.edge_percent is None: return AlertReply(text("at_cap", language, cap=self._cap)) - source = AddressSource.TYPED if intent.address else AddressSource.SAVED + source = AddressSource.SAVED if saved else AddressSource.TYPED request = _Request(person, intent, address, source, language, direct) read = await self._targets.read(address, intent.kind, intent.chain) log.info( diff --git a/src/chatmemory/app/fact_replies.py b/src/chatmemory/app/fact_replies.py index fb86db6..e4d6ee9 100644 --- a/src/chatmemory/app/fact_replies.py +++ b/src/chatmemory/app/fact_replies.py @@ -230,6 +230,16 @@ def possessive(kind: FactKind, language: Language, *, plural: bool = False) -> s EN: "You haven't told me {p}.", PT: "Você ainda não me disse {p}.", }, + "currency_missing": { + EN: ( + "I don't have a preferred currency saved for you. Say " + "`my currency is the euro` and I'll show amounts in it too." + ), + PT: ( + "Não tenho uma moeda preferida salva para você. Diga " + "`minha moeda é o real` e eu mostro os valores também nela." + ), + }, "one_direct_only": { EN: "I only show {p} in a direct message. Ask me there.", PT: "Só mostro {p} em mensagem direta. Me pergunte lá.", @@ -537,6 +547,9 @@ def _one_fact( # answer, not "you haven't told me". if kind is FactKind.FULL_NAME and FactKind.PREFERRED_NAME in by_kind: return shown_line(FactKind.PREFERRED_NAME, by_kind[FactKind.PREFERRED_NAME], language) + if kind is FactKind.PREFERRED_CURRENCY: + # Not "you haven't told me": how to tell it is the useful part. + return text("currency_missing", language) return text("one_missing", language, p=possessive(kind, language)) diff --git a/src/chatmemory/app/reasoning/service.py b/src/chatmemory/app/reasoning/service.py index 336344b..527dd20 100644 --- a/src/chatmemory/app/reasoning/service.py +++ b/src/chatmemory/app/reasoning/service.py @@ -76,6 +76,7 @@ PositionKind, Route, RoutingDecision, + asker_typed_address, classify, explicit_web_search, market_follow_up, @@ -401,6 +402,10 @@ async def _chain_route( """ handler = CHAIN_HANDLERS[query.route] lookup = _lookup_addresses(question, query) + if query.asked is not None: + # "A carteira que eu acabei de passar": the earlier question, which + # is the asker's own words, is what is asked and rooted against. + question = replace(question, text=query.asked) addresses = lookup.addresses if lookup.choose_from: log.info( @@ -435,6 +440,7 @@ async def _chain_route( route=handler.decision, label=str(query.route), carried=query.carried, + address_source=lookup.source, wallets=len(addresses), ) return ( @@ -473,9 +479,33 @@ class _Lookup: addresses: tuple[str, ...] = () choose_from: tuple[str, ...] = () + #: Where the addresses came from, for the log: "typed" in this question, + #: "carried" from an earlier chain question, "asker_typed" earlier in this + #: conversation, "saved", or "" for none. + source: str = "" def _lookup_addresses(question: Question, query: CryptoQuery) -> _Lookup: + """Whose wallets to read, or which saved one to choose. + + With nothing typed, carried or saved, the latest address the asker typed + in this conversation -- their own earlier words, recalled for them in + this location only, so another person's message or retrieved content is + never a source. "Which wallet?" was asked right after a question naming + one. The spelled-out address is then rooted in the asker's question like + any typed one. + """ + found = _typed_or_saved(question, query) + if found.addresses or found.choose_from: + return found + previous = tuple(turn.question for turn in question.memory.turns) + earlier = asker_typed_address(previous) + if earlier is None: + return found + return _Lookup((earlier,), source="asker_typed") + + +def _typed_or_saved(question: Question, query: CryptoQuery) -> _Lookup: """Whose wallets to read: what was typed or carried, and the saved ones. Every saved wallet joins a portfolio question about the asker's own money, @@ -487,9 +517,10 @@ def _lookup_addresses(question: Question, query: CryptoQuery) -> _Lookup: otherwise asks which -- never silently the first. """ typed = query.addresses + given = "carried" if query.carried else "typed" portfolio = query.route is CryptoRoute.PORTFOLIO if not query.mine or (typed and not portfolio): - return _Lookup(typed) + return _Lookup(typed, source=given if typed else "") saved = _saved_wallets(question) if not portfolio and len(saved) > 1: named = named_by_suffix(question.text, saved) @@ -497,7 +528,8 @@ def _lookup_addresses(question: Question, query: CryptoQuery) -> _Lookup: return _Lookup(choose_from=saved) saved = named lowered = {a.lower() for a in typed} - return _Lookup((*(w for w in saved if w.lower() not in lowered), *typed)) + merged = (*(w for w in saved if w.lower() not in lowered), *typed) + return _Lookup(merged, source="saved" if saved else (given if typed else "")) def _spelled_out(question: Question, addresses: Sequence[str]) -> Question: diff --git a/src/chatmemory/app/routing.py b/src/chatmemory/app/routing.py index 1b6ed23..b8641ea 100644 --- a/src/chatmemory/app/routing.py +++ b/src/chatmemory/app/routing.py @@ -643,6 +643,51 @@ def _chain_turn(text: str) -> tuple[str, ...] | None: return (found.address,) if found.address is not None else () +def asker_typed_address(previous: Sequence[str]) -> str | None: + """The latest address the asker typed in any of their remembered questions. + + For a chain question that needs an address and names none: "Porque você + não respondeu o valor das pools na minha moeda?" right after a question + about 0x... was answered "Which wallet?". `previous` is the asker's own + earlier questions here (memory is kept per person and location), so an + address somebody else wrote, or one in retrieved content or an answer, + is never a candidate -- it is the asker's own words, as rooting requires. + """ + for earlier in reversed(previous): + found = find_addresses(earlier) + if found: + return found[0] + return None + + +_WALLET_REFERENCE = re.compile( + r"\A\s*(?:e\s+|and\s+)?(?:" + # "a carteira que eu acabei de passar", "essa carteira", "use o mesmo endereço" + r"(?:(?:use|usa|usar|com|para|pra|n[ao]|d[ao])\s+)?(?:[ao]\s+)?(?:mesm[ao]\s+)?" + r"(?:(?:ess|est|aquel|dess|dest|ness|nest|naquel)[ae]\s+)?(?:mesm[ao]\s+)?" + r"(?:carteira|wallet|endere[cç]o)(?:\s+mesm[ao])?" + r"(?:\s+que\s+(?:eu\s+)?(?:acabei\s+de\s+|j[aá]\s+)?(?:te\s+|lhe\s+)?" + r"(?:pass|mand|envi|inform|digit|col|cit)(?:ei|ar)|\s+anterior|\s+de\s+antes)?" + r"(?:\s+(?:antes|agora|acima|h[aá]\s+pouco))?" + r"|" + # "the wallet I just gave you", "same address" + r"(?:(?:use|for|with|on)\s+)?(?:the\s+|that\s+|this\s+)?(?:same\s+)?(?:wallet|address)" + r"(?:\s+(?:that\s+)?i\s+(?:just\s+)?(?:gave|sent|passed|typed|pasted|shared|mentioned)" + r"(?:\s+(?:to\s+)?you)?|\s+from\s+before)?(?:\s+(?:before|earlier|above))?" + r")[\s?!.,]*\Z", + re.IGNORECASE, +) + + +def wallet_reference(text: str) -> bool: + """Whether the whole message only points back at a wallet already given. + + "A carteira que eu acabei de passar" answered "Which wallet?" and went to + the corpus search; it means: the last chain question, with that address. + """ + return bool(_WALLET_REFERENCE.match(text)) + + @dataclass(frozen=True, slots=True) class PortfolioQuestion: """What somebody holds in total, on chain. @@ -1366,10 +1411,15 @@ class FactIntent: r"pode|voc[eê]\s+pode)" ) _GREETING = r"(?:hey|hi|hello|ok|okay|so|oi|ol[aá])" -_REMEMBER = r"(?:remember|note|save|keep\s+in\s+mind|lembre(?:-se)?|lembra|anota|guarda|salva)" +_REMEMBER = ( + r"(?:remember|note|save|keep\s+in\s+mind|lembr[ae](?:-se)?|anot[ae]|guard[ae]|salv[ae]|" + r"grave|registr[ae])" +) +# "Guarde na sua memória que ...": where to keep it, said before what. +_IN_MEMORY = r"(?:\s+(?:n[ae]\s+(?:sua\s+)?mem[oó]ria|in\s+(?:your\s+)?memory))?" _LEAD = ( rf"^(?:{_GREETING}\b[,!.]?\s*)*(?:{_POLITE}\b,?\s*)*" - rf"(?:{_REMEMBER}\b(?:\s+(?:that|que|de\s+que))?[:,]?\s*)?" + rf"(?:{_REMEMBER}\b{_IN_MEMORY}(?:\s+(?:that|que|de\s+que))?[:,]?\s*)?" ) _TRAIL = r"(?:,?\s*(?:please|pls|por\s+favor|thanks|thank\s+you|obrigad[oa]))?[\s.!?]*$" _IS = r"(?:is|[ée])" @@ -1413,8 +1463,9 @@ class FactIntent: _A_CURRENCY_OR_CODE = rf"(?P{CURRENCY_NAMES_OR_CODES})" _PREFER_IN = ( r"(?:(?:eu\s+)?(?:prefiro|quero)(?:\s+(?:ver|receber))?" - r"(?:\s+(?:os\s+)?(?:valores|pre[cç]os))?\s+em|" - r"i\s+(?:prefer|want)(?:\s+to\s+see)?(?:\s+(?:prices|values|amounts|figures))?\s+in|" + r"(?:\s+(?:os\s+)?(?:meus\s+)?(?:valores|pre[cç]os))?\s+em|" + r"i\s+(?:prefer|want)(?:\s+to\s+see)?(?:\s+(?:my\s+)?(?:prices|values|amounts|figures))?" + r"\s+in|" r"(?:show|give)\s+me\s+(?:prices|values|amounts|figures)\s+in)" ) _I_USE = r"(?:(?:eu\s+)?uso|i\s+use)" @@ -1613,13 +1664,15 @@ def _wallet_named(shape: str) -> str: rf"what(?:'s|\s+is)\s+my\s+(?:{_EMAIL_WORD}|preferred\s+name|preferred\s+language)|" r"what\s+(?:do\s+you\s+call\s+me|name\s+do\s+you\s+call\s+me|" r"language\s+do\s+you\s+(?:reply|answer)(?:\s+to\s+me)?\s+in)|" - r"(?:o\s*que|oq)\s+(?:voc[eê]|vc)\s+sabe\s+sobre\s+mim|" r"qual\s+(?:[ée]\s+)?(?:o\s+)?meu\s+(?:e-?mail|nome\s+preferido|idioma(?:\s+preferido)?)" r")" + _TRAIL, re.IGNORECASE, ) +# "Você sabe qual é ...?" / "do you know what ...?" asks the same thing. +_DO_YOU_KNOW = r"(?:(?:voc[eê]|vc|tu)\s+sabes?\s+|do\s+you\s+know\s+)?" _WHAT_IS_MY = ( + rf"{_DO_YOU_KNOW}" r"(?:what(?:'s|\s+is|\s+are)\s+my|qual\s+(?:[ée]\s+)?(?:o\s+|a\s+)?(?:meu|minha)|" r"quais\s+(?:s[aã]o\s+)?(?:os\s+|as\s+)?(?:meus|minhas))" ) @@ -1667,14 +1720,56 @@ def _wallet_named(shape: str) -> str: (FactKind.BIRTH_DATE, re.compile( _LEAD + r"(?:when\s+was\s+i\s+born|quando\s+(?:eu\s+)?nasci)" + _TRAIL, re.IGNORECASE )), + # "Você sabe qual é a moeda do meu país?" went to the corpus: the currency + # asked for without the word "minha". + (FactKind.PREFERRED_CURRENCY, re.compile( + _LEAD + _DO_YOU_KNOW + + r"(?:qual\s+(?:[ée]\s+)?(?:a\s+)?(?:minha\s+moeda\s+de\s+base|" + r"moeda\s+(?:do\s+meu\s+pa[ií]s|que\s+(?:eu\s+)?(?:uso|prefiro)))|" + r"what(?:'s|\s+is)\s+(?:my\s+base\s+currency|the\s+currency\s+of\s+my\s+country|" + r"my\s+country'?s\s+currency)|" + r"which\s+currency\s+do\s+i\s+(?:use|prefer))" + _TRAIL, + re.IGNORECASE, + )), +) + +# "O que você sabe sobre mim?" with the subject as people type it: "vc", "tu", +# or a typo -- "Oque vide sabe sobre mim ?" went to the corpus. One word only, +# so "o que o João sabe sobre mim?" is not the asker asking for their facts. +_ABOUT_ME = tuple( + re.compile(_LEAD + body + _TRAIL, re.IGNORECASE) + for body in ( + r"(?:o\s*que|oq|o\s+q)\s+(?P[^\W\d_]+)\s+sabes?\s+(?:sobre|de)\s+mim", + r"what\s+(?:do|did|does)\s+(?P[^\W\d_]+)\s+(?:know|remember)\s+about\s+me", + ) ) +_YOU = frozenset({"você", "voce", "vc", "vce", "tu", "you", "u", "ya"}) +_NOT_YOU = frozenset({ + "ele", "ela", "eles", "elas", "eu", "nos", "nós", "gente", "todos", "alguem", "alguém", + "ninguem", "ninguém", "he", "she", "they", "we", "i", "it", "people", "everyone", +}) +_TYPO_LETTERS = 5 + + +def _means_you(who: str) -> bool: + """Whether the subject of "o que X sabe sobre mim" is the assistant. + + "você" and its spellings, or a short lowercase word that is none of the + other pronouns: a typo of "você" ("vide"). A capitalised word is a name. + """ + folded = who.casefold() + if folded in _YOU: + return True + return len(who) <= _TYPO_LETTERS and who[0].islower() and folded not in _NOT_YOU def _show_intent(text: str) -> FactIntent | None: for kind, pattern in (*_SHOW_ONE, *_SHOW_ONE_QUESTION): if pattern.match(text): return FactIntent(FactAction.SHOW, kind) - if _SHOW_PATTERN.match(text): + if _SHOW_PATTERN.match(text) or any( + (m := p.match(text)) is not None and _means_you(m.group("who")) for p in _ABOUT_ME + ): return FactIntent(FactAction.SHOW) if _CAPABILITIES_PATTERN.match(text): return FactIntent(FactAction.CAPABILITIES) diff --git a/src/chatmemory/app/routing_crypto.py b/src/chatmemory/app/routing_crypto.py index 722deaa..9c8c5c9 100644 --- a/src/chatmemory/app/routing_crypto.py +++ b/src/chatmemory/app/routing_crypto.py @@ -34,9 +34,11 @@ from chatmemory.app.routing import ( PositionKind, + asker_typed_address, defi_question, portfolio_question, wallet_question, + wallet_reference, ) from chatmemory.app.wallet_activity import activity_question @@ -71,6 +73,9 @@ class CryptoQuery: addresses: tuple[str, ...] = () mine: bool = False carried: bool = False + #: A bare "a carteira que eu acabei de passar": the asker's own earlier + #: chain question, asked again with the address they typed. + asked: str | None = None def crypto_route(text: str, previous: Sequence[str] = ()) -> CryptoQuery | None: @@ -78,7 +83,34 @@ def crypto_route(text: str, previous: Sequence[str] = ()) -> CryptoQuery | None: `previous` is the asker's own earlier questions, oldest first, for a follow-up that names no address ("and in total?", "show me the v4 one"). + A message that only points back at a wallet ("essa carteira", "the wallet + I just gave you") asks the latest chain question among them again. """ + found = _question_route(text, previous) + if found is not None or not wallet_reference(text): + return found + return _asked_again(previous) + + +def _asked_again(previous: Sequence[str]) -> CryptoQuery | None: + """The asker's latest chain question, with the latest address they typed. + + With no address typed at all, none is invented: the route then asks for + one. Never the saved wallet -- "the wallet I just gave you" is not it. + """ + typed = asker_typed_address(previous) + for index in range(len(previous) - 1, -1, -1): + earlier = _question_route(previous[index], previous[:index]) + if earlier is None: + continue + addresses = earlier.addresses or ((typed,) if typed is not None else ()) + return CryptoQuery( + earlier.route, addresses, carried=True, asked=previous[index] + ) + return None + + +def _question_route(text: str, previous: Sequence[str]) -> CryptoQuery | None: activity = activity_question(text, previous) if activity is not None: return CryptoQuery( diff --git a/src/chatmemory/domain/currency.py b/src/chatmemory/domain/currency.py index 6680b24..5979b2d 100644 --- a/src/chatmemory/domain/currency.py +++ b/src/chatmemory/domain/currency.py @@ -74,8 +74,10 @@ class Currency: _NAMES: Mapping[str, tuple[str, ...]] = MappingProxyType( { - "BRL": ("real", "reais", "real brasileiro", "reais brasileiros", "brazilian real", - "brazilian reais", "r$"), + # Number and gender as people type them: "reais brasileiro" too. + "BRL": ("real", "reais", "real brasileiro", "reais brasileiros", "reais brasileiro", + "real brasileiros", "brazilian real", "brazilian reais", "brazilian reals", + "r$"), USD: ("dolar", "dolares", "dolar americano", "dolares americanos", "dollar", "dollars", "us dollar", "us dollars", "american dollar", "us$"), "EUR": ("euro", "euros", "€"), diff --git a/tests/e2e/test_facts_and_wallet_follow_up.py b/tests/e2e/test_facts_and_wallet_follow_up.py new file mode 100644 index 0000000..8733f89 --- /dev/null +++ b/tests/e2e/test_facts_and_wallet_follow_up.py @@ -0,0 +1,178 @@ +"""The production DM, replayed: facts said loosely, and the wallet just given. + + Guarde na sua memoria que eu quero os meus valores em reais brasileiro + Oque vide sabe sobre mim ? + Você sabe qual é a moeda do meu país ? + verifique as pools de liquidez nesta wallet 0xB26B…45e0 + Porque você não respondeu o valor ... na minha moeda de base? -> "Qual carteira?" + A carteira que eu acabei de passar -> corpus search + +Each fact line went to the corpus; the follow-ups asked for an address the +asker had typed two turns earlier. The address a follow-up may use is the +asker's own, typed in the same conversation: never somebody else's message, +never retrieved content, never another location's. +""" + +from __future__ import annotations + +from datetime import timedelta + +from tests.e2e.conftest import COLLEAGUE_CRYPTO +from tests.e2e.harness.conversation import E2EBot +from tests.e2e.harness.process import NOW + +ADDRESS = "0xB26B933a075fBB3D4E8b0925CAd4f2bc345475e0" +COLLEAGUES_ADDRESS = "0xD5C95aF87F6e1E83507AC96b2eE4484B9AFEbDd5" +ANAS_ADDRESS = "0x" + "a1" * 20 + +POOLS = f"verifique as pools de liquidez nesta wallet {ADDRESS}" +WHY_NOT_IN_MY_CURRENCY = ( + "Porque você não respondeu o valor das pools na minha moeda de base?" +) +JUST_GAVE = "A carteira que eu acabei de passar" + + +def _reached(bot: E2EBot, since: int, address: str) -> bool: + """Whether a chain read after `since` was about `address`.""" + needle = address.lower()[2:] + explorer = any( + r.url.params.get("holder_address_hash", "").lower() == address.lower() + for r in bot.web.calls[since:] + ) + return explorer or any(needle in str(c).lower() for c in bot.web.rpc_calls(since)) + + +def _no_foreign_reads(bot: E2EBot, since: int) -> None: + for address in (COLLEAGUES_ADDRESS, ANAS_ADDRESS): + assert not _reached(bot, since, address), f"read {address}, which the asker never typed" + + +# --- the facts ------------------------------------------------------------------- + + +async def test_a_currency_kept_in_memory_is_saved_and_shown(bot: E2EBot) -> None: + leo = bot.person("Leo") + dm = bot.dm(leo) + + saved = await dm.say( + "Guarde na sua memoria que eu quero os meus valores em reais brasileiro" + ) + + assert saved.edge() == "NONE" and not saved.searched + assert "também em **real brasileiro (BRL)**" in saved.text + assert (await bot.facts_of(leo))["preferred_currency"] == "BRL" + + shown = await dm.say("Oque vide sabe sobre mim ?") + + assert not shown.searched + assert "• Moeda preferida: **real brasileiro (BRL)**" in shown.text + shown.assert_language("pt") + + currency = await dm.say("Você sabe qual é a moeda do meu país ?") + + assert not currency.searched + assert "Moeda preferida: **real brasileiro (BRL)**" in currency.text + + +async def test_the_currency_asked_for_with_none_saved_says_how_to_save_it(bot: E2EBot) -> None: + leo = bot.person("Leo") + + turn = await bot.dm(leo).say("Você sabe qual é a moeda do meu país ?") + + assert turn.edge() == "NONE" and not turn.searched + assert "Não tenho uma moeda preferida salva para você" in turn.text + assert "`minha moeda é o real`" in turn.text + turn.assert_language("pt") + + +# --- the wallet just given -------------------------------------------------------- + + +async def test_in_a_dm_the_follow_ups_read_the_wallet_the_asker_just_typed(bot: E2EBot) -> None: + leo = bot.person("Leo") + dm = bot.dm(leo) + first = await dm.say(POOLS) + assert first.edge() == "CHAIN" and _reached(bot, 0, ADDRESS) + + since = len(bot.web.calls) + why = await dm.say(WHY_NOT_IN_MY_CURRENCY) + + assert why.edge() == "CHAIN" and not why.searched + assert _reached(bot, since, ADDRESS), "asked 'which wallet?' about the one just given" + assert "Qual carteira" not in why.text and "Which wallet" not in why.text + _no_foreign_reads(bot, since) + + since = len(bot.web.calls) + again = await dm.say(JUST_GAVE) + + assert again.edge() == "CHAIN" and not again.searched, "the follow-up searched the corpus" + assert _reached(bot, since, ADDRESS) + assert COLLEAGUE_CRYPTO not in again.text + _no_foreign_reads(bot, since) + + +async def test_the_bare_follow_up_re_asks_the_question_that_got_which_wallet( + bot: E2EBot, +) -> None: + """The production order: the address typed, a question the route could + not place, then "the wallet I just gave you" -- that question, re-asked.""" + leo = bot.person("Leo") + dm = bot.dm(leo) + await dm.say(f"qual o saldo de {ADDRESS}?") + await dm.say("obrigado") + await dm.say("que dia é hoje?") + await dm.say("e o bitcoin, como está?") + + since = len(bot.web.calls) + turn = await dm.say("the wallet I just gave you") + + assert turn.edge() == "CHAIN" and not turn.searched + assert _reached(bot, since, ADDRESS) + + +async def test_in_a_channel_only_the_address_the_asker_typed_there_is_used(bot: E2EBot) -> None: + leo, ana = bot.person("Leo"), bot.person("Ana") + # Somebody else's address, in the same channel: asked about, and chatted. + await bot.channel("general", ana).say( + f"verifique as pools de liquidez nesta wallet {ANAS_ADDRESS}" + ) + await bot.chatter( + "general", ana, f"minha carteira nova é {ANAS_ADDRESS}", at=NOW - timedelta(minutes=5) + ) + general = bot.channel("general", leo) + await general.say(POOLS) + + since = len(bot.web.calls) + why = await general.say(WHY_NOT_IN_MY_CURRENCY) + again = await general.say(JUST_GAVE) + + assert why.edge() == "CHAIN" and again.edge() == "CHAIN" + assert not why.searched and not again.searched + assert _reached(bot, since, ADDRESS) + _no_foreign_reads(bot, since) + + +async def test_an_address_only_somebody_else_typed_or_the_corpus_holds_is_never_used( + bot: E2EBot, +) -> None: + leo, ana = bot.person("Leo"), bot.person("Ana") + await bot.channel("general", ana).say( + f"verifique as pools de liquidez nesta wallet {ANAS_ADDRESS}" + ) + await bot.chatter( + "general", ana, f"minha carteira nova é {ANAS_ADDRESS}", at=NOW - timedelta(minutes=5) + ) + # Leo's own address, typed in his DM: another location, not this one. + await bot.dm(leo).say(f"qual o saldo de {ADDRESS}?") + general = bot.channel("general", leo) + + since = len(bot.web.calls) + mine = await general.say("quais as minhas pools de liquidez?") + again = await general.say(JUST_GAVE) + + for turn in (mine, again): + assert turn.edge() == "NONE" and not turn.searched + assert "0x" in turn.text, "the reply asks for the address" + _no_foreign_reads(bot, since) + assert not _reached(bot, since, ADDRESS), "an address from the DM leaked into the channel" + assert COLLEAGUE_CRYPTO not in mine.text + again.text diff --git a/tests/unit/test_defi_positions.py b/tests/unit/test_defi_positions.py index b9c57d1..6080072 100644 --- a/tests/unit/test_defi_positions.py +++ b/tests/unit/test_defi_positions.py @@ -634,6 +634,28 @@ async def report( assert third.is_error and "exhausted" in third.text +async def test_a_new_question_about_the_same_wallet_has_its_own_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """"A carteira que eu acabei de passar" re-reads the wallet two questions + after it was typed: a new question, not the same run asking again.""" + + async def report( + self: PositionsProvider, tool: str, address: str, question: str = "" + ) -> str: + return f"{tool} for {address}" + + monkeypatch.setattr(PositionsProvider, "report", report) + provider = _provider(Counting().client(), budget=1) + results = [] + for question in (f"pools of {OWNER}", f"why not in reais {OWNER}", f"pools of {OWNER}"): + with authorized(_clearance(OWNER, question, QueryOrigin.ASKER)): + results.append(await provider.call_tool(LIQUIDITY_TOOL, {"address": OWNER})) + + assert not results[0].is_error and not results[1].is_error + assert results[2].is_error and "exhausted" in results[2].text, "the same question again" + + async def test_one_chain_failing_does_not_hide_the_others( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit/test_facts_and_wallet_follow_up.py b/tests/unit/test_facts_and_wallet_follow_up.py new file mode 100644 index 0000000..800d116 --- /dev/null +++ b/tests/unit/test_facts_and_wallet_follow_up.py @@ -0,0 +1,214 @@ +"""Four production misses in one Portuguese DM, pinned by the sentences sent. + + Guarde na sua memoria que eu quero os meus valores em reais brasileiro + Oque vide sabe sobre mim ? + Você sabe qual é a moeda do meu país ? + verifique as pools de liquidez nesta wallet 0xB26B…45e0 + ... -> "Qual carteira?" + A carteira que eu acabei de passar + +The first three were not recognised as facts and went to the corpus. The last +two asked for an address the asker had typed two turns before. The end-to-end +scenarios are in tests/e2e/test_facts_and_wallet_follow_up.py. +""" + +from __future__ import annotations + +import pytest + +from chatmemory.app.alert_intent import alert_intent +from chatmemory.app.fact_replies import facts_shown_reply +from chatmemory.app.language import Language +from chatmemory.app.routing import ( + FactAction, + asker_typed_address, + fact_intent, + wallet_reference, +) +from chatmemory.app.routing_crypto import CryptoRoute, crypto_route +from chatmemory.domain.currency import currency_code +from chatmemory.ports.facts import FactKind, PersonalFacts + +WALLET = "0xB26B933a075fBB3D4E8b0925CAd4f2bc345475e0" +OTHER = "0xD5C95aF87F6e1E83507AC96b2eE4484B9AFEbDd5" +POOLS = f"verifique as pools de liquidez nesta wallet {WALLET}" + + +# --- 1. a currency kept "in memory" --------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "value"), + [ + ("Guarde na sua memoria que eu quero os meus valores em reais brasileiro", + "reais brasileiro"), + ("guarde na sua memória que eu quero ver os valores em reais", "reais"), + ("lembre que eu quero os meus preços em euros", "euros"), + ("anote que prefiro ver em real brasileiro", "real brasileiro"), + ("remember that I want to see my prices in euros", "euros"), + ("eu quero os valores em reais brasileiros", "reais brasileiros"), + ], +) +def test_a_currency_behind_a_remember_wrapper_is_saved(text: str, value: str) -> None: + intent = fact_intent(text) + assert intent is not None and intent.action is FactAction.SET + assert (intent.kind, intent.value) == (FactKind.PREFERRED_CURRENCY, value) + assert currency_code(value) is not None + + +@pytest.mark.parametrize( + "said", ["reais brasileiro", "real brasileiros", "reais brasileiros", "real brasileiro"] +) +def test_every_number_and_gender_of_the_real_is_brl(said: str) -> None: + assert currency_code(said) == "BRL" + + +@pytest.mark.parametrize( + "text", + [ + "quanto é 100 reais em dólar?", + "quero trocar 100 reais em dólar", + "guarde 100 reais em dólar", + "quero os meus valores em reais do mês passado", + "lembre que eu quero comprar 100 reais em bitcoin", + ], +) +def test_money_talk_behind_the_same_words_is_not_a_preference(text: str) -> None: + intent = fact_intent(text) + assert intent is None or intent.kind is not FactKind.PREFERRED_CURRENCY + + +# --- 2. "what do you know about me", typed as people type ---------------------- + + +@pytest.mark.parametrize( + "text", + [ + "Oque vide sabe sobre mim ?", + "o que vc sabe sobre mim?", + "oq tu sabe sobre mim", + "o que voce sabe de mim?", + "what do u know about me?", + "what do you know about me?", + ], +) +def test_asking_what_the_assistant_knows_shows_the_facts(text: str) -> None: + assert fact_intent(text) == fact_intent("o que você sabe sobre mim?") + intent = fact_intent(text) + assert intent is not None and (intent.action, intent.kind) == (FactAction.SHOW, None) + + +@pytest.mark.parametrize( + "text", + [ + "o que o João sabe sobre mim?", + "o que João sabe sobre mim?", + "o que ele sabe sobre mim?", + "o que a Ana sabe sobre mim?", + "what does John know about me?", + "what do they know about me?", + ], +) +def test_somebody_else_knowing_about_me_is_not_my_facts(text: str) -> None: + intent = fact_intent(text) + assert intent is None or intent.action is not FactAction.SHOW + + +# --- 3. the currency, asked for without "minha" --------------------------------- + + +@pytest.mark.parametrize( + "text", + [ + "Você sabe qual é a moeda do meu país ?", + "qual a moeda do meu país?", + "qual é a minha moeda de base?", + "qual a minha moeda?", + "what's my currency?", + "what is the currency of my country?", + "which currency do I use?", + ], +) +def test_the_currency_asked_for_any_way_is_shown(text: str) -> None: + intent = fact_intent(text) + assert intent is not None + assert (intent.action, intent.kind) == (FactAction.SHOW, FactKind.PREFERRED_CURRENCY) + + +@pytest.mark.parametrize( + ("language", "expected"), + [ + (Language.PORTUGUESE, "Diga `minha moeda é o real`"), + (Language.ENGLISH, "Say `my currency is the euro`"), + ], +) +def test_no_currency_saved_says_how_to_save_one(language: Language, expected: str) -> None: + for direct in (True, False): + text = facts_shown_reply( + PersonalFacts(()), direct, language, FactKind.PREFERRED_CURRENCY + ) + assert expected in text + + +# --- 4. the wallet the asker just gave ------------------------------------------ + + +def test_the_latest_address_the_asker_typed_is_found_in_any_earlier_question() -> None: + previous = (f"saldo de {OTHER}?", POOLS, "e o valor em reais?") + assert asker_typed_address(previous) == WALLET.lower() + assert asker_typed_address(("oi", "tudo bem?")) is None + + +@pytest.mark.parametrize( + "text", + [ + "A carteira que eu acabei de passar", + "essa carteira", + "nessa carteira?", + "a carteira que eu te mandei antes", + "use o mesmo endereço", + "the wallet I just gave you", + "that address", + ], +) +def test_pointing_back_at_a_wallet_is_recognised(text: str) -> None: + assert wallet_reference(text) + + +@pytest.mark.parametrize( + "text", ["qual o saldo dessa carteira", "carteira do João", "the wallet of Ana"] +) +def test_a_question_about_a_wallet_is_not_only_pointing_back(text: str) -> None: + assert not wallet_reference(text) + + +def test_the_bare_follow_up_asks_the_last_chain_question_again_with_that_address() -> None: + previous = (POOLS, "e quanto vale isso nas minhas pools em reais?") + + query = crypto_route("A carteira que eu acabei de passar", previous) + + assert query is not None + assert query.route is CryptoRoute.DEFI_LIQUIDITY + assert query.addresses == (WALLET.lower(),) + assert query.asked == previous[-1] + assert query.carried and not query.mine + + +def test_the_bare_follow_up_with_no_address_typed_asks_for_one() -> None: + query = crypto_route("essa carteira", ("quais as minhas pools de liquidez?",)) + assert query is not None + assert query.addresses == () and not query.mine, "never the saved wallet" + + +def test_the_bare_follow_up_after_no_chain_question_is_not_a_chain_question() -> None: + assert crypto_route("essa carteira", (f"o que disseram sobre {WALLET}?",)) is None + assert crypto_route("essa carteira", ()) is None + + +def test_an_alert_with_no_address_carries_the_one_the_asker_typed_earlier() -> None: + # Further back than a chain follow-up reaches: only the typed-earlier rule. + earlier = (POOLS, "obrigado", "e o bitcoin?", "que dia é hoje?") + intent = alert_intent("me avise quando minha posição sair do range", earlier) + assert intent is not None + assert intent.address is None + assert intent.earlier_address == WALLET.lower() diff --git a/tests/unit/test_suggestion_intent.py b/tests/unit/test_suggestion_intent.py index 712aa8c..443b58f 100644 --- a/tests/unit/test_suggestion_intent.py +++ b/tests/unit/test_suggestion_intent.py @@ -152,6 +152,7 @@ def test_every_route_has_a_case() -> None: NOT_A_ROUTE = { "classify": "fixed path or loop, both the corpus answer", "typed_command_reply": "the reply to a typed command, not the check for one", + "asker_typed_address": "where a chain route's address comes from, not a route", }