walla-cli 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {walla_cli-0.1.4 → walla_cli-0.1.6}/PKG-INFO +4 -2
  2. {walla_cli-0.1.4 → walla_cli-0.1.6}/README.md +3 -1
  3. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/WIRE.md +3 -1
  4. {walla_cli-0.1.4 → walla_cli-0.1.6}/pyproject.toml +1 -1
  5. {walla_cli-0.1.4 → walla_cli-0.1.6}/skills/walla/SKILL.md +12 -9
  6. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_account.py +5 -0
  7. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_edges.py +2 -1
  8. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_negotiate.py +30 -1
  9. walla_cli-0.1.6/tests/test_shortlist.py +50 -0
  10. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_verdict.py +23 -0
  11. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/__init__.py +1 -1
  12. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/actions.py +7 -3
  13. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/cmd_look.py +12 -2
  14. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/instruct.py +33 -4
  15. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/negotiate.py +33 -20
  16. walla_cli-0.1.6/walla/hunter/shortlist.py +50 -0
  17. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/verdict.py +32 -10
  18. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/mcp/server.py +13 -3
  19. {walla_cli-0.1.4 → walla_cli-0.1.6}/.cursor/rules/quality.mdc +0 -0
  20. {walla_cli-0.1.4 → walla_cli-0.1.6}/.github/workflows/ci.yml +0 -0
  21. {walla_cli-0.1.4 → walla_cli-0.1.6}/.gitignore +0 -0
  22. {walla_cli-0.1.4 → walla_cli-0.1.6}/AGENTS.md +0 -0
  23. {walla_cli-0.1.4 → walla_cli-0.1.6}/LICENSE +0 -0
  24. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/assets/walla-github-banner.jpg +0 -0
  25. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/code_quality.md +0 -0
  26. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/data_engineering_standards.md +0 -0
  27. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/mcp.md +0 -0
  28. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/objective_function.md +0 -0
  29. {walla_cli-0.1.4 → walla_cli-0.1.6}/docs/rate_limit_analysis.md +0 -0
  30. {walla_cli-0.1.4 → walla_cli-0.1.6}/fixtures/categories.json +0 -0
  31. {walla_cli-0.1.4 → walla_cli-0.1.6}/fixtures/item_detail.json +0 -0
  32. {walla_cli-0.1.4 → walla_cli-0.1.6}/fixtures/search_bicicleta.json +0 -0
  33. {walla_cli-0.1.4 → walla_cli-0.1.6}/scripts/check_code_quality.py +0 -0
  34. {walla_cli-0.1.4 → walla_cli-0.1.6}/scripts/dogfood_run.py +0 -0
  35. {walla_cli-0.1.4 → walla_cli-0.1.6}/scripts/probe_rate.py +0 -0
  36. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_account_more.py +0 -0
  37. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_catch.py +0 -0
  38. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_cli_json.py +0 -0
  39. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_core.py +0 -0
  40. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_export.py +0 -0
  41. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_http_more.py +0 -0
  42. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_live.py +0 -0
  43. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_parsers.py +0 -0
  44. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_polite.py +0 -0
  45. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_search_api.py +0 -0
  46. {walla_cli-0.1.4 → walla_cli-0.1.6}/tests/test_session_store.py +0 -0
  47. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/__init__.py +0 -0
  48. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/cookies.py +0 -0
  49. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/inbox.py +0 -0
  50. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/login.py +0 -0
  51. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/prompt.py +0 -0
  52. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/account/session_store.py +0 -0
  53. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/__init__.py +0 -0
  54. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/banner.py +0 -0
  55. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/catch.py +0 -0
  56. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/cmd_account.py +0 -0
  57. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/cmd_ops.py +0 -0
  58. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/console.py +0 -0
  59. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/cli/main.py +0 -0
  60. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/__init__.py +0 -0
  61. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/envelope.py +0 -0
  62. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/errors.py +0 -0
  63. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/exceptions.py +0 -0
  64. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/human_fix.py +0 -0
  65. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/core/path.py +0 -0
  66. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/__init__.py +0 -0
  67. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/client.py +0 -0
  68. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/headers.py +0 -0
  69. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/http/polite.py +0 -0
  70. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/__init__.py +0 -0
  71. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/export.py +0 -0
  72. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/export_pdf.py +0 -0
  73. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/profile_store.py +0 -0
  74. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/hunter/watches.py +0 -0
  75. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/mcp/__init__.py +0 -0
  76. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/mcp/_entry.py +0 -0
  77. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/__init__.py +0 -0
  78. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/account.py +0 -0
  79. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/listing.py +0 -0
  80. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/models/profile.py +0 -0
  81. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/search/__init__.py +0 -0
  82. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/search/api.py +0 -0
  83. {walla_cli-0.1.4 → walla_cli-0.1.6}/walla/search/parse.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Wallapop.es CLI - look · offer · talk for humans and agents
