Commit Graph

311 Commits

Author SHA1 Message Date
65c8c4bf96 feat(research/merge): add merge planner, validators, plan+apply endpoints, audit log (D1-D5) 2026-04-25 18:39:01 +02:00
1b991518a4 feat(ai): warn on unsupported schema keys + enrich grounding gate
schemaFromMap now logs a warning when keys genai.Schema ignores
(pattern, minLength, $ref, etc.) are present, keeping the workaround
visible. LLMEnricher skips Google Search grounding when total scraped
chars >= 1500, conserving free-tier quota on content-rich pages.
2026-04-25 18:10:37 +02:00
66aee62646 feat(ai): add PromptHash to ProviderError + log on schema violation
promptHashShort(system+"\x00"+user)[:12] computed on ErrSchemaViolation
and attached to ProviderError.PromptHash. research.go schema-violation
log now includes prompt_hash for cross-referencing ai_usage rows.
2026-04-25 18:09:28 +02:00
ad1da8be66 feat(ai): add prompt_version to ai_usage + wire version constants
Migration 000024 adds prompt_version column + partial index.
PromptVersion plumbed through ChatRequest -> UsageEvent ->
buildUsageEvent -> settings INSERT/SELECT. Version constants
defined in ai/versions.go and wired at all three call sites.
2026-04-25 18:08:53 +02:00
69c6453e26 feat(similarity): confidence calibration anchors + Ronneburg failure-case fixtures (B4-B5)
- Add confidence scale (0.95-1.00 / 0.70-0.90 / 0.50-0.70 / 0.00-0.50)
  with four annotated few-shot examples to the similarity system prompt
- Add two Ronneburg real-world pairs to similarity.json: descriptive-prefix
  swap and low-trigram-overlap rename, both expected same=true
2026-04-25 18:00:39 +02:00
b25ae09bd2 feat(enrich): full category taxonomy, tighter description + opening_hours rules (B1-B3)
- Replace 3-example inline comment with 7-label taxonomy block so the
  model knows all valid categories instead of guessing from partial hints
- Tighten description constraint to 60-220 chars with explicit word bans
- Mark opening_hours as a rough guide, not authoritative for booking
2026-04-25 17:58:08 +02:00
f98ecf8790 fix(discovery): auto-trigger Pass B (LLM enrich) after post-crawl Pass A
Adds ListEnrichedNeedingLLM to the Repository interface and RunLLMEnrichBacklog
to Service, then wires RunLLMEnrichBacklog into the post-crawl goroutine so
LLM enrichment runs automatically after every crawl without manual triggers.
2026-04-25 17:53:56 +02:00
2e3141aaeb fix(discovery): skip enrichment cache for date-less rows (year=0)
Rows without start_datum all hash to year=0, causing cache collisions
across unrelated markets. Gate both cache reads and writes on year!=0.
2026-04-25 17:50:27 +02:00
f151c0865e fix(discovery): use JSON schema instead of JSONMode for LLM enricher
Replaces JSONMode:true with an embedded enricher_schema.json so Gemini
returns structured output against a typed schema, preventing empty {} responses.
Adds an all-empty warning when the LLM returns a valid but blank payload.
2026-04-25 17:48:48 +02:00
eb169689d5 fix(admin): submit save form after applying research suggestions
applyResearch() populated form fields but never triggered a save.
After applying all suggestions and appending the KI-Recherche note,
call requestSubmit() on form[action="?/save"] so the data is persisted.
2026-04-25 17:41:19 +02:00
a6298d2be2 fix(enrich): set Temperature=0.1 on enrich_b and similarity call sites
Deterministic output is preferable for extraction and classification
tasks. Temperature=0.1 also enables the if-gate in gemini.go that
forwards the value to the Gemini API config.

