1|from __future__ import annotations
     2|
     3|import hashlib
     4|import html
     5|import json
     6|import re
     7|from dataclasses import dataclass
     8|from datetime import datetime, timezone, timedelta
     9|from decimal import Decimal, InvalidOperation
    10|from sqlite3 import Connection
    11|from typing import Any, Callable
    12|from urllib.parse import quote_plus, urljoin
    13|from urllib.request import Request, urlopen
    14|
    15|from jarvis_finance.services.budget_common import new_id, row_to_dict
    16|from jarvis_finance.services.grocery_optimizer import _items_for_receipt, upsert_product_match, known_mappings_for_item
    17|
    18|SAFE_MATCH_FLAGS = {"exact_match", "close_match", "cheaper_private_label"}
    19|UNSAFE_FLAGS = {"needs_review", "no_price", "no_unit_price", "different_pack_size", "different_quality", "organic_mismatch"}
    20|
    21|
    22|def _now() -> str:
    23|    return datetime.now(timezone.utc).isoformat()
    24|
    25|
    26|def _decimal_text(value: Any) -> str | None:
    27|    if value is None:
    28|        return None
    29|    text = str(value).replace("CHF", "").replace("'", "").replace("’", "").strip().replace(",", ".")
    30|    match = re.search(r"-?\d+(?:\.\d+)?", text)
    31|    if not match:
    32|        return None
    33|    try:
    34|        return f"{Decimal(match.group(0)).quantize(Decimal('0.01')):.2f}"
    35|    except InvalidOperation:
    36|        return None
    37|
    38|
    39|def _money(value: Any) -> Decimal:
    40|    text = _decimal_text(value)
    41|    return Decimal(text or "0.00")
    42|
    43|
    44|def _abs_url(base: str, url: str) -> str:
    45|    return urljoin(base, html.unescape(url or ""))
    46|
    47|
    48|class GroceryProviderRateLimitError(RuntimeError):
    49|    pass
    50|
    51|
    52|@dataclass
    53|class GroceryProviderResult:
    54|    retailer: str
    55|    product_name: str
    56|    product_url: str
    57|    price_text: str | None = None
    58|    unit_price_text: str | None = None
    59|    brand: str | None = None
    60|    image_url: str | None = None
    61|    package_size: str | None = None
    62|    unit: str | None = None
    63|    currency: str = "CHF"
    64|    availability_status: str = "unknown"
    65|    promotion_text: str | None = None
    66|    source: str = "web_fetch"
    67|    confidence: str = "0.60"
    68|    quality_flags: list[str] | None = None
    69|    status: str = "suggested"
    70|    fetched_at: str | None = None
    71|    raw_result: dict[str, Any] | None = None
    72|
    73|    def as_dict(self) -> dict[str, Any]:
    74|        price_decimal = _decimal_text(self.price_text)
    75|        unit_decimal = _decimal_text(self.unit_price_text)
    76|        flags = list(self.quality_flags or [])
    77|        if not price_decimal and "no_price" not in flags:
    78|            flags.append("no_price")
    79|        if not unit_decimal and "no_unit_price" not in flags:
    80|            flags.append("no_unit_price")
    81|        if not flags:
    82|            flags = ["needs_review"]
    83|        return {
    84|            "retailer": self.retailer,
    85|            "product_name": self.product_name,
    86|            "brand": self.brand,
    87|            "product_url": self.product_url,
    88|            "image_url": self.image_url,
    89|            "price_text": price_decimal or self.price_text,
    90|            "price_decimal_text": price_decimal,
    91|            "currency": self.currency,
    92|            "package_size": self.package_size,
    93|            "unit": self.unit,
    94|            "unit_price_text": self.unit_price_text,
    95|            "unit_price_decimal_text": unit_decimal,
    96|            "availability_status": self.availability_status,
    97|            "promotion_text": self.promotion_text,
    98|            "fetched_at": self.fetched_at or _now(),
    99|            "source": self.source,
   100|            "confidence": self.confidence,
   101|            "quality_flags": flags,
   102|            "status": self.status if self.status else ("suggested" if set(flags) & SAFE_MATCH_FLAGS else "needs_review"),
   103|            "raw_result": self.raw_result or {},
   104|        }
   105|
   106|
   107|class GroceryProductProvider:
   108|    retailer = "generic"
   109|    base_url = "https://example.invalid/"
   110|    future_status: str | None = None
   111|
   112|    def __init__(self, fetcher: Callable[[str], str] | None = None, max_requests: int = 5):
   113|        self.fetcher = fetcher or self._default_fetcher
   114|        self.max_requests = max_requests
   115|        self.request_count = 0
   116|
   117|    def search_url(self, query: str, locale: str = "de-CH") -> str:
   118|        return self.base_url + "search?q=" + quote_plus(query)
   119|
   120|    def _default_fetcher(self, url: str) -> str:
   121|        if self.future_status:
   122|            raise NotImplementedError(self.future_status)
   123|        req = Request(url, headers={"User-Agent": "JarvisFinanceBot/1.0 low-volume product price lookup"})
   124|        with urlopen(req, timeout=8) as response:  # noqa: S310 - explicit user-triggered low-volume public lookup
   125|            if response.status == 429:
   126|                raise GroceryProviderRateLimitError("rate_limited")
   127|            return response.read().decode("utf-8", errors="ignore")
   128|
   129|    def _fetch(self, url: str) -> str:
   130|        self.request_count += 1
   131|        if self.request_count > self.max_requests:
   132|            raise GroceryProviderRateLimitError("rate_limited")
   133|        return self.fetcher(url)
   134|
   135|    def normalize_result(self, raw_result: dict[str, Any]) -> GroceryProviderResult:
   136|        return GroceryProviderResult(retailer=self.retailer, **raw_result)
   137|
   138|    def parse_search_results(self, html_text: str) -> list[dict[str, Any]]:
   139|        chunks = re.findall(r"
]*data-product[^>]*>(.*?)
", html_text, flags=re.I | re.S) or [html_text]
   140|        results: list[dict[str, Any]] = []
   141|        for chunk in chunks[:5]:
   142|            link = re.search(r"]+href=[\"']([^\"']+)[\"'][^>]*>(.*?)", chunk, flags=re.I | re.S)
   143|            price = re.search(r"class=[\"'][^\"']*price[^\"']*[\"'][^>]*>(.*?)</", chunk, flags=re.I | re.S)
   144|            unit_price = re.search(r"class=[\"'][^\"']*unit-price[^\"']*[\"'][^>]*>(.*?)</", chunk, flags=re.I | re.S)
   145|            brand = re.search(r"class=[\"'][^\"']*brand[^\"']*[\"'][^>]*>(.*?)</", chunk, flags=re.I | re.S)
   146|            if not link:
   147|                continue
   148|            name = re.sub(r"<[^>]+>", "", link.group(2)).strip()
   149|            results.append({
   150|                "product_name": html.unescape(name),
   151|                "product_url": _abs_url(self.base_url, link.group(1)),
   152|                "price_text": re.sub(r"<[^>]+>", "", price.group(1)).strip() if price else None,
   153|                "unit_price_text": re.sub(r"<[^>]+>", "", unit_price.group(1)).strip() if unit_price else None,
   154|                "brand": re.sub(r"<[^>]+>", "", brand.group(1)).strip() if brand else None,
   155|                "raw_result": {"source": "html_article"},
   156|            })
   157|        return results
   158|
   159|    def _cache_key(self, query: str) -> str:
   160|        return f"provider-search://{self.retailer}/{quote_plus(query.lower().strip())}"
   161|
   162|    def get_cached_result(self, conn: Connection, query: str, max_results: int = 5, max_age_seconds: int = 86400) -> list[dict[str, Any]]:
   163|        rows = conn.execute("SELECT * FROM grocery_product_details_cache WHERE retailer=? AND source_hash=? AND cache_status='cached' ORDER BY fetched_at DESC LIMIT ?", (self.retailer, self._cache_key(query), max_results)).fetchall()
   164|        output = []
   165|        cutoff = datetime.now(timezone.utc) - timedelta(seconds=max_age_seconds)
   166|        for row in rows:
   167|            d = row_to_dict(row)
   168|            try:
   169|                fetched_dt = datetime.fromisoformat(str(d.get("fetched_at")))
   170|                if fetched_dt.tzinfo is None:
   171|                    fetched_dt = fetched_dt.replace(tzinfo=timezone.utc)
   172|                if fetched_dt < cutoff:
   173|                    continue
   174|            except Exception:
   175|                continue
   176|            output.append({"retailer": d["retailer"], "product_name": d.get("product_name"), "brand": d.get("brand"), "product_url": d.get("product_url"), "image_url": d.get("image_url"), "price_text": d.get("price_text"), "price_decimal_text": d.get("price_decimal_text"), "currency": d.get("currency") or "CHF", "package_size": d.get("package_size"), "unit": d.get("unit"), "unit_price_text": d.get("unit_price_text"), "unit_price_decimal_text": d.get("unit_price_decimal_text"), "availability_status": d.get("availability_status"), "promotion_text": d.get("promotion_text"), "fetched_at": d.get("fetched_at"), "source": "cache", "confidence": d.get("confidence") or "0", "quality_flags": json.loads(d.get("quality_flags_json") or "[]"), "status": "suggested"})
   177|        return output
   178|
   179|    def store_cache(self, conn: Connection, query: str, result: dict[str, Any]) -> None:
   180|        detail_id = new_id("gdetail")
   181|        conn.execute("""INSERT OR REPLACE INTO grocery_product_details_cache(detail_id,retailer,product_url,product_name,price_text,unit_price_text,package_size,ingredients_text,nutrition_json,fetched_at,cache_status,source_hash,brand,image_url,price_decimal_text,currency,unit,unit_price_decimal_text,availability_status,promotion_text,source,confidence,quality_flags_json,raw_result_json)
   182|            VALUES (COALESCE((SELECT detail_id FROM grocery_product_details_cache WHERE retailer=? AND product_url=?), ?), ?, ?, ?, ?, ?, ?, NULL, '{}', ?, 'cached', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
   183|            (self.retailer, result["product_url"], detail_id, self.retailer, result["product_url"], result.get("product_name"), result.get("price_text"), result.get("unit_price_text"), result.get("package_size"), result.get("fetched_at") or _now(), self._cache_key(query), result.get("brand"), result.get("image_url"), result.get("price_decimal_text"), result.get("currency") or "CHF", result.get("unit"), result.get("unit_price_decimal_text"), result.get("availability_status"), result.get("promotion_text"), result.get("source"), result.get("confidence"), json.dumps(result.get("quality_flags") or []), json.dumps(result.get("raw_result") or {})))
   184|        conn.commit()
   185|
   186|    def search_products(self, conn: Connection, query: str, retailer: str | None = None, locale: str = "de-CH", use_cache: bool = True, max_results: int = 5, max_age_seconds: int = 86400) -> list[dict[str, Any]]:
   187|        if self.future_status:
   188|            return [{"retailer": self.retailer, "query": query, "status": "needs_review", "future_status": self.future_status, "quality_flags": ["needs_review"], "source": "skeleton"}]
   189|        if use_cache:
   190|            cached = self.get_cached_result(conn, query, max_results=max_results, max_age_seconds=max_age_seconds)
   191|            if cached:
   192|                return cached
   193|        try:
   194|            html_text = self._fetch(self.search_url(query, locale=locale))
   195|            raw_rows = self.parse_search_results(html_text)[:max_results]
   196|            results = [self.normalize_result(raw).as_dict() for raw in raw_rows]
   197|            for result in results:
   198|                self.store_cache(conn, query, result)
   199|            return results
   200|        except GroceryProviderRateLimitError:
   201|            return [{"retailer": self.retailer, "query": query, "status": "needs_review", "quality_flags": ["rate_limited", "needs_review"], "source": "web_fetch", "fetched_at": _now()}]
   202|        except Exception as exc:
   203|            return [{"retailer": self.retailer, "query": query, "status": "needs_review", "quality_flags": ["provider_error", "needs_review"], "error": type(exc).__name__, "source": "web_fetch", "fetched_at": _now()}]
   204|
   205|    def fetch_product_detail(self, url: str) -> dict[str, Any]:
   206|        html_text = self._fetch(url)
   207|        parsed = self.parse_search_results(html_text)
   208|        return parsed[0] if parsed else {"product_url": url, "status": "needs_review"}
   209|
   210|
   211|class MigrosProvider(GroceryProductProvider):
   212|    retailer = "Migros"
   213|    base_url = "https://www.migros.ch/de/search?query="
   214|
   215|    def search_url(self, query: str, locale: str = "de-CH") -> str:
   216|        return self.base_url + quote_plus(query)
   217|
   218|
   219|class CoopProvider(GroceryProductProvider):
   220|    retailer = "Coop"
   221|    base_url = "https://www.coop.ch/de/search/?text="
   222|
   223|    def search_url(self, query: str, locale: str = "de-CH") -> str:
   224|        return self.base_url + quote_plus(query)
   225|
   226|
   227|class AldiSuisseProvider(GroceryProductProvider):
   228|    retailer = "Aldi Suisse"
   229|    future_status = "skeleton_provider_not_live"
   230|
   231|
   232|class LidlSchweizProvider(GroceryProductProvider):
   233|    retailer = "Lidl"
   234|    future_status = "skeleton_provider_not_live"
   235|
   236|
   237|class DennerProvider(GroceryProductProvider):
   238|    retailer = "Denner"
   239|    future_status = "skeleton_provider_not_live"
   240|
   241|
   242|class OttosProvider(GroceryProductProvider):
   243|    retailer = "Otto's"
   244|    future_status = "skeleton_provider_not_live"
   245|
   246|
   247|def provider_for_retailer(retailer: str) -> GroceryProductProvider:
   248|    mapping: dict[str, type[GroceryProductProvider]] = {"Migros": MigrosProvider, "Coop": CoopProvider, "Aldi Suisse": AldiSuisseProvider, "Lidl": LidlSchweizProvider, "Denner": DennerProvider, "Otto's": OttosProvider}
   249|    return mapping.get(retailer, GroceryProductProvider)()
   250|
   251|
   252|def quality_flags_for_match(original_name: str, result: dict[str, Any]) -> list[str]:
   253|    original = original_name.lower()
   254|    candidate = str(result.get("product_name") or "").lower()
   255|    flags: list[str] = []
   256|    if not result.get("price_decimal_text"):
   257|        flags.append("no_price")
   258|    if not result.get("unit_price_decimal_text"):
   259|        flags.append("no_unit_price")
   260|    if original and original == candidate:
   261|

Use offset=261 to continue reading (showing 1-260 of 348 lines)


1|from __future__ import annotations
     2|
     3|import sqlite3
     4|from pathlib import Path
     5|
     6|import pytest
     7|
     8|from jarvis_finance.storage.migrations import apply_migrations, get_schema_version
     9|from jarvis_finance.services.grocery_optimizer import (
    10|    add_demo_migros_receipt,
    11|    accept_grocery_product_match,
    12|    reject_grocery_product_match,
    13|    create_manual_grocery_mapping,
    14|    refresh_grocery_mapping_price,
    15|    get_grocery_optimizer_dashboard,
    16|    run_grocery_optimization,
    17|    generate_grocery_optimization_report,
    18|)
    19|from jarvis_finance.services.grocery_price_providers import search_and_store_product_matches, CoopProvider, GroceryProductProvider, GroceryProviderResult
    20|
    21|
    22|def db() -> sqlite3.Connection:
    23|    conn = sqlite3.connect(':memory:')
    24|    conn.row_factory = sqlite3.Row
    25|    apply_migrations(conn)
    26|    assert get_schema_version(conn) == 34
    27|    return conn
    28|
    29|
    30|def seed_receipt(conn: sqlite3.Connection, raw='Milch 1L', receipt_id='r1') -> dict:
    31|    return add_demo_migros_receipt(conn, purchase_date='2026-05-19', store_name='Migros', receipt_id=receipt_id, items=[{'raw_product_name': raw, 'quantity_text': '1L', 'unit': 'l', 'unit_price_text': '1.50/l', 'total_price_text': '1.50'}])
    32|
    33|
    34|class StaticProvider(GroceryProductProvider):
    35|    def __init__(self, retailer: str, results: list[GroceryProviderResult]) -> None:
    36|        super().__init__(fetcher=lambda _url: "")
    37|        self.retailer = retailer
    38|        self.base_url = f'https://{retailer.lower().replace(" ", "-")}.example/'
    39|        self._results = results
    40|
    41|    def search_products(self, conn: sqlite3.Connection, query: str, retailer: str | None = None, locale: str = 'de-CH', use_cache: bool = True, max_results: int = 5, max_age_seconds: int = 86400) -> list[dict]:
    42|        self.request_count += 1
    43|        return [r.as_dict() for r in self._results[:max_results]]
    44|
    45|
    46|def test_accept_match_creates_preferred_mapping_and_next_receipt_reuses_without_provider_call() -> None:
    47|    conn = db()
    48|    first = seed_receipt(conn, receipt_id='r1')
    49|    provider = CoopProvider(fetcher=lambda _url: '
Prix Garantie Milch 1LCHF 1.201.20/l
')
    50|    found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[first['items'][0]['product_item_id']], use_cache=False)
    51|    accepted = accept_grocery_product_match(conn, found['matches'][0]['match_id'], user_note='passt')
    52|    assert accepted['status'] == 'accepted'
    53|    assert accepted['target_retailer'] == 'Coop'
    54|
    55|    second = seed_receipt(conn, receipt_id='r2')
    56|    fail_provider = CoopProvider(fetcher=lambda _url: (_ for _ in ()).throw(AssertionError('provider must not be called')))
    57|    reused = search_and_store_product_matches(conn, 'r2', providers=[fail_provider], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=True)
    58|    assert reused['provider_calls'] == 0
    59|    assert reused['known_mapping_hits'] == 1
    60|    assert reused['matches'][0]['source'] == 'known_mapping'
    61|    run = run_grocery_optimization(conn, 'r2', selected_retailers=['Coop'], included_product_item_ids=[second['items'][0]['product_item_id']])
    62|    assert run['summary']['replaceable_product_count'] == 1
    63|
    64|
    65|def test_rejected_match_is_persisted_and_not_recommended_again() -> None:
    66|    conn = db()
    67|    receipt = seed_receipt(conn)
    68|    provider = CoopProvider(fetcher=lambda _url: '
Coop Milch 1LCHF 1.201.20/l
')
    69|    found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[receipt['items'][0]['product_item_id']], use_cache=False)
    70|    rejected = reject_grocery_product_match(conn, found['matches'][0]['match_id'], user_note='falsch')
    71|    assert rejected['status'] == 'rejected'
    72|
    73|    second = seed_receipt(conn, receipt_id='r2')
    74|    reused = search_and_store_product_matches(conn, 'r2', providers=[provider], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=True)
    75|    assert all(m.get('status') != 'suggested' for m in reused['matches'])
    76|    run = run_grocery_optimization(conn, 'r2', selected_retailers=['Coop'], included_product_item_ids=[second['items'][0]['product_item_id']])
    77|    assert run['summary']['replaceable_product_count'] == 0
    78|    assert 'Milch 1L' in run['optimized_lists'][0]['rejected_products']
    79|
    80|
    81|def test_manual_mapping_can_be_saved_and_used_after_sourced_cache_refresh() -> None:
    82|    conn = db()
    83|    receipt = seed_receipt(conn)
    84|    mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Denner', target_product_name='Denner Milch 1L', target_product_url='https://www.denner.ch/milch', target_brand='Denner', target_package_size='1L', target_unit='l', match_type='manual_match', candidate_price_text='1.10', candidate_unit_price_text='1.10/l', user_note='manuell notiert')
    85|    assert mapping['status'] == 'accepted'
    86|    assert mapping['match_type'] == 'manual_match'
    87|    assert mapping['last_price_checked_at'] is None
    88|    run_before = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
    89|    assert run_before['summary']['replaceable_product_count'] == 0
    90|    conn.execute("INSERT INTO grocery_product_details_cache(detail_id,retailer,product_url,product_name,price_text,unit_price_text,package_size,ingredients_text,nutrition_json,fetched_at,cache_status,source_hash) VALUES ('d1','Denner','https://www.denner.ch/milch','Denner Milch 1L','1.10','1.10/l','1L',NULL,'{}','2026-05-19T10:00:00+00:00','cached','hash')")
    91|    refreshed = refresh_grocery_mapping_price(conn, mapping['mapping_id'])
    92|    assert refreshed['last_price_checked_at'] == '2026-05-19T10:00:00+00:00'
    93|    run_after = run_grocery_optimization(conn, 'r1', selected_retailers=['Denner'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
    94|    assert run_after['optimized_total_text'] == '1.10'
    95|
    96|
    97|def test_manual_mapping_rejects_unsafe_url_and_match_type() -> None:
    98|    conn = db()
    99|    receipt = seed_receipt(conn)
   100|    for unsafe_url in ['javascript:alert(1)', 'http://127.1/x', 'http://[::1]/x', 'http://169.254.169.254/latest', 'http://172.31.0.1/x', 'http://localhost/x']:
   101|        with pytest.raises(ValueError):
   102|            create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Bad', target_product_url=unsafe_url)
   103|    with pytest.raises(ValueError):
   104|        create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Bad', target_product_url='https://coop.example/bad', match_type='evil_match')
   105|
   106|
   107|def test_dashboard_surfaces_known_mappings_and_cache_status() -> None:
   108|    conn = db()
   109|    receipt = seed_receipt(conn)
   110|    mapping = create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Coop Milch 1L', target_product_url='https://coop.example/milch', match_type='manual_match', candidate_price_text='1.20')
   111|    dash = get_grocery_optimizer_dashboard(conn)
   112|    item = dash['receipts'][0]['items'][0]
   113|    assert item['known_mappings'][0]['mapping_id'] == mapping['mapping_id']
   114|    assert item['known_mappings'][0]['cache_status'] in {'frisch', 'veraltet', 'fehlt', 'fehler'}
   115|
   116|
   117|def test_report_contains_known_mappings_sources_and_disclaimer_runtime_only(tmp_path: Path) -> None:
   118|    conn = db()
   119|    receipt = seed_receipt(conn)
   120|    create_manual_grocery_mapping(conn, product_item_id=receipt['items'][0]['product_item_id'], target_retailer='Coop', target_product_name='Coop Milch 1L', target_product_url='https://coop.example/milch', match_type='manual_match', candidate_price_text='1.20')
   121|    run = run_grocery_optimization(conn, 'r1', selected_retailers=['Coop'], included_product_item_ids=[receipt['items'][0]['product_item_id']])
   122|    report = generate_grocery_optimization_report(conn, run['run_id'], reports_dir=tmp_path / 'runtime-reports', repo_root=tmp_path / 'repo')
   123|    text = Path(report['report_path']).read_text()
   124|    assert report['runtime_only'] is True
   125|    assert 'Bon-Auswahl' in text
   126|    assert 'Analysierte Produkte' in text
   127|    assert 'Akzeptierte Alternativen' in text
   128|    assert 'Abgelehnte Alternativen' in text
   129|    assert 'Unsichere Produkte' in text
   130|    assert 'Nicht gefundene Produkte' in text
   131|    assert 'Geschätzte Ersparnis nur aus sicheren Matches' in text
   132|    assert 'Bekannte Mappings' in text
   133|    assert 'https://coop.example/milch' in text
   134|    assert 'keine medizinische Empfehlung' in text
   135|
   136|
   137|def test_no_price_provider_candidate_is_persisted_and_rejectable() -> None:
   138|    conn = db()
   139|    receipt = seed_receipt(conn)
   140|    provider = StaticProvider('Coop', [GroceryProviderResult(retailer='Coop', product_name='Coop Milch ohne Preis', product_url='https://coop.example/no-price', price_text=None, unit_price_text=None, confidence='0.50')])
   141|    found = search_and_store_product_matches(conn, 'r1', providers=[provider], included_product_item_ids=[receipt['items'][0]['product_item_id']], use_cache=False)
   142|    assert found['matches'][0]['status'] == 'needs_review'
   143|    assert found['matches'][0]['match_id']
   144|    accepted = accept_grocery_product_match(conn, found['matches'][0]['match_id'], save_as_preferred=False)
   145|    assert accepted['status'] == 'needs_review'
   146|    assert accepted['match_type'] == 'manual_match'
   147|    rejected = reject_grocery_product_match(conn, found['matches'][0]['match_id'])
   148|    assert rejected['status'] == 'rejected'
   149|
   150|
   151|def test_rejected_exact_match_does_not_suppress_other_retailers() -> None:
   152|    conn = db()
   153|    receipt = seed_receipt(conn)
   154|    coop = StaticProvider('Coop', [GroceryProviderResult(retailer='Coop', product_name='Coop Milch 1L', product_url='https://coop.example/milch', price_text='1.20', unit_price_text='1.20/l', quality_flags=['close_match'], confidence='0.80')])
   155|    found = search_and_store_product_matches(conn, 'r1', providers=[coop], included_product_item_ids=[receipt['items'][0]['product_item_id']], use_cache=False)
   156|    reject_grocery_product_match(conn, found['matches'][0]['match_id'])
   157|    denner = StaticProvider('Denner', [GroceryProviderResult(retailer='Denner', product_name='Denner Milch 1L', product_url='https://denner.example/milch', price_text='1.10', unit_price_text='1.10/l', quality_flags=['close_match'], confidence='0.80')])
   158|    second = seed_receipt(conn, receipt_id='r2')
   159|    found_again = search_and_store_product_matches(conn, 'r2', providers=[denner], included_product_item_ids=[second['items'][0]['product_item_id']], prefer_known_mappings=True, use_cache=False)
   160|    assert any(m['retailer'] == 'Denner' and m['status'] == 'suggested' for m in found_again['matches'])
   161|


1|<template>
     2|

     3|

     4|
Budget · Migros Optimizer

     5|
Migros Product Savings & Grocery Optimizer

     6|
Produktebene für Essen & Haushalt: Migros-Bon auswählen, Artikel normalisieren, reviewbare Alternativen bei Schweizer Grossisten vergleichen und Einkaufslisten bis maximal 3 Läden erstellen. Keine medizinische Bewertung, keine Bestellung.

     7|

     8|        <button class="btn" @click="load">Aktualisieren</button>
     9|        <button class="btn-secondary" @click="seedDemo">Demo-Bon anlegen</button>
    10|        <button class="btn-secondary" :disabled="!selectedReceipt" @click="searchOnlinePrices">Preise online suchen</button>
    11|        <button class="btn-secondary" :disabled="!selectedReceipt" @click="startAnalysis">Analyse starten</button>
    12|        <button class="btn-secondary" :disabled="!run" @click="createReport">Report erzeugen</button>
    13|

    14|
Gesundheitsanalyse später: health_analysis_status ist vorbereitet, aber Histamin/Behçet/Trigger werden in v1 nicht bewertet.

    15|

    16|
    17|

    18|

    19|
Bon auswählen

    20|

    21|          <button v-for="receipt in dashboard?.receipts || []" :key="receipt.receipt_id" class="receipt-card" :class="selectedReceipt?.receipt_id === receipt.receipt_id ? 'ring-2 ring-cyan-300' : ''" @click="selectReceipt(receipt)">
    22|            {{ receipt.purchase_date }} · {{ receipt.store_name }}
    23|            {{ receipt.article_count }} Artikel · Gesamtbetrag lokal sichtbar
    24|            Bon-ID: {{ receipt.receipt_id.slice(0, 10) }}…
    25|          </button>
    26|
Noch keine Migros-Bons in der Produktdetail-Ansicht.

    27|

    28|

    29|

    30|

    31|
Artikelliste

    32|          Produktfilter
    33|            <select v-model="productFilter" class="ml-2 rounded-xl border border-slate-300 p-2" data-testid="product-filter">
    34|              <option value="all">alle Produkte</option>
    35|              <option value="without_mapping">ohne Mapping</option>
    36|              <option value="accepted">mit akzeptiertem Mapping</option>
    37|              <option value="needs_review">needs_review</option>
    38|              <option value="rejected">abgelehnt</option>
    39|            </select>
    40|
    41|

    42|
Originalprodukt, normalisierter Name, akzeptierte Alternative, Cache-Status, letzter Preischeck und Review-Status bleiben prüfbar.

    43|

    44|

    45|
{{ item.raw_product_name }}<input v-model="includedItems[item.product_item_id]" type="checkbox" aria-label="für Analyse einschließen" />

    46|
Normalisiert: {{ item.normalized_product_name }}

    47|
Menge: {{ item.quantity_text || '—' }} · Preis: {{ item.total_price_text }} CHF

    48|
Akzeptierte Alternative: {{ acceptedMappingFor(item)?.target_product_name || '—' }}

    49|
Status: {{ productStatus(item) }} · Cache: {{ primaryMappingFor(item)?.cache_status || 'fehlt' }} · letzter Preischeck: {{ primaryMappingFor(item)?.last_price_checked_at || '—' }}

    50|
Aktion: {{ item.action_label }}

    51|
Gesundheitsanalyse später · Roadmap: Grocery Health & Trigger Analysis v1

    52|

    53|              Bekannte Mappings
    54|

    55|                {{ mapping.target_retailer }} · {{ mapping.target_product_name }} · {{ mapping.status }} · Cache: {{ mapping.cache_status || 'fehlt' }} · letzter Preischeck: {{ mapping.last_price_checked_at || '—' }}
    56|                <button class="btn-secondary ml-2" type="button" :data-testid="`refresh-mapping-${mapping.mapping_id}`" @click="refreshMapping(mapping.mapping_id)">Preis aktualisieren</button>
    57|

    58|

    59|

    60|

    61|

    62|

    63|
    64|

    65|
Online-Preissuche

    66|

    67|        <input v-model="selectedRetailers" type="checkbox" value="Coop" /> Coop
    68|        <input v-model="selectedRetailers" type="checkbox" value="Migros" /> Migros
    69|        <input v-model="selectedRetailers" type="checkbox" value="Aldi Suisse" /> Aldi
    70|        <input v-model="selectedRetailers" type="checkbox" value="Lidl" /> Lidl
    71|        <input v-model="selectedRetailers" type="checkbox" value="Denner" /> Denner
    72|        <input v-model="selectedRetailers" type="checkbox" value="Otto's" /> Otto's
    73|        <input v-model="useCache" type="checkbox" /> Cache verwenden
    74|        <input v-model="onlySafeMatches" type="checkbox" /> nur sichere Matches
    75|

    76|
Provider werden nur nach Klick auf „Preise online suchen“ aufgerufen. Keine Provider-Calls beim Seiten-Rendern.

    77|

    78|        <article v-for="match in onlineMatches" :key="`${match.product_item_id}-${match.retailer}-${match.product_url}`" class="comparison-card">
    79|          {{ match.retailer }} · {{ match.product_name }}
    80|          Preis: {{ match.price_text || '—' }} CHF · Einheitspreis: {{ match.unit_price_text || '—' }}
    81|          Confidence {{ match.confidence || '—' }} · Qualitätsflag {{ (match.quality_flags || []).join(', ') || '—' }}
    82|          {{ match.product_url }}
    83|          Aktualisiert am {{ match.fetched_at || '—' }} · Quelle {{ match.source || '—' }}
    84|

    85|            <button class="btn-secondary" type="button" :data-testid="`accept-match-${match.match_id}`" :disabled="!match.match_id" @click="acceptMatch(match)">Akzeptieren</button>
    86|            <button class="btn-secondary" type="button" :data-testid="`reject-match-${match.match_id}`" :disabled="!match.match_id" @click="rejectMatch(match)">Ablehnen</button>
    87|            <button class="btn-secondary" type="button">Später prüfen</button>
    88|            <button class="btn-secondary" type="button">Details öffnen</button>
    89|

    90|

    91|

    92|

    93|
    94|

    95|
Manuell zuordnen

    96|
Falls Websuche nichts findet: Händler, Produkt und Quelle manuell speichern. Keine medizinische Bewertung.

    97|

    98|        <select v-model="manualRetailer" data-testid="manual-retailer" class="rounded-xl border border-slate-300 p-2"><option>Coop</option><option>Denner</option><option>Aldi Suisse</option><option>Lidl</option><option>Otto's</option></select>
    99|        <input v-model="manualName" data-testid="manual-name" class="rounded-xl border border-slate-300 p-2" placeholder="Produktname" />
   100|        <input v-model="manualUrl" data-testid="manual-url" class="rounded-xl border border-slate-300 p-2" placeholder="Produkt-URL" />
   101|        <input v-model="manualPrice" class="rounded-xl border border-slate-300 p-2" placeholder="Preis optional" />
   102|        <input v-model="manualUnitPrice" class="rounded-xl border border-slate-300 p-2" placeholder="Einheitspreis optional" />
   103|        <select v-model="manualMatchType" class="rounded-xl border border-slate-300 p-2"><option>manual_match</option><option>exact_match</option><option>close_match</option><option>cheaper_private_label</option></select>
   104|

   105|      <button class="btn-secondary mt-3" data-testid="save-manual-mapping" :disabled="!selectedReceipt || !manualName || !manualUrl" @click="saveManualMapping">Als bevorzugte Alternative speichern</button>
   106|

   107|
   108|

   109|
Ergebnis

   110|

   111|
Original Migros Total{{ run.original_total_text }} CHF

   112|
Optimiertes Total{{ run.optimized_total_text }} CHF

   113|
Geschätzte Ersparnis{{ run.summary.estimated_savings_text }} CHF

   114|
Qualität{{ run.quality_status }}

   115|

   116|
Preisvergleich

   117|

   118|

   119|
Sichere Ersparnis

   120|          <article v-for="row in safeComparisons" :key="`safe-${row.product_item_id}`" class="comparison-card mt-2">
   121|            {{ row.migros_product }}
   122|            Beste Alternative: {{ row.best_alternative || 'nicht gefunden' }} · {{ row.retailer || '—' }}
   123|            Ersparnis: {{ row.savings_text }} CHF · Confidence {{ row.confidence }} · Status {{ row.status }}
   124|            Qualitätsflags: {{ (row.quality_flags || []).join(', ') || '—' }}
   125|

   126|
Keine sichere Ersparnis. Unsichere Produkte werden nicht eingerechnet.

   127|

   128|

   129|
Unsichere Vorschläge

   130|          <article v-for="row in uncertainComparisons" :key="`uncertain-${row.product_item_id}`" class="comparison-card mt-2">
   131|            {{ row.migros_product }}
   132|            Status {{ row.status }} · Confidence {{ row.confidence || '—' }}
   133|            Qualitätsflags: {{ (row.quality_flags || []).join(', ') || '—' }}
   134|

   135|
Keine unsicheren Vorschläge.

   136|

   137|

   138|
Nicht gefundene Produkte

   139|

{{ name }}

   140|
Keine.

   141|

   142|

   143|
Abgelehnte Produkte

   144|

{{ name }}

   145|
Keine.

   146|

   147|

   148|

   149|        <button class="btn-secondary" type="button" @click="showMappingList = !showMappingList">Mapping-Liste anzeigen</button>
   150|        <button class="btn-secondary" type="button" @click="refreshAllMappings">Alle Preise aktualisieren</button>
   151|

   152|

   153|
Auditierbare Mapping-Liste

   154|
Akzeptierte und abgelehnte Mappings bleiben prüfbar; keine Produktdetails werden ins Git geschrieben.

   155|

   156|
{{ mapping.target_retailer }} · {{ mapping.target_product_name }} · {{ mapping.status }} · Cache: {{ mapping.cache_status || 'fehlt' }}

   157|

   158|

   159|
Optimierte Einkaufsliste max. 3 Läden

   160|

   161|
{{ store.retailer }}

{{ it.product }} → {{ it.candidate_product_name }} ({{ it.price_text }} CHF)

   162|

   163|
{{ reportMessage }}

   164|

   165|

   166|</template>
   167|
   256|
   257|


Explanation:
**Updating provider tests**

I need to update the tests to assert version 35. I’m considering implementing the provider documentation, maybe in the docs file. It looks like I’ll need the OpenPrices provider in price_providers.py and also budget API endpoints. I'll think about the frontend API types, using smaller configurations. It seems like the optimizer run can be made safer with mapping, which perhaps wouldn't need a source or date. I need to enforce those changes.


   261|            mapping_flags = set(json.loads(mapping.get("quality_flags_json") or "[]"))
   262|            if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at") and "manual_price" not in mapping_flags:
   263|                candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": "known_mapping"})
   264|        rejected_mappings = [m for m in conn.execute("SELECT * FROM grocery_product_mappings WHERE source_product_normalized_name=? AND status='rejected'", (item.get("normalized_product_name"),)).fetchall()]
   265|        safe = [m for m in candidates if _safe_match(m)]
   266|        best = min(safe, key=lambda m: _money(m["candidate_price_text"]), default=None)
   267|        if best:
   268|            retailer_savings[best["retailer"]] += max(Decimal("0.00"), item_price - _money(best["candidate_price_text"]))
   269|        elif candidates:
   270|            uncertain += 1
   271|        item_options.append({"item": item, "item_price": item_price, "candidates": candidates, "safe": safe, "best": best, "has_rejected": bool(rejected_mappings)})
   272|
   273|    allowed_retailers = set(r for r, _saving in sorted(retailer_savings.items(), key=lambda kv: kv[1], reverse=True)[:max_store_count])
   274|    comparisons = []
   275|    by_store: dict[str, list[dict[str, Any]]] = defaultdict(list)
   276|    optimized = Decimal("0.00")
   277|    replaceable = 0
   278|    for option in item_options:
   279|        item = option["item"]; item_price = option["item_price"]
   280|        safe_in_allowed = [m for m in option["safe"] if m["retailer"] in allowed_retailers]
   281|        best = min(safe_in_allowed, key=lambda m: _money(m["candidate_price_text"]), default=None)
   282|        original_best = option["best"]
   283|        status = "not_found"
   284|        savings = Decimal("0.00")
   285|        flags: list[str] = []
   286|        if best:
   287|            alt = _money(best["candidate_price_text"])
   288|            savings = max(Decimal("0.00"), item_price - alt)
   289|            optimized += alt
   290|            replaceable += 1
   291|            status = "suggested"
   292|            flags = json.loads(best.get("quality_flags_json") or "[]")
   293|            by_store[best["retailer"]].append({"product": item["raw_product_name"], "candidate_product_name": best["candidate_product_name"], "price_text": _fmt(alt), "source_url": best["candidate_url"]})
   294|        else:
   295|            optimized += item_price
   296|            if original_best and original_best.get("retailer") not in allowed_retailers:
   297|                status = "not_selected_due_to_store_limit"
   298|                flags = json.loads(original_best.get("quality_flags_json") or "[]")
   299|            elif option.get("has_rejected"):
   300|                status = "rejected"
   301|            elif option["candidates"]:
   302|                status = "needs_review"
   303|                flags = json.loads(option["candidates"][0].get("quality_flags_json") or "[]")
   304|        comparisons.append({"product_item_id": item["product_item_id"], "migros_product": item["raw_product_name"], "migros_price_text": _fmt(item_price), "best_alternative": best["candidate_product_name"] if best else None, "retailer": best["retailer"] if best else None, "alternative_price_text": _fmt(best["candidate_price_text"]) if best else None, "unit_price_text": best.get("candidate_unit_price_text") if best else None, "savings_text": _fmt(savings), "confidence": best.get("match_confidence") if best else "0", "status": status, "quality_flags": flags, "source_url": best.get("candidate_url") if best else None})
   305|    stores = sorted(by_store.items(), key=lambda kv: len(kv[1]), reverse=True)
   306|    optimized_lists = [{"variant": "max_3_stores", "store_count": len(stores), "stores": [{"retailer": retailer, "items": rows} for retailer, rows in stores], "not_found_products": [c["migros_product"] for c in comparisons if c["status"] == "not_found"], "needs_review_products": [c["migros_product"] for c in comparisons if c["status"] == "needs_review"], "rejected_products": [c["migros_product"] for c in comparisons if c["status"] == "rejected"], "store_limit_omitted_products": [c["migros_product"] for c in comparisons if c["status"] == "not_selected_due_to_store_limit"]}]
   307|    savings_total = max(Decimal("0.00"), original - optimized)
   308|    quality = "review_required" if uncertain or any(c["status"] != "suggested" for c in comparisons) else "ok"
   309|    run_id = new_id("grun")
   310|    summary = {"replaceable_product_count": replaceable, "uncertain_product_count": uncertain, "estimated_savings_text": _fmt(savings_total), "optimized_lists": optimized_lists, "product_comparisons": comparisons}
   311|    conn.execute("""INSERT INTO grocery_optimization_runs(run_id,receipt_id,run_date,selected_retailers_json,max_store_count,original_total_text,optimized_total_text,estimated_savings_text,quality_status,summary_json,created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (run_id, receipt_id, _now(), json.dumps(selected), max_store_count, _fmt(original), _fmt(optimized), _fmt(savings_total), quality, json.dumps(summary), _now()))
   312|    conn.commit()
   313|    return {"run_id": run_id, "receipt_id": receipt_id, "max_store_count": max_store_count, "original_total_text": _fmt(original), "optimized_total_text": _fmt(optimized), "quality_status": quality, "summary": summary, "optimized_lists": optimized_lists, "product_comparisons": comparisons, "health_analysis_status": "prepared_not_run"}
   314|
   315|def _outside_repo(path: Path, repo_root: Path) -> bool:
   316|    try:
   317|        path.resolve().relative_to(repo_root.resolve())
   318|        return False
   319|    except ValueError:
   320|        return True
   321|
   322|
   323|def generate_grocery_optimization_report(conn: Connection, run_id: str, *, reports_dir: Path | None = None, repo_root: Path | None = None) -> dict[str, Any]:
   324|    repo = (repo_root or find_repo_root()).resolve()
   325|    if reports_dir is None:
   326|        reports_dir = load_settings(repo_root=repo).runtime_paths.reports_dir / "grocery_optimizer"
   327|    reports_dir = reports_dir.resolve()
   328|    if not _outside_repo(reports_dir, repo):
   329|        raise ValueError("reports_dir_must_be_outside_repo")
   330|    row = conn.execute("SELECT * FROM grocery_optimization_runs WHERE run_id=?", (run_id,)).fetchone()
   331|    if not row:
   332|        raise ValueError("run_not_found")
   333|    run = row_to_dict(row); summary = json.loads(run["summary_json"])
   334|    items = _items_for_receipt(conn, run["receipt_id"])
   335|    date = items[0].get("purchase_date") if items else run["run_date"][:10]
   336|    reports_dir.mkdir(parents=True, exist_ok=True)
   337|    path = (reports_dir / f"migros_einkaufsoptimierung_{date}_{run_id}.md").resolve()
   338|    if reports_dir not in path.parents:
   339|        raise ValueError("report_path_must_stay_under_runtime_reports_dir")
   340|    lines = [
   341|        f"# Migros Einkaufsoptimierung – {date}",
   342|        "",
   343|        "## Bon-Auswahl",
   344|        f"- Bon: {run['receipt_id']}",
   345|        f"- Datum: {date}",
   346|        "",
   347|        "## Analysierte Produkte",
   348|        f"- Artikel: {len(items)}",
   349|        f"- Originaltotal: CHF {run['original_total_text']}",
   350|        "",
   351|        "## Optimierung",
   352|        f"- Optimiertes Total: CHF {run['optimized_total_text']}",
   353|        f"- Geschätzte Ersparnis nur aus sicheren Matches: CHF {run['estimated_savings_text']}",
   354|        f"- Qualität: {run['quality_status']}",
   355|        "",
   356|        "## Akzeptierte Alternativen",
   357|    ]
   358|    accepted = [c for c in summary.get("product_comparisons", []) if c.get("best_alternative") and c.get("status") in {"suggested", "accepted"}]
   359|    lines += [f"- {c['migros_product']} → {c['retailer']} · {c['best_alternative']} · Quelle {c.get('source_url') or 'fehlt'} · Preiszeitpunkt siehe Cache" for c in accepted] or ["- keine"]
   360|    lines += ["", "## Abgelehnte Alternativen"]
   361|    lines += [f"- {p}" for p in summary.get("optimized_lists", [{}])[0].get("rejected_products", [])] or ["- keine"]
   362|    lines += ["", "## Unsichere Produkte"]
   363|    lines += [f"- {p}" for p in summary.get("optimized_lists", [{}])[0].get("needs_review_products", [])] or ["- keine"]
   364|    lines += ["", "## Nicht gefundene Produkte"]
   365|    lines += [f"- {p}" for p in summary.get("optimized_lists", [{}])[0].get("not_found_products", [])] or ["- keine"]
   366|    lines += ["", "## Bekannte Mappings"]
   367|    mappings = conn.execute("SELECT * FROM grocery_product_mappings WHERE source_product_normalized_name IN (SELECT normalized_product_name FROM grocery_product_items WHERE receipt_id=?) ORDER BY status, target_retailer", (run["receipt_id"],)).fetchall()
   368|    lines += [f"- {m['source_product_raw_name'] or m['source_product_normalized_name']} → {m['target_retailer']} · {m['target_product_name']} · {m['target_product_url']} · Status {m['status']} · Preiszeitpunkt {m['last_price_checked_at'] or 'fehlt'}" for m in mappings] or ["- keine"]
   369|    lines += ["", "## Quellen", "Alle Alternativen enthalten Quellen-URLs und Zeitstempel im lokalen Cache.", "", "## Disclaimer", "Preise können sich ändern. Produktgleichheit nicht garantiert. Verfügbarkeit nicht garantiert. Dies ist keine medizinische Empfehlung. Keine automatische Bestellung."]
   370|    path.write_text("\n".join(lines), encoding="utf-8")
   371|    conn.execute("UPDATE grocery_optimization_runs SET report_path=? WHERE run_id=?", (str(path), run_id)); conn.commit()
   372|    return {"run_id": run_id, "format": "markdown", "report_path": str(path), "runtime_only": True}
   373|


Ich ergänze jetzt die Backend-MVPs testbar: Provider-Strategie/LLM-Regeln, Rappn nur Link-Out, Open-Prices Spike, manuelle Preis-Preview/Confirm mit Audit und strengere sichere Ersparnis-Regeln.


patch

--- a//home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py
+++ b//home/agent/.hermes/repos/FinanceManager/tests/unit/test_grocery_matching_learning_v2.py
@@ -23,7 +23,7 @@
     conn = sqlite3.connect(':memory:')
     conn.row_factory = sqlite3.Row
     apply_migrations(conn)
-    assert get_schema_version(conn) == 34
+    assert get_schema_version(conn) == 35
     return conn


from urllib.parse import quote_plus, urlparse


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -11,7 +11,7 @@
 from pathlib import Path
 from sqlite3 import Connection
 from typing import Any
-from urllib.parse import urlparse
+from urllib.parse import quote_plus, urlparse

 from jarvis_finance.config.settings import find_repo_root, load_settings
 from jarvis_finance.services.budget_common import new_id, row_to_dict

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.


SAFE_RETAILERS = {"Coop", "Aldi Suisse", "Lidl", "Denner", "Otto's", "Migros"}
SAFE_MATCH_TYPES = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
SAFE_FLAGS = {"exact_match", "close_match", "cheaper_private_label", "manual_match", "manual_price"}
UNCERTAIN_FLAGS = {"different_pack_size", "needs_review", "bio_vs_non_bio", "brand_vs_private_label", "no_price", "no_unit_price"}
GROCERY_LLM_POLICY = {
    "allowed": ["Produktnamen normalisieren", "Einheiten erkennen", "Produktkandidaten vergleichen", "ähnliche Produkte clustern", "Barcode/Produkttext interpretieren", "Review-Vorschläge formulieren"],
    "forbidden": ["Preise erfinden", "als Preisquelle dienen", "sichere Ersparnis entscheiden", "Mapping ohne User Review bestätigen"],
}
PROVIDER_MATRIX = [
    {"provider": "Rappn.ch", "data_source": "Web-App / keine öffentliche API bestätigt", "price": "manuell prüfbar", "unit_price": "manuell prüfbar", "package_size": "manuell prüfbar", "official_api": False, "scraping_risk": "mittel", "stability": "nicht automatisiert", "recommendation": "nur Link-Out und manuelle Erfassung; später API-Anfrage"},
    {"provider": "Open Food Facts", "data_source": "öffentliche Produktdaten-API", "price": "nein", "unit_price": "nein", "package_size": "teilweise", "official_api": True, "scraping_risk": "niedrig", "stability": "mittel", "recommendation": "Produkt-/Barcode-Mapping, nicht Preisquelle"},
    {"provider": "Open Prices", "data_source": "öffentliche Open-Prices-API", "price": "teilweise", "unit_price": "teilweise/ableitbar", "package_size": "über Produktdaten teilweise", "official_api": True, "scraping_risk": "niedrig", "stability": "mittel", "recommendation": "Spike als unsicherer Kandidat mit Quelle/Datum; sichere Ersparnis nur bei vollständigen Daten"},
    {"provider": "FoodRepo", "data_source": "Schweizer Produktdaten, zu Open Food Facts migriert/redirect", "price": "nein", "unit_price": "nein", "package_size": "teilweise", "official_api": "legacy/unklar", "scraping_risk": "niedrig", "stability": "niedrig", "recommendation": "nicht als Preisquelle; ggf. Produktdaten über OFF"},
    {"provider": "Pepesto", "data_source": "kommerzielle Grocery API", "price": "möglich", "unit_price": "möglich", "package_size": "möglich", "official_api": True, "scraping_risk": "niedrig", "stability": "unklar/kostenpflichtig", "recommendation": "spätere Option, nicht im MVP"},
    {"provider": "Migros/Coop/Denner/Aldi/Lidl/Otto's direkt", "data_source": "Händler-Webseiten", "price": "instabil", "unit_price": "instabil", "package_size": "instabil", "official_api": False, "scraping_risk": "mittel/hoch", "stability": "niedrig", "recommendation": "keine stillen Scrapes; höchstens experimentell und nie sichere Ersparnis"},
]

SAFE_RETAILERS = {"Coop", "Aldi Suisse", "Lidl", "Denner", "Otto's", "Migros"}
SAFE_MATCH_TYPES = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
SAFE_FLAGS = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
UNCERTAIN_FLAGS = {"different_pack_size", "needs_review", "bio_vs_non_bio", "brand_vs_private_label"}


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -18,8 +18,20 @@

 SAFE_RETAILERS = {"Coop", "Aldi Suisse", "Lidl", "Denner", "Otto's", "Migros"}
 SAFE_MATCH_TYPES = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
-SAFE_FLAGS = {"exact_match", "close_match", "cheaper_private_label", "manual_match"}
-UNCERTAIN_FLAGS = {"different_pack_size", "needs_review", "bio_vs_non_bio", "brand_vs_private_label"}
+SAFE_FLAGS = {"exact_match", "close_match", "cheaper_private_label", "manual_match", "manual_price"}
+UNCERTAIN_FLAGS = {"different_pack_size", "needs_review", "bio_vs_non_bio", "brand_vs_private_label", "no_price", "no_unit_price"}
+GROCERY_LLM_POLICY = {
+    "allowed": ["Produktnamen normalisieren", "Einheiten erkennen", "Produktkandidaten vergleichen", "ähnliche Produkte clustern", "Barcode/Produkttext interpretieren", "Review-Vorschläge formulieren"],
+    "forbidden": ["Preise erfinden", "als Preisquelle dienen", "sichere Ersparnis entscheiden", "Mapping ohne User Review bestätigen"],
+}
+PROVIDER_MATRIX = [
+    {"provider": "Rappn.ch", "data_source": "Web-App / keine öffentliche API bestätigt", "price": "manuell prüfbar", "unit_price": "manuell prüfbar", "package_size": "manuell prüfbar", "official_api": False, "scraping_risk": "mittel", "stability": "nicht automatisiert", "recommendation": "nur Link-Out und manuelle Erfassung; später API-Anfrage"},
+    {"provider": "Open Food Facts", "data_source": "öffentliche Produktdaten-API", "price": "nein", "unit_price": "nein", "package_size": "teilweise", "official_api": True, "scraping_risk": "niedrig", "stability": "mittel", "recommendation": "Produkt-/Barcode-Mapping, nicht Preisquelle"},
+    {"provider": "Open Prices", "data_source": "öffentliche Open-Prices-API", "price": "teilweise", "unit_price": "teilweise/ableitbar", "package_size": "über Produktdaten teilweise", "official_api": True, "scraping_risk": "niedrig", "stability": "mittel", "recommendation": "Spike als unsicherer Kandidat mit Quelle/Datum; sichere Ersparnis nur bei vollständigen Daten"},
+    {"provider": "FoodRepo", "data_source": "Schweizer Produktdaten, zu Open Food Facts migriert/redirect", "price": "nein", "unit_price": "nein", "package_size": "teilweise", "official_api": "legacy/unklar", "scraping_risk": "niedrig", "stability": "niedrig", "recommendation": "nicht als Preisquelle; ggf. Produktdaten über OFF"},
+    {"provider": "Pepesto", "data_source": "kommerzielle Grocery API", "price": "möglich", "unit_price": "möglich", "package_size": "möglich", "official_api": True, "scraping_risk": "niedrig", "stability": "unklar/kostenpflichtig", "recommendation": "spätere Option, nicht im MVP"},
+    {"provider": "Migros/Coop/Denner/Aldi/Lidl/Otto's direkt", "data_source": "Händler-Webseiten", "price": "instabil", "unit_price": "instabil", "package_size": "instabil", "official_api": False, "scraping_risk": "mittel/hoch", "stability": "niedrig", "recommendation": "keine stillen Scrapes; höchstens experimentell und nie sichere Ersparnis"},
+]

 def _now() -> str:


def _fmt(value: Decimal | Any) -> str:
    return f"{_money(value):.2f}"

def _audit(conn: Connection, *, action: str, source: str, payload: dict[str, Any], mapping_id: str | None = None, product_item_id: str | None = None) -> None:
    conn.execute(
        "INSERT INTO grocery_mapping_audit_events(audit_id,mapping_id,product_item_id,action,source,payload_json,created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
        (new_id("gaudit"), mapping_id, product_item_id, action, source, json.dumps(payload, ensure_ascii=False, sort_keys=True), _now()),
    )

def get_grocery_provider_strategy() -> dict[str, Any]:
    return {
        "layers": {
            "A_product_mapping": "Migros-Bon-Produkt → normalisiertes Produkt → mögliche Alternativen; keine Preise nötig.",
            "B_price_source": "Preis/Einheitspreis mit Datum und Quelle; Open Prices/manuell/Link-Out/offizielle API.",
            "C_savings": "Ersparnis nur bei Preis, Einheitspreis oder Packungsgröße, vergleichbarer Einheit, Preisdatum, Quelle und hoher Confidence.",
        },
        "provider_matrix": PROVIDER_MATRIX,
        "llm_policy": GROCERY_LLM_POLICY,
        "rappn": {"official_public_api_found": False, "mode": "link_out_only", "api_base_observed": "https://api.rappn.ch returned 403/500 during public docs check"},
        "safety": ["Keine Provider-Abfrage beim normalen Rendern", "Keine Preisannahmen durch LLM", "Unsichere Preise zählen nie als sichere Ersparnis"],
    }

def build_rappn_link_out(product_name: str) -> dict[str, Any]:
    query = str(product_name or "").strip()
    return {"provider": "Rappn.ch", "mode": "link_out_only", "url": f"https://rappn.ch/en/search?q={quote_plus(query)}", "query": query, "manual_source": "manual/rappn_user_checked", "official_public_api_found": False}


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -48,6 +48,32 @@

 def _fmt(value: Decimal | Any) -> str:
     return f"{_money(value):.2f}"
+
+
+def _audit(conn: Connection, *, action: str, source: str, payload: dict[str, Any], mapping_id: str | None = None, product_item_id: str | None = None) -> None:
+    conn.execute(
+        "INSERT INTO grocery_mapping_audit_events(audit_id,mapping_id,product_item_id,action,source,payload_json,created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
+        (new_id("gaudit"), mapping_id, product_item_id, action, source, json.dumps(payload, ensure_ascii=False, sort_keys=True), _now()),
+    )
+
+
+def get_grocery_provider_strategy() -> dict[str, Any]:
+    return {
+        "layers": {
+            "A_product_mapping": "Migros-Bon-Produkt → normalisiertes Produkt → mögliche Alternativen; keine Preise nötig.",
+            "B_price_source": "Preis/Einheitspreis mit Datum und Quelle; Open Prices/manuell/Link-Out/offizielle API.",
+            "C_savings": "Ersparnis nur bei Preis, Einheitspreis oder Packungsgröße, vergleichbarer Einheit, Preisdatum, Quelle und hoher Confidence.",
+        },
+        "provider_matrix": PROVIDER_MATRIX,
+        "llm_policy": GROCERY_LLM_POLICY,
+        "rappn": {"official_public_api_found": False, "mode": "link_out_only", "api_base_observed": "https://api.rappn.ch returned 403/500 during public docs check"},
+        "safety": ["Keine Provider-Abfrage beim normalen Rendern", "Keine Preisannahmen durch LLM", "Unsichere Preise zählen nie als sichere Ersparnis"],
+    }
+
+
+def build_rappn_link_out(product_name: str) -> dict[str, Any]:
+    query = str(product_name or "").strip()
+    return {"provider": "Rappn.ch", "mode": "link_out_only", "url": f"https://rappn.ch/en/search?q={quote_plus(query)}", "query": query, "manual_source": "manual/rappn_user_checked", "official_public_api_found": False}

 def normalize_product_name(raw_product_name: str) -> dict[str, str]:


def _insert_mapping(conn: Connection, *, item: dict[str, Any], target_retailer: str, target_product_name: str, target_product_url: str, match_type: str, status: str, confidence: str = "0.80", user_note: str | None = None, source_match_id: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_text: str | None = None, target_unit_price_text: str | None = None, target_price_date: str | None = None, target_price_source: str | None = None, quality_flags: list[str] | None = None, last_price_checked_at: str | None = None) -> dict[str, Any]:

def _insert_mapping(conn: Connection, *, item: dict[str, Any], target_retailer: str, target_product_name: str, target_product_url: str, match_type: str, status: str, confidence: str = "0.80", user_note: str | None = None, source_match_id: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_text: str | None = None, target_unit_price_text: str | None = None, quality_flags: list[str] | None = None, last_price_checked_at: str | None = None) -> dict[str, Any]:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -201,7 +201,7 @@
     return mappings

-def _insert_mapping(conn: Connection, *, item: dict[str, Any], target_retailer: str, target_product_name: str, target_product_url: str, match_type: str, status: str, confidence: str = "0.80", user_note: str | None = None, source_match_id: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_text: str | None = None, target_unit_price_text: str | None = None, quality_flags: list[str] | None = None, last_price_checked_at: str | None = None) -> dict[str, Any]:
+def _insert_mapping(conn: Connection, *, item: dict[str, Any], target_retailer: str, target_product_name: str, target_product_url: str, match_type: str, status: str, confidence: str = "0.80", user_note: str | None = None, source_match_id: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_text: str | None = None, target_unit_price_text: str | None = None, target_price_date: str | None = None, target_price_source: str | None = None, quality_flags: list[str] | None = None, last_price_checked_at: str | None = None) -> dict[str, Any]:
     if target_retailer not in SAFE_RETAILERS:
         raise ValueError("unsupported_retailer")
     if match_type not in SAFE_MATCH_TYPES:


        """INSERT INTO grocery_product_mappings(mapping_id,source_product_normalized_name,source_product_raw_name,source_retailer,target_retailer,target_product_name,target_product_url,target_brand,target_package_size,target_unit,target_price_text,target_unit_price_text,price_currency,target_price_date,target_price_source,match_type,status,confidence,user_note,source_match_id,quality_flags_json,health_analysis_status,health_flags_json,health_notes,created_at,updated_at,last_price_checked_at)
           VALUES (?, ?, ?, 'Migros', ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, ?, ?, ?, ?, 'prepared_not_run', '{}', ?, ?, ?, ?)""",
        (mapping_id, item.get("normalized_product_name"), item.get("raw_product_name"), target_retailer, target_product_name, target_product_url, target_brand, target_package_size, target_unit, _fmt(target_price_text) if target_price_text else None, target_unit_price_text, target_price_date, target_price_source, match_type, status, confidence, user_note, source_match_id, json.dumps(quality_flags or [match_type]), "Gesundheitsanalyse später", now, now, last_price_checked_at),

        """INSERT INTO grocery_product_mappings(mapping_id,source_product_normalized_name,source_product_raw_name,source_retailer,target_retailer,target_product_name,target_product_url,target_brand,target_package_size,target_unit,target_price_text,target_unit_price_text,price_currency,match_type,status,confidence,user_note,source_match_id,quality_flags_json,health_analysis_status,health_flags_json,health_notes,created_at,updated_at,last_price_checked_at)
           VALUES (?, ?, ?, 'Migros', ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, ?, ?, 'prepared_not_run', '{}', ?, ?, ?, ?)""",
        (mapping_id, item.get("normalized_product_name"), item.get("raw_product_name"), target_retailer, target_product_name, target_product_url, target_brand, target_package_size, target_unit, _fmt(target_price_text) if target_price_text else None, target_unit_price_text, match_type, status, confidence, user_note, source_match_id, json.dumps(quality_flags or [match_type]), "Gesundheitsanalyse später", now, now, last_price_checked_at),


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -210,9 +210,9 @@
     now = _now()
     mapping_id = new_id("gmap")
     conn.execute(
-        """INSERT INTO grocery_product_mappings(mapping_id,source_product_normalized_name,source_product_raw_name,source_retailer,target_retailer,target_product_name,target_product_url,target_brand,target_package_size,target_unit,target_price_text,target_unit_price_text,price_currency,match_type,status,confidence,user_note,source_match_id,quality_flags_json,health_analysis_status,health_flags_json,health_notes,created_at,updated_at,last_price_checked_at)
-           VALUES (?, ?, ?, 'Migros', ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, ?, ?, 'prepared_not_run', '{}', ?, ?, ?, ?)""",
-        (mapping_id, item.get("normalized_product_name"), item.get("raw_product_name"), target_retailer, target_product_name, target_product_url, target_brand, target_package_size, target_unit, _fmt(target_price_text) if target_price_text else None, target_unit_price_text, match_type, status, confidence, user_note, source_match_id, json.dumps(quality_flags or [match_type]), "Gesundheitsanalyse später", now, now, last_price_checked_at),
+        """INSERT INTO grocery_product_mappings(mapping_id,source_product_normalized_name,source_product_raw_name,source_retailer,target_retailer,target_product_name,target_product_url,target_brand,target_package_size,target_unit,target_price_text,target_unit_price_text,price_currency,target_price_date,target_price_source,match_type,status,confidence,user_note,source_match_id,quality_flags_json,health_analysis_status,health_flags_json,health_notes,created_at,updated_at,last_price_checked_at)
+           VALUES (?, ?, ?, 'Migros', ?, ?, ?, ?, ?, ?, ?, ?, 'CHF', ?, ?, ?, ?, ?, ?, ?, ?, 'prepared_not_run', '{}', ?, ?, ?, ?)""",
+        (mapping_id, item.get("normalized_product_name"), item.get("raw_product_name"), target_retailer, target_product_name, target_product_url, target_brand, target_package_size, target_unit, _fmt(target_price_text) if target_price_text else None, target_unit_price_text, target_price_date, target_price_source, match_type, status, confidence, user_note, source_match_id, json.dumps(quality_flags or [match_type]), "Gesundheitsanalyse später", now, now, last_price_checked_at),
     )
     conn.commit()
     return row_to_dict(conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone())


    conn.commit()
    mapping = row_to_dict(conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone())
    _audit(conn, action="mapping_inserted", source=target_price_source or "grocery_optimizer", mapping_id=mapping_id, product_item_id=item.get("product_item_id"), payload={"status": status, "target_retailer": target_retailer, "has_price": bool(target_price_text), "has_unit_price": bool(target_unit_price_text), "price_date": target_price_date})
    conn.commit()
    return mapping

    conn.commit()
    return row_to_dict(conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone())


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -215,7 +215,10 @@
         (mapping_id, item.get("normalized_product_name"), item.get("raw_product_name"), target_retailer, target_product_name, target_product_url, target_brand, target_package_size, target_unit, _fmt(target_price_text) if target_price_text else None, target_unit_price_text, target_price_date, target_price_source, match_type, status, confidence, user_note, source_match_id, json.dumps(quality_flags or [match_type]), "Gesundheitsanalyse später", now, now, last_price_checked_at),
     )
     conn.commit()