5
5
  Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
6
6
  Project-URL: Repository, https://github.com/berendgort/wallapop-cli
@@ -149,7 +149,7 @@ Optional MCP:
149
149
  | Step | Agent alone | Ask human |
150
150
  |------|-------------|-----------|
151
151
  | Search inside mandate | yes | if query vague or budget unset |
152
- | Shortlist | show GRABs | if more than one GRAB, pick the id |
152
+ | Shortlist | show ranked Wallapop rows with markdown item links | if more than one GRAB, pick the id |
153
153
  | Negotiate draft | yes | approve Spanish text |
154
154
  | `say --yes` | never | yes to that exact text |
155
155
  | `offer --yes` | never; refuse if EUR > budget | yes to that exact EUR |
@@ -161,6 +161,8 @@ Rules:
161
161
  - `--yes` means send chat or price offer only. It never completes a purchase.
162
162
  - If they only asked to look, do not send.
163
163
  - Ambiguous match or multiple GRABs: ask once with candidates.
164
+ - Always present Wallapop shortlists ranked best-first with a markdown link on every item URL.
165
+ - Default search is all of Spain. Shipping counts anywhere. Use `walla search --local` only when the human asked for nearby or pickup.
164
166
  - In-person offers outside pickup radius: refuse.
165
167
  - Envelope: `ok`, `api_version`, `data` or `error` / `error_type` / `retryable`.
166
168
 
@@ -105,7 +105,7 @@ Optional MCP:
105
105
  | Step | Agent alone | Ask human |
106
106
  |------|-------------|-----------|
107
107
  | Search inside mandate | yes | if query vague or budget unset |
108
- | Shortlist | show GRABs | if more than one GRAB, pick the id |
108
+ | Shortlist | show ranked Wallapop rows with markdown item links | if more than one GRAB, pick the id |
109
109
  | Negotiate draft | yes | approve Spanish text |
110
110
  | `say --yes` | never | yes to that exact text |
111
111
  | `offer --yes` | never; refuse if EUR > budget | yes to that exact EUR |
@@ -117,6 +117,8 @@ Rules:
117
117
  - `--yes` means send chat or price offer only. It never completes a purchase.
118
118
  - If they only asked to look, do not send.
119
119
  - Ambiguous match or multiple GRABs: ask once with candidates.
120
+ - Always present Wallapop shortlists ranked best-first with a markdown link on every item URL.
121
+ - Default search is all of Spain. Shipping counts anywhere. Use `walla search --local` only when the human asked for nearby or pickup.
120
122
  - In-person offers outside pickup radius: refuse.
121
123
  - Envelope: `ok`, `api_version`, `data` or `error` / `error_type` / `retryable`.
122
124
 
@@ -48,7 +48,9 @@ Reported shapes (confirm with a live token before trusting writes):
48
48
 
49
49
  - Inbox: `GET /api/v3/conversations`
50
50
  - Messages: `GET|POST /api/v3/conversations/{id}/messages`
51
- - Offers: `POST /api/v3/delivery/buyer/offers`
51
+ - Offers: `POST /api/v3/delivery/buyer/offers` with
52
+ `{offer_id, offer_price_amount, offer_price_currency, item_ids}`.
53
+ `offer_id` is a client UUID. `item_ids` is a one-element list of the listing id.
52
54
  - Favorites: `/api/v3/users/me/favorites`, `/api/v3/items/{id}/favorite`
53
55
 
54
56
  Writes that fail closed as `error_type: unsupported` until fixtures exist.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Wallapop.es CLI - look · offer · talk for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -16,7 +16,7 @@ license: MIT
16
16
  pipx install 'walla-cli[mcp]' # PyPI: https://pypi.org/project/walla-cli/
17
17
  walla doctor --json
18
18
  # If auth.session is false: ask human to run `walla login` and paste cookie