Also add llm_enricher.go (renamed from mistral.go) with the temperature
field applied.
2026-04-25 17:41:19 +02:00
87e2f06323 fix(research): remove fetch instructions + add ziel_jahr to prompt payload
The researcher prompt incorrectly told the model to open/fetch URLs; it
only ever sees pre-fetched quellen[].text. Replace all "oeffnen" and
"aufgerufen" references with instructions to work from quellen[].

Add ZielJahr to research.Input and the JSON user-prompt payload so the
model has an explicit target year separate from recherche_datum (wall-clock
time of the request). Use ziel_jahr in the prompt instead of deriving the
year from recherche_datum. Fix the search query in the orchestrator to use
ZielJahr rather than RechercheDatum.Year().
2026-04-25 17:41:00 +02:00
2f32d4b954 chore: remove Mistral/Ollama legacy references after Gemini migration
Rename mistral.go → llm_enricher.go and mistral_test.go →
llm_enricher_test.go; update all test function names and stale model
strings (mistral-large-latest → gemini-2.5-flash-lite); drop Ollama
block from .env; mark superseded planning specs; update provider
references in planning docs and CLAUDE.md to Google Gemini.
2026-04-25 17:31:58 +02:00
33539b703a feat(research): add logo_url field + require per-field hints
- Add logo_url as a distinct DB column (migration 000023) and expose it
  through model, DTOs, repository, service, and all frontend types
- Update KI-Recherche prompt and both JSON schemas: logo_url field rule,
  clarified bild_url rule, hinweis now mandatory non-null (maxLength 200)
