From 9f9176449388d3b58bd956192ec98be8829d263d Mon Sep 17 00:00:00 2001 From: chansg Date: Thu, 21 May 2026 12:44:32 +0100 Subject: [PATCH] Harden demo trade confirmation UX --- README.md | 3 +- core/router.py | 5 ++ core/trading/proposals.py | 108 +++++++++++++++++++++++++++++--- docs/trading212-demo-adapter.md | 13 +++- tests/test_router.py | 6 ++ tests/test_trade_proposals.py | 51 ++++++++++++++- tools/run_validation.py | 23 ++++++- 7 files changed, 192 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6b6648e..781ca35 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The long-term direction is a personal AI analyst and desktop co-pilot that can m | Memory | SQLite-backed episodic and semantic memory foundation | | Terminal dashboard | Rich live dashboard for state, latest response, logs, insights, and visual placeholder status | | Finance MVP | Stock quote routing, daily market snapshots, compact spoken finance replies | -| Broker sandbox | Trading 212 demo adapter, read-only account data, and local demo trade proposal journaling | +| Broker sandbox | Trading 212 demo adapter, read-only account data, local demo trade proposal journaling, and pending-proposal review | | Validation | Pytest suite plus a validation harness that writes structured session reports | ## What Aria Is Not @@ -156,6 +156,7 @@ Aria's safety model is architectural, not cosmetic: - Trading 212 is demo-only in the current code path. - Broker operations are read-only until a separate human-gated execution layer exists. - Demo trade proposals are local records only; confirmation does not send broker orders in the current build. +- Trade confirmations prefer typed input; voice recovery is limited to matching an existing pending proposal. - Proactive analyst output is queued by default so it does not interrupt conversation. - Test and validation output should exist before code is merged. - Aria may propose changes, but the user reviews and approves them. diff --git a/core/router.py b/core/router.py index d94fadc..87efec5 100644 --- a/core/router.py +++ b/core/router.py @@ -441,6 +441,11 @@ def _matches_finance_current_event(text_lower: str) -> bool: def _matches_trade_order_proposal(text: str, text_lower: str) -> bool: """Route explicit order intent to the demo trade proposal layer.""" + if any(term in text_lower for term in ("review", "show", "read", "what is", "what's")) and any( + term in text_lower for term in ("pending trade", "trade proposal", "pending proposal") + ): + return True + if "cancel" in text_lower and any( term in text_lower for term in ("trade", "order", "proposal") ): diff --git a/core/trading/proposals.py b/core/trading/proposals.py index 89d651a..3b91f81 100644 --- a/core/trading/proposals.py +++ b/core/trading/proposals.py @@ -30,6 +30,29 @@ SELL_ALIASES = ("sell",) SIDE_TERMS = BUY_ALIASES + SELL_ALIASES +NUMBER_WORDS = { + "zero": "0", + "one": "1", + "two": "2", + "three": "3", + "four": "4", + "five": "5", + "six": "6", + "seven": "7", + "eight": "8", + "nine": "9", + "ten": "10", + "eleven": "11", + "twelve": "12", + "thirteen": "13", + "fourteen": "14", + "fifteen": "15", + "twenty": "20", + "thirty": "30", + "fifty": "50", + "hundred": "100", +} + SYMBOL_ALIASES = { "apple": "AAPL", "nvidia": "NVDA", @@ -111,6 +134,8 @@ def confirmation_phrase(self) -> str: def handle_trade_command(text: str) -> str: """Handle a routed trade proposal, confirmation, or cancellation command.""" text_lower = text.lower() + if _is_review_command(text_lower): + return describe_pending_trade(text) if _is_cancel_command(text_lower): return cancel_pending_trade(text) if _is_confirm_command(text_lower): @@ -135,18 +160,19 @@ def stage_trade_proposal(text: str) -> str: _pending_proposal = proposal _append_journal("proposal_staged", proposal, {}) log.info( - "Demo trade proposal staged: id=%s side=%s symbol=%s quantity=%s notional=%s", + "Demo trade proposal staged: id=%s side=%s symbol=%s quantity=%s " + "notional=%s required_confirmation=%r", proposal.id, proposal.side, proposal.symbol, proposal.quantity, proposal.notional_value, + proposal.confirmation_phrase(), ) return ( - f"Demo trade proposal staged: {proposal.side} {_format_size(proposal)} " - f"{proposal.symbol} at market. Execution is disabled in this build. " - f"To confirm the proposal record, say: {proposal.confirmation_phrase()}. " - "To cancel it, say: cancel trade." + f"Demo proposal staged: {proposal.side} {_format_size(proposal)} " + f"{proposal.symbol}. Execution disabled. Use typed confirmation, " + "or ask me to review pending trade." ) @@ -165,9 +191,13 @@ def confirm_pending_trade(text: str) -> str: proposal, {"source_text": text, "required_phrase": proposal.confirmation_phrase()}, ) + log.info( + "Pending demo trade confirmation phrase: %s", + proposal.confirmation_phrase(), + ) return ( "That confirmation did not match the pending demo trade. " - f"Say: {proposal.confirmation_phrase()}. Or say: cancel trade." + "Use typed confirmation or ask me to review pending trade." ) blocked = _replace_status(proposal, "blocked", ["execution_disabled"]) @@ -202,6 +232,22 @@ def cancel_pending_trade(text: str = "") -> str: return f"Cancelled the pending demo trade proposal for {proposal.symbol}." +def describe_pending_trade(text: str = "") -> str: + """Describe the active pending proposal and log the exact confirmation.""" + proposal = _active_pending_proposal() + if proposal is None: + _append_journal("review_without_pending_proposal", None, {"source_text": text}) + return "There is no pending demo trade proposal, Chan." + + _append_journal("proposal_reviewed", proposal, {"source_text": text}) + log.info("Pending demo trade confirmation phrase: %s", proposal.confirmation_phrase()) + return ( + f"Pending demo proposal: {proposal.side} {_format_size(proposal)} " + f"{proposal.symbol} at market. Execution is disabled. " + "Use typed confirmation, or say cancel trade." + ) + + def parse_trade_proposal(text: str) -> TradeProposal: """Parse user text into a structured proposal and validation status.""" created_at = _utc_now() @@ -392,13 +438,61 @@ def _pending_expired(proposal: TradeProposal) -> bool: def _confirmation_matches_proposal(text: str, proposal: TradeProposal) -> bool: normalized = _normalize_confirmation_text(text) required = _normalize_confirmation_text(proposal.confirmation_phrase()) - return required in normalized + if required in normalized: + return True + + recovered = _extract_confirmation_fields(text) + if not recovered["confirmed"] or not recovered["has_demo_context"]: + return False + if recovered["side"] != proposal.side: + return False + if recovered["symbol"] != proposal.symbol: + return False + + if proposal.quantity is not None: + return recovered["quantity"] == proposal.quantity + if proposal.notional_value is not None: + return recovered["notional_value"] == proposal.notional_value + return False def _normalize_confirmation_text(text: str) -> str: return re.sub(r"\s+", " ", text.lower().replace(",", " ")).strip() +def _extract_confirmation_fields(text: str) -> dict[str, Any]: + recovered_text = _normalize_trade_confirmation_text(text) + return { + "confirmed": "confirm" in recovered_text.split(), + "has_demo_context": any( + term in recovered_text for term in ("demo", "trade", "proposal", "order") + ), + "side": _extract_side(recovered_text), + "symbol": _extract_symbol(recovered_text), + "quantity": _extract_quantity(recovered_text), + "notional_value": _extract_notional(recovered_text)[0], + "recovered_text": recovered_text, + } + + +def _normalize_trade_confirmation_text(text: str) -> str: + """Normalize common STT mistakes only for pending-trade confirmation.""" + text_lower = text.lower() + text_lower = re.sub(r"\b(\d+)upple\b", r"\1 apple", text_lower) + text_lower = re.sub(r"[^a-z0-9£$.\s]", " ", text_lower) + for word, number in NUMBER_WORDS.items(): + text_lower = re.sub(rf"\b{word}\b", number, text_lower) + text_lower = re.sub(r"\bby\b", "buy", text_lower) + text_lower = text_lower.replace("a a p l", "aapl") + return re.sub(r"\s+", " ", text_lower).strip() + + +def _is_review_command(text_lower: str) -> bool: + if not any(term in text_lower for term in ("review", "show", "read", "what is", "what's")): + return False + return any(term in text_lower for term in ("pending trade", "trade proposal", "pending proposal")) + + def _is_cancel_command(text_lower: str) -> bool: return "cancel" in text_lower and any(term in text_lower for term in ("trade", "order", "proposal")) diff --git a/docs/trading212-demo-adapter.md b/docs/trading212-demo-adapter.md index 855d258..cf01011 100644 --- a/docs/trading212-demo-adapter.md +++ b/docs/trading212-demo-adapter.md @@ -73,13 +73,19 @@ Examples: Aria, buy 5 AAPL in demo. Aria, sell 3 shares of NVDA in demo. Aria, buy 250 pounds of Apple in the demo account. +Aria, review pending trade proposal. Aria, cancel trade. Aria, confirm demo buy 5 AAPL. ``` -The confirmation phrase confirms the local proposal record only. In this phase, -Aria responds that execution is disabled and does not send an order to -Trading 212. +Aria keeps spoken proposal replies short to reduce TTS latency. The exact +confirmation phrase is written to `aria.log` and is safest through the terminal +typed input path. Voice confirmation can recover narrow speech-to-text errors +seen in testing, such as `by` for `buy`, `five` for `5`, and `Apple` for +`AAPL`, but only when the recovered side, size, and symbol match the pending +proposal. The confirmation phrase confirms the local proposal record only. In +this phase, Aria responds that execution is disabled and does not send an order +to Trading 212. Proposal events are written to: @@ -90,6 +96,7 @@ data/trading/trade_proposals.jsonl Journal event types include: - `proposal_staged` +- `proposal_reviewed` - `proposal_blocked` - `confirmation_phrase_mismatch` - `proposal_confirmed_execution_blocked` diff --git a/tests/test_router.py b/tests/test_router.py index 2726797..69a3de0 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -125,6 +125,12 @@ def test_demo_trade_cancel_routes_to_proposal_layer() -> None: assert route == {"intent": "trade_order_proposal", "tier": 1} +def test_pending_trade_review_routes_to_proposal_layer() -> None: + route = classify("review pending trade proposal") + + assert route == {"intent": "trade_order_proposal", "tier": 1} + + def test_generic_buy_request_does_not_route_to_trade_proposal() -> None: route = classify("I need to buy milk later") diff --git a/tests/test_trade_proposals.py b/tests/test_trade_proposals.py index 2205f85..7c95020 100644 --- a/tests/test_trade_proposals.py +++ b/tests/test_trade_proposals.py @@ -67,7 +67,8 @@ def test_parse_notional_trade_proposal() -> None: def test_stage_trade_proposal_writes_journal(proposal_journal: Path) -> None: response = proposals.stage_trade_proposal("sell 3 shares of NVDA in demo") - assert "Demo trade proposal staged" in response + assert "Demo proposal staged" in response + assert len(response) < 130 pending = proposals.get_pending_proposal_for_tests() assert pending is not None assert pending.symbol == "NVDA" @@ -99,12 +100,56 @@ def test_confirmation_requires_matching_phrase(proposal_journal: Path) -> None: response = proposals.confirm_pending_trade("confirm trade") assert "did not match" in response + assert "confirm demo buy 5 AAPL" not in response assert proposals.get_pending_proposal_for_tests() is not None events = _read_events(proposal_journal) assert events[-1]["event_type"] == "confirmation_phrase_mismatch" +def test_confirmation_recovers_common_stt_errors(proposal_journal: Path) -> None: + proposals.stage_trade_proposal("buy 5 AAPL in demo") + + response = proposals.confirm_pending_trade("confirm trade by five apple in demo") + + assert "Execution is disabled" in response + assert proposals.get_pending_proposal_for_tests() is None + + events = _read_events(proposal_journal) + assert events[-1]["event_type"] == "proposal_confirmed_execution_blocked" + assert events[-1]["source_text"] == "confirm trade by five apple in demo" + + +def test_confirmation_recovers_by_and_fused_apple_stt_error(proposal_journal: Path) -> None: + proposals.stage_trade_proposal("buy 5 AAPL in demo") + + response = proposals.confirm_pending_trade("Confirm demo by 5upple") + + assert "Execution is disabled" in response + assert proposals.get_pending_proposal_for_tests() is None + + +def test_confirmation_recovery_still_requires_matching_symbol(proposal_journal: Path) -> None: + proposals.stage_trade_proposal("buy 5 AAPL in demo") + + response = proposals.confirm_pending_trade("confirm demo by five nvidia") + + assert "did not match" in response + assert proposals.get_pending_proposal_for_tests() is not None + + +def test_describe_pending_trade_logs_review(proposal_journal: Path) -> None: + proposals.stage_trade_proposal("buy 5 AAPL in demo") + + response = proposals.describe_pending_trade("review pending trade proposal") + + assert "Pending demo proposal: buy 5 AAPL" in response + assert "Execution is disabled" in response + + events = _read_events(proposal_journal) + assert events[-1]["event_type"] == "proposal_reviewed" + + def test_cancel_pending_trade(proposal_journal: Path) -> None: proposals.stage_trade_proposal("buy 5 AAPL in demo") @@ -132,7 +177,7 @@ def test_rejects_missing_order_size(proposal_journal: Path) -> None: def test_brain_trade_handler_stages_without_execution(proposal_journal: Path) -> None: response = brain._handle_trade_order_proposal("buy 5 AAPL in demo") - assert "Demo trade proposal staged" in response - assert "Execution is disabled" in response + assert "Demo proposal staged" in response + assert "Execution disabled" in response events = _read_events(proposal_journal) assert events[0]["event_type"] == "proposal_staged" diff --git a/tools/run_validation.py b/tools/run_validation.py index 194b13d..1868577 100644 --- a/tools/run_validation.py +++ b/tools/run_validation.py @@ -380,13 +380,30 @@ def check_trade_proposal_safety() -> ValidationItem: ) staged = proposals.stage_trade_proposal("buy 5 AAPL in demo") - if "Demo trade proposal staged" not in staged: + if "Demo proposal staged" not in staged: return _item( "trade_proposal_safety", "fail", "valid demo trade proposal was not staged", response=staged, ) + if len(staged) >= 130: + return _item( + "trade_proposal_safety", + "fail", + "demo trade proposal response is too long for voice", + response=staged, + response_chars=len(staged), + ) + + reviewed = proposals.describe_pending_trade("review pending trade proposal") + if "Pending demo proposal" not in reviewed: + return _item( + "trade_proposal_safety", + "fail", + "pending trade review did not describe the proposal", + response=reviewed, + ) mismatch = proposals.confirm_pending_trade("confirm trade") if "did not match" not in mismatch: @@ -397,12 +414,12 @@ def check_trade_proposal_safety() -> ValidationItem: response=mismatch, ) - confirmed = proposals.confirm_pending_trade("confirm demo buy 5 AAPL") + confirmed = proposals.confirm_pending_trade("confirm trade by five apple in demo") if "Execution is disabled" not in confirmed: return _item( "trade_proposal_safety", "fail", - "confirmed proposal did not block execution", + "recovered voice confirmation did not block execution", response=confirmed, )