feat: preferences store, community signals, a11y + API fixes
Some checks are pending
CI / Python tests (push) Waiting to run
CI / Frontend typecheck + tests (push) Waiting to run
Mirror / mirror (push) Waiting to run

- Store: add save_community_signal, get/set_user_preference, get_all_preferences
- App.vue: move skip link before nav (correct a11y order); bootstrap preferences store after session
- useTrustFeedback: prefix fetch URL with VITE_API_BASE (fixes menagerie routing)
- search.ts: guard v-model.number empty-string from cleared price inputs
- Python: isort import ordering pass across adapters, trust, tasks, tests
This commit is contained in:
pyr0ball 2026-04-14 16:15:09 -07:00
parent af1ffa1d94
commit f8dd1d261d
23 changed files with 124 additions and 29 deletions

View file

@ -26,11 +26,11 @@ from pathlib import Path
from typing import Optional from typing import Optional
import requests import requests
from fastapi import APIRouter, Header, HTTPException, Request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key from cryptography.hazmat.primitives.serialization import load_pem_public_key
from fastapi import APIRouter, Header, HTTPException, Request
from app.db.store import Store from app.db.store import Store

View file

@ -1,5 +1,6 @@
"""Dataclasses for all Snipe domain objects.""" """Dataclasses for all Snipe domain objects."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional

View file

@ -1,5 +1,6 @@
"""Thin SQLite read/write layer for all Snipe models.""" """Thin SQLite read/write layer for all Snipe models."""
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -7,7 +8,7 @@ from typing import Optional
from circuitforge_core.db import get_connection, run_migrations from circuitforge_core.db import get_connection, run_migrations
from .models import Listing, Seller, TrustScore, MarketComp, SavedSearch, ScammerEntry from .models import Listing, MarketComp, SavedSearch, ScammerEntry, Seller, TrustScore
MIGRATIONS_DIR = Path(__file__).parent / "migrations" MIGRATIONS_DIR = Path(__file__).parent / "migrations"
@ -381,6 +382,59 @@ class Store:
for r in rows for r in rows
] ]
def save_community_signal(self, seller_id: str, confirmed: bool) -> None:
"""Record a user's trust-score feedback signal into the shared DB."""
self._conn.execute(
"INSERT INTO community_signals (seller_id, confirmed) VALUES (?, ?)",
(seller_id, 1 if confirmed else 0),
)
self._conn.commit()
# --- User Preferences ---
def get_user_preference(self, path: str, default=None):
"""Read a preference value at dot-separated path (e.g. 'affiliate.opt_out').
Reads from the singleton user_preferences row; returns *default* if the
table is empty or the path is not set.
"""
from circuitforge_core.preferences.paths import get_path
row = self._conn.execute(
"SELECT prefs_json FROM user_preferences WHERE id=1"
).fetchone()
if not row:
return default
return get_path(json.loads(row[0]), path, default=default)
def set_user_preference(self, path: str, value) -> None:
"""Write *value* at dot-separated path (immutable JSON update).
Creates the singleton row on first write; merges subsequent updates
so sibling paths are preserved.
"""
from circuitforge_core.preferences.paths import set_path
row = self._conn.execute(
"SELECT prefs_json FROM user_preferences WHERE id=1"
).fetchone()
prefs = json.loads(row[0]) if row else {}
updated = set_path(prefs, path, value)
self._conn.execute(
"INSERT INTO user_preferences (id, prefs_json, updated_at) "
"VALUES (1, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) "
"ON CONFLICT(id) DO UPDATE SET "
" prefs_json = excluded.prefs_json, "
" updated_at = excluded.updated_at",
(json.dumps(updated),),
)
self._conn.commit()
def get_all_preferences(self) -> dict:
"""Return all preferences as a plain dict (empty dict if not yet set)."""
row = self._conn.execute(
"SELECT prefs_json FROM user_preferences WHERE id=1"
).fetchone()
return json.loads(row[0]) if row else {}
def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]: def get_market_comp(self, platform: str, query_hash: str) -> Optional[MarketComp]:
row = self._conn.execute( row = self._conn.execute(
"SELECT platform, query_hash, median_price, sample_count, expires_at, id, fetched_at " "SELECT platform, query_hash, median_price, sample_count, expires_at, id, fetched_at "

View file

@ -1,8 +1,10 @@
"""PlatformAdapter abstract base and shared types.""" """PlatformAdapter abstract base and shared types."""
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from app.db.models import Listing, Seller from app.db.models import Listing, Seller

View file

@ -1,10 +1,12 @@
"""eBay Browse API adapter.""" """eBay Browse API adapter."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import logging import logging
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import requests import requests
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -18,7 +20,7 @@ _SHOPPING_API_MAX_PER_SEARCH = 5 # sellers enriched per search call
_SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls _SHOPPING_API_INTER_REQUEST_DELAY = 0.5 # seconds between successive calls
_SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window _SELLER_ENRICH_TTL_HOURS = 24 # skip re-enrichment within this window
from app.db.models import Listing, Seller, MarketComp from app.db.models import Listing, MarketComp, Seller
from app.db.store import Store from app.db.store import Store
from app.platforms import PlatformAdapter, SearchFilters from app.platforms import PlatformAdapter, SearchFilters
from app.platforms.ebay.auth import EbayTokenManager from app.platforms.ebay.auth import EbayTokenManager

View file

@ -1,8 +1,10 @@
"""eBay OAuth2 client credentials token manager.""" """eBay OAuth2 client credentials token manager."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import time import time
from typing import Optional from typing import Optional
import requests import requests
EBAY_OAUTH_URLS = { EBAY_OAUTH_URLS = {

View file

@ -1,8 +1,10 @@
"""Convert raw eBay API responses into Snipe domain objects.""" """Convert raw eBay API responses into Snipe domain objects."""
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from app.db.models import Listing, Seller from app.db.models import Listing, Seller

View file

@ -9,6 +9,7 @@ from circuitforge_core.tasks.scheduler import (
) )
from circuitforge_core.tasks.scheduler import ( from circuitforge_core.tasks.scheduler import (
get_scheduler as _base_get_scheduler, get_scheduler as _base_get_scheduler,
reset_scheduler, # re-export for lifespan teardown
) )
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task

View file

@ -1,7 +1,9 @@
"""Five metadata trust signals, each scored 020.""" """Five metadata trust signals, each scored 020."""
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Optional from typing import Optional
from app.db.models import Seller from app.db.models import Seller
ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"} ELECTRONICS_CATEGORIES = {"ELECTRONICS", "COMPUTERS_TABLETS", "VIDEO_GAMES", "CELL_PHONES"}

View file

@ -1,7 +1,9 @@
"""Perceptual hash deduplication within a result set (free tier, v0.1).""" """Perceptual hash deduplication within a result set (free tier, v0.1)."""
from __future__ import annotations from __future__ import annotations
from typing import Optional
import io import io
from typing import Optional
import requests import requests
try: try:

View file

@ -1,19 +1,24 @@
"""Main search + results page.""" """Main search + results page."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import streamlit as st import streamlit as st
from circuitforge_core.config import load_env from circuitforge_core.config import load_env
from app.db.store import Store from app.db.store import Store
from app.platforms import PlatformAdapter, SearchFilters from app.platforms import PlatformAdapter, SearchFilters
from app.trust import TrustScorer from app.trust import TrustScorer
from app.ui.components.filters import build_filter_options, render_filter_sidebar, FilterState
from app.ui.components.listing_row import render_listing_row
from app.ui.components.easter_eggs import ( from app.ui.components.easter_eggs import (
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
auction_hours_remaining, auction_hours_remaining,
check_snipe_mode,
inject_steal_css,
render_snipe_mode_banner,
) )
from app.ui.components.filters import FilterState, build_filter_options, render_filter_sidebar
from app.ui.components.listing_row import render_listing_row
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View file

@ -22,7 +22,6 @@ import streamlit as st
from app.db.models import Listing, TrustScore from app.db.models import Listing, TrustScore
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Konami → Snipe Mode # 1. Konami → Snipe Mode
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,9 +1,12 @@
"""Build dynamic filter options from a result set and render the Streamlit sidebar.""" """Build dynamic filter options from a result set and render the Streamlit sidebar."""
from __future__ import annotations from __future__ import annotations
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
import streamlit as st import streamlit as st
from app.db.models import Listing, TrustScore from app.db.models import Listing, TrustScore

View file

@ -1,13 +1,17 @@
"""Render a single listing row with trust score, badges, and error states.""" """Render a single listing row with trust score, badges, and error states."""
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Optional from typing import Optional
import streamlit as st import streamlit as st
from app.db.models import Listing, TrustScore, Seller from app.db.models import Listing, Seller, TrustScore
from app.ui.components.easter_eggs import ( from app.ui.components.easter_eggs import (
is_steal, render_steal_banner, render_auction_notice, auction_hours_remaining, auction_hours_remaining,
is_steal,
render_auction_notice,
render_steal_banner,
) )

View file

@ -1,6 +1,8 @@
"""First-run wizard: collect eBay credentials and write .env.""" """First-run wizard: collect eBay credentials and write .env."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import streamlit as st import streamlit as st
from circuitforge_core.wizard import BaseWizard from circuitforge_core.wizard import BaseWizard

View file

@ -1,6 +1,8 @@
"""Streamlit entrypoint.""" """Streamlit entrypoint."""
from pathlib import Path from pathlib import Path
import streamlit as st import streamlit as st
from app.wizard import SnipeSetupWizard from app.wizard import SnipeSetupWizard
st.set_page_config( st.set_page_config(
@ -16,6 +18,7 @@ if not wizard.is_configured():
st.stop() st.stop()
from app.ui.components.easter_eggs import inject_konami_detector from app.ui.components.easter_eggs import inject_konami_detector
inject_konami_detector() inject_konami_detector()
with st.sidebar: with st.sidebar:
@ -27,4 +30,5 @@ with st.sidebar:
) )
from app.ui.Search import render from app.ui.Search import render
render(audio_enabled=audio_enabled) render(audio_enabled=audio_enabled)

View file

@ -1,7 +1,9 @@
import time import time
import requests from unittest.mock import MagicMock, patch
from unittest.mock import patch, MagicMock
import pytest import pytest
import requests
from app.platforms.ebay.auth import EbayTokenManager from app.platforms.ebay.auth import EbayTokenManager

View file

@ -1,4 +1,5 @@
import pytest import pytest
from app.platforms.ebay.normaliser import normalise_listing, normalise_seller from app.platforms.ebay.normaliser import normalise_listing, normalise_seller

View file

@ -3,16 +3,18 @@
Uses a minimal HTML fixture mirroring eBay's current s-card markup. Uses a minimal HTML fixture mirroring eBay's current s-card markup.
No HTTP requests are made all tests operate on the pure parsing functions. No HTTP requests are made all tests operate on the pure parsing functions.
""" """
import pytest
from datetime import timedelta from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from app.platforms.ebay.scraper import ( from app.platforms.ebay.scraper import (
scrape_listings, _extract_seller_from_card,
scrape_sellers,
_parse_price, _parse_price,
_parse_time_left, _parse_time_left,
_extract_seller_from_card, scrape_listings,
scrape_sellers,
) )
from bs4 import BeautifulSoup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Minimal eBay search results HTML fixture (li.s-card schema) # Minimal eBay search results HTML fixture (li.s-card schema)

View file

@ -4,12 +4,10 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from circuitforge_core.api.feedback import make_feedback_router
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ────────────────────────────────────────────────────────── # ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient: def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:

View file

@ -2,10 +2,10 @@
<!-- Root uses .app-root class, NOT id="app" index.html owns #app. <!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. --> Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }"> <div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
<!-- Skip to main content must be first focusable element before the nav -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<AppNav /> <AppNav />
<main class="app-main" id="main-content" tabindex="-1"> <main class="app-main" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView /> <RouterView />
</main> </main>
@ -22,6 +22,7 @@ import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode' import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session' import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist' import { useBlocklistStore } from './stores/blocklist'
import { usePreferencesStore } from './stores/preferences'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
import FeedbackButton from './components/FeedbackButton.vue' import FeedbackButton from './components/FeedbackButton.vue'
@ -29,14 +30,16 @@ const motion = useMotion()
const { activate, restore } = useSnipeMode() const { activate, restore } = useSnipeMode()
const session = useSessionStore() const session = useSessionStore()
const blocklistStore = useBlocklistStore() const blocklistStore = useBlocklistStore()
const preferencesStore = usePreferencesStore()
const route = useRoute() const route = useRoute()
useKonamiCode(activate) useKonamiCode(activate)
onMounted(() => { onMounted(async () => {
restore() // re-apply snipe mode from localStorage on hard reload restore() // re-apply snipe mode from localStorage on hard reload
session.bootstrap() // fetch tier + feature flags from API await session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
preferencesStore.load() // load user preferences after session resolves
}) })
</script> </script>

View file

@ -10,8 +10,9 @@ export function useTrustFeedback(sellerId: string) {
async function submitFeedback(confirmed: boolean): Promise<void> { async function submitFeedback(confirmed: boolean): Promise<void> {
if (state.value !== 'idle') return if (state.value !== 'idle') return
state.value = 'sending' state.value = 'sending'
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
try { try {
await fetch('/api/community/signal', { await fetch(`${apiBase}/api/community/signal`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seller_id: sellerId, confirmed }), body: JSON.stringify({ seller_id: sellerId, confirmed }),

View file

@ -163,8 +163,11 @@ export const useSearchStore = defineStore('search', () => {
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite) // VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
const params = new URLSearchParams({ q }) const params = new URLSearchParams({ q })
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice)) // v-model.number sends empty string when a number input is cleared — guard against that
if (filters.minPrice != null) params.set('min_price', String(filters.minPrice)) const maxPrice = Number(filters.maxPrice)
const minPrice = Number(filters.minPrice)
if (Number.isFinite(maxPrice) && maxPrice > 0) params.set('max_price', String(maxPrice))
if (Number.isFinite(minPrice) && minPrice > 0) params.set('min_price', String(minPrice))
if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages)) if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages))
if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim()) if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim())
if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode) if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode)