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
import requests
from fastapi import APIRouter, Header, HTTPException, Request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ec import ECDSA
from cryptography.hazmat.primitives.hashes import SHA1
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from fastapi import APIRouter, Header, HTTPException, Request
from app.db.store import Store

View file

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

View file

@ -1,5 +1,6 @@
"""Thin SQLite read/write layer for all Snipe models."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
@ -7,7 +8,7 @@ from typing import Optional
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"
@ -381,6 +382,59 @@ class Store:
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]:
row = self._conn.execute(
"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."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
from app.db.models import Listing, Seller

View file

@ -1,10 +1,12 @@
"""eBay Browse API adapter."""
from __future__ import annotations
import hashlib
import logging
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from typing import Optional
import requests
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
_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.platforms import PlatformAdapter, SearchFilters
from app.platforms.ebay.auth import EbayTokenManager

View file

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

View file

@ -1,8 +1,10 @@
"""Convert raw eBay API responses into Snipe domain objects."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Optional
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 (
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

View file

@ -1,7 +1,9 @@
"""Five metadata trust signals, each scored 020."""
from __future__ import annotations
import json
from typing import Optional
from app.db.models import Seller
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)."""
from __future__ import annotations
from typing import Optional
import io
from typing import Optional
import requests
try:

View file

@ -1,19 +1,24 @@
"""Main search + results page."""
from __future__ import annotations
import logging
import os
from pathlib import Path
import streamlit as st
from circuitforge_core.config import load_env
from app.db.store import Store
from app.platforms import PlatformAdapter, SearchFilters
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 (
inject_steal_css, check_snipe_mode, render_snipe_mode_banner,
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__)

View file

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

View file

@ -1,9 +1,12 @@
"""Build dynamic filter options from a result set and render the Streamlit sidebar."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Optional
import streamlit as st
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."""
from __future__ import annotations
import json
from typing import Optional
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 (
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."""
from __future__ import annotations
from pathlib import Path
import streamlit as st
from circuitforge_core.wizard import BaseWizard

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import pytest
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.
No HTTP requests are made all tests operate on the pure parsing functions.
"""
import pytest
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from app.platforms.ebay.scraper import (
scrape_listings,
scrape_sellers,
_extract_seller_from_card,
_parse_price,
_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)

View file

@ -4,12 +4,10 @@ from __future__ import annotations
from collections.abc import Callable
from unittest.mock import MagicMock, patch
from circuitforge_core.api.feedback import make_feedback_router
from fastapi import FastAPI
from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ──────────────────────────────────────────────────────────
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.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<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 />
<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 />
</main>
@ -22,6 +22,7 @@ import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist'
import { usePreferencesStore } from './stores/preferences'
import AppNav from './components/AppNav.vue'
import FeedbackButton from './components/FeedbackButton.vue'
@ -29,14 +30,16 @@ const motion = useMotion()
const { activate, restore } = useSnipeMode()
const session = useSessionStore()
const blocklistStore = useBlocklistStore()
const preferencesStore = usePreferencesStore()
const route = useRoute()
useKonamiCode(activate)
onMounted(() => {
restore() // re-apply snipe mode from localStorage on hard reload
session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
onMounted(async () => {
restore() // re-apply snipe mode from localStorage on hard reload
await session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
preferencesStore.load() // load user preferences after session resolves
})
</script>

View file

@ -10,8 +10,9 @@ export function useTrustFeedback(sellerId: string) {
async function submitFeedback(confirmed: boolean): Promise<void> {
if (state.value !== 'idle') return
state.value = 'sending'
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
try {
await fetch('/api/community/signal', {
await fetch(`${apiBase}/api/community/signal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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)
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
const params = new URLSearchParams({ q })
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
if (filters.minPrice != null) params.set('min_price', String(filters.minPrice))
// v-model.number sends empty string when a number input is cleared — guard against that
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.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim())
if (filters.mustIncludeMode) params.set('must_include_mode', filters.mustIncludeMode)