vikingowl e0b73acfd6 feat(discovery): AI tiebreak for ambiguous similarity matches
Ship 2 MR 4. Adds per-pair AI-backed classification for operator use
inside the existing Similar panel: an "AI?" button next to each
candidate asks Mistral whether the two queue rows refer to the same
underlying market. Result shown inline as a green "✓ same N%" or
grey "✗ diff N%" chip with the LLM's reason on hover.

No scraping — the classifier works from (name, city, year) alone,
which is enough for the common cases (same venue on two calendars,
typos, cross-year recurrence). Call is short (usually <3s) so the
handler is synchronous, 15s deadline.

Caching
- Migration 000020 adds similarity_ai_cache keyed on a content hash
  over (normalized_name|stadt|year) for both rows, sorted for
  symmetry. Survives queue row accept/reject because the hash is
  about markt-content, not queue-row lifecycle.
- enrich.SimilarityPairKey computes the key. Classify(a,b) and
  Classify(b,a) hit the same entry. Stadt casing drift doesn't
  invalidate.
- Repo methods GetSimilarityCache / SetSimilarityCache + corresponding
  mock hooks. DefaultSimilarityCacheTTL=30d.

Mistral integration
- enrich.MistralSimilarityClassifier reuses the same aiPass2
  interface as the enricher. English system prompt asks for
  JSON-only output with {same_market, confidence 0..1, reason}.
  Confidence clamped to [0,1] because models occasionally return
  1.2 or -0.1. Reason is short German justification.
- NoopSimilarityClassifier returns an error — callers must check
  ai.Enabled() before deciding which binding to pass.

Service.ClassifySimilarPair loads both rows, computes pair key,
cache-first, calls classifier on miss, writes cache, returns
verdict. Rejects self-comparison (pair-key collapses). Handler
POST /admin/discovery/queue/:aid/similar/:bid/classify.

UI: new AI? column inside the Similar panel. Per-candidate pending
state via Set<string>, disabled button while in-flight, inline
verdict chip after response. Tooltip shows the LLM's reason.

Tests: pair-key symmetry + differentiation + casing tolerance;
Mistral classifier happy path, clamping edge cases, error
propagation, bad-JSON handling, Noop rejection. Service tests:
happy path writes cache, cache-hit skips LLM, self-comparison
rejected, classifier errors don't poison the cache.

NewService signature grows by one param (sim enrich.
SimilarityClassifier). All 14 existing callers (routes.go + tests)
updated; tests pass nil.
2026-04-24 11:04:15 +02:00
2026-02-21 07:10:30 +01:00
Description
No description provided
1.7 MiB
Languages
Go 60.3%
Svelte 20.3%
Dart 11.1%
TypeScript 5%
PLpgSQL 1.1%
Other 2.1%