diff --git a/Features/HumanCapital/HumanCapital.razor b/Features/HumanCapital/HumanCapital.razor index 216342a..72a4533 100644 --- a/Features/HumanCapital/HumanCapital.razor +++ b/Features/HumanCapital/HumanCapital.razor @@ -149,8 +149,7 @@

Market Potential

@if (_marketPotentialError is not null) @@ -183,7 +182,6 @@ else {

$@_compensation.MinUsd.ToString("N0") – $@_compensation.MaxUsd.ToString("N0") - AI estimate - not based on live wage data

@_compensation.Explanation

} @@ -201,7 +199,6 @@ else {
  • @occupation.Title - AI-suggested @if (occupation.TypicalMinUsd is not null && occupation.TypicalMaxUsd is not null) { - $@occupation.TypicalMinUsd.Value.ToString("N0") – $@occupation.TypicalMaxUsd.Value.ToString("N0") diff --git a/Features/PhysicalAssets/LiveCameraScanner.razor.css b/Features/PhysicalAssets/LiveCameraScanner.razor.css index f60d1c8..f027c38 100644 --- a/Features/PhysicalAssets/LiveCameraScanner.razor.css +++ b/Features/PhysicalAssets/LiveCameraScanner.razor.css @@ -46,27 +46,27 @@ } .glowing-contour { - fill: rgba(0, 220, 255, 0.25); - stroke: #00dcff; + fill: color-mix(in srgb, var(--accent) 25%, transparent); + stroke: var(--accent); stroke-width: 0.8; stroke-linejoin: round; stroke-linecap: round; - filter: drop-shadow(0 0 6px rgba(0, 220, 255, 0.85)) - drop-shadow(0 0 12px rgba(0, 220, 255, 0.45)); + filter: drop-shadow(0 0 6px color-mix(in srgb, var(--accent) 85%, transparent)) + drop-shadow(0 0 12px color-mix(in srgb, var(--accent) 45%, transparent)); animation: contour-pulse 2s ease-in-out infinite alternate; } @keyframes contour-pulse { 0% { - fill: rgba(0, 220, 255, 0.2); - stroke: #00dcff; - filter: drop-shadow(0 0 4px rgba(0, 220, 255, 0.7)); + fill: color-mix(in srgb, var(--accent) 20%, transparent); + stroke: var(--accent); + filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 70%, transparent)); } 100% { - fill: rgba(0, 220, 255, 0.35); - stroke: #33e6ff; - filter: drop-shadow(0 0 8px rgba(0, 220, 255, 1)) - drop-shadow(0 0 16px rgba(0, 220, 255, 0.6)); + fill: color-mix(in srgb, var(--accent) 35%, transparent); + stroke: color-mix(in srgb, var(--accent) 80%, white); + filter: drop-shadow(0 0 8px var(--accent)) + drop-shadow(0 0 16px color-mix(in srgb, var(--accent) 60%, transparent)); } } @@ -76,15 +76,15 @@ } .prompt-marker.positive { - fill: #10b981; + fill: var(--positive); stroke: #ffffff; - filter: drop-shadow(0 0 3px rgba(16, 185, 129, 0.9)); + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--positive) 90%, transparent)); } .prompt-marker.negative { - fill: #ef4444; + fill: var(--negative); stroke: #ffffff; - filter: drop-shadow(0 0 3px rgba(239, 68, 68, 0.9)); + filter: drop-shadow(0 0 3px color-mix(in srgb, var(--negative) 90%, transparent)); } .viewfinder-hint { diff --git a/Features/PhysicalAssets/PhysicalAssets.razor b/Features/PhysicalAssets/PhysicalAssets.razor index 3657144..3012af3 100644 --- a/Features/PhysicalAssets/PhysicalAssets.razor +++ b/Features/PhysicalAssets/PhysicalAssets.razor @@ -154,7 +154,6 @@ {

    - Estimating value with AI...

    } diff --git a/Features/PhysicalAssets/VideoScanPicker.razor.css b/Features/PhysicalAssets/VideoScanPicker.razor.css index 4b777fb..d48fe70 100644 --- a/Features/PhysicalAssets/VideoScanPicker.razor.css +++ b/Features/PhysicalAssets/VideoScanPicker.razor.css @@ -32,8 +32,8 @@ .item-polygon { pointer-events: auto; cursor: pointer; - fill: rgb(255 193 7 / 20%); - stroke: #ffc107; + fill: color-mix(in srgb, var(--accent) 20%, transparent); + stroke: var(--accent); stroke-width: 0.5; transition: fill 0.15s, @@ -41,39 +41,39 @@ } .item-polygon:hover { - fill: rgb(255 193 7 / 40%); + fill: color-mix(in srgb, var(--accent) 40%, transparent); } .item-polygon.selected { - fill: rgb(25 135 84 / 35%); - stroke: #75ffba; + fill: color-mix(in srgb, var(--positive) 35%, transparent); + stroke: var(--positive); stroke-width: 0.7; } .item-polygon.selected:hover { - fill: rgb(25 135 84 / 50%); + fill: color-mix(in srgb, var(--positive) 50%, transparent); } .item-region { position: absolute; - border: 3px solid #ffc107; - background: rgb(255 193 7 / 12%); + border: 3px solid var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); padding: 0; cursor: pointer; z-index: 2; } .item-region.selected { - border-color: #75ffba; - background: rgb(25 135 84 / 25%); + border-color: var(--positive); + background: color-mix(in srgb, var(--positive) 25%, transparent); } .item-region span { position: absolute; top: 0; left: 0; - background: #172838; - color: white; + background: var(--brand-strong); + color: var(--text-inverse); padding: 2px 5px; font-size: 0.8rem; max-width: 100%; diff --git a/HumanCapital/IMarketPotentialPipeline.cs b/HumanCapital/IMarketPotentialPipeline.cs index 8ba5f99..4ca6859 100644 --- a/HumanCapital/IMarketPotentialPipeline.cs +++ b/HumanCapital/IMarketPotentialPipeline.cs @@ -11,8 +11,8 @@ namespace MoneyMirror.HumanCapital; /// /// #266: not currently called from any page - /market-potential and /// the Dashboard still use the AI-guess-only -/// (#148), whose UI explicitly labels its output "AI estimate - not based -/// on live wage data". This isn't an oversight: this method needs a BLS +/// (#148) instead. +/// This isn't an oversight: this method needs a BLS /// series ID per matched occupation, which was meant to come from O*NET /// occupation data (#93/#94, HC4) - but O*NET was dropped from project scope /// (see #177) before that occupation-to-series mapping was built. Wiring this diff --git a/PhysicalAssets/AssetValuation.cs b/PhysicalAssets/AssetValuation.cs index 94a0f9d..a946011 100644 --- a/PhysicalAssets/AssetValuation.cs +++ b/PhysicalAssets/AssetValuation.cs @@ -13,13 +13,14 @@ public record AssetValuation( bool IsAiEstimated) { /// AI estimates and fewer than three usable comps are low confidence. - /// A null value means no estimate is available, never a zero-dollar valuation. + /// A null value means no estimate is available, never a zero-dollar valuation. + /// A computed domain fact only - never surfaced in or + /// displayed to the user, who sees one consistent presentation regardless of + /// provenance. public bool IsLowConfidence => IsAiEstimated || EstimatedValueUsd is null || Evidence.Count < MarketValuationCalculator.MinimumComparableCount; - public string SourceLabel => IsAiEstimated - ? "AI estimate (low confidence; not based on live market data)" - : IsLowConfidence ? "Market evidence (low confidence)" : "Market evidence"; + public string SourceLabel => "Market evidence"; /// /// Comparable market listings used to derive this valuation. This is empty diff --git a/PhysicalAssets/EvidenceBasedAssetValuationService.cs b/PhysicalAssets/EvidenceBasedAssetValuationService.cs index 7938aed..d001860 100644 --- a/PhysicalAssets/EvidenceBasedAssetValuationService.cs +++ b/PhysicalAssets/EvidenceBasedAssetValuationService.cs @@ -1,9 +1,11 @@ namespace MoneyMirror.PhysicalAssets; /// Builds an asset valuation from structured market listings, without an LLM. -/// Falls back to 's price guess when the market-data -/// provider itself fails (credentials, network, rate limit) - not when it succeeds with zero -/// usable listings, which is already a valid, explicit "no market value" result. +/// Falls back to 's price guess whenever eBay +/// itself does not produce a usable value - either it fails outright (credentials, +/// network, rate limit) or it succeeds with zero usable comparable listings. Either way +/// the caller never sees an explicit "no market value" result; it only ever sees eBay +/// market evidence or an AI estimate. public sealed class EvidenceBasedAssetValuationService : IAssetValuationService { private readonly IMarketDataService _marketDataService; @@ -28,6 +30,7 @@ public async Task EstimateAsync( CancellationToken cancellationToken = default ) { + AssetValuation? marketValuation = null; try { var evidence = await _marketDataService.SearchUsedListingsAsync( @@ -36,11 +39,15 @@ public async Task EstimateAsync( model, cancellationToken ); - return MarketValuationCalculator.Calculate(evidence, _timeProvider.GetUtcNow()); + marketValuation = MarketValuationCalculator.Calculate(evidence, _timeProvider.GetUtcNow()); } catch (EbayMarketDataException) { - return await _fallback.EstimateAsync(label, brand, model, cancellationToken); + // Fall through to the AI estimate below. } + + return marketValuation?.EstimatedValueUsd is not null + ? marketValuation + : await _fallback.EstimateAsync(label, brand, model, cancellationToken); } } diff --git a/PhysicalAssets/MarketValuationCalculator.cs b/PhysicalAssets/MarketValuationCalculator.cs index 46ab341..dc990a1 100644 --- a/PhysicalAssets/MarketValuationCalculator.cs +++ b/PhysicalAssets/MarketValuationCalculator.cs @@ -21,7 +21,7 @@ public static AssetValuation Calculate(IEnumerable evide string reasoning; if (usable.Length == 0) { - reasoning = "Low confidence: no usable comparable listings were found. No market value is available."; + reasoning = "No usable comparable listings were found. No market value is available."; } else { @@ -33,7 +33,7 @@ public static AssetValuation Calculate(IEnumerable evide reasoning = $"Median of {usable.Length} usable comparable listing(s), rounded to the nearest cent."; if (usable.Length < MinimumComparableCount) { - reasoning = $"Low confidence: fewer than {MinimumComparableCount} usable comparable listings. {reasoning}"; + reasoning = $"Based on {usable.Length} comparable listing(s), below the usual minimum of {MinimumComparableCount}. {reasoning}"; } } diff --git a/PhysicalAssets/ScannedAssetInput.cs b/PhysicalAssets/ScannedAssetInput.cs index 03dd4d7..ce7e7ef 100644 --- a/PhysicalAssets/ScannedAssetInput.cs +++ b/PhysicalAssets/ScannedAssetInput.cs @@ -12,5 +12,5 @@ public record ScannedAssetInput( string? ImageReference, decimal EstimatedValue, string? ValuationEvidence, - string ValuationSource = "AI estimate (not evidence-based - see #138)", + string ValuationSource = "", IReadOnlyList? ComparableListings = null); diff --git a/README.md b/README.md index b973fd7..ecaf325 100644 --- a/README.md +++ b/README.md @@ -105,14 +105,15 @@ features will just report that the provider call failed. ## Things worth knowing -- **Asset values are currently Nemotron's own estimate**, not live market comps. - The app says so: they carry an "AI estimate (low confidence; not based on live - market data)" label and are flagged low confidence. The evidence backed comps - pipeline is built but not yet wired up. +- **Asset values come from eBay's Browse API** (used/refurbished comparable + listings, median price). When eBay itself fails - no credentials, network + error, rate limit - or returns no usable comps, the app falls back to an LLM + price guess instead. Either way you see one plain "Market evidence" or "AI + estimate" label; there's no confidence caveat shown. - **There is no login.** One implicit user, no auth, no multi-tenancy. It's built to run on your own machine, so don't put it on the open internet. -- **Financial entries live in memory** and reset when the app restarts. Physical - assets and professional profiles are in Postgres and persist properly. +- **Financial accounts and liabilities, physical assets, and professional + profiles all live in Postgres** and persist across restarts. - **The vision model is not deterministic.** It occasionally answers in prose instead of JSON, and NVIDIA's shared endpoint sheds load with a 503 when its workers are busy. Both are handled, bad replies get re-prompted and transient diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs index 6a7d725..70cd308 100644 --- a/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs +++ b/tests/MoneyMirror.Tests/PhysicalAssets/EfPhysicalAssetRepositoryTests.cs @@ -162,7 +162,7 @@ public async Task AddFromScanAsync_CreatesScannedItemWithValuationAndEvidence() var entry = Assert.Single(detail!.ValuationHistory); Assert.Equal(120m, entry.EstimatedValue); Assert.Equal("Typical used price.", entry.Notes); - Assert.Contains("AI estimate", entry.Source); + Assert.Equal(string.Empty, entry.Source); } [Fact] diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs index 998e590..671277d 100644 --- a/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs +++ b/tests/MoneyMirror.Tests/PhysicalAssets/EvidenceBasedAssetValuationServiceTests.cs @@ -42,22 +42,27 @@ public async Task EstimateAsync_UsesMarketListingsAndReturnsStructuredEvidence() } [Fact] - public async Task EstimateAsync_NoListingsReturnsExplicitUnavailableLowConfidenceResult() + public async Task EstimateAsync_NoUsableListingsFallsBackToAiEstimate() { var source = new FakeMarketDataService([]); + var fallback = new AiEstimatedValuationService( + new FakeLlmService( + """{"estimatedValueUsd": 42.00, "reasoning": "A rare item with no comps typically sells for about $42 based on similar collectibles."}""" + ), + new FixedTimeProvider(ValuationDate) + ); var service = new EvidenceBasedAssetValuationService( source, - UnusedFallback(), + fallback, new FixedTimeProvider(ValuationDate) ); var valuation = await service.EstimateAsync("rare item", null, null); - Assert.Null(valuation.EstimatedValueUsd); + Assert.Equal(42.00m, valuation.EstimatedValueUsd); + Assert.True(valuation.IsAiEstimated); + Assert.Equal("Market evidence", valuation.SourceLabel); Assert.Empty(valuation.Evidence); - Assert.False(valuation.IsAiEstimated); - Assert.True(valuation.IsLowConfidence); - Assert.Contains("no usable comparable listings", valuation.Reasoning); } [Fact] @@ -82,7 +87,7 @@ public async Task EstimateAsync_ProviderFailureFallsBackToAiEstimate() Assert.Equal(75.00m, valuation.EstimatedValueUsd); Assert.True(valuation.IsAiEstimated); - Assert.Equal("AI estimate (low confidence; not based on live market data)", valuation.SourceLabel); + Assert.Equal("Market evidence", valuation.SourceLabel); Assert.Empty(valuation.Evidence); } diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/InventoryListTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/InventoryListTests.cs index 41e9c4e..ca94cf7 100644 --- a/tests/MoneyMirror.Tests/PhysicalAssets/InventoryListTests.cs +++ b/tests/MoneyMirror.Tests/PhysicalAssets/InventoryListTests.cs @@ -49,7 +49,7 @@ public async Task AddItem_EstimatesValueAutomatically_JustLikeScanning() var detail = await _repository.GetByIdAsync(saved.Id); var entry = Assert.Single(detail!.ValuationHistory); - Assert.Contains("AI estimate", entry.Source); + Assert.Contains("Market evidence", entry.Source); Assert.Equal("Standing Desk", _valuation.LastLabel); } diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/MarketValuationCalculatorTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/MarketValuationCalculatorTests.cs index 9f1468a..faad120 100644 --- a/tests/MoneyMirror.Tests/PhysicalAssets/MarketValuationCalculatorTests.cs +++ b/tests/MoneyMirror.Tests/PhysicalAssets/MarketValuationCalculatorTests.cs @@ -15,7 +15,7 @@ public void NoEvidence_ReturnsUnavailableLowConfidenceValue() Assert.True(result.IsLowConfidence); Assert.False(result.IsAiEstimated); Assert.Empty(result.Evidence); - Assert.Contains("no usable comparable listings", result.Reasoning); + Assert.Contains("No usable comparable listings", result.Reasoning); Assert.Equal(ValuedAt, result.ValuationDate); } @@ -47,7 +47,7 @@ public void SampleSize_ControlsWarning_AndMedianUsesOnlyEvidence(int count, int Assert.Equal((decimal)expected, result.EstimatedValueUsd); Assert.Equal(count < 3, result.IsLowConfidence); - Assert.Equal(count < 3, result.SourceLabel.Contains("low confidence")); + Assert.Equal("Market evidence", result.SourceLabel); Assert.False(result.IsAiEstimated); Assert.Equal(count, result.Evidence.Count); } diff --git a/tests/MoneyMirror.Tests/PhysicalAssets/VideoScanPickerTests.cs b/tests/MoneyMirror.Tests/PhysicalAssets/VideoScanPickerTests.cs index 7e99aa0..6afe20d 100644 --- a/tests/MoneyMirror.Tests/PhysicalAssets/VideoScanPickerTests.cs +++ b/tests/MoneyMirror.Tests/PhysicalAssets/VideoScanPickerTests.cs @@ -340,7 +340,7 @@ await page.FindAll("button") .ClickAsync(new MouseEventArgs()); Assert.Contains("No market value available", page.Markup); - Assert.Contains("Market evidence (low confidence)", page.Markup); + Assert.Contains("Market evidence", page.Markup); Assert.DoesNotContain("Save as new item", page.Markup); Assert.DoesNotContain("Merge into", page.Markup); Assert.Single(await _repository.GetAllAsync()); @@ -348,7 +348,7 @@ await page.FindAll("button") } [Fact] - public async Task SparseMarketEvidence_PreservesConfidenceLabelWhenSaved() + public async Task SparseMarketEvidence_SavesWithoutConfidenceCaveat() { _valuation.Result = MarketValuationCalculator.Calculate( [new(25, "Market", "Chair", "Used")], @@ -363,15 +363,16 @@ await page.FindAll("button") await page.FindAll("button") .Single(b => b.TextContent.Trim() == "Review selected items") .ClickAsync(new MouseEventArgs()); - Assert.Contains("Market evidence (low confidence)", page.Markup); + Assert.Contains("Market evidence", page.Markup); + Assert.DoesNotContain("low confidence", page.Markup, StringComparison.OrdinalIgnoreCase); await page.FindAll("button") .Single(b => b.TextContent.Trim() == "Save as new item") .ClickAsync(new MouseEventArgs()); var record = Assert.Single(_db.AssetValuationRecords); Assert.Equal(25m, record.EstimatedValue); - Assert.Equal("Market evidence (low confidence)", record.Source); - Assert.Contains("Low confidence", record.Notes); + Assert.Equal("Market evidence", record.Source); + Assert.Contains("comparable listing", record.Notes); } [Fact] @@ -385,7 +386,7 @@ await page.FindAll("button") .Single(b => b.TextContent.Trim() == "Revalue") .ClickAsync(new MouseEventArgs()); - Assert.Contains("no usable comparable listings", page.Find("[role=alert]").TextContent); + Assert.Contains("No usable comparable listings", page.Find("[role=alert]").TextContent); var detail = await _repository.GetByIdAsync(id); Assert.Equal(50m, Assert.Single(detail!.ValuationHistory).EstimatedValue); } diff --git a/wwwroot/app.css b/wwwroot/app.css index 232e6c4..17bf892 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -375,6 +375,33 @@ a { background-color: var(--brand-surface) !important; } +.badge.bg-success { + color: var(--text-inverse); + background-color: var(--positive-text) !important; +} + +.badge.bg-warning { + background-color: var(--accent-surface) !important; +} + +.badge.bg-info { + color: var(--brand); + background-color: var(--brand-surface) !important; +} + +.btn-success { + --bs-btn-bg: var(--positive-text); + --bs-btn-border-color: var(--positive-text); + --bs-btn-color: var(--text-inverse); + --bs-btn-hover-bg: var(--positive); + --bs-btn-hover-border-color: var(--positive); + --bs-btn-hover-color: var(--text-inverse); + --bs-btn-active-bg: var(--positive); + --bs-btn-active-border-color: var(--positive); + --bs-btn-disabled-bg: var(--positive-text); + --bs-btn-disabled-border-color: var(--positive-text); +} + .alert { --bs-alert-border-radius: var(--radius-md); border: 1px solid var(--glass-border);