-    return row_to_dict(conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone())
+    mapping = row_to_dict(conn.execute("SELECT * FROM grocery_product_mappings WHERE mapping_id=?", (mapping_id,)).fetchone())
+    _audit(conn, action="mapping_inserted", source=target_price_source or "grocery_optimizer", mapping_id=mapping_id, product_item_id=item.get("product_item_id"), payload={"status": status, "target_retailer": target_retailer, "has_price": bool(target_price_text), "has_unit_price": bool(target_unit_price_text), "price_date": target_price_date})
+    conn.commit()
+    return mapping

 def accept_grocery_product_match(conn: Connection, match_id: str, *, user_note: str | None = None, save_as_preferred: bool = True) -> dict[str, Any]:


def preview_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_date: str | None = None, target_price_source: str | None = None, user_note: str | None = None) -> dict[str, Any]:
    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (product_item_id,)).fetchone())
    if not item:
        raise ValueError("product_item_not_found")
    _validate_product_url(target_product_url)
    price = _fmt(candidate_price_text) if candidate_price_text else None
    unit_price = candidate_unit_price_text or (f"{price}/{target_unit}" if price and target_unit else None)
    complete = all([price, unit_price, target_price_date, target_price_source, target_product_url])
    quality_flags = ["manual_match"] + (["manual_price"] if price else ["no_price"]) + ([] if unit_price else ["no_unit_price"])
    if not complete:
        quality_flags.append("needs_review")
    preview = {"preview_id": new_id("gprev"), "product_item_id": product_item_id, "source_product": item.get("raw_product_name"), "target_retailer": target_retailer, "target_product_name": target_product_name, "target_product_url": target_product_url, "target_package_size": target_package_size, "target_unit": target_unit, "candidate_price_text": price, "candidate_unit_price_text": unit_price, "target_price_date": target_price_date, "target_price_source": target_price_source, "user_note": user_note, "quality_flags": quality_flags, "can_count_as_secure_saving": bool(complete), "requires_confirm": True}
    _audit(conn, action="manual_mapping_preview", source=target_price_source or "manual", product_item_id=product_item_id, payload={k: v for k, v in preview.items() if k not in {"source_product"}})
    conn.commit()
    return preview

