<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Hybrid Image Search Demo on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/tags/hybrid-image-search-demo/</link><description>Recent content in Hybrid Image Search Demo on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Fri, 17 Apr 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/tags/hybrid-image-search-demo/index.xml" rel="self" type="application/rss+xml"/><item><title>hybrid-image-search-demo Dev Log #16 — Lens Preset Expansion, Hover Previews, OTLP Telemetry</title><link>https://ice-ice-bear.github.io/posts/2026-04-17-hybrid-search-dev16/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-04-17-hybrid-search-dev16/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search-demo Dev Log #16 — Lens Preset Expansion, Hover Previews, OTLP Telemetry" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;Three commits, three themes. Lens presets expanded to 5 general options plus a beauty-specific Briese lighting preset. AnglePicker and LensPicker gained 31 hover-preview thumbnails so users can see what each preset actually produces before clicking. The headline work was wiring the production FastAPI backend&amp;rsquo;s traces, metrics, and logs to a local Grafana Alloy agent over OTLP, which forwards to Grafana Cloud. The same interval saw the telemetry&amp;rsquo;s first real use — debugging a user&amp;rsquo;s missing auto-fill tone image by following a trace through Loki. Two sessions, three commits, 5h 54m total.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-16-hybrid-search-dev15/" &gt;Previous post: hybrid-image-search-demo Dev Log #15&lt;/a&gt;&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 A["FastAPI backend (prod)"] --&gt;|"OTLP HTTP :4318"| B["Grafana Alloy (per EC2)"]
 B --&gt; C["Grafana Cloud: Traces (Tempo)"]
 B --&gt; D["Grafana Cloud: Metrics (Prometheus)"]
 B --&gt; E["Grafana Cloud: Logs (Loki)"]
 F["logging.getLogger().error(...)"] --&gt;|"stdlib LogRecord"| A
 G["FastAPI span"] --&gt;|"auto-instrumented"| A
 C -. "trace_id link" .-&gt; E&lt;/pre&gt;&lt;h2 id="lens-presets--5-general--1-beauty"&gt;Lens Presets — 5 General + 1 Beauty
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;c4fb076 feat(gen): expand lens presets to 5 general + beauty w/ Briese lighting&lt;/code&gt; touched &lt;code&gt;backend/src/generation/lens_presets.py&lt;/code&gt;. The previous three lens options weren&amp;rsquo;t enough to cover the generation scenarios our users wanted. This change did two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Expand to 5 general focal lengths&lt;/strong&gt; — 24mm (wide), 35mm (street/environmental), 50mm (natural), 85mm (portrait), 135mm (tight). A standard photography focal-length ladder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a beauty-specific preset — Briese lighting&lt;/strong&gt;. Briese is the large reflector rig used heavily in advertising and beauty photography. This is the first time we&amp;rsquo;ve injected a &lt;em&gt;lighting&lt;/em&gt; directive alongside focal length. &lt;code&gt;prompt.py&lt;/code&gt;&amp;rsquo;s &lt;code&gt;build_generation_prompt&lt;/code&gt; now combines the lens text with the lighting directive when the category is beauty.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Test coverage: one new unit test in &lt;code&gt;backend/tests/test_lens_presets.py&lt;/code&gt; asserts each preset produces the expected string through the prompt builder.&lt;/p&gt;
&lt;p&gt;Frontend &lt;code&gt;LensPicker.tsx&lt;/code&gt; grew its radio options to five and grouped the beauty preset separately. &lt;code&gt;GeneratedImageDetail.tsx&lt;/code&gt; surfaces the selected lens text in the info panel.&lt;/p&gt;
&lt;h2 id="31-hover-preview-thumbnails"&gt;31 Hover-Preview Thumbnails
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;4b886a9 feat(ui): hover-preview examples for angle/lens pickers&lt;/code&gt; is a 31-file commit. Most of those files are the actual example images under &lt;code&gt;frontend/public/preset-examples/angles/*.jpg&lt;/code&gt; and &lt;code&gt;lens/*.jpg&lt;/code&gt; — bird&amp;rsquo;s-eye-view, close-up-cu, dutch-angle, extreme-close-up-ecu, extreme-long-shot-els, eye-level, high-angle, insert-shot, long-shot-ls, low-angle, master-shot, medium-close-up-mcu, and so on.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 A["User hovers an angle/lens option"] --&gt; B["AnglePicker/LensPicker computes preview URL"]
 B --&gt; C["/preset-examples/{kind}/{slug}.jpg"]
 C --&gt; D["Floating tooltip renders the image"]
 D --&gt; E["User picks with visual context"]&lt;/pre&gt;&lt;p&gt;A generator script at &lt;code&gt;backend/scripts/generate_preset_examples.py&lt;/code&gt; batch-produced these thumbnails, calling the same generation pipeline from previous posts on a fixed reference character and dumping outputs into &lt;code&gt;frontend/public/preset-examples/&lt;/code&gt;. &lt;code&gt;.gitignore&lt;/code&gt; was updated to exclude the raw source materials.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AnglePicker.tsx&lt;/code&gt; and &lt;code&gt;LensPicker.tsx&lt;/code&gt; share a floating tooltip pattern on hover. The UX call here is to stop making users pick by jargon alone — &amp;ldquo;extreme-long-shot (ELS)&amp;rdquo; is opaque if you&amp;rsquo;ve never shot cinema, but a thumbnail communicates it instantly.&lt;/p&gt;
&lt;h2 id="grafana-otlp-telemetry"&gt;Grafana OTLP Telemetry
&lt;/h2&gt;&lt;p&gt;The weight of the interval is &lt;code&gt;7a55e9b feat(telemetry): ship prod logs to Alloy/Grafana Cloud via OTLP&lt;/code&gt;. Only four files changed, but it&amp;rsquo;s a significant operational shift.&lt;/p&gt;
&lt;h3 id="the-brief"&gt;The Brief
&lt;/h3&gt;&lt;p&gt;User brief was precise: &amp;ldquo;I&amp;rsquo;m on the free Grafana tier. I&amp;rsquo;d like at least the API logs, or at minimum any API-level error logs. Confirm it&amp;rsquo;s possible under the free tier.&amp;rdquo; Collect prod only, manage the packages globally through &lt;code&gt;pyproject.toml&lt;/code&gt;, enable prod-only via an &lt;code&gt;.env&lt;/code&gt; variable.&lt;/p&gt;
&lt;h3 id="architecture"&gt;Architecture
&lt;/h3&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["FastAPI app"] --&gt;|"OTLP HTTP"| B["Alloy (localhost:4318)"]
 B --&gt; C["Grafana Cloud OTLP endpoint"]
 C --&gt; D["Tempo / Loki / Prometheus"]
 E["stdlib logging"] --&gt;|"LoggingHandler"| A&lt;/pre&gt;&lt;p&gt;The FastAPI app emits to a local Alloy agent over OTLP HTTP on port 4318. Alloy forwards to Grafana Cloud&amp;rsquo;s OTLP endpoint. This puts Grafana Cloud credentials in Alloy&amp;rsquo;s config instead of the app&amp;rsquo;s environment — rotating prod images doesn&amp;rsquo;t expose the Grafana token.&lt;/p&gt;
&lt;h3 id="implementation"&gt;Implementation
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;backend/src/telemetry.py&lt;/code&gt;&lt;/strong&gt; — initialization behind a &lt;code&gt;_telemetry_enabled&lt;/code&gt; flag gated on &lt;code&gt;DEPLOYMENT_ENV == &amp;quot;prod&amp;quot;&lt;/code&gt;. Traces via &lt;code&gt;OTLPSpanExporter&lt;/code&gt;, metrics via &lt;code&gt;OTLPMetricExporter&lt;/code&gt;, logs via &lt;code&gt;OTLPLogExporter&lt;/code&gt;. Auto-instrumentation through &lt;code&gt;FastAPIInstrumentor&lt;/code&gt;, &lt;code&gt;SQLAlchemyInstrumentor&lt;/code&gt;, and &lt;code&gt;LoggingInstrumentor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;stdlib logging → OTLP bridge.&lt;/strong&gt; The critical detail. A root-level &lt;code&gt;LoggingHandler&lt;/code&gt; attaches to the stdlib root logger so every &lt;code&gt;logging.getLogger(...)&lt;/code&gt; call (uvicorn access logs, SQLAlchemy chatter, app &lt;code&gt;logger.error&lt;/code&gt;s) ships as an OTLP log. The handler reads the active span context on emit and attaches &lt;code&gt;trace_id&lt;/code&gt; to each LogRecord — so clicking a log line in Grafana jumps to the trace that produced it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global &lt;code&gt;pyproject.toml&lt;/code&gt; additions.&lt;/strong&gt; &lt;code&gt;opentelemetry-instrumentation-fastapi&lt;/code&gt;, &lt;code&gt;-sqlalchemy&lt;/code&gt;, &lt;code&gt;-logging&lt;/code&gt;, &lt;code&gt;-exporter-otlp-proto-http&lt;/code&gt;, &lt;code&gt;-exporter-otlp-proto-grpc&lt;/code&gt;, all pinned to &lt;code&gt;&amp;gt;=0.54b0&lt;/code&gt; / &lt;code&gt;&amp;gt;=1.33.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;infra/alloy/config.alloy&lt;/code&gt;&lt;/strong&gt; — Alloy config. OTLP receiver opens grpc on 4317 and http on 4318, passes through a batch processor, forwards to Grafana Cloud. Short and boring, which is the right shape for infra config.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;infra/alloy/SETUP.md&lt;/code&gt;&lt;/strong&gt; — per-EC2 manual install: &lt;code&gt;sudo apt install grafana-alloy&lt;/code&gt;, drop the config, enable via systemd.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="deploy-and-a-pm2-gotcha"&gt;Deploy and a PM2 Gotcha
&lt;/h3&gt;&lt;p&gt;Deployed dev → prod through the &lt;code&gt;/deploy-diff&lt;/code&gt; workflow. Verified traces arriving in Grafana Cloud&amp;rsquo;s Explore view. One trap documented but not yet fixed:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ecosystem.config.js&lt;/code&gt; sets &lt;code&gt;DEPLOYMENT_ENV: process.env.DEPLOYMENT_ENV || &amp;quot;&amp;quot;&lt;/code&gt; — which depends on the PM2 daemon&amp;rsquo;s shell environment. If prod EC2 reboots or &lt;code&gt;pm2 kill&lt;/code&gt; + resurrect runs outside a login shell, &lt;code&gt;DEPLOYMENT_ENV&lt;/code&gt; comes back empty, &lt;code&gt;_telemetry_enabled&lt;/code&gt; flips to false, and telemetry silently dies in prod. Fix is to set &lt;code&gt;Environment=DEPLOYMENT_ENV=prod&lt;/code&gt; in the systemd unit that launches PM2. Recorded for the next interval.&lt;/p&gt;
&lt;h2 id="first-live-use--debugging-a-user-issue"&gt;First Live Use — Debugging a User Issue
&lt;/h2&gt;&lt;p&gt;Session 4 was telemetry&amp;rsquo;s first real workout. A user (&lt;a class="link" href="mailto:khk@diffs.studio" &gt;khk@diffs.studio&lt;/a&gt;) generated an image with the prompt &amp;ldquo;우주의 신비로운 모습&amp;rdquo; around 4/16 13:20, and the auto-fill tone image didn&amp;rsquo;t render in the detail view. Normally this would start with an SSH into the prod box and &lt;code&gt;grep&lt;/code&gt; through files. This time the first query was in Grafana Loki: &lt;code&gt;{service_name=&amp;quot;hybrid-image-search&amp;quot;} |= &amp;quot;khk@diffs.studio&amp;quot;&lt;/code&gt; — which pulled the relevant generation logs immediately.&lt;/p&gt;
&lt;p&gt;The chase turned up three intertwined issues:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;blob:http://...&lt;/code&gt; URL throwing an &amp;ldquo;insecure connection&amp;rdquo; warning — the EC2 host hadn&amp;rsquo;t moved to HTTPS yet.&lt;/li&gt;
&lt;li&gt;A 502 Bad Gateway on a different request — likely to resolve together once the HTTPS + nginx upstream config lands.&lt;/li&gt;
&lt;li&gt;A 401 on a third server from an expired session token that wasn&amp;rsquo;t being refreshed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The workflow pattern was clean. Follow the trace link in Grafana → see the FastAPI span → jump to the connected log record and read the error. What used to be &amp;ldquo;SSH to prod and tail logs&amp;rdquo; became &amp;ldquo;click the trace in Grafana.&amp;rdquo; Fixes deferred to the next interval; the forensics part of this interval was the point.&lt;/p&gt;
&lt;h2 id="commit-log"&gt;Commit Log
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Message&lt;/th&gt;
 &lt;th&gt;Changes&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(gen): expand lens presets to 5 general + beauty w/ Briese lighting&lt;/td&gt;
 &lt;td&gt;5 files&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(ui): hover-preview examples for angle/lens pickers&lt;/td&gt;
 &lt;td&gt;31 files (mostly images)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(telemetry): ship prod logs to Alloy/Grafana Cloud via OTLP&lt;/td&gt;
 &lt;td&gt;4 files&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;The best signal of this interval was that the telemetry work and the telemetry&amp;rsquo;s first real use overlapped in the same interval. &amp;ldquo;Set this up because it&amp;rsquo;ll be useful eventually&amp;rdquo; usually takes weeks to pay off, but the OTLP + Alloy stack got drafted into a real user debug on deployment day. Two effects. First, the shape of what Grafana captures and what it misses is now concrete — trace_id linking between logs and traces works; browser-side errors are not captured (OTLP covers server-side only). Second, the query &amp;ldquo;who ran what prompt when, with what error&amp;rdquo; now has a one-liner in Loki that takes an email and returns ordered log records — the next support ticket answers itself in 2 seconds. Being able to use a tool on the day it arrives is a signal that the tool landed on a real problem, not on a hypothetical one. This interval landed right.&lt;/p&gt;</description></item><item><title>hybrid-image-search-demo Dev Log #15 — Removing Tone Count, Unifying A/B Naming</title><link>https://ice-ice-bear.github.io/posts/2026-04-16-hybrid-search-dev15/</link><pubDate>Thu, 16 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-04-16-hybrid-search-dev15/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search-demo Dev Log #15 — Removing Tone Count, Unifying A/B Naming" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;This session focused on fully removing the tone_count system across the entire project and unifying generated image naming to a clean A/B pair. The change touched backend logic, existing DB rows, and the frontend UI, resulting in 7 commits. A deploy environment issue and an angle/lens-only regeneration bug were also fixed along the way.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-15-hybrid-search-dev14/" &gt;Previous: hybrid-image-search-demo Dev Log #14&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="summary-of-changes"&gt;Summary of Changes
&lt;/h2&gt;&lt;h3 id="why-remove-tone-count"&gt;Why Remove Tone Count?
&lt;/h3&gt;&lt;p&gt;The original design managed the number of tone (color) variants per generation via a &lt;code&gt;tone_count&lt;/code&gt; parameter. In practice, two variants (A and B) were always sufficient. The tone count concept added unnecessary complexity to both the UI and the prompt construction pipeline. This session removes it entirely.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["Before: tone_count=N"] --&gt;|"Removed"| B["Fixed A/B pair"]
 B --&gt; C["Simpler prompts"]
 B --&gt; D["Cleaner UI labels"]
 B --&gt; E["DB migration"]&lt;/pre&gt;&lt;h3 id="db-migration-alembic"&gt;DB Migration (Alembic)
&lt;/h3&gt;&lt;p&gt;Existing rows in the &lt;code&gt;injection_reason&lt;/code&gt; column carried suffixes like &lt;code&gt;_tone2&lt;/code&gt; or &lt;code&gt;_tone3&lt;/code&gt;. An Alembic migration strips these suffixes from all existing rows. The parsing logic in &lt;code&gt;app_utils.py&lt;/code&gt; was also updated to ignore any lingering suffixes.&lt;/p&gt;
&lt;h3 id="backend-changes"&gt;Backend Changes
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;app_utils.py&lt;/code&gt; — Removed tone_count suffix appending logic; added suffix stripping during parsing&lt;/li&gt;
&lt;li&gt;&lt;code&gt;routes/generation.py&lt;/code&gt; — Removed tone_count parameter&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generation/injection.py&lt;/code&gt; — Removed tone ratio logic&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generation/prompt.py&lt;/code&gt; — Enriched the B variant with more detail in the prompt&lt;/li&gt;
&lt;li&gt;&lt;code&gt;routes/history.py&lt;/code&gt; — Added backward-compatible tone suffix handling for history queries&lt;/li&gt;
&lt;li&gt;&lt;code&gt;schemas.py&lt;/code&gt; — Removed tone_count field&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="frontend-changes"&gt;Frontend Changes
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;App.tsx&lt;/code&gt; — Removed tone count badges, unified to A/B naming&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GeneratedImageDetail.tsx&lt;/code&gt; — Removed tone-related labels&lt;/li&gt;
&lt;li&gt;&lt;code&gt;api.ts&lt;/code&gt; — Removed tone_count parameter&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="anglelens-only-regeneration-fix"&gt;Angle/Lens-Only Regeneration Fix
&lt;/h3&gt;&lt;p&gt;When regenerating with only an angle or lens change (no attribute injection), the prompt was not constructed correctly. This was fixed by explicitly handling the angle/lens-only case in the generation pipeline.&lt;/p&gt;
&lt;h3 id="deploy-script-fix"&gt;Deploy Script Fix
&lt;/h3&gt;&lt;p&gt;The &lt;code&gt;uv&lt;/code&gt; binary installs to &lt;code&gt;~/.local/bin&lt;/code&gt; on EC2, but the deploy script&amp;rsquo;s PATH did not include this directory, causing deployment failures. Fixed by adding it to PATH in the script.&lt;/p&gt;
&lt;h2 id="commit-log"&gt;Commit Log
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th style="text-align: center"&gt;#&lt;/th&gt;
 &lt;th style="text-align: center"&gt;Scope&lt;/th&gt;
 &lt;th style="text-align: left"&gt;Description&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;1&lt;/td&gt;
 &lt;td style="text-align: center"&gt;db&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Alembic migration to strip tone_count suffix from existing injection_reason rows&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;2&lt;/td&gt;
 &lt;td style="text-align: center"&gt;gen&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Stop appending tone_count to the reason string&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;3&lt;/td&gt;
 &lt;td style="text-align: center"&gt;history&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Strip tone_count suffix before parsing category from reason&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;4&lt;/td&gt;
 &lt;td style="text-align: center"&gt;ui&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Remove tone count badge from cards, use A/B only&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;5&lt;/td&gt;
 &lt;td style="text-align: center"&gt;ui&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Replace remaining tone labels with A/B naming&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;6&lt;/td&gt;
 &lt;td style="text-align: center"&gt;deploy&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Add ~/.local/bin to PATH for uv on EC2&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;7&lt;/td&gt;
 &lt;td style="text-align: center"&gt;gen&lt;/td&gt;
 &lt;td style="text-align: left"&gt;Remove tone ratio entirely, fix angle/lens-only regen, enrich B variant detail&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Incremental removal is safer&lt;/strong&gt; — Rather than deleting tone_count in one massive commit, the work was split into DB migration, backend logic, then frontend. Each step could be verified for backward compatibility with existing data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A/B beats N variants&lt;/strong&gt; — From a user perspective, &amp;ldquo;A / B&amp;rdquo; is far more intuitive than &amp;ldquo;Tone 3 images.&amp;rdquo; Reducing choice complexity improves UX.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PATH differences between dev and prod&lt;/strong&gt; — A classic failure mode: works locally but breaks on EC2. Explicitly setting PATH in deploy scripts is a habit worth building.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>hybrid-image-search-demo Dev Log #14 — Clock Skew, S3-First Ref Cache, Attribute-Aware Injection</title><link>https://ice-ice-bear.github.io/posts/2026-04-15-hybrid-search-dev14/</link><pubDate>Wed, 15 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/posts/2026-04-15-hybrid-search-dev14/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search-demo Dev Log #14 — Clock Skew, S3-First Ref Cache, Attribute-Aware Injection" /&gt;&lt;h2 id="overview"&gt;Overview
&lt;/h2&gt;&lt;p&gt;Short but sharp week. Five commits, all production-hardening: a Google OAuth clock-skew fix blocking logins, ecosystem.config.js made host-portable for PM2 across dev/prod, the reference-image key cache moved off local filesystem onto S3 (so dev and prod see the same state), attribute-aware model auto-injection wired up, and personas relabeled with a 3-shot prompt that adds age estimates.&lt;/p&gt;
&lt;p&gt;Previous: &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-13-hybrid-search-dev13/" &gt;hybrid-image-search-demo Dev Log #13&lt;/a&gt;&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 A["Login: Invalid token 'used too early'"] --&gt; B["clock_skew_in_seconds=10"]
 C["ref cache from local fs"] --&gt; D[ref cache from S3]
 E[model auto-injection: any image] --&gt; F[attribute-aware injection by tag]
 G[personas: old labels] --&gt; H[3-shot relabel + age estimates]&lt;/pre&gt;&lt;hr&gt;
&lt;h2 id="google-oauth-clock-skew"&gt;Google OAuth clock skew
&lt;/h2&gt;&lt;h3 id="context"&gt;Context
&lt;/h3&gt;&lt;p&gt;Login blocked with &lt;code&gt;Invalid Google token: Token used too early, 1776217862 &amp;lt; 1776217863. Check that your computer's clock is set correctly.&lt;/code&gt; The server&amp;rsquo;s clock was ~1 second ahead of Google&amp;rsquo;s — the JWT &lt;code&gt;iat&lt;/code&gt; was in the future from the server&amp;rsquo;s perspective.&lt;/p&gt;
&lt;h3 id="fix"&gt;Fix
&lt;/h3&gt;&lt;p&gt;Added &lt;code&gt;clock_skew_in_seconds=10&lt;/code&gt; to &lt;code&gt;id_token.verify_oauth2_token(...)&lt;/code&gt; in &lt;code&gt;backend/src/auth.py&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;id_token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_oauth2_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;google_requests&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;GOOGLE_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;clock_skew_in_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Resolved immediately. A server should never trust its own clock to the second against a third party&amp;rsquo;s &lt;code&gt;iat&lt;/code&gt; — 10 seconds of tolerance is standard practice for JWT validation and doesn&amp;rsquo;t open any meaningful attack surface.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="s3-first-reference-key-cache"&gt;S3-first reference key cache
&lt;/h2&gt;&lt;h3 id="context-1"&gt;Context
&lt;/h3&gt;&lt;p&gt;The model/reference-image cache was built from the local filesystem. This broke in production because prod&amp;rsquo;s S3-mounted paths didn&amp;rsquo;t always reflect the latest uploads, and because dev and prod had divergent local filesystem state. When a user regenerated in &amp;ldquo;tone only&amp;rdquo; mode, the UI showed the wrong reference image because the path resolved against local state, not S3 reality.&lt;/p&gt;
&lt;h3 id="fix-1"&gt;Fix
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;ce33906 fix(storage): build ref key cache from S3, not local filesystem&lt;/code&gt; — cache construction now enumerates S3 objects directly. All image retrieval paths resolved against S3 keys. Also backfilled existing generation history so old records point to the correct S3 URL.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="attribute-aware-model-auto-injection"&gt;Attribute-aware model auto-injection
&lt;/h2&gt;&lt;h3 id="context-2"&gt;Context
&lt;/h3&gt;&lt;p&gt;Previous injection logic would pull &lt;em&gt;any&lt;/em&gt; image matching a loose condition, so the comparison mode (&amp;ldquo;tone + angle&amp;rdquo; vs &amp;ldquo;tone only&amp;rdquo;) sometimes injected a model image that didn&amp;rsquo;t match the tagged attribute. Users saw the wrong reference in the output grid.&lt;/p&gt;
&lt;h3 id="fix-2"&gt;Fix
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;d492ee1 feat(gen): attribute-aware model auto-injection&lt;/code&gt; — injection now keys on the tagged attributes (angle, tone) of the requested model folder. Subfolders under &lt;code&gt;s3://diffs-studio-hybrid-search/.../01. Model&lt;/code&gt; are treated as attribute groups, one reference per group.&lt;/p&gt;
&lt;p&gt;Pre-requisite: each model reference was relabeled so attributes are trustworthy. Grouping by folder means labels are a filesystem-visible schema, not a DB column, which matters because the ops team can audit and edit labels with just S3 browsing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="persona-relabeling-with-3-shot-prompt--age"&gt;Persona relabeling with 3-shot prompt + age
&lt;/h2&gt;&lt;h3 id="context-3"&gt;Context
&lt;/h3&gt;&lt;p&gt;Persona labels were set earlier with a zero-shot prompt and did not include age estimates. User-facing filters needed age granularity.&lt;/p&gt;
&lt;h3 id="fix-3"&gt;Fix
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;2743eaf chore(labels): re-label personas with 3-shot prompt and age estimates&lt;/code&gt; — re-ran the labeler with three in-context examples per request and an age-range field. Labels pushed to the repo so every server picks them up, avoiding per-instance label drift.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pm2--tsc-fixes"&gt;PM2 / TSC fixes
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;95f8bbc fix(deploy): make ecosystem.config.js host-portable&lt;/code&gt; — removed hardcoded absolute paths so the same config works on dev and prod. PM2 now boots the same from any &lt;code&gt;$HOME&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;6ebab0d fix(ui): drop unused generatingCount state to unblock tsc build&lt;/code&gt; — dead state variable tripped the TypeScript build after a recent cleanup. Deleted and the build passed.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="commit-log"&gt;Commit log
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Message&lt;/th&gt;
 &lt;th&gt;Area&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;fix(deploy): make ecosystem.config.js host-portable&lt;/td&gt;
 &lt;td&gt;PM2&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;fix(storage): build ref key cache from S3, not local filesystem&lt;/td&gt;
 &lt;td&gt;Storage&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(gen): attribute-aware model auto-injection&lt;/td&gt;
 &lt;td&gt;Generation logic&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;fix(ui): drop unused generatingCount state to unblock tsc build&lt;/td&gt;
 &lt;td&gt;Frontend&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;chore(labels): re-label personas with 3-shot prompt and age estimates&lt;/td&gt;
 &lt;td&gt;Labeling&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="insights"&gt;Insights
&lt;/h2&gt;&lt;p&gt;Two patterns worth locking in. First, &amp;ldquo;build cache from the source of truth&amp;rdquo; beats &amp;ldquo;sync cache with source of truth&amp;rdquo; every time. The ref-key cache was fragile as long as it started from local state and hoped to reconcile with S3 later; building directly from S3 removes a whole category of drift bugs. Second, the clock-skew fix is a reminder that production OAuth failures are almost always distributed-systems issues (clock sync, DNS propagation, key rotation) rather than crypto issues — a 1-line fix after 10 minutes of log reading, which is exactly how it should feel in a mature stack.&lt;/p&gt;</description></item></channel></rss>