- imageURLReachable now also verifies Content-Type: image/* for both
  bild_url and logo_url before surfacing suggestions
- MarketCard: image-first with cover style, logo fallback with contain
  style, city-initial placeholder as last resort
- /markt/[slug]: hero section follows same image→logo→nothing precedence;
  OG/JSON-LD updated accordingly
- Map view on search page: pagination hidden, map height increased to 600px
- Fix einstellungen Svelte warning: wrap showKeyInput init in untrack()
2026-04-25 16:58:47 +02:00
bde41be767 fix(research): surface errors to UI + proceed without pages when all fetches fail 2026-04-25 13:59:05 +02:00
0bff6771ce feat(admin): filter markets by missing fields + row indicators
- Add missing= query param (description/image/website/location) to
  AdminSearchParams; both AdminSearch and AdminSearchGrouped apply the
  SQL condition
- Add has_description/has_image/has_website/has_location booleans to
  AdminMarketSummary, populated in ToAdminSummary from existing Market fields
- Dropdown filter in the admin market list routes to the missing param
- Coloured dot indicators per row (amber=image, orange=desc, red=website,
  purple=location) with title tooltips
2026-04-25 13:46:14 +02:00
51fc9828a0 chore(docs): document local branch-merge-push git workflow 2026-04-25 13:35:41 +02:00
e166ad5e48 content(impressum): add data accuracy disclaimer section 2026-04-25 13:34:32 +02:00
bc93213d16 fix(ai): switch grounding tool from GoogleSearchRetrieval to GoogleSearch
Gemini API no longer supports googleSearchRetrieval; requests fail with
INVALID_ARGUMENT. Replace with the google_search tool as required.
2026-04-25 13:34:07 +02:00
bb3ab382e1 Merge branch 'fix/gemini-model-filter-tuned-check' into 'main'
fix(ai): drop TunedModelInfo nil check in model filter

See merge request vikingowl/marktvogt.de!24
2026-04-25 11:12:21 +00:00
0110156018 fix(ai): drop TunedModelInfo nil check in model filter
SDK's modelFromMldev maps _self to tunedModelInfo for every model,
making the nil check always true and silently dropping all results.
Name-based filtering is the correct gate; tuned models are excluded
by the gemini- prefix requirement.
2026-04-25 13:09:55 +02:00
cae0d7ae3e merge: feat/gemini-filter-image-display 2026-04-25 12:46:36 +02:00
9d9520bcad feat(ui): image display improvements across admin and public views
- MarketCard: object-fit contain with padding instead of cropped 16:9;
  city-initial placeholder so all cards are uniform height in the grid;
  imgFailed state falls back to placeholder on broken URLs
- Admin market detail: show image thumbnail + Bild-URL link in Details
- Admin edit form: live image preview below Bild-URL input
- Public detail page: contain + max-height 250px instead of cover crop
- onerror handlers hide broken images on public card and detail pages
- Time inputs changed to text + pattern for reliable 24h display
2026-04-25 12:46:13 +02:00
9d457462d5 feat(research): year verification in LLM prompt + image URL HEAD check
- Prompt now requires year verification before extracting any field
- Opening times and prices from prior years must be nulled with a hint
- imageURLReachable does a HEAD request (5s timeout) and strips the
  image_url from research results when the resource returns 4xx/5xx
2026-04-25 12:44:01 +02:00
6b3c673cd0 feat(ai): tighter Gemini model filter with per-model pricing
- Replace ListModelNames with ListModels returning ModelInfo structs
- Name-based filter: require gemini- prefix, drop tuned models, block
  EOL 2.0 family, TTS/image/live/audio/robotics/embedding, Gemma/Imagen/Veo
- Static pricing table with longest-prefix match; stable vs preview flag
- Settings handler validates SetModel against allowed list (degrade-open)
- Frontend dropdown shows input/output price per 1M tokens + Preview tag
- Table-driven unit tests for filter, sort order and pricing lookup
2026-04-25 12:42:53 +02:00
0036a63557 fix(research): move all form fields to reactive state, add setField dispatcher 2026-04-25 11:25:35 +02:00
da9754cb2f fix(research): move all form fields to reactive state, add setField dispatcher
All Input fields used market?.xxx as initial value, so a Svelte re-render
triggered by researchResult=null would reset them back to the server-loaded
value, wiping every applied research suggestion.

Replace all research-applicable fields with $state variables and route all
apply calls through setField() instead of querySelector+dispatch. Country
name->code mapping added for LLM-returned values like "Deutschland" -> "DE".
writeReverseResult also updated to use setField.
2026-04-25 11:25:21 +02:00
cc69bf51bc fix(research): apply description via reactive state, add name correction 2026-04-25 11:15:33 +02:00
c5c84ff297 fix(research): apply description via reactive state, add name correction
Description wasn't being applied because querySelector-then-assign runs before
Svelte's reactive flush of researchResult=null, which resets the textarea to
its initial market.description value. Fix: reactive state + exported setter
(same pattern as setHours/setAdmission).

Also add markt_name to felder in both schemas and the prompt so the LLM can
suggest a name correction. Name suggestions are gated to extraktion=direkt
(high confidence only) and guarded on the frontend with setName().
2026-04-25 11:15:16 +02:00
6b8d2d621f fix(research): add beschreibung to prompt, auto-note on apply 2026-04-25 11:05:43 +02:00
282d59e6c1 fix(research): add beschreibung to prompt, auto-note on apply
The beschreibung field was schema-required but absent from ## Felder,
causing the LLM to always return null. Add explicit extraction instruction.

Also reword the opening line which said "Keine Beschreibungstexte" —
contradicting the field we actually want.

On apply, append "KI-Recherche: DD.MM.YYYY HH:MM" to admin_notes so
there's a permanent audit trail of when research was run.
2026-04-25 11:05:27 +02:00
d7dd003a67 fix(research): convert LLM schema shapes to form-compatible types on apply 2026-04-25 11:01:36 +02:00
dd9a5ae9cc fix(research): convert LLM schema shapes to form-compatible types on apply
Researcher emits {datum_von,von,bis} for opening hours and [{name,betrag,waehrung}]
for admission info — both incompatible with the form's {day,open,close} and
AdmissionInfo shapes. Normalize on apply; extend normalizeDayName to handle
ISO YYYY-MM-DD dates the LLM produces. ResearchPanel renders both LLM and
form-native formats with dedicated table/list views.
2026-04-25 11:01:18 +02:00
25b682f030 fix(research): remove Grounded from LLM call — incompatible with JSONSchema in Gemini API
Gemini rejects requests that set both GoogleSearchRetrieval and
response_schema. The orchestrator already provides web content via
SearxNG + scraping, so grounding is unnecessary here.
2026-04-25 10:50:01 +02:00
eff7b7ec65 fix(ai): strip models/ prefix from Gemini model names in ListModelNames 2026-04-25 10:46:32 +02:00
016d7a0792 fix(settings): handle missing migrations gracefully, guard AI status page
factory.go: treat DB errors from GetGeminiAPIKey as "no key" and fall
back to the GEMINI_API_KEY env var instead of propagating the error
(which caused a panic/crash when migrations haven't been run yet).

gemini.go: ListModelNames returns a ProviderError when the client is
nil so that connected=false is reported correctly in GetAI instead of
the previous nil,nil→connected=true false positive.

+page.server.ts: catch fetch errors so a backend outage doesn't 500 the
whole page. +page.svelte: guard all data.ai access with {#if data.ai}
so the page renders an error banner instead of crashing on null access.
2026-04-25 10:41:25 +02:00
c6ce0f3a2d feat(discovery): auto-accept high-confidence crawl rows during crawl
When a freshly-inserted discovered_market has a matched series, konfidenz
"hoch" (≥2 sources), and both start/end dates present, Accept() is called
inline with a nil reviewer (mapped to NULL reviewed_by) so the row goes
straight to accepted without manual review.

CrawlSummary gains auto_accepted counter; slog summary logs it.
MarkAccepted / Service.Accept now take *uuid.UUID for reviewer so nil
cleanly maps to NULL in the DB column (already nullable).
2026-04-25 10:08:26 +02:00
3ddfd87408 feat(ai): migrate to Google Gemini 2.5 Flash-Lite, drop Mistral/Ollama
Replace the Mistral + Ollama AI stack with a single Google Gemini provider
backed by google.golang.org/genai. API key moves from env/Helm to the DB
(AES-256-GCM, key derived from JWT_SECRET via HKDF) so it can be rotated
via the admin UI without a pod restart.

New:
- pkg/crypto/secretbox — AES-256-GCM encrypt/decrypt for secrets at rest
- pkg/ai/gemini — GeminiProvider with grounding, structured output, usage
  recording, and hot-reload (Reinitialize swaps client under mutex)
- pkg/ai/usage — UsageRecorder interface + UsageEvent struct
- domain/settings/store — DB-backed settings (model, grounding toggle, key)
- domain/settings/usage — UsageRepo implementing UsageRecorder; ai_usage table
- migrations 000021 (system_settings) + 000022 (ai_usage)
- settings API: GET /ai, POST /ai/key, POST /ai/model, POST /ai/grounding,
  GET /ai/usage
- admin UI: 4-card settings page — provider status, model selector, grounding
  toggle with quota, usage rollups + recent-calls table

Removed:
- pkg/ai/ollama, mistral_provider, ratelimiter (+ tests)
- Helm AI_API_KEY, AI_PROVIDER, AI_MODEL_COMPLEX, AI_AGENT_DISCOVERY,
  AI_RATE_LIMIT_RPS env vars

Call sites set Grounded+CallType: research (true/"research"), enrich Pass B
(true/"enrich_b"), similarity (false/"similarity"). Integration test updated
to use a stub ai.Provider instead of a fake Ollama HTTP server.
2026-04-25 09:54:49 +02:00
80149de317 feat(discovery): auto-trigger Pass A enrichment after crawl 2026-04-25 08:42:28 +02:00
7552e5073f feat(discovery): auto-trigger Pass A enrichment after crawl
Run CrawlEnrich + Nominatim geocoding in the background immediately
after a crawl discovers new rows. Manual triggers via the
/enrichment/crawl-all endpoint remain for backfills but are no longer
needed for fresh crawls.
2026-04-25 08:42:20 +02:00
c4207865c8 feat(settings): Ollama connection status + runtime model selector
Add /admin/settings/ai endpoint (GET status + available models, POST
model switch). OllamaProvider gains SetModel/Model/ListModels with a
RWMutex so the active model can be swapped at runtime without restart.
New /admin/einstellungen page shows provider, connection badge, and a
model dropdown that calls the API on submit.
2026-04-25 08:29:38 +02:00
f13cd55393 feat(research): wire LLM output to ResearchResult, add beschreibung field
Transform raw LLM felder output into FieldSuggestion[] for the UI panel.
Skip suggestions identical to current market values. Add beschreibung to
both schemas, the Go struct, and the transformation mapping so description
is extracted during research. Fix field labels (Land, Startdatum, Enddatum)
in ResearchPanel.
2026-04-25 08:12:28 +02:00
c18babce5b fix(research): use anyOf for nullable fields in Ollama constraint schema
Ollama's llama.cpp grammar converter supports anyOf with primitive
null — use it for all nullable wert/hinweis fields instead of
type:string-only, so constrained decoding emits JSON null directly.
This also fixes the orchestrator test fixture which uses JSON null
for optional wert fields.
2026-04-24 18:18:05 +02:00
67b2eb5d74 feat(market): in-backend research orchestrator with SearxNG + schema-validated LLM
Adds pkg/search (SearxNG impl), domain/market/research (orchestrator + embedded
German prompt and JSON schema), and reinstates POST /markets/:id/research on
top of the new pipeline. Seeds URLs from crawler provenance; falls back to
search when fewer than two distinct seed domains are known.
2026-04-24 17:06:04 +02:00
24e072b63d feat(ai): pluggable provider interface, Ollama + Mistral impls, migrate Pass2 sites
Replaces the Mistral-only ai.Client with an ai.Provider interface backed by
Ollama and Mistral implementations. Migrates enrichment + similarity callers
to ai.Provider.Chat. Research endpoint returns 501 until commit 2 reinstates
it on the new orchestrator.
2026-04-24 16:35:18 +02:00
2adb4882c7 docs(planning): add implementation plan for pluggable AI provider migration 2026-04-24 15:43:12 +02:00
020f4069b5 docs(planning): add spec for pluggable AI provider and local research orchestrator 2026-04-24 15:31:47 +02:00
7a2e81c8c9 Merge branch 'feat/reverse-geocode' into 'main'
feat(market): reverse geocoding

See merge request vikingowl/marktvogt.de!23
2026-04-24 13:01:00 +00:00
c9a2f8622f feat(market): reverse geocoding — lat/lng to address
Complements the existing forward geocoder with Nominatim's /reverse
endpoint so the admin edit form can populate the address from
coordinates (useful when a crawl gave us lat/lng but no street,
e.g. after running crawl-enrich).

Backend:
- geocode.Reverse(ctx, lat, lng) hits Nominatim /reverse with
  addressdetails=1 and accept-language=de, reuses the 1 rps mutex
  already guarding forward calls. Falls through city → town →
  village → municipality → hamlet for small places. Returns nil
  when Nominatim has no match so callers can distinguish "no hit"
  from "all-empty address."
- New DTOs ReverseGeocodeRequest/Response.
- GeocodeHandler.ReverseGeocode wired at POST /reverse-geocode
  behind the same geocodeLimit middleware as /geocode.

Frontend:
- /api/reverse-geocode SvelteKit proxy mirrors /api/geocode.
- MarketForm gets a second button next to "Koordinaten aus Adresse
  ermitteln" — "Adresse aus Koordinaten ermitteln". Writes non-empty
  street/city/zip back into the form; empty result surfaces
  "Keine Adresse gefunden."
2026-04-24 15:00:23 +02:00
a250fddbc2 Merge branch 'fix/discovery-remove-auto-research' into 'main'
fix(discovery): stop auto-firing research on Accept

See merge request vikingowl/marktvogt.de!22
2026-04-24 12:56:43 +00:00