Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions Features/HumanCapital/HumanCapital.razor
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,7 @@
<h2 class="page-section-title">Market Potential</h2>

<div class="alert alert-info page-card" role="alert">
Market Potential is a separate estimate of what your skills and experience could earn -
it is <strong>never</strong> added to, or combined with, your financial net worth.
Market Potential is a separate estimate of what your skills and experience could earn
</div>

@if (_marketPotentialError is not null)
Expand Down Expand Up @@ -183,7 +182,6 @@ else
{
<p class="fs-4 fw-bold">
$@_compensation.MinUsd.ToString("N0") &ndash; $@_compensation.MaxUsd.ToString("N0")
<span class="badge bg-secondary fs-6 align-middle">AI estimate - not based on live wage data</span>
</p>
<p class="text-muted">@_compensation.Explanation</p>
}
Expand All @@ -201,7 +199,6 @@ else
{
<li>
<strong>@occupation.Title</strong>
<span class="badge bg-secondary">AI-suggested</span>
@if (occupation.TypicalMinUsd is not null && occupation.TypicalMaxUsd is not null)
{
<span class="text-muted"> - $@occupation.TypicalMinUsd.Value.ToString("N0") &ndash; $@occupation.TypicalMaxUsd.Value.ToString("N0")</span>
Expand Down
30 changes: 15 additions & 15 deletions Features/PhysicalAssets/LiveCameraScanner.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

Expand All @@ -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 {
Expand Down
1 change: 0 additions & 1 deletion Features/PhysicalAssets/PhysicalAssets.razor
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@
{
<p class="text-muted mt-2 mb-0">
<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>
<em>Estimating value with AI...</em>
</p>
}

Expand Down
24 changes: 12 additions & 12 deletions Features/PhysicalAssets/VideoScanPicker.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,48 +32,48 @@
.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,
stroke 0.15s;
}

.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%;
Expand Down
4 changes: 2 additions & 2 deletions HumanCapital/IMarketPotentialPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ namespace MoneyMirror.HumanCapital;
/// <remarks>
/// #266: not currently called from any page - <c>/market-potential</c> and
/// the Dashboard still use the AI-guess-only <see cref="ICompensationEstimationService"/>
/// (#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
Expand Down
9 changes: 5 additions & 4 deletions PhysicalAssets/AssetValuation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ public record AssetValuation(
bool IsAiEstimated)
{
/// <summary>AI estimates and fewer than three usable comps are low confidence.
/// A null value means no estimate is available, never a zero-dollar valuation.</summary>
/// A null value means no estimate is available, never a zero-dollar valuation.
/// A computed domain fact only - never surfaced in <see cref="SourceLabel"/> or
/// displayed to the user, who sees one consistent presentation regardless of
/// provenance.</summary>
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";

/// <summary>
/// Comparable market listings used to derive this valuation. This is empty
Expand Down
17 changes: 12 additions & 5 deletions PhysicalAssets/EvidenceBasedAssetValuationService.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
namespace MoneyMirror.PhysicalAssets;

/// <summary>Builds an asset valuation from structured market listings, without an LLM.
/// Falls back to <see cref="AiEstimatedValuationService"/>'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.</summary>
/// Falls back to <see cref="AiEstimatedValuationService"/>'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.</summary>
public sealed class EvidenceBasedAssetValuationService : IAssetValuationService
{
private readonly IMarketDataService _marketDataService;
Expand All @@ -28,6 +30,7 @@ public async Task<AssetValuation> EstimateAsync(
CancellationToken cancellationToken = default
)
{
AssetValuation? marketValuation = null;
try
{
var evidence = await _marketDataService.SearchUsedListingsAsync(
Expand All @@ -36,11 +39,15 @@ public async Task<AssetValuation> 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);
}
}
4 changes: 2 additions & 2 deletions PhysicalAssets/MarketValuationCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public static AssetValuation Calculate(IEnumerable<AssetValuationEvidence> 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
{
Expand All @@ -33,7 +33,7 @@ public static AssetValuation Calculate(IEnumerable<AssetValuationEvidence> 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}";
}
}

Expand Down
2 changes: 1 addition & 1 deletion PhysicalAssets/ScannedAssetInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssetValuationEvidence>? ComparableListings = null);
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down
13 changes: 7 additions & 6 deletions tests/MoneyMirror.Tests/PhysicalAssets/VideoScanPickerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -340,15 +340,15 @@ 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());
Assert.Single(_db.AssetValuationRecords);
}

[Fact]
public async Task SparseMarketEvidence_PreservesConfidenceLabelWhenSaved()
public async Task SparseMarketEvidence_SavesWithoutConfidenceCaveat()
{
_valuation.Result = MarketValuationCalculator.Calculate(
[new(25, "Market", "Chair", "Used")],
Expand All @@ -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]
Expand All @@ -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);
}
Expand Down
Loading
Loading