def confirm_manual_grocery_mapping(conn: Connection, preview: dict[str, Any]) -> dict[str, Any]:
    required = ["product_item_id", "target_retailer", "target_product_name", "target_product_url"]
    if any(not preview.get(k) for k in required):
        raise ValueError("manual_mapping_preview_incomplete")
    return create_manual_grocery_mapping(
        conn,
        product_item_id=preview["product_item_id"],
        target_retailer=preview["target_retailer"],
        target_product_name=preview["target_product_name"],
        target_product_url=preview["target_product_url"],
        candidate_price_text=preview.get("candidate_price_text"),
        candidate_unit_price_text=preview.get("candidate_unit_price_text"),
        target_brand=preview.get("target_brand"),
        target_package_size=preview.get("target_package_size"),
        target_unit=preview.get("target_unit"),
        target_price_date=preview.get("target_price_date"),
        target_price_source=preview.get("target_price_source") or "manual",
        user_note=preview.get("user_note"),
    )

def create_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, match_type: str = "manual_match", candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_date: str | None = None, target_price_source: str | None = None, user_note: str | None = None, confidence: str = "0.95") -> dict[str, Any]:
    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (product_item_id,)).fetchone())
    flags = [match_type]
    if candidate_price_text:
        flags.append("manual_price")
    else:
        flags.append("no_price")
    if not candidate_unit_price_text:
        flags.append("no_unit_price")
    if candidate_price_text and (not target_price_date or not target_price_source):
        flags.append("needs_review")
    mapping = _insert_mapping(conn, item=item, target_retailer=target_retailer, target_product_name=target_product_name, target_product_url=target_product_url, target_brand=target_brand, target_package_size=target_package_size, target_unit=target_unit, target_price_text=candidate_price_text, target_unit_price_text=candidate_unit_price_text, target_price_date=target_price_date, target_price_source=target_price_source, match_type=match_type, status="accepted", confidence=confidence, user_note=user_note, quality_flags=flags, last_price_checked_at=target_price_date)
    return mapping

def create_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, match_type: str = "manual_match", candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, user_note: str | None = None, confidence: str = "0.95") -> dict[str, Any]:
    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (product_item_id,)).fetchone())
    mapping = _insert_mapping(conn, item=item, target_retailer=target_retailer, target_product_name=target_product_name, target_product_url=target_product_url, target_brand=target_brand, target_package_size=target_package_size, target_unit=target_unit, target_price_text=candidate_price_text, target_unit_price_text=candidate_unit_price_text, match_type=match_type, status="accepted", confidence=confidence, user_note=user_note, quality_flags=[match_type, "manual_price" if candidate_price_text else "no_price"], last_price_checked_at=None)
    return mapping


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -245,9 +245,56 @@
     return mapping

-def create_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, match_type: str = "manual_match", candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, user_note: str | None = None, confidence: str = "0.95") -> dict[str, Any]:
+def preview_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_date: str | None = None, target_price_source: str | None = None, user_note: str | None = None) -> dict[str, Any]:
     item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (product_item_id,)).fetchone())
-    mapping = _insert_mapping(conn, item=item, target_retailer=target_retailer, target_product_name=target_product_name, target_product_url=target_product_url, target_brand=target_brand, target_package_size=target_package_size, target_unit=target_unit, target_price_text=candidate_price_text, target_unit_price_text=candidate_unit_price_text, match_type=match_type, status="accepted", confidence=confidence, user_note=user_note, quality_flags=[match_type, "manual_price" if candidate_price_text else "no_price"], last_price_checked_at=None)
+    if not item:
+        raise ValueError("product_item_not_found")
+    _validate_product_url(target_product_url)
+    price = _fmt(candidate_price_text) if candidate_price_text else None
+    unit_price = candidate_unit_price_text or (f"{price}/{target_unit}" if price and target_unit else None)
+    complete = all([price, unit_price, target_price_date, target_price_source, target_product_url])
+    quality_flags = ["manual_match"] + (["manual_price"] if price else ["no_price"]) + ([] if unit_price else ["no_unit_price"])
+    if not complete:
+        quality_flags.append("needs_review")
+    preview = {"preview_id": new_id("gprev"), "product_item_id": product_item_id, "source_product": item.get("raw_product_name"), "target_retailer": target_retailer, "target_product_name": target_product_name, "target_product_url": target_product_url, "target_package_size": target_package_size, "target_unit": target_unit, "candidate_price_text": price, "candidate_unit_price_text": unit_price, "target_price_date": target_price_date, "target_price_source": target_price_source, "user_note": user_note, "quality_flags": quality_flags, "can_count_as_secure_saving": bool(complete), "requires_confirm": True}
+    _audit(conn, action="manual_mapping_preview", source=target_price_source or "manual", product_item_id=product_item_id, payload={k: v for k, v in preview.items() if k not in {"source_product"}})
+    conn.commit()
+    return preview
+
+
+def confirm_manual_grocery_mapping(conn: Connection, preview: dict[str, Any]) -> dict[str, Any]:
+    required = ["product_item_id", "target_retailer", "target_product_name", "target_product_url"]
+    if any(not preview.get(k) for k in required):
+        raise ValueError("manual_mapping_preview_incomplete")
+    return create_manual_grocery_mapping(
+        conn,
+        product_item_id=preview["product_item_id"],
+        target_retailer=preview["target_retailer"],
+        target_product_name=preview["target_product_name"],
+        target_product_url=preview["target_product_url"],
+        candidate_price_text=preview.get("candidate_price_text"),
+        candidate_unit_price_text=preview.get("candidate_unit_price_text"),
+        target_brand=preview.get("target_brand"),
+        target_package_size=preview.get("target_package_size"),
+        target_unit=preview.get("target_unit"),
+        target_price_date=preview.get("target_price_date"),
+        target_price_source=preview.get("target_price_source") or "manual",
+        user_note=preview.get("user_note"),
+    )
+
+
+def create_manual_grocery_mapping(conn: Connection, *, product_item_id: str, target_retailer: str, target_product_name: str, target_product_url: str, match_type: str = "manual_match", candidate_price_text: str | None = None, candidate_unit_price_text: str | None = None, target_brand: str | None = None, target_package_size: str | None = None, target_unit: str | None = None, target_price_date: str | None = None, target_price_source: str | None = None, user_note: str | None = None, confidence: str = "0.95") -> dict[str, Any]:
+    item = row_to_dict(conn.execute("SELECT * FROM grocery_product_items WHERE product_item_id=?", (product_item_id,)).fetchone())
+    flags = [match_type]
+    if candidate_price_text:
+        flags.append("manual_price")
+    else:
+        flags.append("no_price")
+    if not candidate_unit_price_text:
+        flags.append("no_unit_price")
+    if candidate_price_text and (not target_price_date or not target_price_source):
+        flags.append("needs_review")
+    mapping = _insert_mapping(conn, item=item, target_retailer=target_retailer, target_product_name=target_product_name, target_product_url=target_product_url, target_brand=target_brand, target_package_size=target_package_size, target_unit=target_unit, target_price_text=candidate_price_text, target_unit_price_text=candidate_unit_price_text, target_price_date=target_price_date, target_price_source=target_price_source, match_type=match_type, status="accepted", confidence=confidence, user_note=user_note, quality_flags=flags, last_price_checked_at=target_price_date)
     return mapping