19
- walla instruct --json # read mandate + hitl.ladder
19
+ walla instruct --json # read mandate + hitl.ladder + present
20
20
  ```
21
21
 
22
22
  If `mandate.budget` is null, ask once:
@@ -28,12 +28,15 @@ walla setup --lat 41.39 --lon 2.17 --km 30 --budget 400 \
28
28
 
29
29
  ## Loop (efficient HITL)
30
30
 
31
- 1. Search inside mandate: `walla search "…" --json`
31
+ 1. Search Spain-wide inside budget: `walla search "…" --json`
32
+ Distance only if the human asked nearby or pickup: add `--local`.
32
33
  Optional shortlist share: `--export md,csv,html,pdf --out ./shortlist`
33
- or later `walla export --format md,csv,html,pdf --out ./shortlist --json`.
34
- 2. One GRAB → `walla negotiate <id> --json`. Several GRABs → ask human to pick an id.
35
- 3. Show opening + offer_eur + walk_away. Human approves text and EUR.
36
- 4. Only then: `walla say … --yes` or `walla offer … --eur N --yes`
37
- 5. **Stop.** Human pays or meets in the Wallapop app. `--yes` never means buy.
38
-
39
- Reads and negotiate drafts are safe. Sends need confirm. Narrate GRAB/LOOK/PASS + URLs.
34
+ 2. **Present Wallapop results** from `data.shortlist` (already ranked best-first):
35
+ say they are on wallapop.es, number them 1…N, and put a markdown link on
36
+ every `https://es.wallapop.com/item/…` URL. Never summarize without links.
37
+ 3. One GRAB → `walla negotiate <id> --json`. Several GRABs → ask human to pick an id.
38
+ 4. Show opening + offer_eur + walk_away. Human approves text and EUR.
39
+ 5. Only then: `walla say … --yes` or `walla offer … --eur N --yes`
40
+ 6. **Stop.** Human pays or meets in the Wallapop app. `--yes` never means buy.
41
+
42
+ Reads and negotiate drafts are safe. Sends need confirm.
@@ -126,3 +126,8 @@ def test_make_offer_confirm_and_wire(
126
126
  out = make_offer("1", 40, confirm=True)
127
127
  assert out["sent"] is True
128
128
  assert out["quote"]["offer_eur"] == 40
129
+ body = client.post.call_args.kwargs["json_body"]
130
+ assert body["item_ids"] == ["1"]
131
+ assert body["offer_price_amount"] == 40
132
+ assert body["offer_price_currency"] == "EUR"
133
+ assert body["offer_id"] == out["offer_id"]
@@ -68,7 +68,8 @@ def test_verdict_distance_pass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
68
68
  user_allows_shipping=False,
69
69
  location=ListingLocation(latitude=40.4, longitude=-3.7),
70
70
  )
71
- assert score_listing(far, p) == "PASS"
71
+ assert score_listing(far, p) == "GRAB"
72
+ assert score_listing(far, p, local=True) == "PASS"
72
73
 
73
74
 
74
75
  def test_cookie_bare_token(tmp_path: Path) -> None:
@@ -51,10 +51,20 @@ def test_draft_opening_is_specific_and_polite() -> None:
51
51
  def test_offer_band_never_lowballs() -> None:
52
52
  profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=400)
53
53
  brief = draft_negotiation(_listing(price=200), profile)
54
+ assert brief.offer_eur == 164 # fair is 82% of ask
54
55
  assert brief.offer_eur >= 200 * 0.80
55
56
  assert brief.offer_eur <= brief.walk_away_eur
56
57
 
57
58
 
59
+ def test_fair_on_800_opens_near_650() -> None:
60
+ profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=850)
61
+ brief = draft_negotiation(
62
+ _listing(price=800, ship=True, title="North Reach 13m"),
63
+ profile,
64
+ )
65
+ assert brief.offer_eur == 656
66
+
67
+
58
68
  def test_firm_offer_at_floor_within_budget() -> None:
59
69
  profile = Profile(
60
70
  lat=41.39, lon=2.17, km=30, pickup_km=30, budget=400, aggressiveness="firm"
@@ -91,9 +101,28 @@ def test_refuse_outside_pickup() -> None:
91
101
  draft_negotiation(far, profile)
92
102
 
93
103
 
94
- def test_instruct_has_never_pay(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
104
+ def test_shipping_draft_asks_envio_not_pickup() -> None:
105
+ profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=800)
106
+ brief = draft_negotiation(
107
+ _listing(price=800, lat=39.53, lon=2.72, ship=True, title="North Reach 13m 2023"),
108
+ profile,
109
+ )
110
+ blob = f"{brief.opening}\n{brief.offer_line}".lower()
111
+ assert "envío" in blob
112
+ assert "pasar hoy" not in blob
113
+ assert "recojo" not in blob
114
+
115
+
116
+ def test_instruct_spain_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
95
117
  monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
96
118
  data = instruct_recipe()
97
119
  assert data["hitl"]["never_pay"] is True
98
120
  assert data["hitl"]["yes_means"]
99
121
  assert any(s["step"] == "pay_or_buy" for s in data["hitl"]["ladder"])
122
+ blob = " ".join(data["rules"]) + data["hitl"]["ladder"][0]["agent"]
123
+ assert "Spain" in blob
124
+ assert "--local" in blob
125
+ assert "present" in data
126
+ assert "wallapop.es" in data["present"]["channel"]
127
+ assert "es.wallapop.com/item" in data["present"]["format"]
128
+ assert "link" in data["hitl"]["ladder"][1]["agent"].lower()
@@ -0,0 +1,50 @@
1
+ """Ranked shortlist helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
6
+
7
+
8
+ def test_rank_grab_before_look_and_cheaper_first() -> None:
9
+ rows = [
10
+ {
11
+ "id": "look-cheap",
12
+ "title": "Look cheap",
13
+ "verdict": "LOOK",
14
+ "price": {"amount": 200},
15
+ "url": "https://es.wallapop.com/item/look-cheap",
16
+ "location": {"city": "A"},
17
+ "shippable": True,
18
+ },
19
+ {
20
+ "id": "grab-dear",
21
+ "title": "Grab dear",
22
+ "verdict": "GRAB",
23
+ "price": {"amount": 700},
24
+ "url": "https://es.wallapop.com/item/grab-dear",
25
+ "location": {"city": "B"},
26
+ "user_allows_shipping": True,
27
+ },
28
+ {
29
+ "id": "grab-cheap",
30
+ "title": "Grab cheap",
31
+ "verdict": "GRAB",
32
+ "price": {"amount": 430},
33
+ "url": "https://es.wallapop.com/item/grab-cheap",
34
+ "location": {"city": "C"},
35
+ "shippable": True,
36
+ },
37
+ {
38
+ "id": "pass",
39
+ "title": "Pass",
40
+ "verdict": "PASS",
41
+ "price": {"amount": 10},
42
+ "url": "https://es.wallapop.com/item/pass",
43
+ },
44
+ ]
45
+ ranked = rank_shortlist(rows)
46
+ assert [r["id"] for r in ranked] == ["grab-cheap", "grab-dear", "look-cheap"]
47
+ assert ranked[0]["rank"] == 1
48
+ assert ranked[0]["url"].startswith("https://es.wallapop.com/item/")
49
+ assert "Wallapop" in PRESENT_RULE
50
+ assert "markdown" in PRESENT_RULE.lower()
@@ -42,6 +42,29 @@ def test_verdict_grab() -> None:
42
42
  assert score_listing(_listing(price=Money(amount=100, currency="EUR")), p) == "GRAB"
43
43
 
44
44
 
45
+ def test_far_shipping_is_grab_unless_local() -> None:
46
+ p = Profile(lat=41.39, lon=2.17, km=30, budget=800)
47
+ far = _listing(
48
+ price=Money(amount=700, currency="EUR"),
49
+ user_allows_shipping=True,
50
+ shippable=True,
51
+ location=ListingLocation(latitude=39.53, longitude=2.72, city="Mallorca"),
52
+ )
53
+ assert score_listing(far, p) == "GRAB"
54
+ assert score_listing(far, p, local=True) == "PASS"
55
+
56
+
57
+ def test_far_pickup_stays_visible() -> None:
58
+ p = Profile(lat=41.39, lon=2.17, km=5, budget=500)
59
+ far = _listing(
60
+ price=Money(amount=400, currency="EUR"),
61
+ user_allows_shipping=False,
62
+ location=ListingLocation(latitude=40.4, longitude=-3.7, city="Madrid"),
63
+ )
64
+ assert score_listing(far, p) == "GRAB"
65
+ assert score_listing(far, p, local=True) == "PASS"
66
+
67
+
45
68
  def test_watch_diff_and_merge() -> None:
46
69
  assert diff_new_ids(["a"], ["a", "b"]) == ["b"]
47
70
  w = Watch(id="1", keywords="bici", seen_ids=["a"])
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.4"
7
+ __version__ = "0.1.6"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import uuid
5
6
  from typing import Any
6
7
 
7
8
  from walla.account.login import ensure_access_token
@@ -116,18 +117,21 @@ def make_offer(
116
117
  f"In-person listing outside pickup radius ({item.title}). Refuse offer."
117
118
  )
118
119
  http = client or _auth_client()
120
+ offer_id = str(uuid.uuid4())
119
121
  try:
120
122
  raw = http.post(
121
123
  "/api/v3/delivery/buyer/offers",
122
124
  json_body={
123
- "item_id": item_id,
124
- "amount": offer_eur,
125
- "currency": "EUR",
125
+ "offer_id": offer_id,
126
+ "offer_price_amount": offer_eur,
127
+ "offer_price_currency": "EUR",
128
+ "item_ids": [item_id],
126
129
  },
127
130
  auth=True,
128
131
  )
129
132
  return {
130
133
  "sent": True,
134
+ "offer_id": offer_id,
131
135
  "quote": quote.model_dump(mode="json"),
132
136
  "title": item.title,
133
137
  "response": raw,
@@ -11,7 +11,8 @@ import typer
11
11
  from walla.cli.catch import run_cmd
12
12
  from walla.hunter.export import load_last_search, save_last_search, write_exports
13
13
  from walla.hunter.profile_store import load_profile
14
- from walla.hunter.verdict import score_listing
14
+ from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
15
+ from walla.hunter.verdict import geo_scope, score_listing
15
16
  from walla.hunter.watches import (
16
17
  Watch,
17
18
  diff_new_ids,
@@ -33,6 +34,9 @@ def register(app: typer.Typer) -> None:
33
34
  max_price: float | None = typer.Option(None, "--max-price"),
34
35
  category_id: int | None = typer.Option(None, "--category"),
35
36
  order_by: str = typer.Option("most_relevance", "--order"),
37
+ local: bool = typer.Option(
38
+ False, "--local", help="PASS listings outside profile.km"
39
+ ),
36
40
  export: str | None = typer.Option(
37
41
  None, "--export", help="Formats: md,csv,html,pdf"
38
42
  ),
@@ -58,7 +62,7 @@ def register(app: typer.Typer) -> None:
58
62
  )
59
63
  rows = []
60
64
  for listing in result.listings:
61
- listing.verdict = score_listing(listing, profile)
65
+ listing.verdict = score_listing(listing, profile, local=local)
62
66
  rows.append(listing.model_dump(mode="json"))
63
67
  mandate = {
64
68
  "budget": profile.spend_cap,
@@ -66,15 +70,21 @@ def register(app: typer.Typer) -> None:
66
70
  "must_match": profile.must_match,
67
71
  }
68
72
  save_last_search(keywords=keywords, listings=rows, mandate=mandate)
73
+ shortlist = rank_shortlist(rows)
69
74
  payload: dict[str, Any] = {
70
75
  "count": len(rows),
71
76
  "next_page": result.next_page,
72
77
  "listings": rows,
78
+ "shortlist": shortlist,
79
+ "geo": geo_scope(local=local),
73
80
  "mandate": mandate,
81
+ "source": "wallapop.es",
74
82
  "hitl": {
75
83
  "grabs": sum(1 for r in rows if r.get("verdict") == "GRAB"),
76
84
  "ask_if_multiple_grabs": True,
85
+ "present": PRESENT_RULE,
77
86
  "next": (
87
+ "Show shortlist with markdown links (data.shortlist). "
78
88
  "If one GRAB: walla negotiate <id> --json. "
79
89
  "If several: ask human to pick an id. Never pay."
80
90
  ),
@@ -36,12 +36,20 @@ def instruct_recipe() -> dict[str, Any]:
36
36
  "ladder": [
37
37
  {
38
38
  "step": "search",
39
- "agent": "alone inside budget + radius + must_match",
39
+ "agent": (
40
+ "alone, Spain-wide, inside budget + must_match. "
41
+ "Keep model and size in the keywords. "
42
+ "--local only if the human asked nearby or pickup."
43
+ ),
40
44
  "ask_human": "only if query vague or budget unset",
41
45
  },
42
46
  {
43
47
  "step": "shortlist",
44
- "agent": "show GRAB title, EUR, URL, verdict",
48
+ "agent": (
49
+ "Say these are Wallapop.es results. Show numbered "
50
+ "data.shortlist best-first with markdown links on every "
51
+ "https://es.wallapop.com/item/… URL. Never omit links."
52
+ ),
45
53
  "ask_human": "if more than one GRAB, human picks the id",
46
54
  },
47
55
  {
@@ -75,11 +83,28 @@ def instruct_recipe() -> dict[str, Any]:
75
83
  "If no session: ask the human to run `walla login` and paste "
76
84
  "__Secure-next-auth.session-token (or pass --cookie for non-interactive).",
77
85
  "If mandate.budget is null: ask once for budget + aggression + must words.",
78
- "Do the ask with walla … --json and narrate in prose.",
86
+ "Do the ask with walla … --json. Always narrate as Wallapop.es "
87
+ "and paste data.shortlist with full item URLs (markdown links).",
79
88
  "Draft with walla negotiate; never say/offer without human --yes; never pay.",
80
89
  ],
90
+ "present": {
91
+ "channel": "wallapop.es",
92
+ "format": (
93
+ "1. GRAB · Title · €N · City\n"
94
+ " [open on Wallapop](https://es.wallapop.com/item/<web_slug>)"
95
+ ),
96
+ "order": "best-first: GRAB then LOOK; lower EUR within band",
97
+ "rule": (
98
+ "Every shortlist row must include the full item URL. "
99
+ "Never table-only without links."
100
+ ),
101
+ },
81
102
  "verbs": {
82
- "look": ["walla search <q> --json", "walla item <id> --json"],
103
+ "look": [
104
+ "walla search <q> --json",
105
+ "walla search <q> --local --json",
106
+ "walla item <id> --json",
107
+ ],
83
108
  "talk": [
84
109
  "walla negotiate <id> --json",
85
110
  "walla inbox --json",
@@ -92,10 +117,14 @@ def instruct_recipe() -> dict[str, Any]:
92
117
  ],
93
118
  },
94
119
  "rules": [
120
+ "Always present Wallapop shortlists ranked best-first with markdown item links.",
95
121
  "search / item / inbox / negotiate are safe reads or drafts.",
96
122
  "say and offer require --yes / confirm=true; name listing + euros.",
97
123
  "If only asked to look, do not send.",
98
124
  "Ambiguous match or multiple GRABs: ask once with candidates.",
125
+ "Default search is all of Spain. Shipping is enough; do not fence on profile.km.",
126
+ "walla search --local only when the human asked nearby, pickup, or no shipping.",
127
+ "Keep model and size tokens in the query. Do not drop them to widen geography.",
99
128
  "Refuse in-person offers outside pickup radius.",
100
129
  "Refuse offer_eur above mandate.budget.",
101
130
  "Negotiate drafts are win-win: respectful Spanish, no lowballs, no pressure.",
@@ -31,8 +31,8 @@ BANNED_PHRASES = (
31
31
  )
32
32
 
33
33
  _AGGRESSION_PCT: dict[Aggression, float] = {
34
- "soft": 0.95,
35
- "fair": 0.90,
34
+ "soft": 0.90,
35
+ "fair": 0.82,
36
36
  "firm": 0.80,
37
37
  }
38
38
 
@@ -82,17 +82,10 @@ def draft_negotiation(
82
82
  f"Listing is PASS under mandate (must_match / budget / radius): {listing.title}"
83
83
  )
84
84
  walk = _walk_away(ask, profile)
85
- offer = _first_offer(ask, walk, verdict, profile.aggressiveness)
85
+ offer = _first_offer(ask, walk, profile.aggressiveness)
86
86
  specific = _specific_thing(listing)
87
87
  greeting = f"Hola {seller_name}," if seller_name else "Hola,"
88
- opening = (
89
- f"{greeting} me interesa tu {specific}. "
90
- "¿Sigue disponible? Puedo pasar hoy si te va bien."
91
- )
92
- offer_line = (
93
- f"Gracias. Por el estado y lo que hay ahora mismo, te propongo "
94
- f"{_fmt_eur(offer)} € y lo recojo cuando te venga bien."
95
- )
88
+ opening, offer_line, you_give = _copy(greeting, specific, offer, _ships(listing))
96
89
  return NegotiationBrief(
97
90
  item_id=listing.id,
98
91
  title=listing.title,
@@ -105,11 +98,37 @@ def draft_negotiation(
105
98
  offer_line=offer_line,
106
99
  why=_why(ask, offer, verdict, profile.aggressiveness),
107
100
  seller_gives="Un precio justo y una venta rapida sin pelea.",
108
- you_give="Recogida flexible, pago claro, trato respetuoso.",
101
+ you_give=you_give,
109
102
  tone_checks=_tone_checks(opening, offer_line, offer, ask),
110
103
  )
111
104
 
112
105
 
106
+ def _ships(listing: Listing) -> bool:
107
+ return bool(listing.user_allows_shipping or listing.shippable)
108
+
109
+
110
+ def _copy(greeting: str, specific: str, offer: float, ships: bool) -> tuple[str, str, str]:
111
+ if ships:
112
+ opening = (
113
+ f"{greeting} me interesa tu {specific}. "
114
+ "¿Sigue disponible? Me iría bien envío Wallapop."
115
+ )
116
+ offer_line = (
117
+ f"Gracias. Por el estado y lo que hay ahora mismo, te propongo "
118
+ f"{_fmt_eur(offer)} € con envío Wallapop."
119
+ )
120
+ return opening, offer_line, "Envío Wallapop, pago claro, trato respetuoso."
121
+ opening = (
122
+ f"{greeting} me interesa tu {specific}. "
123
+ "¿Sigue disponible? Puedo pasar hoy si te va bien."
124
+ )
125
+ offer_line = (
126
+ f"Gracias. Por el estado y lo que hay ahora mismo, te propongo "
127
+ f"{_fmt_eur(offer)} € y lo recojo cuando te venga bien."
128
+ )
129
+ return opening, offer_line, "Recogida flexible, pago claro, trato respetuoso."
130
+
131
+
113
132
  def _assert_reachable(listing: Listing, profile: Profile) -> None:
114
133
  if listing.user_allows_shipping or listing.shippable:
115
134
  return
@@ -140,17 +159,11 @@ def _walk_away(ask: float, profile: Profile) -> float:
140
159
  return round(min(cap, ask), 2)
141
160
 
142
161
 
143
- def _first_offer(
144
- ask: float, walk: float, verdict: str, aggressiveness: Aggression
145
- ) -> float:
162
+ def _first_offer(ask: float, walk: float, aggressiveness: Aggression) -> float:
146
163
  """Aggression sets the ask percent; never below 80% on first message."""
147
164
  if ask <= 0:
148
165
  return 0.0
149
- pct = _AGGRESSION_PCT.get(aggressiveness, 0.90)
150
- if ask <= walk and verdict == "GRAB" and aggressiveness == "fair":
151
- pct = max(pct, 0.95)
152
- elif ask <= walk and verdict == "GRAB" and aggressiveness == "soft":
153
- pct = 0.98
166
+ pct = _AGGRESSION_PCT.get(aggressiveness, 0.82)
154
167
  raw = ask * pct
155
168
  floor = ask * 0.80
156
169
  offer = max(floor, min(raw, walk, ask))
@@ -0,0 +1,50 @@
1
+ """Ranked shortlist for agents (pure; no HTTP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = ("PRESENT_RULE", "rank_shortlist")
8
+
9
+ _VERDICT_RANK = {"GRAB": 0, "LOOK": 1, "PASS": 2}
10
+
11
+ PRESENT_RULE = (
12
+ "Always say these are Wallapop.es listings. Present a numbered shortlist "
13
+ "ranked best-first (GRAB before LOOK; lower EUR first). Each row must "
14
+ "include the full https://es.wallapop.com/item/… URL as a markdown link. "
15
+ "Never summarize without links."
16
+ )
17
+
18
+
19
+ def rank_shortlist(
20
+ listings: list[dict[str, Any]], *, limit: int = 12
21
+ ) -> list[dict[str, Any]]:
22
+ """Return ranked rows: GRAB then LOOK then PASS; cheaper first within band."""
23
+ scored: list[tuple[int, float, dict[str, Any]]] = []
24
+ for row in listings:
25
+ verdict = str(row.get("verdict") or "LOOK")
26
+ if verdict == "PASS":
27
+ continue
28
+ price = row.get("price") or {}
29
+ amount = float(price.get("amount") or 0) if isinstance(price, dict) else 0.0
30
+ scored.append((_VERDICT_RANK.get(verdict, 9), amount, row))
31
+ scored.sort(key=lambda t: (t[0], t[1]))
32
+ out: list[dict[str, Any]] = []
33
+ for i, (_, amount, row) in enumerate(scored[:limit], start=1):
34
+ loc = row.get("location") or {}
35
+ city = loc.get("city") if isinstance(loc, dict) else None
36
+ out.append(
37
+ {
38
+ "rank": i,
39
+ "id": row.get("id"),
40
+ "title": row.get("title"),
41
+ "price_eur": amount,
42
+ "verdict": row.get("verdict"),
43
+ "url": row.get("url"),
44
+ "city": city,
45
+ "shippable": bool(
46
+ row.get("user_allows_shipping") or row.get("shippable")
47
+ ),
48
+ }
49
+ )
50
+ return out
@@ -8,6 +8,7 @@ from walla.models.listing import Listing
8
8
  from walla.models.profile import Profile
9
9
 
10
10
  __all__ = (
11
+ "geo_scope",
11
12
  "haversine_km",
12
13
  "matches_must",
13
14
  "score_listing",
@@ -32,8 +33,26 @@ def matches_must(listing: Listing, profile: Profile) -> bool:
32
33
  return all(tok in blob for tok in tokens)
33
34
 
34
35
 
35
- def score_listing(listing: Listing, profile: Profile) -> str:
36
- """Return GRAB / LOOK / PASS."""
36
+ def geo_scope(*, local: bool) -> dict[str, object]:
37
+ """Search contract for agents. lat/lon on the wire is a ranking bias."""
38
+ if local:
39
+ return {
40
+ "scope": "local",
41
+ "distance_filter": True,
42
+ "note": "Human asked nearby or pickup. PASS outside profile.km.",
43
+ }
44
+ return {
45
+ "scope": "spain",
46
+ "distance_filter": False,
47
+ "note": (
48
+ "Spain-wide. Shipping counts anywhere. "
49
+ "Use --local only if the human asked nearby, pickup, or no shipping."
50
+ ),
51
+ }
52
+
53
+
54
+ def score_listing(listing: Listing, profile: Profile, *, local: bool = False) -> str:
55
+ """Return GRAB / LOOK / PASS. Distance fences only when local=True."""
37
56
  if listing.reserved:
38
57
  return "PASS"
39
58
  if not matches_must(listing, profile):
@@ -41,14 +60,17 @@ def score_listing(listing: Listing, profile: Profile) -> str:
41
60
  budget = profile.spend_cap
42
61
  if budget is not None and listing.price.amount > budget:
43
62
  return "PASS"
44
- if profile.lat is not None and profile.lon is not None and listing.location:
45
- la, lo = listing.location.latitude, listing.location.longitude
46
- if la is not None and lo is not None:
47
- dist = haversine_km(profile.lat, profile.lon, la, lo)
48
- if dist > profile.km and not listing.user_allows_shipping:
49
- return "PASS"
50
- if budget is not None and listing.price.amount <= budget * 0.85 and dist <= profile.km:
51
- return "GRAB"
63
+ if local and _outside_km(listing, profile):
64
+ return "PASS"
52
65
  if budget is not None and listing.price.amount <= budget * 0.9:
53
66
  return "GRAB"
54
67
  return "LOOK"
68
+
69
+
70
+ def _outside_km(listing: Listing, profile: Profile) -> bool:
71
+ if profile.lat is None or profile.lon is None or not listing.location:
72
+ return False
73
+ la, lo = listing.location.latitude, listing.location.longitude
74
+ if la is None or lo is None:
75
+ return False
76
+ return haversine_km(profile.lat, profile.lon, la, lo) > profile.km
@@ -10,7 +10,8 @@ from walla.account.login import login_cookie_text, whoami
10
10
  from walla.core.envelope import error_payload, success_payload
11
11
  from walla.core.instruct import instruct_recipe
12
12
  from walla.hunter.profile_store import load_profile
13
- from walla.hunter.verdict import score_listing
13
+ from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
14
+ from walla.hunter.verdict import geo_scope, score_listing
14
15
  from walla.search.api import get_categories, get_item, search_listings
15
16
 
16
17
  __all__ = ("build_server",)
@@ -38,6 +39,7 @@ def build_server() -> Any:
38
39
  max_results: int = 40,
39
40
  min_price: float | None = None,
40
41
  max_price: float | None = None,
42
+ local: bool = False,
41
43
  ) -> dict[str, Any]:
42
44
  def _run() -> dict[str, Any]:
43
45
  profile = load_profile()
@@ -56,9 +58,17 @@ def build_server() -> Any:
56
58
  )
57
59
  rows = []
58
60
  for listing in result.listings:
59
- listing.verdict = score_listing(listing, profile)
61
+ listing.verdict = score_listing(listing, profile, local=local)
60
62
  rows.append(listing.model_dump(mode="json"))
61
- return {"count": len(rows), "listings": rows}
63
+ shortlist = rank_shortlist(rows)
64
+ return {
65
+ "count": len(rows),
66
+ "source": "wallapop.es",
67
+ "geo": geo_scope(local=local),
68
+ "shortlist": shortlist,
69
+ "listings": rows,
70
+ "hitl": {"present": PRESENT_RULE},
71
+ }
62
72
 
63
73
  return _wrap(_run)
64
74
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes