From afadd0c0d0ba4a0822ecf613709add289c239112 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 20 Mar 2026 13:35:07 -0400 Subject: [PATCH] Restore skip and move search to find --- pm/tasks.org | 9 ++-- review_products.py | 11 ++++- tests/test_review_workflow.py | 78 +++++++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/pm/tasks.org b/pm/tasks.org index de64779..a7b61de 100644 --- a/pm/tasks.org +++ b/pm/tasks.org @@ -718,12 +718,12 @@ replace the old observed/canonical workflow with a review-first pipeline that us - Direct numeric selection works well for suggestion-heavy review, while `[l]ink existing` remains available as a fallback when the suggestion list is empty or incomplete. - I kept the review data model unchanged from `t1.15`; this task only tightened the prompt format, field order, and save behavior. -* [x] t1.16.1: add catalog search flow to review ui (2-3 commits) +* [X] t1.16.1: add catalog search flow to review ui (2-3 commits) enable fast lookup of catalog items during review via tokenized search and replace manual list scanning ** acceptance criteria -1. replace `[l]ink existing` with `[s]earch` in review prompt: - - `[#] link to suggestion [s]earch [n]ew [x]exclude [q]uit >` +1. replace `[l]ink existing` with `[f]ind` in review prompt: + - `[#] link to suggestion [f]ind [n]ew [s]kip [x]exclude [q]uit >` 2. implement search flow: - on `s`, prompt: `search: ` - tokenize input using same normalization rules as suggestion matching @@ -755,12 +755,13 @@ enable fast lookup of catalog items during review via tokenized search and repla ** evidence - commit: `f93b9aa` - tests: `./venv/bin/python -m unittest discover -s tests`; `./venv/bin/python review_products.py --help`; `./venv/bin/python review_products.py --refresh-only` -- datetime: 2026-03-20 13:32:09 EDT +- datetime: 2026-03-20 13:34:57 EDT ** notes - The search path reuses the same lightweight token matching rules as suggestion ranking, so there is still only one matching system to maintain. - Direct numeric suggestion-pick remains the fastest happy path; search is the fallback when suggestions are sparse or missing. - Search intentionally optimizes for manual speed rather than smart ranking: simple token overlap, max 10 rows, and immediate persistence on selection. +- Follow-up fix: search moved to `[f]ind` so `[s]kip` remains available at the main prompt. * [ ] 1t.10: add optional llm-assisted suggestion workflow for unresolved normalized retailer items (2-4 commits) diff --git a/review_products.py b/review_products.py index 2bbd2ee..9a8a7e8 100644 --- a/review_products.py +++ b/review_products.py @@ -382,7 +382,7 @@ def prompt_resolution(queue_row, related_rows, purchase_rows, catalog_rows, queu prompt_bits = [] if suggestions: prompt_bits.append("[#] link to suggestion") - prompt_bits.extend(["[s]earch", "[n]ew", "e[x]clude", "[q]uit"]) + prompt_bits.extend(["[f]ind", "[n]ew", "[s]kip", "e[x]clude", "[q]uit"]) click.secho(" ".join(prompt_bits) + " >", fg=PROMPT_COLOR) action = click.prompt("", type=str, prompt_suffix=" ").strip().lower() if action.isdigit() and suggestions: @@ -403,6 +403,15 @@ def prompt_resolution(queue_row, related_rows, purchase_rows, catalog_rows, queu if action == "q": return None, None if action == "s": + return { + "normalized_item_id": queue_row["normalized_item_id"], + "catalog_id": "", + "resolution_action": "skip", + "status": "pending", + "resolution_notes": queue_row.get("resolution_notes", ""), + "reviewed_at": str(date.today()), + }, None + if action == "f": while True: query = click.prompt(click.style("search", fg=PROMPT_COLOR), default="", show_default=False).strip() if not query: diff --git a/tests/test_review_workflow.py b/tests/test_review_workflow.py index f0f3e0d..ce8e3d9 100644 --- a/tests/test_review_workflow.py +++ b/tests/test_review_workflow.py @@ -219,7 +219,7 @@ class ReviewWorkflowTests(unittest.TestCase): self.assertIn("Review guide:", result.output) self.assertIn("Review 1/1: MIXED PEPPER", result.output) self.assertIn("2 matched items:", result.output) - self.assertIn("[#] link to suggestion [s]earch [n]ew e[x]clude [q]uit >", result.output) + self.assertIn("[#] link to suggestion [f]ind [n]ew [s]kip e[x]clude [q]uit >", result.output) first_item = result.output.index("[1] MIXED PEPPER 6-PACK | costco | 2026-03-14 | 7.49 | ") second_item = result.output.index("[2] MIXED PEPPER 6-PACK | costco | 2026-03-12 | 6.99 | https://example.test/mixed-pepper.jpg") self.assertLess(first_item, second_item) @@ -401,7 +401,7 @@ class ReviewWorkflowTests(unittest.TestCase): "--limit", "1", ], - input="s\nmixed pepper\n1\nlinked by test\n", + input="f\nmixed pepper\n1\nlinked by test\n", color=True, ) @@ -492,13 +492,85 @@ class ReviewWorkflowTests(unittest.TestCase): "--links-csv", str(links_csv), ], - input="s\nzzz\nq\nq\n", + input="f\nzzz\nq\nq\n", color=True, ) self.assertEqual(0, result.exit_code) self.assertIn("no matches found", result.output) + def test_skip_remains_available_from_main_prompt(self): + with tempfile.TemporaryDirectory() as tmpdir: + purchases_csv = Path(tmpdir) / "purchases.csv" + queue_csv = Path(tmpdir) / "review_queue.csv" + resolutions_csv = Path(tmpdir) / "review_resolutions.csv" + catalog_csv = Path(tmpdir) / "catalog.csv" + links_csv = Path(tmpdir) / "product_links.csv" + + with purchases_csv.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter( + handle, + fieldnames=[ + "purchase_date", + "retailer", + "order_id", + "line_no", + "normalized_item_id", + "catalog_id", + "raw_item_name", + "normalized_item_name", + "image_url", + "upc", + "line_total", + ], + ) + writer.writeheader() + writer.writerow( + { + "purchase_date": "2026-03-14", + "retailer": "giant", + "order_id": "g1", + "line_no": "1", + "normalized_item_id": "gnorm_skip", + "catalog_id": "", + "raw_item_name": "TEST ITEM", + "normalized_item_name": "TEST ITEM", + "image_url": "", + "upc": "", + "line_total": "1.00", + } + ) + + with catalog_csv.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=review_products.build_purchases.CATALOG_FIELDS) + writer.writeheader() + + result = CliRunner().invoke( + review_products.main, + [ + "--purchases-csv", + str(purchases_csv), + "--queue-csv", + str(queue_csv), + "--resolutions-csv", + str(resolutions_csv), + "--catalog-csv", + str(catalog_csv), + "--links-csv", + str(links_csv), + "--limit", + "1", + ], + input="s\n", + color=True, + ) + + self.assertEqual(0, result.exit_code) + with resolutions_csv.open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual("skip", rows[0]["resolution_action"]) + self.assertEqual("pending", rows[0]["status"]) + def test_review_products_creates_catalog_and_resolution(self): with tempfile.TemporaryDirectory() as tmpdir: purchases_csv = Path(tmpdir) / "purchases.csv"