From d6b05798a15e72b21b6c352d7a9e24fd2b2a10bc Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 13 Apr 2026 18:42:56 +0100 Subject: [PATCH] Updates --- .../Services/AiAssistantService.cs | 56 ++++++- EbayListingTool/Services/EbayAuthService.cs | 89 +++++++++-- .../Services/EbayPriceResearchService.cs | 97 ++++++++++++ EbayListingTool/Views/BulkImportView.xaml | 2 +- EbayListingTool/Views/MainWindow.xaml | 4 +- EbayListingTool/Views/MainWindow.xaml.cs | 4 +- EbayListingTool/Views/PhotoAnalysisView.xaml | 53 +++++++ .../Views/PhotoAnalysisView.xaml.cs | 139 ++++++++++++++++-- 8 files changed, 417 insertions(+), 27 deletions(-) create mode 100644 EbayListingTool/Services/EbayPriceResearchService.cs diff --git a/EbayListingTool/Services/AiAssistantService.cs b/EbayListingTool/Services/AiAssistantService.cs index aa0466d..86e4cb0 100644 --- a/EbayListingTool/Services/AiAssistantService.cs +++ b/EbayListingTool/Services/AiAssistantService.cs @@ -16,7 +16,7 @@ public class AiAssistantService { private readonly string _apiKey; private readonly string _model; - private static readonly HttpClient _http = new(); + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(90) }; private const string ApiUrl = "https://openrouter.ai/api/v1/chat/completions"; @@ -134,6 +134,60 @@ public class AiAssistantService } } + /// + /// Takes an existing AI-generated listing and rewrites title, description and price + /// to incorporate user corrections (e.g. "earrings are white gold, not silver"). + /// Returns the updated fields; other PhotoAnalysisResult fields are unchanged. + /// + public async Task<(string Title, string Description, decimal Price, string PriceReasoning)> + RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections) + { + var priceContext = price > 0 + ? $"Current price: £{price:F2}\n\n" + : ""; + + var prompt = + "You are an expert eBay UK seller. I have a listing with incorrect details that need fixing.\n\n" + + $"Current title: {title}\n" + + $"Current description:\n{description}\n\n" + + priceContext + + $"CORRECTIONS FROM SELLER: {corrections}\n\n" + + "Rewrite the listing incorporating these corrections. " + + "Return ONLY valid JSON — no markdown, no explanation:\n" + + "{\n" + + " \"title\": \"corrected eBay UK title, max 80 chars, keyword-rich\",\n" + + " \"description\": \"corrected full plain-text eBay UK description\",\n" + + " \"price_suggested\": 29.99,\n" + + " \"price_reasoning\": \"one sentence why this price\"\n" + + "}\n\n" + + "price_suggested MUST be a realistic GBP amount greater than 0. Never return 0."; + + var json = await CallAsync(prompt); + + try + { + JObject obj; + try { obj = JObject.Parse(json.Trim()); } + catch (JsonReaderException) + { + var m = System.Text.RegularExpressions.Regex.Match(json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```"); + obj = JObject.Parse(m.Success ? m.Groups[1].Value : json.Trim()); + } + + var parsedPrice = obj["price_suggested"]?.Value() ?? 0; + return ( + obj["title"]?.ToString() ?? title, + obj["description"]?.ToString() ?? description, + parsedPrice > 0 ? parsedPrice : price, // treat 0 as "not provided", keep original + obj["price_reasoning"]?.ToString() ?? "" + ); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Could not parse AI refinement response: {ex.Message}"); + } + } + /// Convenience wrapper — analyses a single photo. public Task AnalyseItemFromPhotoAsync(string imagePath) => AnalyseItemFromPhotosAsync(new[] { imagePath }); diff --git a/EbayListingTool/Services/EbayAuthService.cs b/EbayListingTool/Services/EbayAuthService.cs index f0167b2..6552324 100644 --- a/EbayListingTool/Services/EbayAuthService.cs +++ b/EbayListingTool/Services/EbayAuthService.cs @@ -15,6 +15,14 @@ public class EbayAuthService private readonly string _tokenPath; private EbayToken? _token; + // App-level (client credentials) token — no user login needed, used for Browse API + private string? _appToken; + private DateTime _appTokenExpiry = DateTime.MinValue; + private readonly SemaphoreSlim _appTokenLock = new(1, 1); // prevents concurrent token fetches + + // Shared HttpClient for all auth/token calls — avoids socket exhaustion from per-call `new HttpClient()` + private static readonly HttpClient _http = new(); + private static readonly string[] Scopes = [ "https://api.ebay.com/oauth/api_scope", @@ -110,19 +118,19 @@ public class EbayAuthService ? "https://api.sandbox.ebay.com/identity/v1/oauth2/token" : "https://api.ebay.com/identity/v1/oauth2/token"; - using var http = new HttpClient(); var credentials = Convert.ToBase64String( Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}")); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); - var body = new FormUrlEncodedContent(new Dictionary + using var codeRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl); + codeRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + codeRequest.Content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = _settings.RuName }); - var response = await http.PostAsync(tokenUrl, body); + var response = await _http.SendAsync(codeRequest); var json = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) @@ -148,19 +156,19 @@ public class EbayAuthService ? "https://api.sandbox.ebay.com/identity/v1/oauth2/token" : "https://api.ebay.com/identity/v1/oauth2/token"; - using var http = new HttpClient(); var credentials = Convert.ToBase64String( Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}")); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); - var body = new FormUrlEncodedContent(new Dictionary + using var refreshRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl); + refreshRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + refreshRequest.Content = new FormUrlEncodedContent(new Dictionary { - ["grant_type"] = "refresh_token", + ["grant_type"] = "refresh_token", ["refresh_token"] = _token!.RefreshToken, - ["scope"] = string.Join(" ", Scopes) + ["scope"] = string.Join(" ", Scopes) }); - var response = await http.PostAsync(tokenUrl, body); + var response = await _http.SendAsync(refreshRequest); var json = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) @@ -176,12 +184,13 @@ public class EbayAuthService { try { - using var http = new HttpClient(); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var url = _settings.Sandbox ? "https://apiz.sandbox.ebay.com/commerce/identity/v1/user/" : "https://apiz.ebay.com/commerce/identity/v1/user/"; - var json = await http.GetStringAsync(url); + using var idRequest = new HttpRequestMessage(HttpMethod.Get, url); + idRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var idResponse = await _http.SendAsync(idRequest); + var json = await idResponse.Content.ReadAsStringAsync(); var obj = JObject.Parse(json); return obj["username"]?.ToString() ?? "Unknown"; } @@ -191,6 +200,60 @@ public class EbayAuthService } } + /// + /// Returns an application-level OAuth token (client credentials grant). + /// Does NOT require the user to be logged in — uses Client ID + Secret only. + /// Cached for the token lifetime minus a 5-minute safety margin. + /// + public async Task GetAppTokenAsync() + { + // Fast path — no lock needed for a read when token is valid + if (_appToken != null && DateTime.UtcNow < _appTokenExpiry) + return _appToken; + + await _appTokenLock.WaitAsync(); + try + { + // Re-check inside lock (another thread may have refreshed while we waited) + if (_appToken != null && DateTime.UtcNow < _appTokenExpiry) + return _appToken; + + if (string.IsNullOrEmpty(_settings.ClientId) || string.IsNullOrEmpty(_settings.ClientSecret)) + throw new InvalidOperationException("eBay Client ID / Secret not configured in appsettings.json."); + + var tokenUrl = _settings.Sandbox + ? "https://api.sandbox.ebay.com/identity/v1/oauth2/token" + : "https://api.ebay.com/identity/v1/oauth2/token"; + + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}")); + + using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["scope"] = "https://api.ebay.com/oauth/api_scope" + }); + + var response = await _http.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"App token request failed ({(int)response.StatusCode})"); + + var obj = JObject.Parse(json); + _appToken = obj["access_token"]!.ToString(); + var expiresIn = obj["expires_in"]?.Value() ?? 7200; + _appTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn - 300); // 5-min buffer + return _appToken; + } + finally + { + _appTokenLock.Release(); + } + } + public void Disconnect() { _token = null; diff --git a/EbayListingTool/Services/EbayPriceResearchService.cs b/EbayListingTool/Services/EbayPriceResearchService.cs new file mode 100644 index 0000000..9fa47d2 --- /dev/null +++ b/EbayListingTool/Services/EbayPriceResearchService.cs @@ -0,0 +1,97 @@ +using System.Net.Http.Headers; +using Newtonsoft.Json.Linq; + +namespace EbayListingTool.Services; + +public class LivePriceResult +{ + public List Prices { get; init; } = new(); + public int Count => Prices.Count; + public decimal Min => Prices.Count > 0 ? Prices.Min() : 0; + public decimal Max => Prices.Count > 0 ? Prices.Max() : 0; + public decimal Median => Prices.Count > 0 ? Percentile(50) : 0; + + // Require ≥5 samples before offering a suggestion; 40th pct = competitive but not cheapest + public decimal Suggested => Prices.Count >= MinSampleSize ? Percentile(40) : 0; + public bool HasSuggestion => Prices.Count >= MinSampleSize; + + internal const int MinSampleSize = 5; + + private decimal Percentile(int pct) + { + var sorted = Prices.OrderBy(p => p).ToList(); + int idx = (int)Math.Round((pct / 100.0) * (sorted.Count - 1)); + return sorted[Math.Clamp(idx, 0, sorted.Count - 1)]; + } +} + +/// +/// Uses the eBay Browse API (app-level token, no user login required) to fetch +/// current Buy It Now prices for similar items on eBay UK. +/// +public class EbayPriceResearchService +{ + private readonly EbayAuthService _auth; + + // 15-second timeout — price lookup is non-critical; don't leave spinner hanging + private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; + + public EbayPriceResearchService(EbayAuthService auth) + { + _auth = auth; + } + + public async Task GetLivePricesAsync(string query, int limit = 30) + { + if (string.IsNullOrWhiteSpace(query)) + return new LivePriceResult(); + + var token = await _auth.GetAppTokenAsync(); + var baseUrl = _auth.BaseUrl; + + // Omit sort= to use eBay BEST_MATCH — price-sorted results are biased toward + // the cheapest (broken/spam) listings; BEST_MATCH gives a more representative sample + using var request = new HttpRequestMessage(HttpMethod.Get, + $"{baseUrl}/buy/browse/v1/item_summary/search" + + $"?q={Uri.EscapeDataString(query)}" + + $"&filter=buyingOptions%3A%7BFIXED_PRICE%7D" + + $"&limit={limit}"); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB"); + + var response = await _http.SendAsync(request); + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"eBay Browse API error ({(int)response.StatusCode})"); + + var obj = JObject.Parse(json); + var items = obj["itemSummaries"] as JArray ?? new JArray(); + var prices = new List(); + + foreach (var item in items) + { + var priceVal = item["price"]?["value"]?.ToString(); + var currency = item["price"]?["currency"]?.ToString(); + if (currency == "GBP" && decimal.TryParse(priceVal, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var p) && p > 0) + { + prices.Add(p); + } + } + + // Trim top and bottom 10% to remove outliers (spammers, broken items, typos). + // Only trim when there are enough data points to make it worthwhile. + if (prices.Count >= 10) + { + prices.Sort(); + int trim = (int)Math.Floor(prices.Count * 0.10); + prices = prices.Skip(trim).Take(prices.Count - 2 * trim).ToList(); + } + + return new LivePriceResult { Prices = prices }; + } +} diff --git a/EbayListingTool/Views/BulkImportView.xaml b/EbayListingTool/Views/BulkImportView.xaml index 3424ee7..fa61e1f 100644 --- a/EbayListingTool/Views/BulkImportView.xaml +++ b/EbayListingTool/Views/BulkImportView.xaml @@ -198,7 +198,7 @@ Background="{DynamicResource MahApps.Brushes.Gray8}" VerticalAlignment="Center"> - - @@ -278,7 +278,7 @@ - diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs index 375dcf4..6e4bde5 100644 --- a/EbayListingTool/Views/MainWindow.xaml.cs +++ b/EbayListingTool/Views/MainWindow.xaml.cs @@ -14,6 +14,7 @@ public partial class MainWindow : MetroWindow private readonly AiAssistantService _aiService; private readonly BulkImportService _bulkService; private readonly SavedListingsService _savedService; + private readonly EbayPriceResearchService _priceService; public MainWindow() { @@ -26,9 +27,10 @@ public partial class MainWindow : MetroWindow _aiService = new AiAssistantService(config); _bulkService = new BulkImportService(); _savedService = new SavedListingsService(); + _priceService = new EbayPriceResearchService(_auth); // Photo Analysis tab — no eBay needed - PhotoView.Initialise(_aiService, _savedService); + PhotoView.Initialise(_aiService, _savedService, _priceService); PhotoView.UseDetailsRequested += OnUseDetailsRequested; // Saved Listings tab diff --git a/EbayListingTool/Views/PhotoAnalysisView.xaml b/EbayListingTool/Views/PhotoAnalysisView.xaml index 6fb16d2..7c24000 100644 --- a/EbayListingTool/Views/PhotoAnalysisView.xaml +++ b/EbayListingTool/Views/PhotoAnalysisView.xaml @@ -490,6 +490,18 @@ StringFormat="F2" Interval="0.5"/> + + + + + + + + + + + + + + + + + + + diff --git a/EbayListingTool/Views/PhotoAnalysisView.xaml.cs b/EbayListingTool/Views/PhotoAnalysisView.xaml.cs index f55a34b..224c041 100644 --- a/EbayListingTool/Views/PhotoAnalysisView.xaml.cs +++ b/EbayListingTool/Views/PhotoAnalysisView.xaml.cs @@ -12,8 +12,9 @@ namespace EbayListingTool.Views; public partial class PhotoAnalysisView : UserControl { - private AiAssistantService? _aiService; - private SavedListingsService? _savedService; + private AiAssistantService? _aiService; + private SavedListingsService? _savedService; + private EbayPriceResearchService? _priceService; private List _currentImagePaths = new(); private PhotoAnalysisResult? _lastResult; private int _activePhotoIndex = 0; @@ -50,10 +51,12 @@ public partial class PhotoAnalysisView : UserControl PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged; } - public void Initialise(AiAssistantService aiService, SavedListingsService savedService) + public void Initialise(AiAssistantService aiService, SavedListingsService savedService, + EbayPriceResearchService priceService) { _aiService = aiService; _savedService = savedService; + _priceService = priceService; } // ---- Photo clip geometry sync ---- @@ -204,6 +207,8 @@ public partial class PhotoAnalysisView : UserControl var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths); _lastResult = result; ShowResults(result); + // Fire live price lookup in background — updates price display when ready + _ = UpdateLivePricesAsync(result.Title); } catch (Exception ex) { @@ -220,6 +225,64 @@ public partial class PhotoAnalysisView : UserControl private void ReAnalyse_Click(object sender, RoutedEventArgs e) => Analyse_Click(sender, e); + private async void Refine_Click(object sender, RoutedEventArgs e) + { + if (_aiService == null || _lastResult == null) return; + + var corrections = CorrectionsBox.Text.Trim(); + if (string.IsNullOrEmpty(corrections)) + { + CorrectionsBox.Focus(); + return; + } + + var title = TitleBox.Text; + var description = DescriptionBox.Text; + var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested); + + SetRefining(true); + try + { + var (newTitle, newDesc, newPrice, newReasoning) = + await _aiService.RefineWithCorrectionsAsync(title, description, price, corrections); + + TitleBox.Text = newTitle; + DescriptionBox.Text = newDesc; + PriceOverride.Value = (double)Math.Round(newPrice, 2); // Issue 6 + PriceSuggestedText.Text = newPrice > 0 ? $"£{newPrice:F2}" : "—"; + + _lastResult.Title = newTitle; + _lastResult.Description = newDesc; + _lastResult.PriceSuggested = newPrice; + + if (!string.IsNullOrWhiteSpace(newReasoning)) + { + PriceReasoningText.Text = newReasoning; + PriceReasoningText.Visibility = Visibility.Visible; + } + + // Clear the corrections box now they're applied + CorrectionsBox.Text = ""; + } + catch (Exception ex) + { + MessageBox.Show($"Refinement failed:\n\n{ex.Message}", "AI Error", + MessageBoxButton.OK, MessageBoxImage.Warning); + } + finally + { + SetRefining(false); + } + } + + private void SetRefining(bool busy) + { + RefineBtn.IsEnabled = !busy; + RefineIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + RefineSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + RefineBtnText.Text = busy ? "Refining…" : "Refine with AI"; + } + private void ShowResults(PhotoAnalysisResult r) { IdlePanel.Visibility = Visibility.Collapsed; @@ -275,7 +338,7 @@ public partial class PhotoAnalysisView : UserControl PriceRangeBar.Visibility = Visibility.Collapsed; } - PriceOverride.Value = (double)r.PriceSuggested; + PriceOverride.Value = (double)Math.Round(r.PriceSuggested, 2); // Issue 6 // Price reasoning PriceReasoningText.Text = r.PriceReasoning; @@ -286,11 +349,69 @@ public partial class PhotoAnalysisView : UserControl TitleBox.Text = r.Title; DescriptionBox.Text = r.Description; + // Reset live price row until lookup completes + LivePriceRow.Visibility = Visibility.Collapsed; + // Animate results in var sb = (Storyboard)FindResource("ResultsReveal"); sb.Begin(this); } + private async Task UpdateLivePricesAsync(string query) + { + if (_priceService == null) return; + + // Issue 7: guard against off-thread callers (fire-and-forget may lose sync context) + if (!Dispatcher.CheckAccess()) + { + await Dispatcher.InvokeAsync(() => UpdateLivePricesAsync(query)).Task.Unwrap(); + return; + } + + try + { + // Issue 1: spinner-show inside try so a disposed control doesn't crash the caller + LivePriceRow.Visibility = Visibility.Visible; + LivePriceSpinner.Visibility = Visibility.Visible; + LivePriceStatus.Text = "Checking live eBay UK prices…"; + + var live = await _priceService.GetLivePricesAsync(query); + + if (live.Count == 0) + { + LivePriceStatus.Text = "No matching live listings found."; + LivePriceSpinner.Visibility = Visibility.Collapsed; + return; + } + + // Update range bar with real data + PriceMinText.Text = $"£{live.Min:F2}"; + PriceMaxText.Text = $"£{live.Max:F2}"; + PriceRangeBar.Visibility = Visibility.Visible; + + // Update suggested price to 40th percentile (competitive but not cheapest) + var suggested = live.Suggested; + PriceSuggestedText.Text = $"£{suggested:F2}"; + PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift + if (_lastResult != null) _lastResult.PriceSuggested = suggested; + + // Update status label + LivePriceSpinner.Visibility = Visibility.Collapsed; + LivePriceStatus.Text = + $"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " + + $"(range £{live.Min:F2} – £{live.Max:F2})"; + } + catch (Exception ex) + { + try + { + LivePriceSpinner.Visibility = Visibility.Collapsed; + LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}"; + } + catch { /* control may be unloaded by the time catch runs */ } + } + } + private void TitleBox_TextChanged(object sender, TextChangedEventArgs e) { var len = TitleBox.Text.Length; @@ -492,12 +613,13 @@ public partial class PhotoAnalysisView : UserControl // ---- Save toast ---- - private bool _toastAnimating = false; - private void ShowSaveToast() { - if (_toastAnimating) return; - _toastAnimating = true; + // Issue 8: always restart — stop any in-progress hold timer and cancel the running + // animation so the flag can never get permanently stuck and rapid saves feel responsive. + _holdTimer?.Stop(); + _holdTimer = null; + ToastTranslate.BeginAnimation(TranslateTransform.YProperty, null); // cancel current animation SaveToast.Visibility = Visibility.Visible; @@ -521,7 +643,6 @@ public partial class PhotoAnalysisView : UserControl slideOut.Completed += (_, _) => { SaveToast.Visibility = Visibility.Collapsed; - _toastAnimating = false; }; ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut); };