def _safe_match(match: dict[str, Any]) -> bool:
    flags = set(json.loads(match.get("quality_flags_json") or "[]"))
    has_required_price_data = bool(match.get("candidate_price_text")) and bool(match.get("candidate_unit_price_text")) and bool(match.get("candidate_url")) and bool(match.get("fetched_at") or match.get("target_price_date") or match.get("last_price_checked_at")) and bool(match.get("source") or match.get("target_price_source"))
    if not has_required_price_data:
        return False
    if flags & UNCERTAIN_FLAGS:
        return False
    return match.get("status") in {"suggested", "accepted"} and bool(flags & SAFE_FLAGS)

def _safe_match(match: dict[str, Any]) -> bool:
    flags = set(json.loads(match.get("quality_flags_json") or "[]"))
    if "manual_price" in flags or "no_price" in flags:
        return False
    return match.get("status") in {"suggested", "accepted"} and flags <= SAFE_FLAGS and bool(flags & SAFE_FLAGS) and bool(match.get("candidate_price_text"))


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -322,9 +322,12 @@

 def _safe_match(match: dict[str, Any]) -> bool:
     flags = set(json.loads(match.get("quality_flags_json") or "[]"))
-    if "manual_price" in flags or "no_price" in flags:
+    has_required_price_data = bool(match.get("candidate_price_text")) and bool(match.get("candidate_unit_price_text")) and bool(match.get("candidate_url")) and bool(match.get("fetched_at") or match.get("target_price_date") or match.get("last_price_checked_at")) and bool(match.get("source") or match.get("target_price_source"))
+    if not has_required_price_data:
         return False
-    return match.get("status") in {"suggested", "accepted"} and flags <= SAFE_FLAGS and bool(flags & SAFE_FLAGS) and bool(match.get("candidate_price_text"))
+    if flags & UNCERTAIN_FLAGS:
+        return False
+    return match.get("status") in {"suggested", "accepted"} and bool(flags & SAFE_FLAGS)

 def run_grocery_optimization(conn: Connection, receipt_id: str, *, selected_retailers: list[str], max_store_count: int = 3, included_product_item_ids: list[str] | None = None) -> dict[str, Any]:


candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": mapping.get("target_price_source") or "known_mapping", "target_price_source": mapping.get("target_price_source"), "target_price_date": mapping.get("target_price_date"), "last_price_checked_at": mapping.get("last_price_checked_at")})

candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": "known_mapping"})


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -351,7 +351,7 @@
         for mapping in known_mappings_for_item(conn, item, include_rejected=False):
             mapping_flags = set(json.loads(mapping.get("quality_flags_json") or "[]"))
             if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at") and "manual_price" not in mapping_flags:
-                candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": "known_mapping"})
+                candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": mapping.get("target_price_source") or "known_mapping", "target_price_source": mapping.get("target_price_source"), "target_price_date": mapping.get("target_price_date"), "last_price_checked_at": mapping.get("last_price_checked_at")})
         rejected_mappings = [m for m in conn.execute("SELECT * FROM grocery_product_mappings WHERE source_product_normalized_name=? AND status='rejected'", (item.get("normalized_product_name"),)).fetchall()]
         safe = [m for m in candidates if _safe_match(m)]
         best = min(safe, key=lambda m: _money(m["candidate_price_text"]), default=None)


if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at"):

if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at") and "manual_price" not in mapping_flags:


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -350,7 +350,7 @@
         candidates = [m for m in _matches(conn, item["product_item_id"]) if m["retailer"] in selected]
         for mapping in known_mappings_for_item(conn, item, include_rejected=False):
             mapping_flags = set(json.loads(mapping.get("quality_flags_json") or "[]"))
-            if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at") and "manual_price" not in mapping_flags:
+            if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at"):
                 candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": mapping.get("target_price_source") or "known_mapping", "target_price_source": mapping.get("target_price_source"), "target_price_date": mapping.get("target_price_date"), "last_price_checked_at": mapping.get("last_price_checked_at")})
         rejected_mappings = [m for m in conn.execute("SELECT * FROM grocery_product_mappings WHERE source_product_normalized_name=? AND status='rejected'", (item.get("normalized_product_name"),)).fetchall()]
         safe = [m for m in candidates if _safe_match(m)]


Explanation:
**Considering code clarity**

I'm contemplating whether to remove unused mapping flags. Linting tools like pyflakes will likely care about these, so it’s important to take them out. It seems best to clean up the code by removing that line. Maintaining clear and tidy code is key, after all! I want to ensure that my code is not only functional but also easy to read and understand for myself and others.

        for mapping in known_mappings_for_item(conn, item, include_rejected=False):
            if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at"):

        for mapping in known_mappings_for_item(conn, item, include_rejected=False):
            mapping_flags = set(json.loads(mapping.get("quality_flags_json") or "[]"))
            if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at"):


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -349,7 +349,6 @@
         item_price = _money(item["total_price_text"])
         candidates = [m for m in _matches(conn, item["product_item_id"]) if m["retailer"] in selected]
         for mapping in known_mappings_for_item(conn, item, include_rejected=False):
