Skip to content
Merged
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
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,19 +219,28 @@ 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;
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
Expand Down
47 changes: 47 additions & 0 deletions openspec/changes/fix-facts-and-wallet-follow-up/proposal.md
Original file line number Diff line number Diff line change
@@ -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 <one word> sabe sobre mim" and "what do <one
word> 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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions openspec/changes/fix-facts-and-wallet-follow-up/tasks.md
Original file line number Diff line number Diff line change
@@ -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 <you or a typo> sabe sobre mim" / "what do <you> 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
10 changes: 8 additions & 2 deletions src/chatmemory/adapters/chain/clearance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
10 changes: 9 additions & 1 deletion src/chatmemory/app/alert_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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,
)
Expand Down
5 changes: 4 additions & 1 deletion src/chatmemory/app/alert_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions src/chatmemory/app/fact_replies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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á.",
Expand Down Expand Up @@ -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))


Expand Down
36 changes: 34 additions & 2 deletions src/chatmemory/app/reasoning/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
PositionKind,
Route,
RoutingDecision,
asker_typed_address,
classify,
explicit_web_search,
market_follow_up,
Expand Down Expand 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(
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -487,17 +517,19 @@ 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)
if len(named) != 1:
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:
Expand Down
Loading
Loading