-            mapping_flags = set(json.loads(mapping.get("quality_flags_json") or "[]"))
             if mapping["status"] == "accepted" and mapping["target_retailer"] in selected and mapping.get("target_price_text") and mapping.get("last_price_checked_at"):
                 candidates.append({"match_id": mapping["mapping_id"], "product_item_id": item["product_item_id"], "retailer": mapping["target_retailer"], "candidate_product_name": mapping["target_product_name"], "candidate_url": mapping["target_product_url"], "candidate_brand": mapping.get("target_brand"), "candidate_package_size": mapping.get("target_package_size"), "candidate_unit": mapping.get("target_unit"), "candidate_price_text": mapping.get("target_price_text"), "candidate_unit_price_text": mapping.get("target_unit_price_text"), "match_confidence": mapping.get("confidence") or "0.95", "quality_flags_json": mapping.get("quality_flags_json") or json.dumps([mapping.get("match_type") or "manual_match"]), "status": "accepted", "source": mapping.get("target_price_source") or "known_mapping", "target_price_source": mapping.get("target_price_source"), "target_price_date": mapping.get("target_price_date"), "last_price_checked_at": mapping.get("last_price_checked_at")})
         rejected_mappings = [m for m in conn.execute("SELECT * FROM grocery_product_mappings WHERE source_product_normalized_name=? AND status='rejected'", (item.get("normalized_product_name"),)).fetchall()]


               SET target_price_text=?, target_unit_price_text=?, target_price_date=?, target_price_source=?, quality_flags_json=?, last_price_checked_at=?, updated_at=?
               WHERE mapping_id=?""",
            (cache.get("price_text"), cache.get("unit_price_text"), (cache.get("last_price_checked_at") or "")[:10] or None, "cache", json.dumps(flags), cache.get("last_price_checked_at"), _now(), mapping_id),

               SET target_price_text=?, target_unit_price_text=?, quality_flags_json=?, last_price_checked_at=?, updated_at=?
               WHERE mapping_id=?""",
            (cache.get("price_text"), cache.get("unit_price_text"), json.dumps(flags), cache.get("last_price_checked_at"), _now(), mapping_id),


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/services/grocery_optimizer.py
@@ -310,9 +310,9 @@
             flags.append("manual_match")
         conn.execute(
             """UPDATE grocery_product_mappings
-               SET target_price_text=?, target_unit_price_text=?, quality_flags_json=?, last_price_checked_at=?, updated_at=?
+               SET target_price_text=?, target_unit_price_text=?, target_price_date=?, target_price_source=?, quality_flags_json=?, last_price_checked_at=?, updated_at=?
                WHERE mapping_id=?""",
-            (cache.get("price_text"), cache.get("unit_price_text"), json.dumps(flags), cache.get("last_price_checked_at"), _now(), mapping_id),
+            (cache.get("price_text"), cache.get("unit_price_text"), (cache.get("last_price_checked_at") or "")[:10] or None, "cache", json.dumps(flags), cache.get("last_price_checked_at"), _now(), mapping_id),
         )
         conn.commit()
         mapping.update({"target_price_text": cache.get("price_text"), "target_unit_price_text": cache.get("unit_price_text"), "last_price_checked_at": cache.get("last_price_checked_at")})


    90|)
    91|from jarvis_finance.services.budget_monthly_import import (
    92|    apply_rule_suggestion,
    93|    get_import_history,
    94|    get_monthly_import_dashboard,
    95|    list_rule_suggestions,
    96|    preview_drive_monthly_import,
    97|    reject_rule_suggestion,
    98|    run_monthly_import_dry_run,
    99|    scan_budget_drive_files,
   100|    scan_google_drive_budget_folder,
   101|)
   102|from jarvis_finance.services.grocery_optimizer import (
   103|    accept_grocery_product_match,
   104|    add_demo_migros_receipt,
   105|    create_manual_grocery_mapping,
   106|    generate_grocery_optimization_report,
   107|    get_grocery_optimizer_dashboard,
   108|    refresh_grocery_mapping_price,
   109|    reject_grocery_product_match,
   110|    run_grocery_optimization,
   111|    upsert_product_match,
   112|)
   113|from jarvis_finance.services.grocery_price_providers import provider_for_retailer, search_and_store_product_matches
   114|from jarvis_finance.services.budget_plans import (
   115|    archive_budget_plan_item,
   116|    confirm_budget_plan_item,
   117|    confirm_income_plan_templates,
   118|    confirm_update_income_plan_item,
   119|    create_budget_plan_item_preview,
   120|    list_budget_plan_items,
   121|    list_income_plan_items,
   122|    list_income_seed_candidates,
   123|    preview_income_plan_templates,
   124|    preview_update_income_plan_item,
   125|    reorder_income_plan_items,
   126|)
   127|from jarvis_finance.services.budget_seed_review import (
   128|    confirm_budget_plan_takeover,
   129|    confirm_clear_category_takeover,
   130|    confirm_seed_candidate,
   131|    convert_unclean_range,
   132|    ignore_seed_candidate,
   133|    list_seed_candidates,
   134|    preview_budget_plan_takeover,
   135|    preview_clear_category_takeover,
   136|    preview_seed_candidate,
   137|    reopen_seed_candidate,
   138|    seed_budget_candidates_from_rows,
   139|    set_seed_candidate_status,
   140|)
   141|from jarvis_finance.services.budget_transactions import (
   142|    confirm_budget_transaction,
   143|    confirm_reverse_budget_transaction,
   144|    confirm_transfer,
   145|    create_budget_transaction_preview,
   146|    create_transfer_preview,
   147|    list_budget_transactions,
   148|    list_budget_transfers,
   149|    reverse_budget_transaction_preview,
   150|    update_budget_transaction,
   151|)
   152|
   153|router = APIRouter(prefix="/budget", tags=["budget"])
   154|
   155|
   156|@router.get("/grocery-optimizer/dashboard")
   157|def grocery_optimizer_dashboard(conn: Connection = Depends(get_db)):
   158|    return get_grocery_optimizer_dashboard(conn)
   159|
   160|
   161|@router.post("/grocery-optimizer/demo-receipt")
   162|def grocery_optimizer_demo_receipt(payload: dict, conn: Connection = Depends(get_db)):
   163|    return add_demo_migros_receipt(conn, purchase_date=payload.get("purchase_date") or "2026-05-19", store_name=payload.get("store_name") or "Migros", items=payload.get("items") or [])
   164|
   165|
   166|@router.post("/grocery-optimizer/matches")
   167|def grocery_optimizer_match(payload: dict, conn: Connection = Depends(get_db)):
   168|    return upsert_product_match(conn, payload["product_item_id"], retailer=payload["retailer"], candidate_product_name=payload["candidate_product_name"], candidate_url=payload.get("candidate_url") or "https://example.invalid/review", candidate_price_text=payload["candidate_price_text"], candidate_unit_price_text=payload.get("candidate_unit_price_text"), quality_flags=payload.get("quality_flags") or ["needs_review"], match_confidence=payload.get("match_confidence") or "0.60", status=payload.get("status") or "suggested", source=payload.get("source") or "manual")
   169|
   170|

Use offset=170 to continue reading (showing 90-169 of 898 lines)


    build_rappn_link_out,
    confirm_manual_grocery_mapping,
    create_manual_grocery_mapping,
    generate_grocery_optimization_report,
    get_grocery_optimizer_dashboard,
    get_grocery_provider_strategy,
    preview_manual_grocery_mapping,


--- a//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
+++ b//home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py
@@ -102,9 +102,13 @@
 from jarvis_finance.services.grocery_optimizer import (
     accept_grocery_product_match,
     add_demo_migros_receipt,
+    build_rappn_link_out,
+    confirm_manual_grocery_mapping,
     create_manual_grocery_mapping,
     generate_grocery_optimization_report,
     get_grocery_optimizer_dashboard,
+    get_grocery_provider_strategy,
+    preview_manual_grocery_mapping,
     refresh_grocery_mapping_price,
     reject_grocery_product_match,
     run_grocery_optimization,

/home/agent/.hermes/repos/FinanceManager/src/jarvis_finance/api/routers/budget.py was last read with offset/limit pagination (partial view). Re-read the whole file before overwriting it.