Compare commits

...
58 Commits
Author SHA1 Message Date
hanzo-dev dee04c5c89 feat(world/ai): Enso Flywheel panel + /v1/world/enso-training
The router self-improvement loop made visible for the AI variant.
/v1/world/enso-training folds three honest sources: the routing-decision ledger
tail (export-routing-ledger ?since=, super-admin JSONL) → decision count,
engine-vs-heuristic mix, a routing-confidence histogram, task/model distributions;
the reward tail (export-routing-rewards) → rewarded count + mean reward; and the
latest enso-bench eval scores. Eval scores come from a committed snapshot of
enso-bench results/summary.json (go:embed), overridable live via ENSO_BENCH_URL —
so the panel is useful even signed-out; state (live/partial/demo) says which live
sources resolved. The payload is event-typed (Events[]) so retrain/deploy
milestones slot into the same timeline.

Frontend: enso-training.ts + EnsoFlywheelPanel registered for the ai variant,
reusing the existing cloud stat-tile / model-row / share-bar idioms.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:33:23 -07:00
hanzo-dev 7cd8035964 feat(world/ai): AI Compute panel + /v1/world/ai-pulse SSE
Live Hanzo inference-plane telemetry for the AI variant. /v1/world/ai-pulse
reuses the analyst SSE plumbing + the shared cloud-usage seam: it polls the
platform usage ledger (get-cloud-usages allOrgs → tokens/s, req/s, spend, top
models) + visor (gpu/machine/region counts) on an interval and pushes typed
usage/fleet/status frames to EventSource clients, coalesced through cache.go so N
streams cause ~one upstream sweep per 10s. A plain GET returns one JSON snapshot —
the panel's poll fallback. No token ⇒ honest state:"unavailable".

Frontend: ai-pulse.ts (SSE stream + poll fallback, cloned from cloud-pulse.ts),
AiComputePanel registered for the ai variant (panels.ts AI_PANELS + variants/ai.ts
+ App.ts). Reuses the existing cloud-panel stat-tile / model-row idioms.

Shared usage helpers (usageRate/topModelsFromUsage) decomplected into
cloud_service.go so cloud-pulse and ai-pulse derive rate/top-models one way.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:27:00 -07:00
hanzo-dev 4ef1b36e45 feat(world/cloud): cloud-pulse folds real platform volume (get-cloud-usages allOrgs)
/v1/world/cloud-pulse now reads MEASURED 24h request/token volume + top models
from the ClickHouse-backed usage ledger (get-cloud-usages ?org=all, super-admin)
on top of the live model/node/GPU/region counts, dropping demo:true AND
volumeModeled:true. Without a token — or with a non-admin token that can't reach
the allOrgs ledger — it stays honestly demo/modeled; platform numbers are never
faked silently, and the service bearer never reaches the browser.

One credential, one accessor: serviceToken()/serviceAuth()/apiHost() are now the
single service-plane seam (cloud-pulse + cloud-map share it) reading the existing
KMS-injected HANZO_CLOUD_PULSE_TOKEN. New cloud_service.go holds the shared
get-cloud-usages decode + fetcher, reused by the forthcoming ai-pulse.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:18:49 -07:00
hanzo-dev 7bbfe0f5a7 world 2.4.3: analyst top-up CTA on out-of-credits + 'Ask anything. Update your world.' placeholder
The analyst returned an opaque 'empty response' when a signed-in user's AI
credits were exhausted. The ONE AI gateway (hanzo/ai routers/filter_balance.go)
signals this as HTTP 402 {code: insufficient_balance}; the gateway can also
answer 2xx with an error envelope carrying no choices. World now detects EXACTLY
that one contract (no keyword heuristics, no invented codes — world stays a thin
client of api.hanzo.ai) and renders a wallet CTA: 'You're out of AI credits →
Add credits (console.hanzo.ai/billing) / View usage' instead of a dead bubble.
Adds failure logging on the analyst path (was silent). Placeholder set to the
canonical 'Ask anything. Update your world.'
2026-07-15 12:40:48 -07:00
hanzo-dev 426f918286 fix(world/analyst): shrink chat placeholder so it stops wrapping to two lines
The ~52-char placeholder wrapped at 14px inside the ~360px composer,
shifting the box when it did. The placeholder now renders at 12.5px; the
unitless 1.5 line-height stays pegged to the input's 14px row, so a
smaller placeholder never moves the row vertically — typed text stays
14px, the composer stays one line.
2026-07-15 01:57:32 -07:00
hanzo-dev 322248b98c chore(world): 2.4.2 — streaming analyst + model menu
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 21:54:39 -07:00
hanzo-dev 27e52c0ea2 feat(world/ai): the analyst answers stream live (SSE)
/v1/world/analyst with Accept: text/event-stream now emits round /
think / delta / tool / done events while the agentic loop runs; the
done event carries exactly the JSON path's payload, so deltas are
cosmetic and the final render is unchanged. Server: chatMessagesModelStream
reads upstream OpenAI SSE; replyExtractor incrementally decodes the
strict-JSON envelope's "reply" value (chunk-safe escapes, byte-at-a-time
tested) and routes a reasoning model's pre-envelope prose to think
events. Tool rounds stream silently; tools surface as live chips.
Client: streaming bubble with breathing caret, dim italic thinking that
real answer text supersedes; non-SSE responses (old backend, sign-in
gate, agent path) fall back to the JSON contract in the same call.
Proven live: 61 think + 41 delta frames then done, gpt-oss-120b.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 21:49:28 -07:00
hanzo-dev 5b3f257a78 feat(world/ai): grouped model menu with family marks — no Zen ring on gpt-oss
The composer pill (fixed Zen logo + native <select>) becomes a proper
listbox popover: rows carry per-family marks (Zen ring ONLY on zen*,
sparkles for the Best router, G/L/C monograms for gpt/llama/claude,
bot for agents), group headers, active check. Assistant avatars and the
thinking row now wear the SERVING model's mark. One mapping in
src/utils/model-marks.ts, mirrored server-side by modelGroup() so the
roster groups honestly (Auto/Zen/GPT/Llama/Claude). Menu is
body-mounted + fixed so panel overflow never clips it. npm run dev
gains a /v1 proxy (prod by default, VITE_DEV_API_PROXY override).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:55:17 -07:00
hanzo-dev 9ff7367f47 Merge feat/china-macro: China macro snapshot + release calendar
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:08:00 -07:00
hanzo-dev b2c86b60f5 feat(world/econ): China macro snapshot + release calendar
/v1/world/china-macro — OECD CPI+CLI (2 sequential, non-retryable), BIS
policy rate (kv-seeded; not_configured until a feed exists), FRED
DEXCHUS, HKMA CNY context, NBS release-calendar scrape + PBoC LPR
candidates (2026 CN business calendar; 2027 entry required before Jan).
launchReady gates on all four required categories;
contentObservationDate anchors to the OLDEST required observation.
EconomicPanel CN tab renders staleness-honest tiles + upcoming releases;
keys mirrored across all 13 locales. Ported from upstream worldmonitor.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:07:52 -07:00
hanzo-dev ca6ab46c0a fix(world/tech): CISA feed rides our same-origin /v1 rss-proxy
The one feed still pinned to upstream's rss.worldmonitor.app/api host;
cisa.gov is already on the server allowlist.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:00:42 -07:00
hanzo-dev 9e56b6768f chore(world): 2.4.1 changelog; PWA guardrail asserts real precache intent
The stale guard demanded an html-free precache glob, contradicting the
committed vite.config (offline.html precached, index/settings excluded
via globIgnores). Assert the actual invariant instead.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:31 -07:00
hanzo-dev df125ac864 feat(world/natural): Western Pacific cyclone attribution
Cross-agency TC canonicalization ported from upstream worldmonitor:
GDACS WP-box observations + HKO warning signals (new same-origin
/v1/world/hko-warnings passthrough; browser CORS) dedup by alias
(<=750km/<=18h) or unnamed proximity (<=90km/<=3h), preserving each
agency's own wind + averaging period. Popup renders per-agency wind
rows and canonical-match confidence. JMA/JTWC upstream stubs skipped
(preflight-blocked upstream, fetch nothing).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:31 -07:00
hanzo-dev 8f024d9227 perf(world/go): round sparkline closes to 7 significant digits
Yahoo closes come back float32-widened (17.209999084472656); Go re-emits
the noise verbatim, ~40-50% of sparkline payload bytes. One sparkline()
emit path (né tailN) rounds via strconv 'g'/7; scalars stay exact.
Ported from upstream worldmonitor seed-utils.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev f4d5827263 perf(world/ui): paint news before ML, bound analyst snapshot fetches
News panels gated their first DOM write on enrichWithVelocityML — on a
cold browser that means a 65MB sentiment-model download before any
headline appears (15-25s after the feeds answered in <1s). Paint with
keyword velocity immediately, upgrade sentiment in place, guarded by
renderRequestId. Analyst context fetches now cap at 2.5s so a cold
endpoint can't delay the chat send.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev 8511a46d38 perf(world/go): SWR cache with single-flight + GDELT warm keys
cachedJSON/passthrough now serve stale instantly and refresh in the
background (one coalesced flight per key; cold misses single-flighted).
gdelt-doc/gdelt-geo stalled every request ~10s on each 5-minute TTL
lapse; the analyst grounding + protests-layer keys are additionally
kept warm every ~4min, GDELT-paced.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev 014080d3e8 fix(world/ai): 'best' default model, honest upstream errors, roster lead
zen5 went dark when the inference plane's claim catalog shifted — an
unclaimed id falls through to a proxy that rejects every credential, so
every AI endpoint died with an opaque 402/401. 'best' is the gateway's
own routing alias (owned_by hanzo) and always resolves; non-2xx bodies
now surface their code (insufficient_balance, spend_cap_exceeded, …) in
the chat instead of a bare status.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:02 -07:00
hanzo-dev bd5aec5e12 fix(world/globe): arcs hug the sphere (greatCircle) + compact model selector
The 3D globe launched arcs far off the silhouette: deck.gl ArcLayer arches each
arc by ~1x the straight-line CHORD, so long cross-continent arcs (displacement
flows, traffic pulses) ballooned into space — the 'off-globe' glitch in the
screenshots. On the globe (mode==='3d') set greatCircle:true + getHeight:0 so
arcs follow the great circle flat on the surface; the flat map keeps its arch.

Also: the analyst model selector was a wide bar (max-width 160px showing full
model ids) — capped to 104px + ellipsis so it reads as a compact chip.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-13 08:20:16 -07:00
hanzo-dev 7f58503cf6 Merge feat/analyst-full-control: layout/monitor/queue/language commands 2026-07-12 11:31:56 -07:00
hanzo-dev baac10392f feat(world/ai): full app control from chat — layout mode, monitors, queue, language
The analyst could show/hide/move panels and drive the map, but NOT change the
layout mode, add a topic to watch, or drive the Watch Queue — so 'reconfigure
the layout and all widgets from the chat' was not actually true.

New commands (each drives the SAME public API the UI does; no second path):
- set_layout_mode: grid / free / immersive
- set_immersive_background: map | video
- set_language
- add_monitor / remove_monitor — 'watch for X'; goes through the one monitor
  path, so an AI-added monitor persists + matches server-side like a typed one
- queue_next / queue_prev — drive the Watch Queue

AppState snapshot now exposes layoutMode, immersiveBg, language, the user's
monitors, and queue depth — the model cannot drive what it cannot see.

Fixes a real WatchQueue restore bug the e2e caught: a persisted blob whose
currentId pointed at a non-'watching' item silently lost watch-tracking
(next() only finishes a 'watching' item). load() now restores the invariant.

e2e/analyst-full-control.spec.ts: AI really flips to immersive, really adds a
monitor that lands in storage, really advances the queue — 3/3. Full suite 56.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 11:31:56 -07:00
hanzo-dev 881c464e08 Merge feat/go-native-monitors: server-side monitor list + lake-wide matching 2026-07-12 09:23:02 -07:00
hanzo-dev b03f317986 feat(world/go): Go-native monitors — server-side list + lake-wide matching
Monitors lived entirely in the browser: keywords in localStorage, matching done
client-side over whatever headlines that tab had loaded. So a monitor only ever
saw one client's slice of the corpus, and it ceased to exist when you closed the
tab or switched device.

- /v1/world/monitors (GET/PUT): the list is persisted PER IDENTITY, reusing the
  existing per-identity store (namespaced 'monitors') — no new mechanism, no new
  table. Bounded + normalised at the boundary (sanitizeMonitors).
- /v1/world/monitors/matches: matching runs against the LAKE — every item the
  backend ingested — via the existing FTS search. FTS tokenising can over-match,
  so each hit is re-checked against the same word-boundary rule the browser used
  ('ai' must never trip on 'train').
- store: Lake.Flush() — drains the write-behind buffer synchronously. Run() is
  still the steady-state consumer; Flush is for callers that need the write to be
  VISIBLE (deterministic tests, and a shutdown that wants its buffer on disk).
- services/monitors.ts: the ONE path to where monitors live. Signed in → server
  (and localStorage as the offline mirror); signed out → localStorage, unchanged.
  Local monitors are ADOPTED on first sign-in rather than silently dropped.
- MonitorPanel.renderServerMatches renders lake matches with the same markup (and
  the data-ctx-* attrs, so Summarize-with-AI works on them too).

Tests run against the real SQLite lake (temp WORLD_DATA_DIR), not a mock:
per-identity round-trip + isolation (user-2 cannot see user-1's monitors),
matching against the lake, and the word-boundary guarantee. 4/4 pass.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 09:23:02 -07:00
hanzo-dev 0ad40998e1 Merge feat/go-native-enrichment: server-side threat + geo enrichment 2026-07-12 09:11:19 -07:00
hanzo-dev a452df5282 feat(world/go): move news enrichment into the Go backend (proven parity)
The browser was classifying threat level and inferring geo for every headline,
on every client, on every render — the data and the logic both lived in the
frontend. The Go backend is the product; the browser is a view.

- enrichdata/{threat-keywords,geo-hubs}.json: the tables are DATA now, one
  source of truth (extracted by EVALUATING the TS, not transcribed).
- internal/world/enrich.go: Go classifier + geo inference. Two parity traps
  handled deliberately — JS object key order IS match priority (tables stay
  ordered slices, not maps), and JS sort is stable (sort.SliceStable).
- feeds-batch now returns threat + resolved geo (hub, lat/lon, name) per item;
  parseFeedItems stays a pure XML parser, enrichFeedItems does the meaning.
- rss.ts consumes the server's enrichment; the browser no longer geo-locates
  anything (the import is gone). Single feeds now batch too, so the per-feed
  browser path survives only for feeds that aren't proxy-shaped.

PROOF, not a claim: enrich_test.go replays a 494-case golden corpus generated
from the real TypeScript (every keyword × both variants, every geo-hub keyword,
every exclusion, plus adversarial word-boundary cases) — 494×2 classifications
and all geo inferences are byte-identical to the code being replaced.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 09:11:19 -07:00
hanzo-dev 959138ac28 Merge feat/ui-polish: summarize context action, chrome cleanup, auth-aware CTA 2026-07-11 23:57:29 -07:00
hanzo-dev 329a741409 test(world): refresh e2e screenshots from the ui-polish run 2026-07-11 23:57:29 -07:00
hanzo-dev a8485d11af feat(world/ui): Summarize-with-AI on right-click; drop header rule, drag pill; hide Try-Hanzo when signed in
- Summarize with AI: any item already annotated data-ctx-headline (news, custom
  feeds) gets it as the FIRST context-menu entry. It routes into the ONE analyst
  dock via a capability port (registerSummarizePort), mirroring the existing
  map port — the menu never reaches into the AI. New AnalystChat.ask() /
  AiAnalystDock.askInDock() drive the same send path as typing; no second AI.
- Header: drop the border-bottom. The header shares the app background, so the
  rule just cut the page in half.
- Map: drop the visible drag pill (::after). The grip strip is still the drag
  target (cursor:grab) — the map stays chromeless, drag is unchanged.
- Try Hanzo is an ACQUISITION CTA: it now disappears once identity resolves as
  signed-in, driven by one new 'hanzo:auth' event emitted from the single place
  auth resolves (AccountMenu.refresh).
  FIXES A REAL BUG the e2e caught: .try-hanzo sets display:inline-flex, which
  beats [hidden]'s UA rule — so hiding it would have silently done nothing.

e2e/ui-polish.spec.ts covers all four against real DOM/CSS: 4/4 pass.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:51:55 -07:00
hanzo-dev abfbd61d1d feat(world/queue): keyless YouTube search — no API key required
queue_video was dead in prod: no YOUTUBE_API_KEY is configured, so Data-API
search returned empty. Scrape the results page instead — the same keyless
pattern handleYouTubeLive already uses (browser UA + consent cookies, since
datacenter IPs get an interstitial). The Data API stays as the fast path when
a key exists; the scraper degrades to an honest empty list, never a fake hit.

Verified live: 'Milken Institute Jensen Huang' returns the real talks
(HT8-KPAjpiA = Global Conference 2025) as the top results.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:23:25 -07:00
hanzo-dev cb67e2bbec chore(world): drop the accidentally-committed cmd/world binary; gitignore it 2026-07-11 23:22:00 -07:00
hanzo-dev 44ad7190cc Merge feat/watch-queue: persistent Watch Queue + AI queue_video + youtube search 2026-07-11 23:21:51 -07:00
hanzo-dev bc11f1111e feat(world/queue): persistent Watch Queue — AI-queued video, skip/back, tracked watched
Content the AI surfaced was ephemeral: a video it found (e.g. the Milken
Institute Jensen Huang talk) was lost on reload. Now everything consumable
lands in ONE persistent queue.

- WatchQueuePanel: stage (video/image/story) + list; prev / finish-and-next /
  fullscreen; click a row to play, x to remove. Owns no state — a pure view
  over watchQueue, so panel and immersive views can never disagree.
- queue_video app-command: the analyst resolves free text ('milken jensen
  2025') to a real video and queues it — reachable over MCP like every other
  command.
- /v1/world/youtube/search: Data-API search, cached, honest empty result when
  no key is configured (never fakes hits).
- Queue persists to localStorage: survives reload, tracks watched/finished.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:21:51 -07:00
hanzo-dev c75b442523 feat(world/queue): WatchQueue core — one source of truth for consumable media
Unifies non-live news clips, AI-surfaced media, and story cards into ONE
ordered queue with tracked per-item state (queued/watching/watched), so
content can be consumed reliably instead of flashing by. Pure logic, no DOM;
persisted to localStorage so the queue + watched history survive reload.
Idempotent enqueue (dedup by id) + bounded size (prune oldest watched).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 11:10:22 -07:00
hanzo-dev 6bf39c2ebd feat(world/video): fullscreen/enlarge button on the live-news player
Adds a fullscreen control next to mute/play that promotes the video player
container to true fullscreen (and toggles back). CSS makes the iframe fill
the fullscreen root edge-to-edge. Answers 'make the video bigger / go full
screen so I can enjoy streaming it'.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 11:02:28 -07:00
hanzo-dev 2351a1e5bb fix(world/video): stop idle timer from killing a video you're watching
Both live-video panels ran a 5-min input-idle timer (mousedown/keydown/
scroll/touchstart on document). Passively watching a video fires none of
those, so after ~5 min the timer destroyed the player/iframes mid-watch —
exactly the 'video disappears after a few minutes' report. LiveNewsPanel
had no non-visibility resume path, so it stayed dead until a tab switch.

Fix: when the idle timer fires while the video is actively playing and the
tab is visible, reschedule instead of tearing down. The visibilitychange
handler (tab hidden) and the webcam IntersectionObserver (panel off-screen)
still cover the genuine 'user left' cases, so abandoned tabs still pause.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 10:58:02 -07:00
hanzo-dev 6461b9f7eb Merge perf/world-gzip-cache: gzip + immutable-cache static SPA 2026-07-11 10:50:06 -07:00
hanzo-dev 9f6ae73148 perf(world): gzip + immutable-cache the static SPA
The cutover from the hanzoai/static image to the cmd/world Go binary dropped
response compression and asset caching, so every cold load pulled the Vite
bundle raw: main.js 1.5MB, map.js 2.7MB — ~4.5MB of uncompressed JS.

- gzipStatic wraps ONLY the SPA handler (never /v1/world/*, which streams):
  gzip for compressible types, Range + non-gzip clients pass through,
  already-compressed media skipped. main.js -73%, map.js -72% on the wire.
- setCacheHeaders: content-hashed assets/ → immutable 1y; every unhashed file
  (favicons, manifest, service worker) stays no-cache so SW updates aren't
  stuck behind a stale cache.
- static_test.go: proves encoding, ratio, round-trip decode, cache policy.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 10:50:00 -07:00
hanzo-dev 43bfcd9463 feat(world/layout): resize free-mode panels from all four corners
Free mode gains NW/NE/SW corner grips alongside the existing SE one, so a
panel can be resized from any corner like a real window manager. The resize
is anchor-aware: dragging a corner pins the opposite corner — w/n edges shift
the panel's left/top in lock-step with the size, e/s grow from a fixed
top-left as before. One primitive (applyFreeResize over a {n,s,e,w} edge set)
drives every edge and corner handle; attachPanelCornerResize takes a `corner`
and only the SE corner also drives grid-mode span/column snapping (top/left
resize has no meaning in grid flow, so those grips are free-mode only, hidden
in grid via CSS). Header controls are raised above the grips so the close/info
buttons stay clickable where a top corner overlaps them.

Verified: e2e all-four-corners test asserts each corner resizes and pins its
opposite corner (14/14 panel-drag specs green); tsc clean.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 21:46:29 -07:00
hanzo-dev 71c53613e4 test(world): keep RED adversarial admin-gate matrix (17 cases, hermetic) — CI coverage for requireAdmin fail-closed 2026-07-10 14:32:22 -07:00
hanzo-dev d5cccfa026 chore(world): gitignore the embedded SQLite lake (world.db + WAL/SHM)
The datastore lake/settings DB (store.dbFile = "world.db", opened in WAL
mode) lands in the repo's data/ dir when WORLD_DATA_DIR points there in
local dev. Ignore world.db and its -wal/-shm sidecars alongside the
existing world-model runtime snapshots so they are never committed.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev fb5b277927 refactor(world/src): delete 3 orphaned data-fetcher services
conflict-impact, github-trending, hackernews (244 LOC): none imported
anywhere, none re-exported via services/index.ts, and every exported
symbol (correlateConflictImpact, fetchGitHubTrending, fetchHackerNews, …)
has zero references repo-wide. The github/HN panels now fetch through the
/v1/world/* URL registry in config/variants/base.ts, which superseded
these client-side fetchers. tsc + vite build green; bundle unaffected.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev c90e39c018 chore(world): drop unused deps @vercel/analytics, youtubei.js
Neither is imported anywhere in src/, src-tauri/, or api/ (0 refs
repo-wide). Removing them drops 4 packages from the lockfile (the two
plus their now-orphaned transitives @bufbuild/protobuf and meriyah) and
~26 MB of install footprint (youtubei.js alone is 21 MB).

Proven inert: with all four physically removed from node_modules,
`tsc --noEmit` and `vite build` pass and the JS bundle is byte-identical
(6,139,678 B) — they were never bundled, so bundle-size delta is 0; the
win is pure supply-chain / install-surface reduction.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev a5d6da77ae refactor(world): one IAM-userinfo introspection path
introspectOwner and introspectIdentity were the same memoized userinfo
call under two cache-key namespaces ("cloud-owner:" vs "identity:"), so a
token used for both the admin gate and settings sync was fetched and
cached twice. Delete introspectOwner; route requireAdmin through
introspectIdentity and read the owner claim off it. One function, one
cache entry per token, one source of identity truth.

Fail-closed semantics unchanged: no token → 401, introspection error or
non-admin owner → 403. Admin-gate and settings tests green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev 217f13a582 refactor(world): delete dead code (deadcode-verified, tests included)
Five funcs unreachable even counting test callers, per
golang.org/x/tools/cmd/deadcode -test:

- strings.go fieldsCollapse   — 0 refs
- mcp.go     toolError         — 0 refs
- model.Engine.History()       — dead accessor; the /history route reads
                                 e.history directly (comment was stale)
- model.Engine.Context()       — superseded grounding path; AI handlers
                                 ground via CountryContext + HistoryDigest
                                 (comment "called by the AI handlers" was false)
- model.toneNote()             — only called by the removed Context()

ftoa/signed/round2 verified still live (history.go, model.go). Build,
vet, and internal/world tests green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev 73bd9d78f8 feat(world): "Try Hanzo" product-switcher dropdown in header
Replace the signed-out header CTA with the hanzo.ai "Try" pattern: a white
.hz-cta pill "Try Hanzo ▾" that drops a monochrome menu of Hanzo products
(World/Chat/Dev/App/Cloud/Desktop), with world.hanzo.ai highlighted as the
current product. The former "Try Hanzo World" pill was the AccountMenu login
CTA — relabel it to a secondary "Sign in" so the header has one primary action
(hanzo.ai layout: [Try Hanzo ▾] [Sign in]).

- New self-contained stylesheet src/styles/try-hanzo.css (not main.css).
- Menu portaled to <body> + position:fixed so it never widens the page and is
  never clipped by the header's mobile overflow-y:hidden tab scroller.
- Closes on click-away and Escape; icon-only trigger < 680px. No h-overflow at
  1440/390; theme-aware (white pill dark / black pill light).
- All product URLs verified live (200): world.hanzo.ai, hanzo.chat,
  hanzo.ai/code, hanzo.app, console.hanzo.ai, hanzo.ai/desktop.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:28:33 -07:00
hanzo-dev 5dc7232cda feat(world/analyst): redesign the analyst copilot — markdown, real Zen mark, window modes
The dock chat is rebuilt to a native-@hanzo/ai, hanzo.chat-grade copilot in a
self-contained `hzc-` namespace + its own stylesheet (analyst-chat.css), so it
never touches main.css.

P0 fixes
- Markdown now renders: a tiny XSS-safe renderer (utils/markdown.ts) does
  bold/italic/lists/headings/code/links/tables — no raw HTML passthrough, every
  text run escaped once, every href run through sanitizeUrl.
- HTML entities decoded at the source: the backend HTML-escapes replies, so an
  apostrophe arrived as the literal `&#39;`. decodeEntities() runs first, then a
  single re-escape on output — the browser shows `'`, never `&#39;` and never the
  double-escaped `&amp;#39;`.

Brand + look
- Real Zen mark everywhere (the notched ring from zen/logo/dist/logo-mono.svg):
  icon('zen') is now the scaled logo and a new zenLogo() drives the FAB, the
  header, the assistant avatar, and the pulsing "thinking" state — no more
  generic enso/sparkle.
- vercel-black monochrome, Geist, sentence case: clean user/assistant bubbles,
  a card composer with an inline model selector, the model tag on each reply,
  tidy action-log + tool-trace styling.

Window modes (expanded scope)
- Closed = a small Zen-mark FAB bottom-right. Open = dock / sidebar / split /
  fullscreen, toggled from the header. Sidebar is the default and reflows #app
  via `body.hzc-shift` + the `--hzc-width` var (the auto-fit grid reflows
  cleanly); left-edge drag resizes; mode/width/open-state persist so a
  variant-switch reload reopens the same rail.

App control + data
- New bulk commands hide_all + show_only in the command registry (composed from
  existing AppHost methods, so they auto-propagate to the backend via
  commandManifest()); "hide all panels / show news / flip to finance" now
  visibly re-aligns the dashboard with an action-log receipt.
- MCP data-tool traces render inline as compact tables, not raw JSON.

Transport is still request/response (no SSE server yet), so slow zen5 shows a
live pulsing-Zen "thinking" state rather than a dead spinner; the SSE seam stays
in analyst-transport.ts for when a streaming cap lands.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:17:43 -07:00
hanzo-dev 9119ebbf3c fix(world/iam): sign-out clears local session first, bounds the IdP call (2.5s) so it never hangs; RP-logout with id_token_hint ends SSO
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-10 14:05:47 -07:00
hanzo-dev 966882d9b7 fix(world): uncapped panel height resize (no max) + silence CartoDB CJK glyph 403 spam
1. PANEL HEIGHT CAP (CTO bug): grid-mode height snapped to the span ladder and
   hard-capped at span-4 (~800px); dragging the bottom edge further did nothing.
   Now height snaps to the fine tier ladder [120,200,400,600,800] up to its top,
   then grows CONTINUOUSLY with NO upper cap — each extra ~200px adds a whole row
   (spanForHeight, shared by the bottom-edge + corner handles). Spans beyond the
   CSS ladder (>4) are driven inline (grid-row + min-height; dataset.span is the
   source of truth via currentSpan), so a panel can be made arbitrarily tall.
   Free mode was already pixel-unbounded. MIN (120px) kept, no MAX. resetHeight
   clears the inline geometry too.
   Proof: grid 200→1628px, free 200→1700px — both persist across reload.

2. CJK glyph 403 spam: CartoDB's CJK glyph fonts (HanWang / NanumBarun / Noto
   Sans CJK…) 403 on CORS whenever a CJK label is in view, flooding the console.
   A map transformRequest repoints just those glyph fetches at the CORS-open Latin
   font (Montserrat), so CJK labels fall back cleanly with zero failed requests.
   Latin fonts (incl. "Noto Sans Regular") are untouched.
   Proof (panned to East Asia): 0 font 403s; Montserrat fallback in use.

Gate: typecheck + build clean; e2e panel-drag 13/13; responsive 17/17.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:49:01 -07:00
hanzo-dev 56c8f3770a feat(world/menu): workspace context menu on empty background — always a way back
Right-clicking the empty workspace (grid / content background, no panel or map
under the cursor) now opens a workspace menu: Grid / Free / Immersive layout, Add
widget, Reset layout. So even if the user hides every panel and the map, a
right-click always offers a way to reset and restore. Native menu still shows in
text inputs and over the chrome (header, dock, modals); panel/map right-clicks keep
their existing specific menus.

Proof: synthetic contextmenu on .main-content → menu with
[Grid layout, Free layout, Immersive layout, Add widget, Reset layout].

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:20:58 -07:00
hanzo-dev 897a9e1280 feat(world/layout): window-manager snap-zone tiling — drag to halves/quadrants with live preview
Free-mode drag now tiles like macOS / FancyZones: dragging a panel near an EDGE
previews a HALF, near a CORNER previews a QUADRANT, the centre stays free
placement. A monochrome translucent target rectangle fades in during the drag and
glides between zones as the cursor moves; on drop the panel resizes + positions to
the zone (with a clean gutter). Escape cancels (restores the pre-drag position);
reduced-motion drops the glide. One mechanism: the zone's viewport rect converts to
the panel's grid-relative free geometry on commit — no new coordinate system.

Zones are computed against the visible workspace (grid ∩ scroll viewport), so
tiling always fills the on-screen area regardless of scroll. This is the "toss
panels around like a window manager" experience over the map/globe.

Proof: drag → left-half preview 720×810 → drop → panel = left half (708×798);
top-right corner → quadrant 720×405 → panel = top-right quarter. Screenshots of the
mid-drag highlight captured.

Follow-ups (noted, not in this commit): center/left/right THIRDS, and split-view
when dragging over another panel's half.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev c953754197 fix(world/map): never black out without a Mapbox token — keyless deck raster basemap
mapbox-gl v3 refuses to paint ANY basemap without a token (the canvas goes fully
black even though the third-party CartoDB tiles fetch 200), which the CTO hit on
localhost and any deploy missing VITE_MAPBOX_TOKEN. Fix: when no token is present,
drape a keyless CartoDB raster basemap rendered by DECK (token-independent, same as
the data dots and the native ESRI globe) at the bottom of the overlaid deck stack —
theme-aware (dark_all / light_all), works in both the flat MapView and the globe
_GlobeView. Data layers draw on top. The token path is untouched: with a token the
mapbox vector basemap stands and no deck basemap is added.

Proof (VITE_MAPBOX_TOKEN unset): 2D = full CartoDB dark basemap w/ coastlines +
labels (60% non-black, was 0), 3D = basemap draped on the globe. Never a black void.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 490acbb1c5 fix(world/panels): header cleanup — count clear of ✕ + hidden at 0, retire LIVE chip, label the summarize button
- Item count moves inline after the title (left), so it never collides with the
  hover-✕ (top-right); hidden entirely until count>0 (a loading "0" read as broken).
- Retire the "LIVE" text chip: it's all live by default and the static label
  misled while a feed was loading/empty. Live state now shows only a subtle pulsing
  dot (reduced-motion: static); cached/unavailable keep their informative labels.
- Summarize: the cryptic  becomes a labelled " Summarize" button (monochrome,
  sentence case, aria-label), reserved clear of the ✕. Funded-key path unchanged.

Playwright: 0 LIVE text chips, 0 zero/empty counts shown, summarize reads
" Summarize".

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev da5be4b051 feat(world/layout): one Grid/Free/Immersive mode dropdown; decomplect immersive (map bg) from snap; news-tag WCAG fix
- Dock: replace the Immersive button + Grid/Free segmented control with a single
  Layout dropdown (Grid / Free / Immersive). One control, one way.
- Decomplect background from snap: immersive is a BACKGROUND mode (map fills the
  viewport, panels float on top); grid/free is the SNAP behaviour. Immersive
  defaults to freestyle floating (free snap) over the FULL viewport — the old
  narrow right-docked column is gone (it collapsed once free-mode made the cards
  absolute). Map stays fixed full-viewport via !important over the inline free geom.
- Background Map/Video + collapse controls show only while immersive is active.
- NewsPanel category tags: drop inline color (threat colour as text failed WCAG);
  text now var(--text), threat colour carried by border + translucent bg only.

Verified: dropdown drives grid→free→immersive→grid (map fixed full-viewport in
immersive, cards float visibly over it); 17/17 P0 checks still green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 678bd10d0d fix(world/layout): kill map/panel overlap, fill full width, retire mobile modal, map edge-to-edge + maplogo flag
Layered on the integrated layout engine (194e6657). The grid's auto-rows
collapsed to their 120px floor, so every span-2 panel (min-height 400) — the
map included — overflowed ~150px onto the row below and OVERLAPPED it
(43 overlaps across 41 panels). Root fix: definite 200px row unit
(grid-auto-rows: var(--panel-row,200px)) so span-N reserves exactly its height;
content that exceeds a panel scrolls inside it, never onto neighbours.

- Full width: auto-fill → auto-fit so real columns always stretch to consume
  100% width, no dead right-hand gutter at any size (verified 9 widths).
- Map edge-to-edge: the 14px drag-grip is now an absolute overlay, not in flow,
  so the canvas fills the panel top (black top strip 15px → 1px).
- ?maplogo=0 (+ localStorage) hides the basemap wordmark; a compact "ⓘ"
  AttributionControl is always mounted so basemap ToS still holds.
- Mobile-view warning modal retired (layout is responsive to 390px).
- span-0 stays a genuine 120px short tier (top-aligned in its 200px track).

Proof: 17/17 playwright checks (full/crypto/saas × 1920–390): hOverflow=0,
0 overlaps, gutter=grid-padding only, no modal. Live-news video grows to full
width (353→1430px, 16:9, no cap). Dark/Sat/Terrain inline with 2D/3D in the dock.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev a338fc440b feat(world): panel layout engine integrated onto origin/main + free live-news video
Rebuilds the free-form + snap-to-grid layout engine on top of the advanced
origin/main (footer dock + responsive work) and unifies the two grid-cell
mechanisms into one:

- grid-config drives the dock's `--panel-col-min` (default 160px, range 140–360,
  matching the base .panels-grid rule + the dock slider) — the duplicate
  `--grid-cell` override is gone. One variable, one way. Default 160 = the grid
  is byte-identical until the slider moves it, so no fixed-span panel shrinks.
- window.worldGrid exposes the engine to the footer dock's Grid⇄Free toggle +
  cell-size slider (App.gridApi delegates to it).
- main.css layout-engine section is append-only after the dock CSS; only NEW
  selectors (corner grip, snap overlay, free-mode absolute layout, mobile guard).

Live News (video) now resizes freely with the video filling at any size (CTO
ask): the panel carries id="live-news" so the intended `#live-news .panel-content`
fill CSS (padding:0 + flex-column) finally applies — the 16:9 `.live-news-player`
fills the container edge-to-edge (was 16px short under generic padding). With the
new right-edge + corner grips and the uncapped column snap (span up to full
width), Live News drags to 2-3 cols or full-width-top in grid mode and to any
pixel size in free mode; the width-driven video fills at every size.

Tests (13, all green; typecheck + build pass): 4 original drag/resize + 7
layout-engine (grid snap, corner resize, overlay, cell re-snap, free drag+resize
persist across reload, map participates, toggle) + 2 real-app live-news (grid
default→3col→full-width fill setup + resize, free arbitrary-size). Screenshot:
e2e/layout-shots/live-news-fullwidth.png (full-width-top, large video area).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 6fdfc0d3a6 feat(world): instant news via warm feed cache + queryable ingested-data lake
Make news/feeds INSTANT and give world one place to query everything.

Feed hot cache (two-tier, ONE way to read a feed body):
- L1 per-pod in-mem mirror (sub-ms hot reads) + L2 hanzo-kv (shared across
  pods, survives restart). A body warmed by any pod is instantly served by
  every pod; a restarted pod reads the still-warm shared cache instead of
  cold-starting. Graceful degrade to L1-only when hanzo-kv is unreachable.
- Background warmer: on boot + every ~5m (jittered) fetches every warm feed,
  write-throughs the body, and folds items into the lake. rss-proxy and
  feeds-batch serve from the warm cache and never block on upstream while any
  cached copy exists (stale-while-revalidate; revalidation runs in background).
- Demand-driven warm set (fleet-wide via hanzo-kv) + a curated seed of the
  crypto / financial-regulation / crypto-news feeds so a brand-new pod warms
  THOSE first. Cross-pod fetch dedupe via shared copy freshness.

Ingested-data lake (embedded SQLite, modernc.org/sqlite — pure Go, FTS5):
- One normalized `items` table (id, kind, source, ts, title, text, tickers,
  country, geo, payload) fed by feed news AND world-model observations, so
  everything is searchable/countable together.
- GET /v1/world/search (FTS5 bm25 ranking; filters kind/since/country/ticker)
  and GET /v1/world/analytics (totals, by kind/source, top tickers). Rolling
  retention window with an hourly prune job. Write-behind ingest (never on the
  request path).

Per-identity settings (same SQLite DB, own table):
- GET/PUT /v1/world/settings, bearer-gated, keyed by (org, user_sub, project)
  from IAM userinfo, so a signed-in dashboard syncs across devices. Anonymous
  → 401 (client keeps localStorage).

Everything degrades cleanly (never-5xx): no hanzo-kv → per-pod cache; no SQLite
→ empty search/analytics + settings say not-stored. Binary stays CGO-free
(CGO_ENABLED=0). go directive + Dockerfile bumped to 1.25 for modernc.

Latency (local, warm): feeds-batch warm 0.5-1.9ms vs 154ms cold; search 0.8-1.5ms.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:51:22 -07:00
hanzo-dev 1043182671 fix(world/analyst): enforce paid usage — chat needs a user bearer, never the funded key
The analyst chat is paid usage: metered to the signed-in user's org via their
own IAM bearer. handleAnalyst used s.ai.bearerFor(r), which falls back to the
funded service key (HANZO_AI_KEY) when no user token is present — so an anonymous
chat silently ran on the shared key (proven live: an unauthenticated POST to
/v1/world/analyst returns a real 200 answer). That made the backend "Sign in to
chat" gate dead code and meant paid usage was never actually enforced or metered.

Use userBearer(r) for the chat path (user-only). The funded key now backs ONLY the
anonymous auto-insight endpoints (summarize/classify/country-intel), exactly the
intended pro model. The sign-in gate is now live server-side, and the frontend
gate gains defense-in-depth. Roster listing (handleModels) stays on bearerFor —
it is read-only, not metered inference.

Test: TestAnalystChatRequiresUserBearer pins the contract (funded key present,
anonymous chat still refused with the sign-in skip). TestAnalystDataToolLoop now
drives the chat as a signed-in caller (Authorization header), the real paid path.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:25:14 -07:00
hanzo-dev 68a28f2eda feat(world/map): dark/satellite/terrain basemap styles on the native globe
The native deck GlobeView now honors the 2D basemap switcher (dark | satellite |
terrain), reading the SAME localStorage key (hanzo-world-basemap-style) and
reacting live to a basemap-style-changed event DeckGLMap fires — no reload.

- dark: the existing monochrome vercel-black sphere.
- satellite: keyless ESRI World Imagery draped via a deck TileLayer/BitmapLayer,
  back-faces culled so the far hemisphere never bleeds through; the near-black
  ocean sphere underneath supplies depth + darkens the imagery so data dots stay
  legible. Thin light country borders overlaid for intel orientation.
- terrain: keyless ESRI World Physical Map (natural relief).

Data layers (dots/arcs/borders) render ON TOP and keep picking; ocean sphere is
tinted dark slate-blue on bright styles so the Web-Mercator polar gap blends.
Since native uses keyless ESRI, the switcher's sat/terrain buttons no longer need
a Mapbox token when native is the 3D renderer (default), and setBasemapStyle
notifies the globe before the tokenless mapbox setStyle so a missing token can't
block the switch. Adds a style-switch e2e (single-context preserved, data still
picks) + harness hooks.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:20:44 -07:00
hanzo-dev a8af187b99 fix(world): split header/tooling into a bottom dock; kill 1366 overflow; layers collapsed; globe fit
Live 1366×768 had 167px of horizontal page overflow (documentElement.scrollWidth
1533 vs innerWidth 1366): the header's action row (Copy Link / theme / Immersive /
PANELS / SOURCES + variant tabs) could not shrink and pushed the account CTA off the
right edge. The layers panel also rendered OPEN by default, floating over the map and
the panels beneath it.

CTO-approved restructure: the header is now IDENTITY + NAV only (Hanzo mark, variant
tabs, LIVE, search, theme, account). All operational controls move to a new slim,
monochrome, collapsible BOTTOM TOOLBAR DOCK:

- Map's 2D/3D toggle, basemap switcher (Dark/Sat/Terrain) and time-range pills mount
  into the dock via a new optional DeckGLMap `controlsHost` (defaults to the map
  container, so the e2e harnesses that drive the overlay controls are unchanged).
- Region select, Layers toggle, Immersive controls, Panels, Sources, Copy link and
  Fullscreen relocate into the dock (same ids → existing handlers still bind).
- New controls: layout-mode (Grid ⇄ Free) + widget-size slider, wired through a
  feature-checked adapter that prefers window.worldGrid (world-layoutengine) and
  otherwise applies a self-contained fallback (CSS column-floor var + body data attr);
  and "+ Add widget", a searchable palette over the panel registry that shows a panel
  via the one setPanelEnabled() path — works in both grid and immersive layouts.

Fixes:
1. Horizontal overflow — scrollWidth === innerWidth at 1920/1440/1366/1024/768/390.
   Header groups are min-width:0 shrinkable; variant tabs collapse to icons then scroll
   INSIDE their own strip; the dock scrolls horizontally so the PAGE never scrolls.
2. Layers panel — hidden by default (`.open` reveals it via the dock's Layers button),
   anchored top-left WITHIN the map box, height-capped with internal scroll, draggable.
3. Basemap switcher — verified switching Dark/Sat/Terrain in 2D (tiles load, deck data
   layers survive setStyle, choice persists in localStorage). The token IS in the
   runtime bundle. In native-3D-globe mode the deck GlobeView renders its own sphere,
   so sat/terrain don't retexture it there — a pre-existing renderer limitation, noted.
4. Globe fit — the deck globe canvas fills the map container exactly (measured
   1356×384 == map box; no letterboxing); CSS forces the wrappers/canvas to 100%.

Gates: typecheck ✓, vite build ✓, e2e globe-overlay 1/1 ✓, panel-drag 4/4 ✓.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:01:09 -07:00
172 changed files with 26766 additions and 1031 deletions
+10
View File
@@ -27,3 +27,13 @@ data/world-model.json
data/world-model.json.tmp
data/world-model-history.json.gz
data/world-model-history.json.gz.tmp
# embedded datastore lake + settings (SQLite, WAL mode) — regenerated on boot,
# lands here only when WORLD_DATA_DIR points at the repo (local dev)
world.db
world.db-wal
world.db-shm
data/world.db
data/world.db-wal
data/world.db-shm
/world
+51
View File
@@ -2,6 +2,57 @@
All notable changes to World Monitor are documented here.
## [2.4.6]
### Added
- **Enso Flywheel panel (AI variant)**: the router self-improvement loop made visible. New `/v1/world/enso-training` folds the routing-decision ledger tail + reward tail (`export-routing-ledger` / `export-routing-rewards`, super-admin) into ledger growth, engine-vs-heuristic mix, a routing-confidence histogram, and task/model distributions, alongside the latest enso-bench eval scores (an embedded `results/summary.json` snapshot, overridable live via `ENSO_BENCH_URL`). The response is event-typed so future retrain/deploy milestones slot into the same timeline. Eval scores render even signed-out; `state` (live/partial/demo) says which live sources resolved.
## [2.4.5]
### Added
- **AI Compute panel (AI variant)**: live Hanzo inference-plane telemetry over SSE — tokens/s, requests/s, 24h spend, top models by real spend, and the serving fleet (GPUs, machines online, models served). New `/v1/world/ai-pulse` pushes typed `usage`/`fleet`/`status` frames (EventSource) and answers a plain GET with one JSON snapshot as the poll fallback. Honest "connecting"/"unavailable" states — never a zero dressed up as live; the service bearer stays server-side.
## [2.4.4]
### Changed
- **Cloud Pulse is real when a service token is wired**: `/v1/world/cloud-pulse` now folds MEASURED platform-wide 24h request/token volume from the ClickHouse-backed usage ledger (`get-cloud-usages ?org=all`, super-admin) on top of the live model/node/GPU/region counts — dropping both `demo:true` and `volumeModeled:true`. Top models come from real ledger spend. Without a token, or with a non-admin token, it stays honestly demo/modeled — platform numbers are never faked silently. The service bearer stays server-side (never sent to the browser).
## [2.4.2]
### Added
- **Streaming analyst**: answers flow in live over SSE — reasoning shows as dim thinking text, tool calls appear as chips the moment they run, the reply types itself in; the final render and command dispatch are unchanged (done event = the old JSON contract)
- **Model menu**: the composer pill opens a grouped popover (Auto / Zen / GPT / Llama / Claude / Agents) with per-family marks and an active check
### Fixed
- **Model identity**: the Zen ring appears only on zen* models — gpt-oss/llama/claude get their own marks on the pill, menu rows, avatars, and the thinking row
- **Dev server**: `npm run dev` proxies /v1 to production by default (VITE_DEV_API_PROXY overrides)
## [2.4.1]
### Added
- **Western Pacific cyclones**: cross-agency tropical-cyclone attribution (GDACS + HKO warnings via new `/v1/world/hko-warnings` proxy) with per-agency wind observations, canonical dedup, and map popup detail rows
- **China macro snapshot**: `/v1/world/china-macro` — CPI/CLI (OECD), policy rate, USD/CNY (FRED), HKMA context, NBS release calendar + PBoC LPR dates, surfaced with staleness-honest indicator tiles
- **Model roster**: `Best (auto)` leads the analyst model picker — the gateway routing alias that always resolves
### Changed
- **AI default model**: `zen5``best`; a pinned family id goes dark when the inference plane's claim catalog shifts, the routing alias never does
- **Server cache is now stale-while-revalidate**: `cachedJSON`/`passthrough` serve stale instantly and refresh in the background (single-flight); GDELT/theater-posture no longer stall requests ~10s on TTL expiry
- **GDELT cache warmers**: hot keys (analyst grounding, protests layer) refreshed every ~4min so no user ever eats a cold miss
- **Sparkline payloads**: close arrays rounded to 7 significant digits (float32-widening noise stripped, ~40% smaller; scalars untouched)
### Fixed
- **News first paint**: panels no longer gate their first DOM write on a 65MB ML sentiment model download — headlines paint immediately, sentiment refines in place
- **Analyst grounding snapshot**: context fetches bounded at 2.5s so a cold endpoint can't hold the chat send hostage
- **AI errors are honest**: upstream error codes (`insufficient_balance`, `spend_cap_exceeded`, …) surface in the chat instead of a bare `status 402`
## [2.4.0] - 2026-02-19
### Added
+7 -3
View File
@@ -37,10 +37,14 @@ ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
RUN npm run build
# ---- go stage: build the static server binary (CGO-free) -----------------
FROM golang:1.23-alpine AS gobuild
# go 1.25: the embedded datastore (modernc.org/sqlite, pure Go) needs it. The
# binary stays CGO-free — modernc's SQLite is pure Go, so no C toolchain is added.
FROM golang:1.25-alpine AS gobuild
WORKDIR /src
# stdlib-only module: go.mod has no requires, so there is no go.sum to copy.
COPY go.mod ./
# Deps: hanzo-kv client (go-redis) + embedded SQLite (modernc). Download once for
# a cached layer before the source is copied.
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/world ./cmd/world
+1
View File
@@ -0,0 +1 @@
2.4.6
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
// gzip on the fly for the static SPA. The hanzoai/static image this binary
// replaced compressed responses for us; serving the Vite bundle raw (main.js
// 1.5MB, map.js 2.7MB) is the whole reason a cold load felt slow. Restoring
// gzip here cuts the JS/CSS wire size ~75%. Paired with immutable cache headers
// on hashed assets (see setCacheHeaders), each client pays the transfer once.
//
// Scope: wraps ONLY the SPA handler, never /v1/world/* — those include SSE /
// streaming endpoints that must not be buffered through a gzip.Writer.
var gzipPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
return w
},
}
// gzipStatic compresses compressible responses when the client accepts gzip.
// Range requests pass through untouched (compressing a byte-range is invalid).
func gzipStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" || !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if strings.EqualFold(strings.TrimSpace(strings.SplitN(enc, ";", 2)[0]), "gzip") {
return true
}
}
return false
}
// compressible reports whether a content type shrinks under gzip. Already-
// compressed media (png/jpg/woff2/…) is skipped so we never waste CPU or grow
// the payload.
func compressible(ct string) bool {
ct = strings.ToLower(ct)
switch {
case strings.HasPrefix(ct, "text/"),
strings.Contains(ct, "javascript"),
strings.Contains(ct, "json"),
strings.Contains(ct, "svg"),
strings.Contains(ct, "xml"),
strings.Contains(ct, "wasm"),
strings.Contains(ct, "manifest"):
return true
}
return false
}
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
compress bool
}
func (g *gzipResponseWriter) WriteHeader(status int) {
g.decide(status)
g.ResponseWriter.WriteHeader(status)
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
if !g.decided {
g.decide(http.StatusOK) // FileServer may Write without an explicit WriteHeader
}
if g.compress {
return g.gz.Write(b)
}
return g.ResponseWriter.Write(b)
}
// decide inspects the headers the wrapped handler set (Content-Type is already
// populated by http.ServeContent at this point) and commits to compress-or-not
// exactly once.
func (g *gzipResponseWriter) decide(status int) {
if g.decided {
return
}
g.decided = true
h := g.Header()
if status == http.StatusOK && h.Get("Content-Encoding") == "" && compressible(h.Get("Content-Type")) {
g.compress = true
h.Del("Content-Length") // length changes after compression
h.Set("Content-Encoding", "gzip")
h.Add("Vary", "Accept-Encoding")
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
}
func (g *gzipResponseWriter) close() {
if g.gz != nil {
_ = g.gz.Close()
gzipPool.Put(g.gz)
g.gz = nil
}
}
+18 -2
View File
@@ -46,12 +46,16 @@ func main() {
world.LoadKMSSecrets(rootCtx)
srv := world.NewServer()
srv.StartModel(rootCtx) // continuously-folded world-state engine
defer srv.Close() // release hanzo-kv + embedded datastore handles
srv.StartModel(rootCtx) // continuously-folded world-state engine
srv.StartDatastore(rootCtx) // shared feed warmer + lake write-behind/prune
mux := http.NewServeMux()
srv.Mount(mux) // /v1/world/* routes
// Static SPA + fallback handles everything not matched by an /api route.
mux.Handle("/", newSPAHandler(*root))
// gzipStatic wraps ONLY this handler — /v1/world/* keeps its streaming
// endpoints unbuffered.
mux.Handle("/", gzipStatic(newSPAHandler(*root)))
httpSrv := &http.Server{
Addr: *addr,
@@ -123,6 +127,7 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(full)
switch {
case err == nil && !info.IsDir():
setCacheHeaders(w, rel)
h.fileSrv.ServeHTTP(w, r) // real file
case errors.Is(err, fs.ErrNotExist) && !hasExt(rel):
h.serveIndex(w, r) // client-routed path → SPA shell
@@ -147,6 +152,17 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
}
}
// setCacheHeaders makes Vite's content-hashed bundles cacheable forever while
// keeping every unhashed file (favicons, manifest, service worker) revalidated
// so SW updates and icon swaps are never stuck behind a stale cache.
func setCacheHeaders(w http.ResponseWriter, rel string) {
if strings.HasPrefix(rel, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
// hasExt reports whether the last path segment has a file extension, used to
// distinguish an asset miss (foo.js → 404) from a client route (/country/US →
// SPA shell).
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeTree lays out a minimal Vite-style dist for the SPA handler tests.
func writeTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
// A hashed JS bundle large enough that gzip clearly wins.
js := strings.Repeat("export const answer = 42;\n", 4000)
files := map[string]string{
"index.html": "<!doctype html><title>world</title>",
"assets/main-abc123.js": js,
"assets/main-abc123.css": strings.Repeat(".panel{display:flex}\n", 2000),
"favicon.ico": "\x00\x00binary",
"manifest.webmanifest": `{"name":"world"}`,
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
func TestStaticGzipAndCache(t *testing.T) {
root := writeTree(t)
srv := httptest.NewServer(gzipStatic(newSPAHandler(root)))
defer srv.Close()
get := func(path, ae string) *http.Response {
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
req.Header.Set("Accept-Encoding", ae)
resp, err := http.DefaultTransport.RoundTrip(req) // no transparent gunzip
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
rawLen := func(root, name string) int {
b, _ := os.ReadFile(filepath.Join(root, name))
return len(b)
}
t.Run("hashed js is gzipped, immutable, and smaller on the wire", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "gzip")
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Fatalf("Cache-Control = %q, want immutable", got)
}
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", resp.Header.Get("Vary"))
}
body, _ := io.ReadAll(resp.Body)
raw := rawLen(root, "assets/main-abc123.js")
if len(body) >= raw {
t.Fatalf("gzip wire size %d not smaller than raw %d", len(body), raw)
}
// And it must actually decode back to the original bytes.
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
dec, _ := io.ReadAll(zr)
if len(dec) != raw {
t.Fatalf("decoded %d bytes, want %d", len(dec), raw)
}
})
t.Run("no gzip when client does not accept it", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "identity")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") != "" {
t.Fatalf("unexpected Content-Encoding %q", resp.Header.Get("Content-Encoding"))
}
})
t.Run("unhashed files revalidate, never immutable", func(t *testing.T) {
for _, p := range []string{"/favicon.ico", "/manifest.webmanifest"} {
resp := get(p, "gzip")
resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("%s Cache-Control = %q, want no-cache", p, cc)
}
}
})
t.Run("already-compressed media is not re-gzipped", func(t *testing.T) {
resp := get("/favicon.ico", "gzip")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") == "gzip" {
t.Fatal("favicon.ico should not be gzipped")
}
})
t.Run("index shell served with no-cache and gzip", func(t *testing.T) {
resp := get("/", "gzip")
defer resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("index Cache-Control = %q, want no-cache", cc)
}
if resp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("index should be gzipped, got %q", resp.Header.Get("Content-Encoding"))
}
})
}
+147
View File
@@ -0,0 +1,147 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Full app control from the chat widget.
*
* The analyst's control surface is the app-command registry (services/app-commands.ts)
* driven through the AppHost port. Until now it could show/hide/move panels and
* touch the map, but it could NOT change the layout mode, add a topic to monitor,
* or drive the Watch Queue — so "reconfigure the layout and all widgets from the
* chat" was not actually true.
*
* Each test stubs the analyst to return ONE command and asserts the app really
* changed — the command is dispatched through the same path a real model reply
* takes. No mocked host, no unit-test stand-in for the DOM.
*/
const SCREENS = 'e2e/screens';
async function stubAnalyst(page: Page, actions: unknown[], reply = 'Done.'): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply, actions, model: 'zen5', tokens: 0, traces: [] }),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ models: [{ id: 'zen5', name: 'Zen 5' }], default: 'zen5' }),
});
}
// Monitors: signed-in sync endpoints. Keep them empty + accepting.
if (url.includes('/v1/world/monitors')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ monitors: [], matches: [], ok: true }),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('.panel', { timeout: 30_000 });
}
/** Send any message; the stub decides which command comes back. */
// Two AnalystChats exist (the in-grid panel and the dock) — they share the SAME
// code path, so drive the dock's explicitly rather than matching both.
async function ask(page: Page, text: string): Promise<void> {
await page.click('.hzc-fab');
const dock = page.locator('.hzc-panel');
await expect(dock).toBeVisible();
const composer = dock.locator('.hzc-input');
await expect(composer).toBeVisible();
await composer.fill(text);
await dock.locator('.hzc-send').click();
}
test.describe('analyst full app control', () => {
test('set_layout_mode switches the app into immersive', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'set_layout_mode', mode: 'immersive' }]);
await appReady(page);
await expect(page.locator('body')).not.toHaveClass(/immersive/);
await ask(page, 'go immersive');
// The app really entered immersive (body class is the mode's source of truth)…
await expect(page.locator('body')).toHaveClass(/immersive/);
// …and the dock select agrees — the AI and the UI cannot disagree about mode.
await expect(page.locator('#dockModeSelect')).toHaveValue('immersive');
await page.screenshot({ path: `${SCREENS}/analyst-immersive.png` });
});
test('add_monitor makes the analyst able to watch a new topic', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'add_monitor', keywords: 'nvidia, gpu' }]);
await appReady(page);
await ask(page, 'watch for nvidia and gpu');
// The monitor list really gained the topic (the panel renders it).
const monitors = page.locator('#monitorsList');
await expect(monitors).toContainText(/nvidia/i);
await expect(monitors).toContainText(/gpu/i);
// And it persisted through the ONE monitor path (localStorage mirror).
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('worldmonitor-monitors') || '[]'),
);
expect(JSON.stringify(stored)).toContain('nvidia');
});
test('queue_next advances the Watch Queue', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'queue_next' }]);
await appReady(page);
// Seed two items straight into the queue's own store, then let the analyst advance it.
await page.evaluate(() => {
localStorage.setItem(
'hanzo-world-watch-queue',
JSON.stringify({
items: [
{ id: 'a', kind: 'video', title: 'First talk', source: 'X', ref: 'aaaaaaaaaaa', addedAt: 1, status: 'queued' },
{ id: 'b', kind: 'video', title: 'Second talk', source: 'X', ref: 'bbbbbbbbbbb', addedAt: 2, status: 'queued' },
],
currentId: 'a',
}),
);
});
await page.reload();
await page.waitForSelector('.panel', { timeout: 30_000 });
await ask(page, 'next video');
// 'a' is finished and 'b' is now current — tracked consumption, driven by the AI.
await expect
.poll(async () =>
page.evaluate(() => {
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
return q.currentId;
}),
)
.toBe('b');
const statusOfA = await page.evaluate(() => {
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
return (q.items || []).find((i: { id: string }) => i.id === 'a')?.status;
});
expect(statusOfA).toBe('watched');
});
});
+22
View File
@@ -24,6 +24,8 @@ type GlobeHarness = {
setCamera: (lon: number, lat: number, zoom: number) => void;
stopSpin: () => void;
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
setBasemapStyle: (style: 'dark' | 'satellite' | 'terrain') => void;
getBasemapStyle: () => string;
destroy: () => void;
};
@@ -111,4 +113,24 @@ test.describe('native deck.gl GlobeView', () => {
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(false);
});
test('basemap style switches dark/satellite/terrain, stays single-context, data still picks', async ({
page,
}) => {
await ready(page, '?globe=native');
const setStyle = (s: 'dark' | 'satellite' | 'terrain') =>
page.evaluate((style) => (window as HarnessWindow).__globeHarness!.setBasemapStyle(style), s);
const getStyle = () =>
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getBasemapStyle());
for (const style of ['satellite', 'terrain', 'dark'] as const) {
await setStyle(style);
expect(await getStyle()).toBe(style);
// Draping imagery must not spawn a second WebGL context...
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
// ...and the data layers keep rendering ON TOP (still pickable on the sphere).
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 15000 }).toBe(true);
}
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+374
View File
@@ -4,6 +4,9 @@ import { expect, test, type Page } from '@playwright/test';
// FLIP reflow, snapping resize) through the deterministic harness.
const HARNESS = '/tests/panel-drag-harness.html';
const LAYOUT_HARNESS = '/tests/layout-harness.html';
// Screenshots land in a repo-relative artifacts dir (Playwright creates it).
const SHOTS = 'e2e/layout-shots';
interface PanelDragHarness {
ready: boolean;
@@ -12,9 +15,31 @@ interface PanelDragHarness {
order: () => string[];
}
interface Rect {
left: number;
top: number;
width: number;
height: number;
}
interface LayoutHarness {
ready: boolean;
mode: () => 'grid' | 'free';
setMode: (m: 'grid' | 'free') => void;
toggle: () => 'grid' | 'free';
cell: () => number;
setCell: (px: number) => void;
rect: (id: string) => Rect | null;
colStep: () => { step: number; cols: number; padL: number };
order: () => string[];
overlayVisible: () => boolean;
gridColumnOf: (id: string) => string;
}
declare global {
interface Window {
__panelDragHarness?: PanelDragHarness;
__layoutHarness?: LayoutHarness;
}
}
@@ -103,3 +128,352 @@ test.describe('panel drag + resize', () => {
expect(span).toBe(2);
});
});
// ── Layout engine: grid ⇄ free, corner resize, cell-size, overlay ──────────
// Drives the real Panel grips + grid-config against the true main.css, at
// 1440x900 (see playwright.layout.config.ts).
const lh = async (page: Page): Promise<void> => {
await page.goto(LAYOUT_HARNESS);
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
await page.waitForTimeout(60); // let the queued registerPanel microtasks settle
};
const rect = (page: Page, id: string): Promise<Rect> =>
page.evaluate((pid) => window.__layoutHarness!.rect(pid)!, id);
const headerBox = async (page: Page, id: string) =>
(await page.locator(`[data-panel="${id}"] .panel-header`).first().boundingBox())!;
test.describe('layout engine', () => {
// Gate viewport: 1440x900 (independent of the base config's default size).
test.use({ viewport: { width: 1440, height: 900 } });
test('grid mode: a dropped panel lands on a cell boundary', async ({ page }) => {
await lh(page);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('grid');
const src = await headerBox(page, 'charlie');
const dst = (await page.locator('[data-panel="echo"]').boundingBox())!;
await page.mouse.move(src.x + 30, src.y + src.height / 2);
await page.mouse.down();
await page.mouse.move(src.x + 60, src.y + src.height / 2, { steps: 4 });
await page.mouse.move(dst.x + dst.width * 0.8, dst.y + dst.height / 2, { steps: 16 });
await page.mouse.up();
// Final resting position is snapped to a grid cell (multiple of the column step).
const { step, padL } = await page.evaluate(() => window.__layoutHarness!.colStep());
const r = await rect(page, 'charlie');
const k = Math.round((r.left - padL) / step);
expect(Math.abs(r.left - padL - k * step)).toBeLessThan(4);
await page.screenshot({ path: `${SHOTS}/grid-snap.png` });
});
test('grid mode: bottom-right corner resizes width + height (snapped)', async ({ page }) => {
await lh(page);
const corner = (await page
.locator('[data-panel="delta"] .panel-corner-resize-handle.se')
.boundingBox())!;
const { step } = await page.evaluate(() => window.__layoutHarness!.colStep());
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
await page.mouse.down();
// Pull out ~1.5 columns wide and ~250px tall.
await page.mouse.move(
corner.x + corner.width / 2 + step * 1.5,
corner.y + corner.height / 2 + 250,
{ steps: 16 },
);
await page.mouse.up();
// Height grew to a taller row-span and width snapped to multiple columns.
await expect(page.locator('[data-panel="delta"]')).toHaveClass(/span-2/);
const gc = await page.evaluate(() => window.__layoutHarness!.gridColumnOf('delta'));
expect(gc).toMatch(/span [2-9]/);
await page.screenshot({ path: `${SHOTS}/resized-from-corner.png` });
});
test('grid mode: overlay appears only while dragging', async ({ page }) => {
await lh(page);
expect(await page.evaluate(() => window.__layoutHarness!.overlayVisible())).toBe(false);
const src = await headerBox(page, 'bravo');
await page.mouse.move(src.x + 30, src.y + src.height / 2);
await page.mouse.down();
await page.mouse.move(src.x + 120, src.y + 40, { steps: 8 });
// Mid-drag: the faint track overlay is shown.
await expect
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
.toBe(true);
await page.screenshot({ path: `${SHOTS}/grid-overlay.png` });
await page.mouse.up();
await expect
.poll(() => page.evaluate(() => window.__layoutHarness!.overlayVisible()))
.toBe(false);
});
test('grid mode: changing cell size re-snaps the grid', async ({ page }) => {
await lh(page);
const before = await page.evaluate(() => window.__layoutHarness!.colStep());
await page.evaluate(() => window.__layoutHarness!.setCell(240));
await page.waitForTimeout(50);
const after = await page.evaluate(() => window.__layoutHarness!.colStep());
// Wider cells ⇒ fewer, wider columns: the panels re-snap to a new track grid.
expect(after.step).toBeGreaterThan(before.step + 20);
expect(after.cols).toBeLessThanOrEqual(before.cols);
expect(await page.evaluate(() => window.__layoutHarness!.cell())).toBe(240);
});
test('free mode: pixel drag + corner resize persist across reload', async ({ page }) => {
await lh(page);
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
await page.waitForTimeout(40);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
// Drag alpha by an arbitrary (non-cell) pixel delta.
const start = await rect(page, 'alpha');
const hdr = await headerBox(page, 'alpha');
const DX = 223;
const DY = -137;
await page.mouse.move(hdr.x + 30, hdr.y + hdr.height / 2);
await page.mouse.down();
await page.mouse.move(hdr.x + 40, hdr.y + hdr.height / 2, { steps: 3 });
await page.mouse.move(hdr.x + 30 + DX, hdr.y + hdr.height / 2 + DY, { steps: 16 });
await page.mouse.up();
const moved = await rect(page, 'alpha');
expect(Math.abs(moved.left - (start.left + DX))).toBeLessThan(6);
expect(Math.abs(moved.top - (start.top + DY))).toBeLessThan(6);
// Arbitrary pixel position — not snapped to a cell.
await page.screenshot({ path: `${SHOTS}/free-form.png` });
// Resize from the corner to an arbitrary size.
const corner = (await page
.locator('[data-panel="alpha"] .panel-corner-resize-handle.se')
.boundingBox())!;
const WD = 118;
const HD = 94;
await page.mouse.move(corner.x + corner.width / 2, corner.y + corner.height / 2);
await page.mouse.down();
await page.mouse.move(
corner.x + corner.width / 2 + WD,
corner.y + corner.height / 2 + HD,
{ steps: 16 },
);
await page.mouse.up();
const resized = await rect(page, 'alpha');
expect(Math.abs(resized.width - (moved.width + WD))).toBeLessThan(6);
expect(Math.abs(resized.height - (moved.height + HD))).toBeLessThan(6);
// Reload: the mode + exact geometry are restored.
await page.reload();
await page.waitForFunction(() => window.__layoutHarness?.ready === true);
await page.waitForTimeout(80);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
const restored = await rect(page, 'alpha');
expect(Math.abs(restored.left - resized.left)).toBeLessThan(3);
expect(Math.abs(restored.top - resized.top)).toBeLessThan(3);
expect(Math.abs(restored.width - resized.width)).toBeLessThan(3);
expect(Math.abs(restored.height - resized.height)).toBeLessThan(3);
});
test('free mode: all four corners resize and pin the opposite corner', async ({ page }) => {
await lh(page);
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
await page.waitForTimeout(40);
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
const D = 60; // drag each corner inward (toward the panel centre) — always in-bounds
// corner → inward drag sign + which opposite corner must stay pinned.
const cases = [
{ panel: 'alpha', corner: 'nw', sx: 1, sy: 1, fix: 'br' },
{ panel: 'bravo', corner: 'ne', sx: -1, sy: 1, fix: 'bl' },
{ panel: 'charlie', corner: 'sw', sx: 1, sy: -1, fix: 'tr' },
{ panel: 'delta', corner: 'se', sx: -1, sy: -1, fix: 'tl' },
] as const;
for (const c of cases) {
const b = await rect(page, c.panel);
const h = (await page
.locator(`[data-panel="${c.panel}"] .panel-corner-resize-handle.${c.corner}`)
.boundingBox())!;
const px = h.x + h.width / 2;
const py = h.y + h.height / 2;
await page.mouse.move(px, py);
await page.mouse.down();
await page.mouse.move(px + c.sx * D, py + c.sy * D, { steps: 12 });
await page.mouse.up();
const a = await rect(page, c.panel);
// The grabbed corner pulled inward on both axes → the panel shrank.
expect(a.width).toBeLessThan(b.width - 20);
expect(a.height).toBeLessThan(b.height - 20);
// The OPPOSITE corner never moved — proof the resize is anchor-aware
// (nw/ne/sw shift left/top to hold the far edge, not just grow w/h).
const bR = b.left + b.width;
const bB = b.top + b.height;
const aR = a.left + a.width;
const aB = a.top + a.height;
if (c.fix === 'br') {
expect(Math.abs(aR - bR)).toBeLessThan(4);
expect(Math.abs(aB - bB)).toBeLessThan(4);
} else if (c.fix === 'bl') {
expect(Math.abs(a.left - b.left)).toBeLessThan(4);
expect(Math.abs(aB - bB)).toBeLessThan(4);
} else if (c.fix === 'tr') {
expect(Math.abs(aR - bR)).toBeLessThan(4);
expect(Math.abs(a.top - b.top)).toBeLessThan(4);
} else {
expect(Math.abs(a.left - b.left)).toBeLessThan(4);
expect(Math.abs(a.top - b.top)).toBeLessThan(4);
}
}
await page.screenshot({ path: `${SHOTS}/free-all-corners.png` });
});
test('free mode: the map participates with a 240px floor', async ({ page }) => {
await lh(page);
await page.evaluate(() => window.__layoutHarness!.setMode('free'));
await page.waitForTimeout(40);
const pos = await page.evaluate(
() => getComputedStyle(document.querySelector('[data-panel="map"]')!).position,
);
expect(pos).toBe('absolute');
const r = await rect(page, 'map');
expect(r.width).toBeGreaterThanOrEqual(240);
expect(r.height).toBeGreaterThanOrEqual(240);
});
test('toggle flips grid ⇄ free and back', async ({ page }) => {
await lh(page);
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('free');
expect(await page.evaluate(() => window.__layoutHarness!.mode())).toBe('free');
expect(await page.evaluate(() => window.__layoutHarness!.toggle())).toBe('grid');
// Back in grid mode the free inline geometry is stripped.
const pos = await page.evaluate(
() => document.querySelector<HTMLElement>('[data-panel="alpha"]')!.style.position,
);
expect(pos).toBe('');
});
});
// ── Live News (video) resizes freely; the video fills the panel at any size ──
// Real app: proves the CTO requirement — Live News can grow to 2-3 cols / full
// width (grid) or any pixel size (free), and the 16:9 video (`.live-news-player`,
// width-driven) scales to fill, never capped small. NOTE: in the offline e2e
// runtime the YouTube embed eventually errors and replaces `.live-news-player`
// with a message, so the video-fill invariant is asserted where it's reliably
// present (default size) and the resize is proved via the player's containing
// block (`.panel-content`, always present), which the player fills 1:1.
const rectW = (page: Page, sel: string): Promise<number> =>
page.evaluate((s) => {
const el = document.querySelector<HTMLElement>(s);
return el ? Math.round(el.getBoundingClientRect().width) : -1;
}, sel);
const LN = '[data-panel="live-news"]';
const LN_CONTENT = `${LN} .panel-content`;
const LN_PLAYER = `${LN} .live-news-player`;
test.describe('live news video resize (real app)', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('grid: default → 3 cols → full-width; the video fills at every width', async ({ page }) => {
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await page.waitForTimeout(500);
const grid = (await page.locator('#panelsGrid').boundingBox())!;
const ln = page.locator(LN);
const colHandle = ln.locator('.panel-col-resize-handle');
// The fill setup is active: the panel-content is a full-bleed flex column
// (padding 0) so the width-driven 16:9 `.live-news-player` fills it edge-to-edge
// at any width. (Keyed on #live-news — dead until the id fix in LiveNewsPanel.)
const setup = await page.evaluate((sel) => {
const c = document.querySelector<HTMLElement>(sel);
if (!c) return null;
const s = getComputedStyle(c);
return { display: s.display, dir: s.flexDirection, padLeft: s.paddingLeft };
}, LN_CONTENT);
expect(setup).toEqual({ display: 'flex', dir: 'column', padLeft: '0px' });
// The video, while present (offline embed errors after a beat), fills the
// container 1:1 at the default width.
const startContent = await rectW(page, LN_CONTENT);
const startVideo = await rectW(page, LN_PLAYER);
if (startVideo > 0) expect(Math.abs(startVideo - startContent)).toBeLessThan(4);
// Drag the right edge out to ~3 columns.
const h1 = (await colHandle.boundingBox())!;
await page.mouse.move(h1.x + h1.width / 2, h1.y + h1.height / 2);
await page.mouse.down();
await page.mouse.move(grid.x + grid.width * 0.42, h1.y + h1.height / 2, { steps: 14 });
await page.mouse.up();
await page.waitForTimeout(200);
const midContent = await rectW(page, LN_CONTENT);
expect(midContent).toBeGreaterThan(startContent + 40); // genuinely wider
// Drag the right edge all the way out → full width. No column cap.
const h2 = (await colHandle.boundingBox())!;
await page.mouse.move(h2.x + h2.width / 2, h2.y + h2.height / 2);
await page.mouse.down();
await page.mouse.move(grid.x + grid.width + 200, h2.y + h2.height / 2, { steps: 16 });
await page.mouse.up();
await page.waitForTimeout(200);
const fullContent = await rectW(page, LN_CONTENT);
expect(fullContent).toBeGreaterThan(midContent);
expect(fullContent).toBeGreaterThan(grid.width * 0.9); // ~full grid width
// The video (when present) fills the now-full-width container 1:1.
const fullVideo = await rectW(page, LN_PLAYER);
if (fullVideo > 0) expect(Math.abs(fullVideo - fullContent)).toBeLessThan(6);
// Make it tall too (drag the bottom edge down) so the 16:9 video is large.
const bottom = ln.locator('.panel-resize-handle');
const b = (await bottom.boundingBox())!;
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
await page.mouse.down();
await page.mouse.move(b.x + b.width / 2, b.y + 520, { steps: 16 });
await page.mouse.up();
await page.waitForTimeout(250);
await page.evaluate(() => document.querySelector('[data-panel="live-news"]')?.scrollIntoView({ block: 'start' }));
await page.waitForTimeout(150);
await page.screenshot({ path: 'e2e/layout-shots/live-news-fullwidth.png' });
// Container is now full-width AND tall → a large video area.
expect(await rectW(page, LN_CONTENT)).toBeGreaterThan(900);
});
test('free: Live News resizes to an arbitrary pixel size; video fills', async ({ page }) => {
await page.goto('/');
await page.waitForSelector(LN_PLAYER, { timeout: 45000 });
await page.waitForTimeout(600);
const startContent = await rectW(page, LN_CONTENT);
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('free'));
await page.waitForTimeout(200);
const corner = page.locator(`${LN} .panel-corner-resize-handle.se`);
const c = (await corner.boundingBox())!;
await page.mouse.move(c.x + c.width / 2, c.y + c.height / 2);
await page.mouse.down();
await page.mouse.move(c.x + 240, c.y + 260, { steps: 18 });
await page.mouse.up();
await page.waitForTimeout(200);
const content = await rectW(page, LN_CONTENT);
expect(content).toBeGreaterThan(startContent + 120); // grew to an arbitrary pixel width
const video = await rectW(page, LN_PLAYER);
if (video > 0) expect(Math.abs(video - content)).toBeLessThan(6); // fills at that size
await page.evaluate(() => (window as unknown as { worldGrid?: { setLayoutMode(m: string): void } }).worldGrid?.setLayoutMode('grid'));
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

+150
View File
@@ -0,0 +1,150 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* UI/UX polish e2e — the four changes shipped together, each asserted against
* the real DOM/CSS rather than by eye:
*
* 1. Right-click a headline → "Summarize with AI" is the FIRST item, and it
* opens the analyst dock with the story as the question.
* 2. The app header carries no underline (border-bottom).
* 3. The map's drag pill is gone, but the grip strip is still the drag target.
* 4. "Try Hanzo" is an acquisition CTA: visible signed-out, hidden once
* identity resolves as signed-in.
*/
const SCREENS = 'e2e/screens';
async function stubWorldApi(page: Page): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
reply: 'Stubbed summary.',
actions: [],
model: 'zen5',
tokens: 0,
traces: [],
}),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ models: [{ id: 'zen5', name: 'Zen 5' }], default: 'zen5' }),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
}
/** Sign in the way the analyst e2e does — the analyst only accepts a question
* when identity is present, so the summarize path needs a signed-in session. */
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('.panel', { timeout: 30_000 });
}
/** Inject a news-shaped item — the exact data-ctx-* convention NewsPanel emits. */
async function addNewsItem(page: Page): Promise<void> {
await page.evaluate(() => {
const grid = document.querySelector('#panelsGrid')!;
const panel = document.createElement('div');
panel.className = 'panel';
panel.dataset.panel = 'e2e-ui-panel';
panel.innerHTML =
'<div class="panel-content"><div class="item" id="e2e-news-item" ' +
'data-ctx-url="https://example.com/story" data-ctx-headline="Nvidia unveils new GPU" ' +
'style="padding:12px;min-height:24px;">Nvidia unveils new GPU</div></div>';
grid.appendChild(panel);
});
}
test.describe('ui polish', () => {
test('headline right-click leads with "Summarize with AI" and opens the analyst', async ({ page }) => {
await stubWorldApi(page);
await signIn(page); // the analyst answers only for a signed-in identity
await appReady(page);
await addNewsItem(page);
await page.locator('#e2e-news-item').scrollIntoViewIfNeeded();
await page.locator('#e2e-news-item').click({ button: 'right' });
await expect(page.locator('#panelContextMenu')).toBeVisible();
const labels = await page
.locator('#panelContextMenu .panel-context-menu-item')
.allTextContents();
const trimmed = labels.map((l) => l.trim());
// It is the action you want on a headline → it leads the menu.
expect(trimmed[0]).toBe('Summarize with AI');
// The copy/open actions are still there, below it.
expect(trimmed).toEqual(expect.arrayContaining(['Open link', 'Copy link', 'Copy headline']));
await page.screenshot({ path: `${SCREENS}/ctxmenu-summarize.png` });
// Clicking it routes into the ONE analyst dock, pre-asked with the story.
await page
.locator('#panelContextMenu .panel-context-menu-item', { hasText: 'Summarize with AI' })
.click();
// .hzc is a 0x0 wrapper (children are position:fixed) — the panel is the
// surface that actually opens.
await expect(page.locator('.hzc-panel')).toBeVisible();
// The question carries the headline (the analyst is asked, not just opened).
await expect(page.locator('.hzc-row.user').first()).toContainText('Nvidia unveils new GPU');
});
test('app header has no underline', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
const borderBottom = await page
.locator('.header')
.first()
.evaluate((el) => getComputedStyle(el).borderBottomWidth);
expect(borderBottom).toBe('0px');
});
test('map shows no drag pill, but the grip strip still drags', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
const grip = page.locator('.map-panel .panel-header.map-drag-grip');
await expect(grip).toHaveCount(1);
// No visible indicator …
const pill = await grip.evaluate((el) => getComputedStyle(el, '::after').content);
expect(pill === 'none' || pill === 'normal').toBe(true);
// … but it is still the drag target (that is what makes hiding it safe).
await expect(grip).toHaveCSS('cursor', 'grab');
});
test('"Try Hanzo" hides once signed in', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
const cta = page.locator('.try-hanzo');
await expect(cta).toBeVisible(); // signed-out: the CTA is the point
// The one signal identity resolution emits.
await page.evaluate(() => {
document.dispatchEvent(new CustomEvent('hanzo:auth', { detail: { authed: true } }));
});
await expect(cta).toBeHidden();
// And it comes back on sign-out.
await page.evaluate(() => {
document.dispatchEvent(new CustomEvent('hanzo:auth', { detail: { authed: false } }));
});
await expect(cta).toBeVisible();
});
});
+22 -1
View File
@@ -1,3 +1,24 @@
module github.com/hanzoai/world
go 1.23
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/redis/go-redis/v9 v9.20.0
modernc.org/sqlite v1.51.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+75
View File
@@ -0,0 +1,75 @@
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+128 -5
View File
@@ -3,6 +3,7 @@ package world
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -26,10 +27,11 @@ func newAIClient() *AIClient {
}
model := env("HANZO_AI_MODEL")
if model == "" {
// zen5 = the current flagship general Zen chat model. The bare "zen" alias
// is NOT a servable id (api.hanzo.ai/v1/models), so it would 4xx and force
// every AI endpoint (analyst included) into its fallback path.
model = "zen5"
// "best" is the gateway's own routing alias (owned_by hanzo in /v1/models):
// it always resolves to a servable model. A pinned family id (zen5) goes
// dark whenever the plane's claim catalog shifts — an unclaimed id falls
// through to a proxy that rejects every credential, killing all AI here.
model = "best"
}
return &AIClient{
base: strings.TrimRight(base, "/"),
@@ -170,21 +172,142 @@ func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, mod
defer cancel()
body, status, err := s.do(cctx, "POST", a.base+"/chat/completions", headers, reqBody)
if err != nil {
logf("world: ai request error: model=%s: %v", model, err)
return "", 0, err
}
if status < 200 || status >= 300 {
return "", status, fmt.Errorf("hanzo ai status %d", status)
e := newAIError(status, body)
logf("world: ai non-success: status=%d code=%q model=%s", status, e.code, model)
return "", status, e
}
var cr chatResponse
if err := json.Unmarshal(body, &cr); err != nil {
logf("world: ai decode error: status=%d model=%s: %v", status, model, err)
return "", status, err
}
if len(cr.Choices) == 0 {
// The gateway answers some failures — notably insufficient balance — with a
// 2xx AND an error envelope carrying NO choices. Parse that envelope with the
// SAME lenient logic aiStatusError uses so an out-of-credits user gets a typed,
// actionable error (→ top-up CTA) instead of an opaque "empty response".
if e := newAIError(status, body); e.code != "" || e.msg != "" {
logf("world: ai 2xx empty-choices error: status=%d code=%q model=%s", status, e.code, model)
return "", status, e
}
logf("world: ai 2xx empty response: status=%d model=%s", status, model)
return "", status, fmt.Errorf("empty response")
}
return strings.TrimSpace(cr.Choices[0].Message.Content), cr.Usage.TotalTokens, nil
}
// aiError is a typed inference error carrying the upstream HTTP status plus the
// error {code,message} parsed from the response body, so callers can branch on
// the code (isBalanceError) instead of string-matching the rendered message. Its
// Error() reproduces the actionable one-liner the SPA renders verbatim in chat:
// "hanzo ai status <n>" plus the upstream code (or a short message) when present.
type aiError struct {
status int
code string
msg string
}
func (e *aiError) Error() string {
base := fmt.Sprintf("hanzo ai status %d", e.status)
switch {
case e.code != "":
return base + ": " + e.code
case e.msg != "":
return base + ": " + trim80(e.msg)
}
return base
}
// parseAIErrorBody leniently extracts an error {code, message} from an inference
// or billing response body, across every shape the gateway/billing layers emit.
// It is the SINGLE parse shared by the non-2xx path (newAIError) and the 2xx
// empty-choices path (chatMessagesModel): the gateway answers some failures —
// notably insufficient balance — with HTTP 2xx and an error envelope carrying no
// choices, so both must read the same envelope. Empty strings ⇒ no error found.
//
// {"error":{"code":"insufficient_balance","message":…}} → code
// {"error":"unauthorized","message":…} → "unauthorized"
// {"id":"Unauthorized","message":…} → "Unauthorized"
// {"detail":…} → message
func parseAIErrorBody(body []byte) (code, msg string) {
var p struct {
Error json.RawMessage `json:"error"`
Detail string `json:"detail"`
ID string `json:"id"`
Message string `json:"message"`
}
if json.Unmarshal(body, &p) != nil {
return "", ""
}
msg = strings.TrimSpace(p.Message)
if len(p.Error) > 0 {
// error is either a nested {code,message} object or a bare string.
var eo struct {
Code string `json:"code"`
Message string `json:"message"`
}
if json.Unmarshal(p.Error, &eo) == nil {
code = strings.TrimSpace(eo.Code)
if msg == "" {
msg = strings.TrimSpace(eo.Message)
}
} else {
var es string
if json.Unmarshal(p.Error, &es) == nil {
code = strings.TrimSpace(es)
}
}
}
if code == "" {
code = strings.TrimSpace(p.ID)
}
if msg == "" {
msg = strings.TrimSpace(p.Detail)
}
return code, msg
}
// newAIError builds a typed aiError from a response status + body.
func newAIError(status int, body []byte) *aiError {
code, msg := parseAIErrorBody(body)
return &aiError{status: status, code: code, msg: msg}
}
// aiStatusError turns a non-2xx inference response into an ACTIONABLE error. The
// SPA renders it verbatim in chat, so surfacing e.g. "insufficient_balance" turns
// a dead chat into a fixable one; a body it can't parse degrades to the bare status.
func aiStatusError(status int, body []byte) error {
return newAIError(status, body)
}
// balanceErrorFrom reports whether err is the canonical "out of credits" signal
// the ONE AI gateway emits — HTTP 402 with code=insufficient_balance
// (hanzo/ai routers/filter_balance.go, the single balance-enforcement point).
// World is a thin client of that contract: it neither re-derives balance
// semantics (no message keyword-matching) nor invents codes the backend never
// sends. The analyst renders a top-up CTA for exactly this case.
func balanceErrorFrom(err error) bool {
var ae *aiError
if errors.As(err, &ae) {
return ae.status == http.StatusPaymentRequired || ae.code == "insufficient_balance"
}
return false
}
// trim80 trims s to at most 80 characters (runes, never splitting UTF-8) so a
// long upstream message stays a compact one-liner in the chat error.
func trim80(s string) string {
r := []rune(strings.TrimSpace(s))
if len(r) > 80 {
return strings.TrimSpace(string(r[:80]))
}
return string(r)
}
// dateContext mirrors the original prompts' current-date grounding.
func dateContext(isTech bool) string {
base := "Current date: " + time.Now().UTC().Format("2006-01-02") + "."
+316
View File
@@ -0,0 +1,316 @@
package world
// Streaming inference — the SSE side of the ONE completion path.
//
// chatMessagesModelStream is chatMessagesModel with "stream":true: it reads the
// upstream OpenAI-style SSE frames and hands each content delta to onDelta while
// accumulating the full output, so callers keep the exact same (content, tokens,
// error) contract and simply gain live deltas.
//
// replyExtractor solves the analyst's envelope problem: the model emits STRICT
// JSON ({"reply":"…","actions":[…],"tools":[…]}), so raw deltas are not
// user-presentable. The extractor is an incremental scanner fed those raw
// deltas; it emits ONLY the unescaped contents of the top-level "reply" string
// as it grows. Tool rounds ({"reply":"","tools":…}) therefore stream nothing,
// and a model that ignores the envelope and answers in prose streams verbatim
// (mirroring parseAnalystTurn's raw-output degrade).
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// chatMessagesModelStream runs one completion with streaming, calling onDelta
// for every content chunk. Returns the full accumulated content. A nil onDelta
// degrades to a plain buffered call semantically identical to chatMessagesModel.
func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, bearer, model string, messages []chatMessage, temperature float64, maxTokens int, extra map[string]string, onDelta func(string)) (string, int, error) {
if strings.TrimSpace(model) == "" {
model = a.model
}
reqBody, err := json.Marshal(struct {
chatRequest
Stream bool `json:"stream"`
}{
chatRequest: chatRequest{Model: model, Messages: messages, Temperature: temperature, MaxTokens: maxTokens, TopP: 0.9},
Stream: true,
})
if err != nil {
return "", 0, err
}
cctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodPost, a.base+"/chat/completions", bytes.NewReader(reqBody))
if err != nil {
return "", 0, err
}
req.Header.Set("Authorization", bearer)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
for k, v := range extra {
req.Header.Set(k, v)
}
resp, err := s.client.Do(req)
if err != nil {
return "", 0, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return "", resp.StatusCode, aiStatusError(resp.StatusCode, body)
}
// Some gateways answer a stream request with a plain JSON body (model or
// route not stream-capable). Sniff the content type and degrade quietly.
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "event-stream") {
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return "", resp.StatusCode, err
}
var cr chatResponse
if err := json.Unmarshal(body, &cr); err != nil {
return "", resp.StatusCode, err
}
if len(cr.Choices) == 0 {
return "", resp.StatusCode, fmt.Errorf("empty response")
}
out := strings.TrimSpace(cr.Choices[0].Message.Content)
if onDelta != nil && out != "" {
onDelta(out)
}
return out, cr.Usage.TotalTokens, nil
}
var full strings.Builder
tokens := 0
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var frame struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if json.Unmarshal([]byte(data), &frame) != nil {
continue // tolerate keep-alives / vendor frames
}
if frame.Usage != nil {
tokens = frame.Usage.TotalTokens
}
for _, c := range frame.Choices {
if c.Delta.Content != "" {
full.WriteString(c.Delta.Content)
if onDelta != nil {
onDelta(c.Delta.Content)
}
}
}
}
if err := sc.Err(); err != nil {
// A mid-stream drop still yields whatever arrived; the caller decides.
if full.Len() == 0 {
return "", resp.StatusCode, err
}
}
return strings.TrimSpace(full.String()), tokens, nil
}
// ── incremental "reply" extraction ───────────────────────────────────────────
// replyExtractor states.
const (
rxSniff = iota // deciding: JSON envelope or raw prose?
rxSeekKey // scanning for "reply"
rxSeekColon
rxSeekQuote
rxInString // inside the reply value — emit unescaped runes
rxDone // reply string closed (or prose mode ended)
rxProse // no envelope — everything is the reply
)
// replyExtractor incrementally extracts the top-level "reply" string value from
// a streaming JSON envelope, emitting decoded text via emit. Reasoning models
// (gpt-oss, zen3-omni) write prose BEFORE the envelope — that pre-envelope text
// goes to emitThink (nil-safe) so the UI can show it as live thinking, and the
// moment a '{' appears the scanner switches to envelope mode. Feed() accepts
// arbitrary chunk boundaries (escapes and \uXXXX may split across chunks).
type replyExtractor struct {
state int
emit func(string)
emitThink func(string)
// key matching
keyBuf string
// string decoding
esc bool // last byte was a backslash
uBuf string // pending \uXXXX hex digits ("" when not in a unicode escape)
lead int // sniff: bytes of leading whitespace seen
}
func newReplyExtractor(emit func(string)) *replyExtractor {
return &replyExtractor{state: rxSniff, emit: emit}
}
// Feed consumes the next raw chunk of model output.
func (x *replyExtractor) Feed(chunk string) {
i := 0
for i < len(chunk) {
c := chunk[i]
switch x.state {
case rxSniff:
if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
i++
continue
}
if c == '{' {
x.state = rxSeekKey
i++
continue
}
// Pre-envelope prose (a reasoning model thinking out loud) — stream
// it as thinking until the envelope's '{' shows up.
x.state = rxProse
case rxProse:
j := i
for j < len(chunk) && chunk[j] != '{' {
j++
}
if j > i && x.emitThink != nil {
x.emitThink(chunk[i:j])
}
if j >= len(chunk) {
return
}
x.state = rxSeekKey // envelope begins
i = j + 1
case rxSeekKey:
// scan for the exact key "reply" — cheap rolling match on quoted
// runs; the envelope contract puts reply first, so this stays
// shallow in practice.
if c == '"' {
x.keyBuf = ""
j := i + 1
for j < len(chunk) && chunk[j] != '"' {
x.keyBuf += string(chunk[j])
j++
}
if j >= len(chunk) {
// key name split across chunks — resume in rxKeyPartial
x.state = rxKeyPartial
return
}
if x.keyBuf == "reply" {
x.state = rxSeekColon
}
i = j + 1
continue
}
i++
case rxKeyPartial:
for i < len(chunk) && chunk[i] != '"' {
x.keyBuf += string(chunk[i])
i++
}
if i < len(chunk) { // closing quote
if x.keyBuf == "reply" {
x.state = rxSeekColon
} else {
x.state = rxSeekKey
}
i++
}
case rxSeekColon:
if c == ':' {
x.state = rxSeekQuote
}
i++
case rxSeekQuote:
if c == '"' {
x.state = rxInString
} else if c != ' ' && c != '\n' && c != '\r' && c != '\t' {
x.state = rxSeekKey // reply wasn't a string (defensive)
}
i++
case rxInString:
if x.uBuf != "" || x.esc {
i = x.feedEscape(chunk, i)
continue
}
if c == '\\' {
x.esc = true
i++
continue
}
if c == '"' {
x.state = rxDone
return
}
// emit the longest plain run in one call
j := i
for j < len(chunk) && chunk[j] != '\\' && chunk[j] != '"' {
j++
}
x.emit(chunk[i:j])
i = j
case rxDone:
return
}
}
}
// rxKeyPartial handles a key name split across chunk boundaries.
const rxKeyPartial = 100
// feedEscape consumes escape-sequence bytes (possibly across chunks).
func (x *replyExtractor) feedEscape(chunk string, i int) int {
if x.uBuf != "" { // inside \uXXXX ("u" + collected hex)
for i < len(chunk) && len(x.uBuf) < 5 {
x.uBuf += string(chunk[i])
i++
}
if len(x.uBuf) == 5 {
if v, err := strconv.ParseUint(x.uBuf[1:], 16, 32); err == nil {
x.emit(string(rune(v)))
}
x.uBuf = ""
}
return i
}
// x.esc: the byte after a backslash
c := chunk[i]
x.esc = false
switch c {
case 'n':
x.emit("\n")
case 't':
x.emit("\t")
case 'r':
x.emit("\r")
case 'u':
x.uBuf = "u"
case '"', '\\', '/':
x.emit(string(c))
default:
x.emit(string(c))
}
return i + 1
}
+64
View File
@@ -0,0 +1,64 @@
package world
import (
"strings"
"testing"
)
// feedChunks drives an extractor with the given chunking and returns the
// emitted reply text and thinking text.
func feedChunks(t *testing.T, chunks []string) (reply, think string) {
t.Helper()
var r, th strings.Builder
x := newReplyExtractor(func(s string) { r.WriteString(s) })
x.emitThink = func(s string) { th.WriteString(s) }
for _, c := range chunks {
x.Feed(c)
}
return r.String(), th.String()
}
func TestReplyExtractor(t *testing.T) {
cases := []struct {
name string
chunks []string
want string
wantThink string
}{
{"whole envelope", []string{`{"reply":"Hello world","actions":[]}`}, "Hello world", ""},
{"split mid-value", []string{`{"reply":"Hel`, `lo wor`, `ld","actions":[]}`}, "Hello world", ""},
{"split mid-key", []string{`{"re`, `ply":"hi"}`}, "hi", ""},
{"escapes", []string{`{"reply":"a\nb\t\"q\" c\\d"}`}, "a\nb\t\"q\" c\\d", ""},
{"escape split across chunks", []string{`{"reply":"x\`, `ny"}`}, "x\ny", ""},
{"unicode escape", []string{`{"reply":"snow ☃!"}`}, "snow ☃!", ""},
{"unicode escape split", []string{`{"reply":"a\u26`, `03b"}`}, "a☃b", ""},
{"tool round stays silent", []string{`{"reply":"","tools":[{"name":"world_brief","arguments":{"n":5}}]}`}, "", ""},
{"prose streams as thinking", []string{"Just a plain ", "prose answer."}, "", "Just a plain prose answer."},
{"reasoning preamble then envelope", []string{"We need to answer briefly. ", `{"reply":"The answer.","actions":[]}`}, "The answer.", "We need to answer briefly. "},
{"reasoning split around the brace", []string{"thinking…", " done ", `{"rep`, `ly":"yes"}`}, "yes", "thinking… done "},
{"leading whitespace then envelope", []string{" \n ", `{"reply":"ok"}`}, "ok", ""},
{"other key first (defensive)", []string{`{"model":"x","reply":"later"}`}, "later", ""},
{"stops at closing quote", []string{`{"reply":"done","actions":[{"type":"noise \"reply\":\"nope\""}]}`}, "done", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, think := feedChunks(t, tc.chunks)
if got != tc.want || think != tc.wantThink {
t.Errorf("got (%q, think %q), want (%q, think %q)", got, think, tc.want, tc.wantThink)
}
})
}
}
// Byte-at-a-time is the cruellest chunking — every state transition lands on a
// boundary.
func TestReplyExtractorByteAtATime(t *testing.T) {
in := `{"reply":"line1\nline2 é end","actions":[]}`
chunks := make([]string, 0, len(in))
for i := 0; i < len(in); i++ {
chunks = append(chunks, in[i:i+1])
}
if got, _ := feedChunks(t, chunks); got != "line1\nline2 é end" {
t.Errorf("got %q", got)
}
}
+43
View File
@@ -0,0 +1,43 @@
package world
import "testing"
// TestAIStatusError: the non-2xx inference body is parsed into an actionable
// error across every shape the gateway/billing layers emit; an unparseable body
// degrades to the bare status.
func TestAIStatusError(t *testing.T) {
cases := []struct {
name string
status int
body string
want string
}{
{"nested code", 402, `{"error":{"code":"insufficient_balance","message":"no funds"}}`, "hanzo ai status 402: insufficient_balance"},
{"nested spend cap", 402, `{"error":{"code":"spend_cap_exceeded","message":"cap hit"}}`, "hanzo ai status 402: spend_cap_exceeded"},
{"error string", 401, `{"error":"unauthorized","message":"bad token"}`, "hanzo ai status 401: unauthorized"},
{"id + message", 401, `{"id":"Unauthorized","message":"login required"}`, "hanzo ai status 401: Unauthorized"},
{"detail only", 400, `{"detail":"missing field query"}`, "hanzo ai status 400: missing field query"},
{"nested message no code", 403, `{"error":{"message":"forbidden here"}}`, "hanzo ai status 403: forbidden here"},
{"unparseable html", 500, `<html>502 bad gateway</html>`, "hanzo ai status 500"},
{"empty body", 429, ``, "hanzo ai status 429"},
}
for _, c := range cases {
if got := aiStatusError(c.status, []byte(c.body)).Error(); got != c.want {
t.Errorf("%s: aiStatusError(%d, %q) = %q, want %q", c.name, c.status, c.body, got, c.want)
}
}
}
// TestTrim80 caps a long upstream message to 80 runes without splitting UTF-8.
func TestTrim80(t *testing.T) {
long := ""
for i := 0; i < 200; i++ {
long += "x"
}
if got := trim80(long); len([]rune(got)) != 80 {
t.Fatalf("trim80 long = %d runes, want 80", len([]rune(got)))
}
if got := trim80(" short "); got != "short" {
t.Fatalf("trim80 trimmed = %q, want %q", got, "short")
}
}
+86
View File
@@ -0,0 +1,86 @@
package world
import (
"context"
"time"
)
// Boot cache warmer: keeps the hottest GDELT-backed cache keys warm so the
// request path serves a fresh hit instead of blocking ~10s on a cold GDELT
// fetch when a key's 5m TTL lapses.
//
// The hot keys are the ones the SPA hits on every load: the analyst grounding
// snapshot (gdelt-doc query=world) and the protests panel (gdelt-geo default).
// Each pod warms its OWN in-memory cache (unlike the shared hanzo-kv feed cache,
// there is no cross-pod copy to reuse), on boot and every ~4min — just under the
// TTL, so a key is refreshed before it can expire. GDELT rate-limits callers to
// one request per ~5s, so the specs are walked sequentially with gdeltPace gaps.
const cacheWarmInterval = 4 * time.Minute
// warmSpec is one hot cache key. warm re-produces its value under the exact key
// the handler reads (via the shared key/produce helpers in handlers_news.go).
type warmSpec struct {
name string
warm func(ctx context.Context) error
}
// warmSpecs is the table of hot keys, each wired to the same produce path and
// key strings its handler uses so warmer and handler can never drift.
func (s *Server) warmSpecs() []warmSpec {
return []warmSpec{
{"gdelt-doc query=world", func(ctx context.Context) error {
v, err := s.produceGDELTDoc(ctx, "world", "72h", 8)
if err != nil {
return err
}
s.cache.Set(gdeltDocKey("world", 8, "72h"), v, gdeltTTL, gdeltStale)
return nil
}},
{"gdelt-geo query=protest", func(ctx context.Context) error {
key := gdeltGeoKey("protest", "geojson", 250, "7d")
_, err := s.fetchAndCache(ctx, key, gdeltGeoURL("protest", "geojson", 250, "7d"), nil, gdeltTTL, gdeltStale)
return err
}},
}
}
// startCacheWarmer launches the warmer loop until ctx is cancelled: it warms
// once shortly after boot then on the jittered interval.
func (s *Server) startCacheWarmer(ctx context.Context) {
go func() {
s.warmCaches(ctx)
for {
select {
case <-ctx.Done():
return
case <-time.After(jitter(cacheWarmInterval)):
s.warmCaches(ctx)
}
}
}()
}
// warmCaches (re)produces every hot key sequentially, each independently
// bounded, with GDELT pacing between them. A failure is logged and skipped; the
// next cycle retries and the stale value keeps serving in the meantime.
func (s *Server) warmCaches(ctx context.Context) {
specs := s.warmSpecs()
for i, spec := range specs {
if ctx.Err() != nil {
return
}
wctx, cancel := context.WithTimeout(ctx, 24*time.Second)
err := spec.warm(wctx)
cancel()
if err != nil {
logf("world-warm: %s failed: %v", spec.name, err)
}
if i < len(specs)-1 {
select {
case <-ctx.Done():
return
case <-time.After(gdeltPace):
}
}
}
}
+5 -24
View File
@@ -2,8 +2,6 @@ package world
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
@@ -58,29 +56,12 @@ func iamIssuer() string {
return "https://hanzo.id"
}
// introspectOwner resolves the caller's IAM `owner` claim from userinfo, memoized
// by token hash for a short TTL so the gate does not hit IAM on every request.
func (s *Server) introspectOwner(ctx context.Context, bearer string) (string, error) {
sum := sha256.Sum256([]byte(bearer))
key := "cloud-owner:" + hex.EncodeToString(sum[:12])
if v, ok := s.cache.Get(key); ok {
return v.(string), nil
}
var u struct {
Owner string `json:"owner"`
}
if err := s.getJSON(ctx, iamIssuer()+"/v1/iam/oauth/userinfo",
map[string]string{"Authorization": bearer}, &u); err != nil {
return "", err
}
s.cache.Set(key, u.Owner, 60*time.Second, 60*time.Second)
return u.Owner, nil
}
// requireAdmin gates an admin-only Cloud endpoint. It returns the caller's bearer
// (to forward upstream) and true only for a validated admin-org owner; otherwise
// it writes the fail-closed response (401 without a token, 403 otherwise) and
// returns false. Every admin handler calls this after preflight.
// returns false. Every admin handler calls this after preflight. Identity is
// resolved through the single IAM-userinfo path (introspectIdentity); the gate
// reads only the authoritative owner claim.
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
bearer := userBearer(r)
if bearer == "" {
@@ -89,8 +70,8 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
owner, err := s.introspectOwner(ctx, bearer)
if err != nil || !isAdminOrg(owner) {
id, err := s.introspectIdentity(ctx, bearer)
if err != nil || !isAdminOrg(id.Org) {
writeError(w, http.StatusForbidden, "Admin only — sign in with the admin org")
return "", false
}
+147
View File
@@ -0,0 +1,147 @@
package world
import (
"context"
"errors"
"time"
)
// Service-plane access to the Hanzo Cloud (api.hanzo.ai) — the ONE place that
// knows how world authenticates as a platform service and reads the platform-wide
// aggregates the public dashboard surfaces (cloud-pulse volume, ai-pulse,
// enso-training). Org-scoped drill-down never comes through here: those panels
// call api.hanzo.ai directly with the CALLER's IAM bearer (see cloud-pulse.ts).
//
// One credential, one base. The service token is the repo's established,
// KMS-injected bearer (kms.go's fetch list) — it is NEVER sent to the browser.
// All aggregate reads share it, so there is exactly one secret for ops to
// provision. For the platform-wide reads (get-cloud-usages ?org=all and the
// routing-ledger exports) it must be a super-admin bearer; a non-admin token
// 4xxes upstream and every caller degrades to its honest demo/unavailable state.
// errNoServiceToken is returned by the service-plane readers when no service
// token is configured, so callers keep their honest degrade instead of faking.
var errNoServiceToken = errors.New("world: no service token configured")
// serviceToken returns the platform service bearer (KMS-injected). Empty ⇒ every
// service-side aggregate read degrades to demo/unavailable. Never exposed to the
// browser.
func serviceToken() string { return env("HANZO_CLOUD_PULSE_TOKEN") }
// serviceAuth is the Authorization header map for a service-side call, or nil
// when no service token is configured (so callers short-circuit to their demo
// path).
func serviceAuth() map[string]string {
tok := serviceToken()
if tok == "" {
return nil
}
return map[string]string{"Authorization": "Bearer " + tok}
}
// ── platform usage ledger (ai GET /v1/get-cloud-usages) ───────────────────────
// cloudUsageOverview decodes the fields world aggregates from ai's
// object.CloudUsageOverview. The upstream is ClickHouse-backed; ?org=all is the
// platform-wide view and requires a super-admin bearer.
type cloudUsageOverview struct {
Range string `json:"range"`
Interval string `json:"interval"`
Totals cloudUsageTotals `json:"totals"`
Series []cloudUsageSeriesPoint `json:"series"`
ByModel cloudUsageByModel `json:"byModel"`
}
type cloudUsageTotals struct {
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
SpendCents int64 `json:"spendCents"`
Models int64 `json:"models"`
}
type cloudUsageSeriesPoint struct {
T string `json:"t"`
Tokens int64 `json:"tokens"`
SpendCents int64 `json:"spendCents"`
Requests int64 `json:"requests"`
}
type cloudUsageModelSpend struct {
Model string `json:"model"`
SpendCents int64 `json:"spendCents"`
Tokens int64 `json:"tokens"`
Requests int64 `json:"requests"`
Pct float64 `json:"pct"` // share of total spend, 0..100
}
type cloudUsageByModel struct {
Items []cloudUsageModelSpend `json:"items"`
}
// fetchCloudUsage reads the platform-wide usage overview (?org=all) for a range
// label ("24h" | "7d" | "30d"). It requires a super-admin service token; it
// returns errNoServiceToken when none is set and the upstream error otherwise, so
// callers keep their honest modeled/demo fallback.
func (s *Server) fetchCloudUsage(ctx context.Context, rangeLabel string) (*cloudUsageOverview, error) {
hdr := serviceAuth()
if hdr == nil {
return nil, errNoServiceToken
}
var ov cloudUsageOverview
url := apiHost() + "/v1/get-cloud-usages?org=all&range=" + rangeLabel
if err := s.getJSON(ctx, url, hdr, &ov); err != nil {
return nil, err
}
return &ov, nil
}
// intervalSeconds parses a Go duration string (the usage series bucket width,
// e.g. "1h") to seconds; 0 when unparseable so callers fall back to a window
// average instead of dividing by a bogus interval.
func intervalSeconds(d string) float64 {
if d == "" {
return 0
}
v, err := time.ParseDuration(d)
if err != nil || v <= 0 {
return 0
}
return v.Seconds()
}
// usageRate returns the freshest honest per-second rate for a metric: the most
// recent complete series bucket over its interval, falling back to the 24h
// average when there is no usable interval/series. sel picks the metric from a
// bucket. Shared by cloud-pulse and ai-pulse so the rate is derived one way.
func usageRate(total24h int64, series []cloudUsageSeriesPoint, interval string, sel func(cloudUsageSeriesPoint) int64) float64 {
if n := len(series); n > 0 {
if iv := intervalSeconds(interval); iv > 0 {
return float64(sel(series[n-1])) / iv
}
}
const windowSecs = 86400.0
return float64(total24h) / windowSecs
}
// seriesRequests / seriesTokens are the bucket selectors for usageRate.
func seriesRequests(p cloudUsageSeriesPoint) int64 { return p.Requests }
func seriesTokens(p cloudUsageSeriesPoint) int64 { return p.Tokens }
// topModelsFromUsage maps the ledger's ranked byModel spend into the shared
// cloudModel shape (share normalized 0..1). nil when the ledger listed none.
func topModelsFromUsage(ov *cloudUsageOverview) []cloudModel {
if len(ov.ByModel.Items) == 0 {
return nil
}
out := make([]cloudModel, 0, len(ov.ByModel.Items))
for _, m := range ov.ByModel.Items {
out = append(out, cloudModel{
ID: m.Model,
Name: m.Model,
Requests24h: m.Requests,
Tokens24h: m.Tokens,
Share: m.Pct / 100,
})
}
return out
}
+124
View File
@@ -0,0 +1,124 @@
package world
import (
"context"
"encoding/json"
"time"
"github.com/hanzoai/world/internal/world/kv"
"github.com/hanzoai/world/internal/world/model"
"github.com/hanzoai/world/internal/world/store"
)
// datastore.go wires world's three storage concerns onto the Server and owns
// their lifecycle, keeping each in its lane:
// - hanzo-kv (shared hot cache) → instant feed bodies (FeedCache L2)
// - embedded SQLite (lake) → the searchable "one place to query everything"
// - embedded SQLite (settings) → signed-in per-identity dashboard sync
//
// Everything degrades cleanly: no hanzo-kv → per-pod in-mem feed cache; no
// SQLite → search/analytics return empty and settings say "not stored". The
// service never 5xxes over storage.
// kvAddr is the hanzo-kv endpoint. Defaults to the in-cluster Service; set empty
// (WORLD_KV_DISABLE=1) to force the pure in-mem path (local dev / CI).
func kvAddr() string {
if env("WORLD_KV_DISABLE") != "" {
return ""
}
if a := env("HANZO_KV_ADDR", "WORLD_KV_ADDR"); a != "" {
return a
}
return "hanzo-kv:6379"
}
// kvPassword is optional — hanzo-kv currently requires none; the hook is here for
// a future KMS-provisioned password (HANZO_KV_PASSWORD / world-secrets).
func kvPassword() string { return env("HANZO_KV_PASSWORD", "WORLD_KV_PASSWORD") }
// lakeRetention is the rolling window ingested items are kept for.
func lakeRetention() time.Duration {
if v := env("WORLD_LAKE_RETENTION"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return store.DefaultRetention
}
// initDatastore opens hanzo-kv + the embedded SQLite datastore and builds the
// two-tier feed cache. Called once from NewServer; never fails hard.
func (s *Server) initDatastore() {
s.kv = kv.Open(kvAddr(), kvPassword())
db, err := store.Open(modelDataDir(), lakeRetention())
if err != nil {
logf("world-store: degraded (no durable lake/settings): %v", err)
}
s.store = db
s.feeds = NewFeedCache(s.kv, 0, curatedFeedSeed)
// The world model dumps its folded observations into the lake so model state
// is queryable alongside news — the engine stays decomplected from storage,
// it just calls this sink.
s.worldModel.SetObservationSink(s.ingestObservations)
}
// StartDatastore starts the background loops: the lake write-behind/prune
// consumer and the feed warmer. Call once from main after the server is built.
func (s *Server) StartDatastore(ctx context.Context) {
if s.kv.Enabled() {
pctx, cancel := context.WithTimeout(ctx, 3*time.Second)
if err := s.kv.Ping(pctx); err != nil {
logf("world-kv: %s unreachable, using per-pod in-mem cache: %v", kvAddr(), err)
} else {
logf("world-kv: connected to %s (shared feed cache)", kvAddr())
}
cancel()
} else {
logf("world-kv: disabled, using per-pod in-mem feed cache")
}
go s.store.Lake.Run(ctx)
s.startFeedWarmer(ctx)
s.startCacheWarmer(ctx)
}
// Close releases the datastore handles. Safe to call once on shutdown.
func (s *Server) Close() {
if s.kv != nil {
s.kv.Close()
}
if s.store != nil {
_ = s.store.Close()
}
}
// ingestObservations folds one cycle of world-model observations into the lake
// (kind=observation), keyed so each entity+source keeps its latest value. This
// is the model half of "dump ALL ingested data into the datastore".
func (s *Server) ingestObservations(obs []model.Observation) {
if s.store == nil {
return
}
now := time.Now().UTC()
for _, o := range obs {
country := ""
if o.Kind == model.KindCountry {
country = o.ID
}
payload, _ := json.Marshal(map[string]any{
"id": o.ID, "kind": o.Kind, "name": o.Name,
"metrics": o.Metrics, "note": o.Note, "src": o.Src,
})
s.store.Lake.Add(store.Item{
ID: "obs:" + o.Kind + ":" + o.ID + ":" + o.Src,
Kind: "observation",
Source: o.Src,
TS: now,
Title: o.Name,
Text: o.Note,
Country: country,
Payload: string(payload),
})
}
}
+257
View File
@@ -0,0 +1,257 @@
package world
import (
_ "embed"
"encoding/json"
"log"
"regexp"
"sort"
"strings"
"sync"
)
// Server-side news enrichment: threat classification + geo inference.
//
// This is the Go-native home for work the browser used to do on every client,
// on every render. The keyword tables and geo hubs are DATA (enrichdata/*.json)
// — one source of truth, embedded here and imported by the frontend, so the two
// runtimes can never drift. enrich_test.go proves byte-for-byte parity with the
// TypeScript implementation over an exhaustive 494-case corpus.
//
// Two parity traps, both handled deliberately:
// - JS object key order IS the match priority (first match wins). Go maps are
// unordered, so the tables stay ORDERED SLICES.
// - JS Array.sort is stable. Go's sort.Slice is not — geo matches use
// sort.SliceStable so equal-confidence hubs keep index order.
//go:embed enrichdata/threat-keywords.json
var threatKeywordsJSON []byte
//go:embed enrichdata/geo-hubs.json
var geoHubsJSON []byte
// ThreatClassification mirrors the frontend type (services/threat-classifier.ts).
type ThreatClassification struct {
Level string `json:"level"`
Category string `json:"category"`
Confidence float64 `json:"confidence"`
Source string `json:"source"`
}
// GeoHub is a strategic location an item can be pinned to.
type GeoHub struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Country string `json:"country"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Type string `json:"type"`
Tier string `json:"tier"`
Keywords []string `json:"keywords"`
}
// GeoMatch is one inferred hub for a headline.
type GeoMatch struct {
HubID string `json:"hubId"`
Confidence float64 `json:"confidence"`
MatchedKeyword string `json:"matchedKeyword"`
}
type keywordEntry struct {
Keyword string `json:"keyword"`
Category string `json:"category"`
}
type threatTier struct {
Level string `json:"level"`
Confidence float64 `json:"confidence"`
Variant string `json:"variant"` // "any" | "tech"
Keywords []keywordEntry `json:"keywords"`
}
type threatTables struct {
Exclusions []string `json:"exclusions"`
ShortKeywords []string `json:"shortKeywords"`
Tiers []threatTier `json:"tiers"`
}
type enricher struct {
tables threatTables
short map[string]bool
res map[string]*regexp.Regexp // keyword → compiled matcher
hubs []GeoHub
hubIndex map[string]*GeoHub
// hubKeywords preserves insertion order, matching the JS Map iteration the
// frontend index relies on.
hubKeywords []hubKeyword
}
type hubKeyword struct {
keyword string
hubIDs []string
re *regexp.Regexp // non-nil only for short keywords (word-boundary)
}
var (
enrichOnce sync.Once
enr *enricher
)
func enrichEngine() *enricher {
enrichOnce.Do(func() {
e := &enricher{
short: map[string]bool{},
res: map[string]*regexp.Regexp{},
hubIndex: map[string]*GeoHub{},
}
if err := json.Unmarshal(threatKeywordsJSON, &e.tables); err != nil {
log.Printf("world: enrich: threat tables: %v", err)
}
for _, s := range e.tables.ShortKeywords {
e.short[s] = true
}
// Precompile one matcher per keyword, mirroring getKeywordRegex: short
// keywords are word-bounded (so "war" never matches "wardrobe"), the rest
// are plain substring matches. Titles are lowercased before matching.
for _, tier := range e.tables.Tiers {
for _, k := range tier.Keywords {
e.res[k.Keyword] = compileKeyword(k.Keyword, e.short[k.Keyword])
}
}
var hubData struct {
Hubs []GeoHub `json:"hubs"`
}
if err := json.Unmarshal(geoHubsJSON, &hubData); err != nil {
log.Printf("world: enrich: geo hubs: %v", err)
}
e.hubs = hubData.Hubs
seen := map[string]int{} // keyword → index into hubKeywords
for i := range e.hubs {
h := &e.hubs[i]
e.hubIndex[h.ID] = h
for _, kw := range h.Keywords {
lower := strings.ToLower(kw)
if idx, ok := seen[lower]; ok {
e.hubKeywords[idx].hubIDs = append(e.hubKeywords[idx].hubIDs, h.ID)
continue
}
seen[lower] = len(e.hubKeywords)
var re *regexp.Regexp
// The frontend word-bounds geo keywords shorter than 5 chars.
if len(lower) < 5 {
re = regexp.MustCompile(`\b` + regexp.QuoteMeta(lower) + `\b`)
}
e.hubKeywords = append(e.hubKeywords, hubKeyword{keyword: lower, hubIDs: []string{h.ID}, re: re})
}
}
enr = e
})
return enr
}
func compileKeyword(kw string, short bool) *regexp.Regexp {
q := regexp.QuoteMeta(kw)
if short {
return regexp.MustCompile(`\b` + q + `\b`)
}
return regexp.MustCompile(q)
}
// ClassifyByKeyword is the Go port of services/threat-classifier.ts
// classifyByKeyword. Same priority cascade, same confidences, same categories.
func ClassifyByKeyword(title, variant string) ThreatClassification {
e := enrichEngine()
lower := strings.ToLower(title)
info := ThreatClassification{Level: "info", Category: "general", Confidence: 0.3, Source: "keyword"}
for _, ex := range e.tables.Exclusions {
if strings.Contains(lower, ex) {
return info
}
}
isTech := variant == "tech"
for _, tier := range e.tables.Tiers {
if tier.Variant == "tech" && !isTech {
continue
}
for _, k := range tier.Keywords {
if re := e.res[k.Keyword]; re != nil && re.MatchString(lower) {
return ThreatClassification{
Level: tier.Level,
Category: k.Category,
Confidence: tier.Confidence,
Source: "keyword",
}
}
}
}
return info
}
// InferGeoHubs is the Go port of services/geo-hub-index.ts inferGeoHubsFromTitle:
// keyword → hub, confidence by keyword length, boosted for conflict/strategic
// type and critical tier, sorted by confidence (stable).
func InferGeoHubs(title string) []GeoMatch {
e := enrichEngine()
lower := strings.ToLower(title)
matches := []GeoMatch{}
seen := map[string]bool{}
for _, hk := range e.hubKeywords {
if len(hk.keyword) < 2 {
continue
}
found := false
if hk.re != nil {
found = hk.re.MatchString(lower)
} else {
found = strings.Contains(lower, hk.keyword)
}
if !found {
continue
}
for _, id := range hk.hubIDs {
if seen[id] {
continue
}
seen[id] = true
hub := e.hubIndex[id]
if hub == nil {
continue
}
confidence := 0.5
switch n := len(hk.keyword); {
case n >= 10:
confidence = 0.9
case n >= 6:
confidence = 0.75
case n >= 4:
confidence = 0.6
}
if hub.Type == "conflict" || hub.Type == "strategic" {
confidence = min1(confidence + 0.1)
}
if hub.Tier == "critical" {
confidence = min1(confidence + 0.1)
}
matches = append(matches, GeoMatch{HubID: id, Confidence: confidence, MatchedKeyword: hk.keyword})
}
}
// JS Array.sort is stable — equal confidences keep discovery order.
sort.SliceStable(matches, func(i, j int) bool { return matches[i].Confidence > matches[j].Confidence })
return matches
}
// GeoHubByID exposes a hub for callers that need its coordinates/name.
func GeoHubByID(id string) *GeoHub {
return enrichEngine().hubIndex[id]
}
func min1(v float64) float64 {
if v > 1 {
return 1
}
return v
}
+111
View File
@@ -0,0 +1,111 @@
package world
import (
"encoding/json"
"math"
"os"
"testing"
)
// Parity: the Go enrichment MUST agree with the TypeScript implementation it
// replaces, or moving the work server-side would silently change what every
// user sees. testdata/enrich_golden.json is generated by EVALUATING the real
// frontend classifier/geo-index (not transcribed) over an exhaustive corpus:
// every keyword in every tier, both variants, every geo-hub keyword, every
// exclusion, plus adversarial word-boundary cases ("war" must not match
// "wardrobe"). Any drift fails here.
type goldenCase struct {
Title string `json:"title"`
Full ThreatClassification `json:"full"`
Tech ThreatClassification `json:"tech"`
Geo []GeoMatch `json:"geo"`
}
func loadGolden(t *testing.T) []goldenCase {
t.Helper()
b, err := os.ReadFile("testdata/enrich_golden.json")
if err != nil {
t.Fatalf("read golden: %v", err)
}
var g struct {
Cases []goldenCase `json:"cases"`
}
if err := json.Unmarshal(b, &g); err != nil {
t.Fatalf("parse golden: %v", err)
}
if len(g.Cases) < 400 {
t.Fatalf("golden corpus too small (%d) — regenerate it", len(g.Cases))
}
return g.Cases
}
func sameClass(a, b ThreatClassification) bool {
return a.Level == b.Level && a.Category == b.Category &&
a.Source == b.Source && math.Abs(a.Confidence-b.Confidence) < 1e-9
}
func TestClassifyByKeywordMatchesTypeScript(t *testing.T) {
cases := loadGolden(t)
bad := 0
for _, c := range cases {
for _, v := range []struct {
variant string
want ThreatClassification
}{{"full", c.Full}, {"tech", c.Tech}} {
got := ClassifyByKeyword(c.Title, v.variant)
if !sameClass(got, v.want) {
bad++
if bad <= 10 {
t.Errorf("classify(%q, %s)\n got %+v\n want %+v", c.Title, v.variant, got, v.want)
}
}
}
}
if bad > 0 {
t.Fatalf("%d/%d classifications diverge from the TypeScript source of truth", bad, len(cases)*2)
}
t.Logf("parity: %d titles × 2 variants identical to TypeScript", len(cases))
}
func TestInferGeoHubsMatchesTypeScript(t *testing.T) {
cases := loadGolden(t)
bad, withGeo := 0, 0
for _, c := range cases {
got := InferGeoHubs(c.Title)
if len(c.Geo) > 0 {
withGeo++
}
if len(got) != len(c.Geo) {
bad++
if bad <= 10 {
t.Errorf("geo(%q): got %d matches, want %d", c.Title, len(got), len(c.Geo))
}
continue
}
for i := range got {
w := c.Geo[i]
if got[i].HubID != w.HubID || got[i].MatchedKeyword != w.MatchedKeyword ||
math.Abs(got[i].Confidence-w.Confidence) > 1e-9 {
bad++
if bad <= 10 {
t.Errorf("geo(%q)[%d]\n got %+v\n want %+v", c.Title, i, got[i], w)
}
break
}
}
}
if bad > 0 {
t.Fatalf("%d/%d geo inferences diverge from the TypeScript source of truth", bad, len(cases))
}
t.Logf("parity: %d titles identical to TypeScript (%d carry geo matches)", len(cases), withGeo)
}
// The adversarial guarantee, asserted directly: short keywords are word-bounded.
func TestShortKeywordsAreWordBounded(t *testing.T) {
for _, title := range []string{"The wardrobe warehouse expands", "Something banned entirely"} {
if got := ClassifyByKeyword(title, "full"); got.Level != "info" {
t.Errorf("%q classified %s — a short keyword matched inside a longer word", title, got.Level)
}
}
}
+691
View File
@@ -0,0 +1,691 @@
{
"hubs": [
{
"id": "washington",
"name": "Washington DC",
"region": "North America",
"country": "USA",
"lat": 38.9072,
"lon": -77.0369,
"type": "capital",
"tier": "critical",
"keywords": [
"washington",
"white house",
"pentagon",
"state department",
"congress",
"capitol hill",
"biden",
"trump"
]
},
{
"id": "moscow",
"name": "Moscow",
"region": "Europe",
"country": "Russia",
"lat": 55.7558,
"lon": 37.6173,
"type": "capital",
"tier": "critical",
"keywords": [
"moscow",
"kremlin",
"putin",
"russia",
"russian"
]
},
{
"id": "beijing",
"name": "Beijing",
"region": "Asia",
"country": "China",
"lat": 39.9042,
"lon": 116.4074,
"type": "capital",
"tier": "critical",
"keywords": [
"beijing",
"xi jinping",
"china",
"chinese",
"ccp",
"prc"
]
},
{
"id": "brussels",
"name": "Brussels",
"region": "Europe",
"country": "Belgium",
"lat": 50.8503,
"lon": 4.3517,
"type": "capital",
"tier": "critical",
"keywords": [
"brussels",
"eu",
"european union",
"nato",
"european commission"
]
},
{
"id": "london",
"name": "London",
"region": "Europe",
"country": "UK",
"lat": 51.5074,
"lon": -0.1278,
"type": "capital",
"tier": "critical",
"keywords": [
"london",
"uk",
"britain",
"british",
"downing street",
"parliament"
]
},
{
"id": "jerusalem",
"name": "Jerusalem",
"region": "Middle East",
"country": "Israel",
"lat": 31.7683,
"lon": 35.2137,
"type": "capital",
"tier": "major",
"keywords": [
"jerusalem",
"israel",
"israeli",
"knesset",
"netanyahu"
]
},
{
"id": "telaviv",
"name": "Tel Aviv",
"region": "Middle East",
"country": "Israel",
"lat": 32.0853,
"lon": 34.7818,
"type": "capital",
"tier": "major",
"keywords": [
"tel aviv",
"idf",
"mossad"
]
},
{
"id": "tehran",
"name": "Tehran",
"region": "Middle East",
"country": "Iran",
"lat": 35.6892,
"lon": 51.389,
"type": "capital",
"tier": "major",
"keywords": [
"tehran",
"iran",
"iranian",
"khamenei",
"irgc",
"ayatollah"
]
},
{
"id": "kyiv",
"name": "Kyiv",
"region": "Europe",
"country": "Ukraine",
"lat": 50.4501,
"lon": 30.5234,
"type": "capital",
"tier": "major",
"keywords": [
"kyiv",
"kiev",
"ukraine",
"ukrainian",
"zelensky",
"zelenskyy"
]
},
{
"id": "taipei",
"name": "Taipei",
"region": "Asia",
"country": "Taiwan",
"lat": 25.033,
"lon": 121.5654,
"type": "capital",
"tier": "major",
"keywords": [
"taipei",
"taiwan",
"taiwanese",
"tsmc"
]
},
{
"id": "tokyo",
"name": "Tokyo",
"region": "Asia",
"country": "Japan",
"lat": 35.6762,
"lon": 139.6503,
"type": "capital",
"tier": "major",
"keywords": [
"tokyo",
"japan",
"japanese"
]
},
{
"id": "seoul",
"name": "Seoul",
"region": "Asia",
"country": "South Korea",
"lat": 37.5665,
"lon": 126.978,
"type": "capital",
"tier": "major",
"keywords": [
"seoul",
"south korea",
"korean"
]
},
{
"id": "pyongyang",
"name": "Pyongyang",
"region": "Asia",
"country": "North Korea",
"lat": 39.0392,
"lon": 125.7625,
"type": "capital",
"tier": "major",
"keywords": [
"pyongyang",
"north korea",
"dprk",
"kim jong un"
]
},
{
"id": "newdelhi",
"name": "New Delhi",
"region": "Asia",
"country": "India",
"lat": 28.6139,
"lon": 77.209,
"type": "capital",
"tier": "major",
"keywords": [
"new delhi",
"delhi",
"india",
"indian",
"modi"
]
},
{
"id": "riyadh",
"name": "Riyadh",
"region": "Middle East",
"country": "Saudi Arabia",
"lat": 24.7136,
"lon": 46.6753,
"type": "capital",
"tier": "major",
"keywords": [
"riyadh",
"saudi",
"saudi arabia",
"mbs",
"mohammed bin salman"
]
},
{
"id": "ankara",
"name": "Ankara",
"region": "Middle East",
"country": "Turkey",
"lat": 39.9334,
"lon": 32.8597,
"type": "capital",
"tier": "major",
"keywords": [
"ankara",
"turkey",
"turkish",
"erdogan"
]
},
{
"id": "paris",
"name": "Paris",
"region": "Europe",
"country": "France",
"lat": 48.8566,
"lon": 2.3522,
"type": "capital",
"tier": "major",
"keywords": [
"paris",
"france",
"french",
"macron",
"elysee"
]
},
{
"id": "berlin",
"name": "Berlin",
"region": "Europe",
"country": "Germany",
"lat": 52.52,
"lon": 13.405,
"type": "capital",
"tier": "major",
"keywords": [
"berlin",
"germany",
"german",
"scholz",
"bundestag"
]
},
{
"id": "cairo",
"name": "Cairo",
"region": "Middle East",
"country": "Egypt",
"lat": 30.0444,
"lon": 31.2357,
"type": "capital",
"tier": "major",
"keywords": [
"cairo",
"egypt",
"egyptian",
"sisi"
]
},
{
"id": "islamabad",
"name": "Islamabad",
"region": "Asia",
"country": "Pakistan",
"lat": 33.6844,
"lon": 73.0479,
"type": "capital",
"tier": "major",
"keywords": [
"islamabad",
"pakistan",
"pakistani"
]
},
{
"id": "gaza",
"name": "Gaza",
"region": "Middle East",
"country": "Palestine",
"lat": 31.5,
"lon": 34.47,
"type": "conflict",
"tier": "critical",
"keywords": [
"gaza",
"hamas",
"palestinian",
"rafah",
"khan younis",
"gaza strip"
]
},
{
"id": "westbank",
"name": "West Bank",
"region": "Middle East",
"country": "Palestine",
"lat": 31.9,
"lon": 35.2,
"type": "conflict",
"tier": "major",
"keywords": [
"west bank",
"ramallah",
"jenin",
"nablus",
"hebron"
]
},
{
"id": "ukraine-front",
"name": "Ukraine Front",
"region": "Europe",
"country": "Ukraine",
"lat": 48.5,
"lon": 37.5,
"type": "conflict",
"tier": "critical",
"keywords": [
"donbas",
"donbass",
"donetsk",
"luhansk",
"kharkiv",
"bakhmut",
"avdiivka",
"zaporizhzhia",
"kherson",
"crimea"
]
},
{
"id": "taiwan-strait",
"name": "Taiwan Strait",
"region": "Asia",
"country": "International",
"lat": 24.5,
"lon": 119.5,
"type": "conflict",
"tier": "critical",
"keywords": [
"taiwan strait",
"formosa",
"pla",
"chinese military"
]
},
{
"id": "southchinasea",
"name": "South China Sea",
"region": "Asia",
"country": "International",
"lat": 12,
"lon": 114,
"type": "strategic",
"tier": "critical",
"keywords": [
"south china sea",
"spratlys",
"paracels",
"nine-dash line",
"scarborough"
]
},
{
"id": "yemen",
"name": "Yemen",
"region": "Middle East",
"country": "Yemen",
"lat": 15.5527,
"lon": 48.5164,
"type": "conflict",
"tier": "major",
"keywords": [
"yemen",
"houthi",
"houthis",
"sanaa",
"aden"
]
},
{
"id": "syria",
"name": "Syria",
"region": "Middle East",
"country": "Syria",
"lat": 34.8,
"lon": 39,
"type": "conflict",
"tier": "major",
"keywords": [
"syria",
"syrian",
"assad",
"damascus",
"idlib",
"aleppo"
]
},
{
"id": "lebanon",
"name": "Lebanon",
"region": "Middle East",
"country": "Lebanon",
"lat": 33.8547,
"lon": 35.8623,
"type": "conflict",
"tier": "major",
"keywords": [
"lebanon",
"lebanese",
"hezbollah",
"beirut"
]
},
{
"id": "sudan",
"name": "Sudan",
"region": "Africa",
"country": "Sudan",
"lat": 15.5007,
"lon": 32.5599,
"type": "conflict",
"tier": "major",
"keywords": [
"sudan",
"sudanese",
"khartoum",
"rsf",
"darfur"
]
},
{
"id": "sahel",
"name": "Sahel",
"region": "Africa",
"country": "International",
"lat": 15,
"lon": 0,
"type": "conflict",
"tier": "major",
"keywords": [
"sahel",
"mali",
"niger",
"burkina faso",
"wagner",
"junta"
]
},
{
"id": "ethiopia",
"name": "Ethiopia",
"region": "Africa",
"country": "Ethiopia",
"lat": 9.145,
"lon": 40.4897,
"type": "conflict",
"tier": "notable",
"keywords": [
"ethiopia",
"ethiopian",
"tigray",
"addis ababa",
"abiy ahmed"
]
},
{
"id": "myanmar",
"name": "Myanmar",
"region": "Asia",
"country": "Myanmar",
"lat": 19.7633,
"lon": 96.0785,
"type": "conflict",
"tier": "notable",
"keywords": [
"myanmar",
"burma",
"rohingya",
"junta",
"naypyidaw"
]
},
{
"id": "hormuz",
"name": "Strait of Hormuz",
"region": "Middle East",
"country": "International",
"lat": 26.5,
"lon": 56.5,
"type": "strategic",
"tier": "critical",
"keywords": [
"hormuz",
"strait of hormuz",
"persian gulf",
"gulf"
]
},
{
"id": "redsea",
"name": "Red Sea",
"region": "Middle East",
"country": "International",
"lat": 20,
"lon": 38,
"type": "strategic",
"tier": "critical",
"keywords": [
"red sea",
"bab el-mandeb",
"bab al-mandab"
]
},
{
"id": "suez",
"name": "Suez Canal",
"region": "Middle East",
"country": "Egypt",
"lat": 30.5,
"lon": 32.3,
"type": "strategic",
"tier": "critical",
"keywords": [
"suez",
"suez canal"
]
},
{
"id": "baltic",
"name": "Baltic Sea",
"region": "Europe",
"country": "International",
"lat": 58,
"lon": 20,
"type": "strategic",
"tier": "major",
"keywords": [
"baltic",
"baltic sea",
"kaliningrad",
"gotland"
]
},
{
"id": "arctic",
"name": "Arctic",
"region": "Arctic",
"country": "International",
"lat": 75,
"lon": 0,
"type": "strategic",
"tier": "major",
"keywords": [
"arctic",
"northern sea route",
"svalbard"
]
},
{
"id": "blacksea",
"name": "Black Sea",
"region": "Europe",
"country": "International",
"lat": 43,
"lon": 35,
"type": "strategic",
"tier": "major",
"keywords": [
"black sea",
"bosphorus",
"sevastopol",
"odesa",
"odessa"
]
},
{
"id": "un-nyc",
"name": "United Nations",
"region": "North America",
"country": "USA",
"lat": 40.7489,
"lon": -73.968,
"type": "organization",
"tier": "critical",
"keywords": [
"united nations",
"un",
"security council",
"general assembly",
"unsc"
]
},
{
"id": "nato-hq",
"name": "NATO HQ",
"region": "Europe",
"country": "Belgium",
"lat": 50.8796,
"lon": 4.4284,
"type": "organization",
"tier": "critical",
"keywords": [
"nato",
"north atlantic",
"alliance",
"stoltenberg"
]
},
{
"id": "iaea-vienna",
"name": "IAEA",
"region": "Europe",
"country": "Austria",
"lat": 48.2352,
"lon": 16.4156,
"type": "organization",
"tier": "major",
"keywords": [
"iaea",
"atomic energy",
"nuclear watchdog",
"grossi"
]
}
]
}
@@ -0,0 +1,621 @@
{
"exclusions": [
"protein",
"couples",
"relationship",
"dating",
"diet",
"fitness",
"recipe",
"cooking",
"shopping",
"fashion",
"celebrity",
"movie",
"tv show",
"sports",
"game",
"concert",
"festival",
"wedding",
"vacation",
"travel tips",
"life hack",
"self-care",
"wellness"
],
"shortKeywords": [
"war",
"coup",
"ban",
"vote",
"riot",
"riots",
"hack",
"talks",
"ipo",
"gdp",
"virus",
"disease",
"flood"
],
"tiers": [
{
"level": "critical",
"confidence": 0.9,
"variant": "any",
"keywords": [
{
"keyword": "nuclear strike",
"category": "military"
},
{
"keyword": "nuclear attack",
"category": "military"
},
{
"keyword": "nuclear war",
"category": "military"
},
{
"keyword": "invasion",
"category": "conflict"
},
{
"keyword": "declaration of war",
"category": "conflict"
},
{
"keyword": "martial law",
"category": "military"
},
{
"keyword": "coup",
"category": "military"
},
{
"keyword": "coup attempt",
"category": "military"
},
{
"keyword": "genocide",
"category": "conflict"
},
{
"keyword": "ethnic cleansing",
"category": "conflict"
},
{
"keyword": "chemical attack",
"category": "terrorism"
},
{
"keyword": "biological attack",
"category": "terrorism"
},
{
"keyword": "dirty bomb",
"category": "terrorism"
},
{
"keyword": "mass casualty",
"category": "conflict"
},
{
"keyword": "pandemic declared",
"category": "health"
},
{
"keyword": "health emergency",
"category": "health"
},
{
"keyword": "nato article 5",
"category": "military"
},
{
"keyword": "evacuation order",
"category": "disaster"
},
{
"keyword": "meltdown",
"category": "disaster"
},
{
"keyword": "nuclear meltdown",
"category": "disaster"
}
]
},
{
"level": "high",
"confidence": 0.8,
"variant": "any",
"keywords": [
{
"keyword": "war",
"category": "conflict"
},
{
"keyword": "armed conflict",
"category": "conflict"
},
{
"keyword": "airstrike",
"category": "conflict"
},
{
"keyword": "air strike",
"category": "conflict"
},
{
"keyword": "drone strike",
"category": "conflict"
},
{
"keyword": "missile",
"category": "military"
},
{
"keyword": "missile launch",
"category": "military"
},
{
"keyword": "troops deployed",
"category": "military"
},
{
"keyword": "military escalation",
"category": "military"
},
{
"keyword": "bombing",
"category": "conflict"
},
{
"keyword": "casualties",
"category": "conflict"
},
{
"keyword": "hostage",
"category": "terrorism"
},
{
"keyword": "terrorist",
"category": "terrorism"
},
{
"keyword": "terror attack",
"category": "terrorism"
},
{
"keyword": "assassination",
"category": "crime"
},
{
"keyword": "cyber attack",
"category": "cyber"
},
{
"keyword": "ransomware",
"category": "cyber"
},
{
"keyword": "data breach",
"category": "cyber"
},
{
"keyword": "sanctions",
"category": "economic"
},
{
"keyword": "embargo",
"category": "economic"
},
{
"keyword": "earthquake",
"category": "disaster"
},
{
"keyword": "tsunami",
"category": "disaster"
},
{
"keyword": "hurricane",
"category": "disaster"
},
{
"keyword": "typhoon",
"category": "disaster"
}
]
},
{
"level": "high",
"confidence": 0.75,
"variant": "tech",
"keywords": [
{
"keyword": "major outage",
"category": "infrastructure"
},
{
"keyword": "service down",
"category": "infrastructure"
},
{
"keyword": "global outage",
"category": "infrastructure"
},
{
"keyword": "zero-day",
"category": "cyber"
},
{
"keyword": "critical vulnerability",
"category": "cyber"
},
{
"keyword": "supply chain attack",
"category": "cyber"
},
{
"keyword": "mass layoff",
"category": "economic"
}
]
},
{
"level": "medium",
"confidence": 0.7,
"variant": "any",
"keywords": [
{
"keyword": "protest",
"category": "protest"
},
{
"keyword": "protests",
"category": "protest"
},
{
"keyword": "riot",
"category": "protest"
},
{
"keyword": "riots",
"category": "protest"
},
{
"keyword": "unrest",
"category": "protest"
},
{
"keyword": "demonstration",
"category": "protest"
},
{
"keyword": "strike action",
"category": "protest"
},
{
"keyword": "military exercise",
"category": "military"
},
{
"keyword": "naval exercise",
"category": "military"
},
{
"keyword": "arms deal",
"category": "military"
},
{
"keyword": "weapons sale",
"category": "military"
},
{
"keyword": "diplomatic crisis",
"category": "diplomatic"
},
{
"keyword": "ambassador recalled",
"category": "diplomatic"
},
{
"keyword": "expel diplomats",
"category": "diplomatic"
},
{
"keyword": "trade war",
"category": "economic"
},
{
"keyword": "tariff",
"category": "economic"
},
{
"keyword": "recession",
"category": "economic"
},
{
"keyword": "inflation",
"category": "economic"
},
{
"keyword": "market crash",
"category": "economic"
},
{
"keyword": "flood",
"category": "disaster"
},
{
"keyword": "flooding",
"category": "disaster"
},
{
"keyword": "wildfire",
"category": "disaster"
},
{
"keyword": "volcano",
"category": "disaster"
},
{
"keyword": "eruption",
"category": "disaster"
},
{
"keyword": "outbreak",
"category": "health"
},
{
"keyword": "epidemic",
"category": "health"
},
{
"keyword": "infection spread",
"category": "health"
},
{
"keyword": "oil spill",
"category": "environmental"
},
{
"keyword": "pipeline explosion",
"category": "infrastructure"
},
{
"keyword": "blackout",
"category": "infrastructure"
},
{
"keyword": "power outage",
"category": "infrastructure"
},
{
"keyword": "internet outage",
"category": "infrastructure"
},
{
"keyword": "derailment",
"category": "infrastructure"
}
]
},
{
"level": "medium",
"confidence": 0.65,
"variant": "tech",
"keywords": [
{
"keyword": "outage",
"category": "infrastructure"
},
{
"keyword": "breach",
"category": "cyber"
},
{
"keyword": "hack",
"category": "cyber"
},
{
"keyword": "vulnerability",
"category": "cyber"
},
{
"keyword": "layoff",
"category": "economic"
},
{
"keyword": "layoffs",
"category": "economic"
},
{
"keyword": "antitrust",
"category": "economic"
},
{
"keyword": "monopoly",
"category": "economic"
},
{
"keyword": "ban",
"category": "economic"
},
{
"keyword": "shutdown",
"category": "infrastructure"
}
]
},
{
"level": "low",
"confidence": 0.6,
"variant": "any",
"keywords": [
{
"keyword": "election",
"category": "diplomatic"
},
{
"keyword": "vote",
"category": "diplomatic"
},
{
"keyword": "referendum",
"category": "diplomatic"
},
{
"keyword": "summit",
"category": "diplomatic"
},
{
"keyword": "treaty",
"category": "diplomatic"
},
{
"keyword": "agreement",
"category": "diplomatic"
},
{
"keyword": "negotiation",
"category": "diplomatic"
},
{
"keyword": "talks",
"category": "diplomatic"
},
{
"keyword": "peacekeeping",
"category": "diplomatic"
},
{
"keyword": "humanitarian aid",
"category": "diplomatic"
},
{
"keyword": "ceasefire",
"category": "diplomatic"
},
{
"keyword": "peace treaty",
"category": "diplomatic"
},
{
"keyword": "climate change",
"category": "environmental"
},
{
"keyword": "emissions",
"category": "environmental"
},
{
"keyword": "pollution",
"category": "environmental"
},
{
"keyword": "deforestation",
"category": "environmental"
},
{
"keyword": "drought",
"category": "environmental"
},
{
"keyword": "vaccine",
"category": "health"
},
{
"keyword": "vaccination",
"category": "health"
},
{
"keyword": "disease",
"category": "health"
},
{
"keyword": "virus",
"category": "health"
},
{
"keyword": "public health",
"category": "health"
},
{
"keyword": "covid",
"category": "health"
},
{
"keyword": "interest rate",
"category": "economic"
},
{
"keyword": "gdp",
"category": "economic"
},
{
"keyword": "unemployment",
"category": "economic"
},
{
"keyword": "regulation",
"category": "economic"
}
]
},
{
"level": "low",
"confidence": 0.55,
"variant": "tech",
"keywords": [
{
"keyword": "ipo",
"category": "economic"
},
{
"keyword": "funding",
"category": "economic"
},
{
"keyword": "acquisition",
"category": "economic"
},
{
"keyword": "merger",
"category": "economic"
},
{
"keyword": "launch",
"category": "tech"
},
{
"keyword": "release",
"category": "tech"
},
{
"keyword": "update",
"category": "tech"
},
{
"keyword": "partnership",
"category": "economic"
},
{
"keyword": "startup",
"category": "tech"
},
{
"keyword": "ai model",
"category": "tech"
},
{
"keyword": "open source",
"category": "tech"
}
]
}
]
}
+177
View File
@@ -0,0 +1,177 @@
{
"measured": {
"gpqa_diamond": {
"enso-ultra": {
"accuracy_pct": 89.9,
"stderr_pct": 2.1,
"n": 198,
"correct": 178,
"errors": 0,
"tokens": {
"prompt": 205635,
"completion": 612706,
"reasoning": 319290,
"calls": 601
},
"usd_est": 16.4333,
"wall_seconds": null,
"run_tag": "v1"
},
"enso": {
"accuracy_pct": 87.9,
"stderr_pct": 2.3,
"n": 198,
"correct": 174,
"errors": 0,
"tokens": {
"prompt": 74531,
"completion": 152519,
"reasoning": 29387,
"calls": 198
},
"usd_est": 10.0211,
"wall_seconds": null,
"run_tag": "v1"
},
"opus-4.8": {
"accuracy_pct": 86.9,
"stderr_pct": 2.4,
"n": 198,
"correct": 172,
"errors": 0,
"tokens": {
"prompt": 78121,
"completion": 126069,
"reasoning": 0,
"calls": 198
},
"usd_est": 10.627,
"wall_seconds": 241.3,
"run_tag": "v1"
},
"fable-5": {
"accuracy_pct": 81.3,
"stderr_pct": 2.8,
"n": 198,
"correct": 161,
"errors": 0,
"tokens": {
"prompt": 78121,
"completion": 199072,
"reasoning": 0,
"calls": 198
},
"usd_est": 3.2204,
"wall_seconds": null,
"run_tag": "v1"
},
"gpt-5.5": {
"accuracy_pct": 90.4,
"stderr_pct": 2.1,
"n": 198,
"correct": 179,
"errors": 0,
"tokens": {
"prompt": 54916,
"completion": 362128,
"reasoning": 326158,
"calls": 198
},
"usd_est": 3.6899,
"wall_seconds": 2396.2,
"run_tag": "v1"
},
"deepseek-v4-pro": {
"accuracy_pct": 76.3,
"stderr_pct": 3.0,
"n": 198,
"correct": 151,
"errors": 0,
"tokens": {
"prompt": 52523,
"completion": 108318,
"reasoning": 0,
"calls": 198
},
"usd_est": 0.2076,
"wall_seconds": null,
"run_tag": "v1"
}
},
"livecodebench": {
"deepseek-v4-pro": {
"accuracy_pct": 48.0,
"stderr_pct": 3.8,
"n": 175,
"correct": 84,
"errors": 0,
"tokens": {
"prompt": 92354,
"completion": 76407,
"reasoning": 0,
"calls": 175
},
"usd_est": 0.1769,
"wall_seconds": null,
"run_tag": "v1"
}
}
},
"grading": {},
"fugu_reported": {
"SWE-Bench Pro": {
"Fugu-Ultra": 73.7,
"Fugu": 59.0,
"Opus-4.8": 69.2,
"Gemini-3.1": 54.2,
"GPT-5.5": 58.6
},
"Terminal Bench 2.1": {
"Fugu-Ultra": 82.1,
"Fugu": 80.2,
"Opus-4.8": 74.6,
"Gemini-3.1": 70.3,
"GPT-5.5": 78.2
},
"LiveCodeBench": {
"Fugu-Ultra": 93.2,
"Fugu": 92.9,
"Opus-4.8": 87.8,
"Gemini-3.1": 88.5,
"GPT-5.5": 85.3
},
"LiveCodeBench Pro": {
"Fugu-Ultra": 90.8,
"Fugu": 87.8,
"Opus-4.8": 84.8,
"Gemini-3.1": 82.9,
"GPT-5.5": 88.4
},
"Humanity's Last Exam": {
"Fugu-Ultra": 50.0,
"Fugu": 47.2,
"Opus-4.8": 49.8,
"Gemini-3.1": 44.4,
"GPT-5.5": 41.4
},
"CharXiv Reasoning": {
"Fugu-Ultra": 86.6,
"Fugu": 85.1,
"Opus-4.8": 84.2,
"Gemini-3.1": 83.3,
"GPT-5.5": 84.1
},
"GPQA Diamond": {
"Fugu-Ultra": 95.5,
"Fugu": 95.5,
"Opus-4.8": 92.0,
"Gemini-3.1": 94.3,
"GPT-5.5": 93.6
}
},
"pending": [
"swebench_pro",
"terminal_bench"
],
"total_usd_est": 44.38
}
+106
View File
@@ -0,0 +1,106 @@
package world
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"net/url"
"time"
"github.com/hanzoai/world/internal/world/store"
)
// rssFetchHeaders is the single header set used for every allowlisted feed
// fetch — by the on-demand fall-through (rss-proxy, feeds-batch) AND the
// background warmer. One place, one behavior.
var rssFetchHeaders = map[string]string{
"User-Agent": browserUA,
"Accept": "application/rss+xml, application/xml, text/xml, */*",
}
// fetchFeedBody performs one bounded, allowlisted GET of a feed and returns the
// body only when it is a usable (non-blank) 2xx. This is the ONLY live-fetch
// path for feed bodies, shared by the request fall-through and the warmer. The
// caller owns the timeout via ctx.
func (s *Server) fetchFeedBody(ctx context.Context, feedURL string) ([]byte, bool) {
body, status, err := s.getAllowlisted(ctx, feedURL, allowedRSSDomains, rssFetchHeaders)
if err != nil || status < 200 || status >= 300 || isBlankBody(body) {
return nil, false
}
return body, true
}
// ingestFeedItems parses a feed body and folds its items into the searchable
// data lake (kind=news). Cheap and write-behind (Lake.Add never blocks), so it
// runs off both the request fall-through and the warmer without touching latency.
func (s *Server) ingestFeedItems(feedURL string, body []byte) {
if s.store == nil {
return
}
host := feedHost(feedURL)
for _, it := range parseFeedItems(body, feedsBatchMaxItems) {
payload, _ := json.Marshal(map[string]any{
"title": it.Title, "link": it.Link, "pubDate": it.PubDate,
"source": host, "tickers": it.Tickers,
})
s.store.Lake.Add(store.Item{
ID: newsItemID(it.Link, it.Title),
Kind: "news",
Source: host,
TS: feedItemTime(it.PubDate),
Title: it.Title,
Tickers: it.Tickers,
Payload: string(payload),
})
}
}
// newsItemID is the stable dedupe key for a news item: its link when present,
// else its title. Re-ingesting the same story upserts instead of duplicating.
func newsItemID(link, title string) string {
seed := link
if seed == "" {
seed = title
}
sum := sha1.Sum([]byte(seed))
return "news:" + hex.EncodeToString(sum[:])
}
// feedItemTime parses a normalized RFC3339 pubDate back to a time, defaulting to
// now when the feed omitted or mangled the date.
func feedItemTime(pubDate string) time.Time {
if pubDate != "" {
if t, err := time.Parse(time.RFC3339, pubDate); err == nil {
return t.UTC()
}
}
return time.Now().UTC()
}
// feedHost returns the feed's host for use as the item's source label.
func feedHost(feedURL string) string {
if u, err := url.Parse(feedURL); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return "feed"
}
// curatedFeedSeed is the bootstrap warm set: the highest-value feeds behind the
// crypto, financial-regulation, and crypto-news panels, so a brand-new pod warms
// THEM first (before any user request). Every URL is on the RSS allowlist. After
// the first real page load the warm set becomes demand-driven and fleet-wide; the
// seed only bridges the very first cold boot.
var curatedFeedSeed = []string{
// crypto / crypto-news
"https://www.coindesk.com/arc/outboundfeeds/rss/",
"https://cointelegraph.com/rss",
// financial regulation
"https://www.sec.gov/news/pressreleases.rss",
"https://www.federalreserve.gov/feeds/press_all.xml",
// markets / financial
"https://www.cnbc.com/id/100003114/device/rss/rss.html",
"https://www.cnbc.com/id/19854910/device/rss/rss.html",
"https://seekingalpha.com/market_currents.xml",
"https://www.ft.com/rss/home",
}
+94
View File
@@ -0,0 +1,94 @@
package world
import (
"context"
"math/rand"
"sync"
"time"
)
// Background feed warmer: the reason news is INSTANT.
//
// On boot and every interval (jittered) it fetches every warm feed, write-throughs
// the body to the shared cache, and folds the items into the lake. So the
// on-demand endpoints (rss-proxy, feeds-batch) ALWAYS serve from the warm cache
// and never block a request on an upstream — stale-while-revalidate, with the
// revalidation done here in the background instead of on the request path.
//
// Cross-pod dedupe is implicit: a feed whose SHARED (hanzo-kv) copy is still
// fresh is skipped, so N pods don't all hammer the same upstream every cycle;
// whichever pod refreshes first, the rest read its result.
const (
feedWarmInterval = 5 * time.Minute
feedWarmParallel = 8
feedWarmFetchTimeout = 10 * time.Second
// feedWarmFreshWindow: skip refetch when a cached copy is younger than this
// (slightly under the interval so each cycle still refreshes its own feeds).
feedWarmFreshWindow = 4 * time.Minute
)
// startFeedWarmer launches the warmer loop until ctx is cancelled. It warms once
// shortly after boot (so a cold pod fills fast) then on the jittered interval.
func (s *Server) startFeedWarmer(ctx context.Context) {
go func() {
s.warmFeeds(ctx)
for {
select {
case <-ctx.Done():
return
case <-time.After(jitter(feedWarmInterval)):
s.warmFeeds(ctx)
}
}
}()
}
// warmFeeds refreshes every warm feed in parallel (bounded), skipping any whose
// shared copy is still fresh. Each fetch is independently bounded; one slow
// upstream cannot hold up the rest.
func (s *Server) warmFeeds(ctx context.Context) {
urls := s.feeds.WarmURLs(ctx)
if len(urls) == 0 {
return
}
sem := make(chan struct{}, feedWarmParallel)
var wg sync.WaitGroup
refreshed := 0
var mu sync.Mutex
for _, u := range urls {
if ctx.Err() != nil {
break
}
if age, ok := s.feeds.Age(ctx, u); ok && age < feedWarmFreshWindow {
continue // a peer (or an earlier cycle) already refreshed it
}
wg.Add(1)
sem <- struct{}{}
go func(u string) {
defer wg.Done()
defer func() { <-sem }()
fctx, cancel := context.WithTimeout(ctx, feedWarmFetchTimeout)
defer cancel()
if body, ok := s.fetchFeedBody(fctx, u); ok {
s.feeds.Put(u, body)
s.ingestFeedItems(u, body)
mu.Lock()
refreshed++
mu.Unlock()
}
}(u)
}
wg.Wait()
if refreshed > 0 {
logf("world-feeds: warmed %d/%d feeds", refreshed, len(urls))
}
}
// jitter returns d spread by ±20% so pods don't synchronize their warm cycles.
func jitter(d time.Duration) time.Duration {
spread := int64(d) / 5 // 20%
if spread <= 0 {
return d
}
return d + time.Duration(rand.Int63n(2*spread)-spread)
}
+115
View File
@@ -0,0 +1,115 @@
package world
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/world/internal/world/kv"
)
const stubRSS = `<?xml version="1.0"?><rss version="2.0"><channel>
<item><title>Bitcoin warms the cache</title><link>https://x/1</link><pubDate>Mon, 02 Jan 2006 15:04:05 -0700</pubDate></item>
<item><title>SEC regulation update</title><link>https://x/2</link></item>
</channel></rss>`
// stubFeed stands up a counting RSS upstream and allowlists its host for the
// duration of the test (the SSRF boundary is a package var).
func stubFeed(t *testing.T) (feedURL string, hits *int32) {
t.Helper()
var n int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&n, 1)
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(stubRSS))
}))
t.Cleanup(srv.Close)
host := mustHost(t, srv.URL)
allowedRSSDomains[host] = true
t.Cleanup(func() { delete(allowedRSSDomains, host) })
return srv.URL + "/rss.xml", &n
}
// newTestServer builds a Server with the embedded store in a temp dir and
// hanzo-kv disabled (pure per-pod cache) — hermetic, no external services.
func newTestServer(t *testing.T) *Server {
t.Helper()
t.Setenv("WORLD_DATA_DIR", t.TempDir())
t.Setenv("WORLD_KV_DISABLE", "1")
s := NewServer()
t.Cleanup(s.Close)
return s
}
func TestWarmerPopulatesCache(t *testing.T) {
feedURL, hits := stubFeed(t)
s := newTestServer(t)
// Seed the warm set with the stub feed (demand-driven registration stand-in).
s.feeds = NewFeedCache(kv.Open("", ""), 0, []string{feedURL})
s.warmFeeds(context.Background())
if got := atomic.LoadInt32(hits); got != 1 {
t.Fatalf("upstream fetched %d times, want 1", got)
}
body, _, ok := s.feeds.Get(context.Background(), feedURL)
if !ok || len(body) == 0 {
t.Fatal("warmer did not populate the cache")
}
if string(body) != stubRSS {
t.Fatalf("cached body mismatch")
}
}
func TestWarmerSkipsFreshFeeds(t *testing.T) {
feedURL, hits := stubFeed(t)
s := newTestServer(t)
s.feeds = NewFeedCache(kv.Open("", ""), 0, nil)
// Pre-warm: a fresh copy already in cache (age < freshWindow).
s.feeds.Put(feedURL, []byte(stubRSS))
s.warmFeeds(context.Background())
if got := atomic.LoadInt32(hits); got != 0 {
t.Fatalf("upstream hit %d times, want 0 (fresh feed must be skipped)", got)
}
}
// TestWarmedFeedServedInstantly is the end-to-end promise: once warmed, the
// feeds-batch fall-through serves from cache WITHOUT any upstream fetch.
func TestWarmedFeedServedInstantly(t *testing.T) {
feedURL, hits := stubFeed(t)
s := newTestServer(t)
s.feeds = NewFeedCache(kv.Open("", ""), 0, []string{feedURL})
s.warmFeeds(context.Background()) // one upstream fetch
// A subsequent request must be served from the warm cache — no new fetch.
start := time.Now()
body, ok, fresh := s.feedXML(context.Background(), feedURL)
elapsed := time.Since(start)
if !ok || fresh {
t.Fatalf("feedXML ok=%v fresh=%v, want served-from-cache", ok, fresh)
}
if len(body) == 0 {
t.Fatal("empty body from warm cache")
}
if got := atomic.LoadInt32(hits); got != 1 {
t.Fatalf("upstream hit %d times, want 1 (warm read must not refetch)", got)
}
if elapsed > 50*time.Millisecond {
t.Fatalf("warm read took %s, want <50ms", elapsed)
}
}
func mustHost(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u.Hostname()
}
+201
View File
@@ -0,0 +1,201 @@
package world
import (
"bytes"
"compress/gzip"
"context"
"encoding/binary"
"io"
"sync"
"time"
"github.com/hanzoai/world/internal/world/kv"
)
// FeedCache is the warm cache for raw RSS/Atom bodies behind the instant
// news/feeds panels. It is a two-tier cache, ONE way to read a feed body:
//
// L1 — a per-pod in-memory mirror: hot reads never touch the network.
// L2 — hanzo-kv (shared, cross-pod, survives pod restart): a warm body written
// by any pod's warmer is instantly available to every pod, and a restarted
// pod repopulates L1 from L2 instead of cold-starting.
//
// The warm-URL set (which feeds to keep fresh) is demand-driven: any feed served
// once is registered, persisted fleet-wide in hanzo-kv, and kept fresh by the
// background warmer. A curated seed guarantees the highest-value panels are warm
// even on a brand-new pod before the first request. When hanzo-kv is
// unreachable, everything degrades to the in-mem tier — still correct, just
// per-pod and cold across restart.
type FeedCache struct {
mu sync.RWMutex
mem map[string]feedRow // L1
warm map[string]struct{} // in-mem warm-set fallback when kv is down
kv *kv.Client
max int
}
type feedRow struct {
body []byte
at time.Time
}
const (
// feedKeyPrefix / warmSetKey namespace the shared hanzo-kv keys.
feedKeyPrefix = "world:feed:v1:"
warmSetKey = "world:feed:warm:v1"
// feedKVTTL is the L2 safety horizon. The warmer refreshes every few minutes,
// so a live entry is normally far younger; the TTL only bounds abandoned feeds.
feedKVTTL = 3 * time.Hour
// defaultFeedCacheMax bounds the L1 mirror (news feeds are small; ~500 covers
// every frontend variant with room to spare).
defaultFeedCacheMax = 1024
)
// NewFeedCache builds the cache over an (optional) hanzo-kv client, seeding the
// warm set with the curated bootstrap feeds so a cold pod warms them first.
func NewFeedCache(kvc *kv.Client, max int, seed []string) *FeedCache {
if max <= 0 {
max = defaultFeedCacheMax
}
c := &FeedCache{
mem: make(map[string]feedRow),
warm: make(map[string]struct{}),
kv: kvc,
max: max,
}
for _, u := range seed {
c.warm[u] = struct{}{}
}
// Publish the seed to the shared warm set too (best-effort), so every pod
// converges on the same fleet-wide set.
if len(seed) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
c.kv.SAdd(ctx, warmSetKey, seed...)
cancel()
}
return c
}
// Get returns a cached feed body and its fetch time. L1 first (instant); on miss
// it consults the shared L2 and, on a hit, backfills L1 so subsequent reads are
// instant. Never touches upstream.
func (c *FeedCache) Get(ctx context.Context, url string) ([]byte, time.Time, bool) {
c.mu.RLock()
row, ok := c.mem[url]
c.mu.RUnlock()
if ok {
return row.body, row.at, true
}
if raw, ok := c.kv.GetBytes(ctx, feedKeyPrefix+url); ok {
if at, body, ok := decodeFeed(raw); ok {
c.storeMem(url, body, at)
return body, at, true
}
}
return nil, time.Time{}, false
}
// Put write-throughs a freshly fetched body to both tiers and registers the URL
// in the warm set (demand-driven). It uses a detached, bounded context for the
// shared-tier writes so a write-through is never truncated by the request that
// triggered it — durability must outlive the request. The fetch time is now.
func (c *FeedCache) Put(url string, body []byte) {
at := time.Now()
c.storeMem(url, body, at)
c.mu.Lock()
c.warm[url] = struct{}{}
c.mu.Unlock()
raw := encodeFeed(at, body)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
c.kv.SetBytes(ctx, feedKeyPrefix+url, raw, feedKVTTL)
c.kv.SAdd(ctx, warmSetKey, url)
}
// Age returns how old the cached copy is, if any (for the warmer's
// skip-if-fresh, cross-pod dedupe).
func (c *FeedCache) Age(ctx context.Context, url string) (time.Duration, bool) {
if _, at, ok := c.Get(ctx, url); ok {
return time.Since(at), true
}
return 0, false
}
// WarmURLs is the set of feeds to keep fresh: the fleet-wide set from hanzo-kv
// unioned with the in-mem set (seed + demand). Deduped.
func (c *FeedCache) WarmURLs(ctx context.Context) []string {
set := make(map[string]struct{})
for _, u := range c.kv.SMembers(ctx, warmSetKey) {
set[u] = struct{}{}
}
c.mu.RLock()
for u := range c.warm {
set[u] = struct{}{}
}
for u := range c.mem {
set[u] = struct{}{}
}
c.mu.RUnlock()
out := make([]string, 0, len(set))
for u := range set {
out = append(out, u)
}
return out
}
// Len reports the L1 mirror size (for tests/introspection).
func (c *FeedCache) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.mem)
}
// storeMem writes L1, evicting the oldest entry when at capacity.
func (c *FeedCache) storeMem(url string, body []byte, at time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.mem[url]; !exists && len(c.mem) >= c.max {
var oldestKey string
var oldest time.Time
first := true
for k, r := range c.mem {
if first || r.at.Before(oldest) {
oldestKey, oldest, first = k, r.at, false
}
}
if oldestKey != "" {
delete(c.mem, oldestKey)
}
}
c.mem[url] = feedRow{body: body, at: at}
}
// ── L2 encoding: [8-byte unix-nano fetch time][gzip(body)] ───────────────────
func encodeFeed(at time.Time, body []byte) []byte {
var buf bytes.Buffer
var ts [8]byte
binary.BigEndian.PutUint64(ts[:], uint64(at.UnixNano()))
buf.Write(ts[:])
gz := gzip.NewWriter(&buf)
_, _ = gz.Write(body)
_ = gz.Close()
return buf.Bytes()
}
func decodeFeed(raw []byte) (time.Time, []byte, bool) {
if len(raw) < 8 {
return time.Time{}, nil, false
}
at := time.Unix(0, int64(binary.BigEndian.Uint64(raw[:8])))
gz, err := gzip.NewReader(bytes.NewReader(raw[8:]))
if err != nil {
return time.Time{}, nil, false
}
defer func() { _ = gz.Close() }()
body, err := io.ReadAll(io.LimitReader(gz, maxBody))
if err != nil {
return time.Time{}, nil, false
}
return at, body, true
}
+118
View File
@@ -0,0 +1,118 @@
package world
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/hanzoai/world/internal/world/kv"
)
func TestFeedEncodeDecodeRoundTrip(t *testing.T) {
at := time.Now().Truncate(time.Nanosecond)
body := []byte("<rss><item>hello</item></rss>")
gotAt, gotBody, ok := decodeFeed(encodeFeed(at, body))
if !ok {
t.Fatal("decode failed")
}
if !gotAt.Equal(at) {
t.Fatalf("time round-trip = %v, want %v", gotAt, at)
}
if string(gotBody) != string(body) {
t.Fatalf("body round-trip = %q, want %q", gotBody, body)
}
if _, _, ok := decodeFeed([]byte("short")); ok {
t.Fatal("decode of truncated blob should fail")
}
}
// TestFeedCacheSharedAcrossPods proves the L2 (hanzo-kv) tier: a body written by
// one pod is served to another pod whose L1 is cold — i.e. warming once benefits
// the fleet, and a restarted pod (empty L1) reads a still-warm shared cache.
func TestFeedCacheSharedAcrossPods(t *testing.T) {
mr := miniredis.RunT(t)
kvA := kv.Open(mr.Addr(), "")
kvB := kv.Open(mr.Addr(), "")
t.Cleanup(func() { kvA.Close(); kvB.Close() })
podA := NewFeedCache(kvA, 0, nil)
podB := NewFeedCache(kvB, 0, nil) // separate pod: cold L1, shared L2
const url = "https://feeds.example.com/rss.xml"
body := []byte("<rss><channel><item><title>Shared</title></item></channel></rss>")
podA.Put(url, body)
if podB.Len() != 0 {
t.Fatalf("podB L1 should start cold, has %d", podB.Len())
}
got, _, ok := podB.Get(context.Background(), url)
if !ok || string(got) != string(body) {
t.Fatalf("podB Get via shared L2 = %q ok=%v, want the shared body", got, ok)
}
if podB.Len() != 1 {
t.Fatal("podB should have backfilled L1 from L2")
}
// The warm-URL set is fleet-wide (shared set), so podB knows to keep it fresh.
if !hasURL(podB.WarmURLs(context.Background()), url) {
t.Fatal("shared warm set missing the demand-added url")
}
}
// TestFeedCacheDegradesWithoutKV proves the graceful fallback: with hanzo-kv
// disabled the cache is per-pod (L1 only) and correct — a "restart" (new cache)
// is cold, never a crash.
func TestFeedCacheDegradesWithoutKV(t *testing.T) {
disabled := kv.Open("", "") // no hanzo-kv
c := NewFeedCache(disabled, 0, nil)
const url = "https://feeds.example.com/x.xml"
body := []byte("<rss/>")
c.Put(url, body)
got, _, ok := c.Get(context.Background(), url)
if !ok || string(got) != string(body) {
t.Fatalf("same-pod L1 Get = %q ok=%v", got, ok)
}
// A fresh cache (simulated restart) with no shared L2 must miss — proving the
// per-pod degrade is honest, not silently wrong.
fresh := NewFeedCache(disabled, 0, nil)
if _, _, ok := fresh.Get(context.Background(), url); ok {
t.Fatal("restart with kv disabled should be cold, got a hit")
}
}
func TestFeedCacheSeedInWarmSet(t *testing.T) {
c := NewFeedCache(kv.Open("", ""), 0, []string{"https://a.example/rss", "https://b.example/rss"})
warm := c.WarmURLs(context.Background())
if !hasURL(warm, "https://a.example/rss") || !hasURL(warm, "https://b.example/rss") {
t.Fatalf("seed missing from warm set: %v", warm)
}
}
func TestFeedCacheEvictsOldest(t *testing.T) {
c := NewFeedCache(kv.Open("", ""), 2, nil)
c.Put("u1", []byte("1"))
time.Sleep(2 * time.Millisecond)
c.Put("u2", []byte("2"))
time.Sleep(2 * time.Millisecond)
c.Put("u3", []byte("3")) // over cap → evict u1 (oldest)
if c.Len() != 2 {
t.Fatalf("L1 size = %d, want 2 (bounded)", c.Len())
}
if _, _, ok := c.Get(context.Background(), "u1"); ok {
t.Fatal("oldest entry u1 was not evicted")
}
if _, _, ok := c.Get(context.Background(), "u3"); !ok {
t.Fatal("newest entry u3 missing")
}
}
func hasURL(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
+247
View File
@@ -0,0 +1,247 @@
package world
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// AI Compute pulse — the live "what is Hanzo's inference plane doing right now"
// stream for the AI variant (world.hanzo.ai/?variant=ai). It is the same honest
// platform aggregate cloud-pulse reads, reshaped for a compute-first panel and
// PUSHED over SSE so the number moves without the client polling.
//
// ONE route, two representations (the handleAnalyst idiom): an EventSource client
// (Accept: text/event-stream) gets typed events — `usage` (tokens/s, req/s, spend,
// top models) and `fleet` (gpu/machine/region counts) — re-emitted each interval,
// plus a `status` frame carrying the honest state. A plain GET gets ONE JSON
// snapshot of the same data, which the frontend uses as its poll fallback.
//
// Honesty: with no service token, or when every upstream is unreachable, the
// state is "unavailable" with a reason — the panel says so rather than showing a
// zero as if it were live. The service bearer is read server-side only and never
// reaches the browser.
const (
aiPulseKey = "ai-pulse"
aiPulseTTL = 10 * time.Second // shared snapshot horizon (coalesces SSE clients)
aiPulseFailTTL = 3 * time.Second // retry an unavailable pulse sooner
aiPulseInterval = 15 * time.Second // SSE re-emit cadence
)
// aiUsage is the measured inference volume (get-cloud-usages ?org=all).
type aiUsage struct {
Window string `json:"window"`
RequestsPerSec float64 `json:"requestsPerSec"`
TokensPerSec float64 `json:"tokensPerSec"`
Requests24h int64 `json:"requests24h"`
Tokens24h int64 `json:"tokens24h"`
SpendCents int64 `json:"spendCents"`
Models []cloudModel `json:"models"` // top by real spend
}
// aiFleet is the live serving fleet (visor + ai catalog). Counts only.
type aiFleet struct {
Machines int `json:"machines"`
MachinesOnline int `json:"machinesOnline"`
Gpus int `json:"gpus"`
Regions int `json:"regions"`
ModelsServed int `json:"modelsServed"`
}
// aiPulse is one snapshot of the compute plane. State is "live" when at least one
// half resolved, else "unavailable" with a Reason.
type aiPulse struct {
State string `json:"state"` // "live" | "unavailable"
Reason string `json:"reason,omitempty"`
UpdatedAt string `json:"updatedAt"`
Usage *aiUsage `json:"usage,omitempty"`
Fleet *aiFleet `json:"fleet,omitempty"`
}
// typed SSE frames. Embedding the pointer flattens its JSON fields under the
// envelope's "type", so the wire shape stays DRY with the snapshot structs.
type aiUsageEvent struct {
Type string `json:"type"` // "usage"
*aiUsage
UpdatedAt string `json:"updatedAt"`
}
type aiFleetEvent struct {
Type string `json:"type"` // "fleet"
*aiFleet
UpdatedAt string `json:"updatedAt"`
}
// handleAIPulse serves the compute pulse as SSE (EventSource) or, for a plain GET,
// a single JSON snapshot (the poll fallback). It never 5xxes.
func (s *Server) handleAIPulse(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
ctx := r.Context()
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
if f, ok := w.(http.Flusher); ok {
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
emit := func(v any) {
b, _ := json.Marshal(v)
_, _ = w.Write([]byte("data: "))
_, _ = w.Write(b)
_, _ = w.Write([]byte("\n\n"))
f.Flush()
}
s.streamAIPulse(ctx, emit)
return
}
}
// Poll fallback: one JSON snapshot (never cached downstream — it is live).
sctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
writeJSON(w, http.StatusOK, "no-store", s.aiPulseSnapshot(sctx))
}
// streamAIPulse pushes the snapshot immediately, then re-emits on the interval
// until the client disconnects (ctx cancelled).
func (s *Server) streamAIPulse(ctx context.Context, emit func(any)) {
send := func(p aiPulse) {
if p.Usage != nil {
emit(aiUsageEvent{Type: "usage", aiUsage: p.Usage, UpdatedAt: p.UpdatedAt})
}
if p.Fleet != nil {
emit(aiFleetEvent{Type: "fleet", aiFleet: p.Fleet, UpdatedAt: p.UpdatedAt})
}
emit(map[string]any{"type": "status", "state": p.State, "reason": p.Reason, "updatedAt": p.UpdatedAt})
}
send(s.aiPulseSnapshot(ctx))
t := time.NewTicker(aiPulseInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
send(s.aiPulseSnapshot(ctx))
}
}
}
// aiPulseSnapshot returns the shared snapshot: a fresh cache hit, else one
// single-flighted produce that many concurrent SSE clients coalesce onto (so N
// streams cause ~one upstream sweep per aiPulseTTL).
func (s *Server) aiPulseSnapshot(ctx context.Context) aiPulse {
if v, ok := s.cache.Get(aiPulseKey); ok {
return v.(aiPulse)
}
v, _ := s.flight.do(aiPulseKey, func() (any, error) {
p := s.produceAIPulse(ctx)
ttl := aiPulseTTL
if p.State != "live" {
ttl = aiPulseFailTTL
}
s.cache.Set(aiPulseKey, p, ttl, 30*time.Second)
return p, nil
})
return v.(aiPulse)
}
// produceAIPulse reads both honest halves. It is "live" when either resolves;
// "unavailable" (with a reason) when there is no token or every upstream fails.
func (s *Server) produceAIPulse(ctx context.Context) aiPulse {
now := nowRFC()
if serviceToken() == "" {
return aiPulse{State: "unavailable", Reason: "service token not configured", UpdatedAt: now}
}
usage := s.buildAIUsage(ctx)
fleet := s.buildAIFleet(ctx)
if usage == nil && fleet == nil {
return aiPulse{State: "unavailable", Reason: "compute plane unreachable", UpdatedAt: now}
}
return aiPulse{State: "live", UpdatedAt: now, Usage: usage, Fleet: fleet}
}
// buildAIUsage maps the measured platform usage ledger into the compute-panel
// shape. nil when the ledger is unreachable (no token / non-admin / upstream
// down) so the panel degrades honestly.
func (s *Server) buildAIUsage(ctx context.Context) *aiUsage {
ov, err := s.fetchCloudUsage(ctx, "24h")
if err != nil {
return nil
}
window := ov.Range
if window == "" {
window = "24h"
}
return &aiUsage{
Window: window,
RequestsPerSec: round1(usageRate(ov.Totals.Requests, ov.Series, ov.Interval, seriesRequests)),
TokensPerSec: round1(usageRate(ov.Totals.Tokens, ov.Series, ov.Interval, seriesTokens)),
Requests24h: ov.Totals.Requests,
Tokens24h: ov.Totals.Tokens,
SpendCents: ov.Totals.SpendCents,
Models: topModelsFromUsage(ov),
}
}
// buildAIFleet reads the live serving fleet (visor machines/gpus + ai model
// catalog). nil only when the machines read fails; gpus/models are best-effort
// bonus counts.
func (s *Server) buildAIFleet(ctx context.Context) *aiFleet {
hdr := serviceAuth()
if hdr == nil {
return nil
}
host := apiHost()
var machines struct {
Machines []struct {
Region string `json:"region"`
Status string `json:"status"`
} `json:"machines"`
}
if err := s.getJSON(ctx, host+"/v1/machines", hdr, &machines); err != nil {
return nil
}
var gpus struct {
Gpus []struct {
Region string `json:"region"`
} `json:"gpus"`
}
_ = s.getJSON(ctx, host+"/v1/gpus", hdr, &gpus)
var models struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
_ = s.getJSON(ctx, host+"/v1/models", hdr, &models)
regions := map[string]struct{}{}
online := 0
for _, m := range machines.Machines {
if machineOnline(m.Status) {
online++
}
if m.Region != "" {
regions[m.Region] = struct{}{}
}
}
for _, g := range gpus.Gpus {
if g.Region != "" {
regions[g.Region] = struct{}{}
}
}
return &aiFleet{
Machines: len(machines.Machines),
MachinesOnline: online,
Gpus: len(gpus.Gpus),
Regions: len(regions),
ModelsServed: len(models.Data),
}
}
+160
View File
@@ -0,0 +1,160 @@
package world
import (
"bufio"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// aiPulseUpstream is a fake api.hanzo.ai serving the four reads ai-pulse folds.
func aiPulseUpstream(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/machines", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"machines":[{"region":"nyc","status":"active"},{"region":"sfo","status":"active"},{"region":"nyc","status":"stopped"}]}`))
})
mux.HandleFunc("/v1/gpus", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"gpus":[{"region":"nyc"},{"region":"sfo"},{"region":"nyc"}]}`))
})
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"id":"zen-1"},{"id":"zen-omni-30b"}]}`))
})
mux.HandleFunc("/v1/get-cloud-usages", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"range":"24h","interval":"1h",
"totals":{"tokens":1180000000,"requests":1000000,"spendCents":42000,"models":2},
"series":[{"t":"2026-07-16T00:00:00Z","tokens":40000000,"requests":34000},
{"t":"2026-07-16T01:00:00Z","tokens":72000000,"requests":36000}],
"byModel":{"items":[{"model":"zen-omni-30b","spendCents":24000,"tokens":600000000,"requests":520000,"pct":57.1}]}
}`))
})
up := httptest.NewServer(mux)
t.Cleanup(up.Close)
return up
}
func aiPulseServer(t *testing.T) *httptest.Server {
t.Helper()
s := NewServer()
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts
}
// TestAIPulseUnavailableWithoutToken: the honest degrade — no token, so the JSON
// snapshot is state:"unavailable" with a reason, never a zeroed "live".
func TestAIPulseUnavailableWithoutToken(t *testing.T) {
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "")
ts := aiPulseServer(t)
resp, err := http.Get(ts.URL + "/v1/world/ai-pulse")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var p aiPulse
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
t.Fatalf("decode: %v", err)
}
if p.State != "unavailable" || p.Reason == "" {
t.Fatalf("want unavailable+reason, got state=%q reason=%q", p.State, p.Reason)
}
if p.Usage != nil || p.Fleet != nil {
t.Fatalf("unavailable must carry no fabricated usage/fleet")
}
}
// TestAIPulseLiveSnapshot: with a token + reachable upstream, the poll-fallback
// JSON snapshot carries measured usage and the live fleet.
func TestAIPulseLiveSnapshot(t *testing.T) {
up := aiPulseUpstream(t)
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "svc-super-admin")
t.Setenv("HANZO_API_BASE", up.URL)
ts := aiPulseServer(t)
resp, err := http.Get(ts.URL + "/v1/world/ai-pulse")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var p aiPulse
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
t.Fatalf("decode: %v", err)
}
if p.State != "live" {
t.Fatalf("want state=live, got %q (reason %q)", p.State, p.Reason)
}
if p.Usage == nil || p.Usage.Requests24h != 1_000_000 || p.Usage.SpendCents != 42000 {
t.Fatalf("want measured usage, got %+v", p.Usage)
}
if len(p.Usage.Models) != 1 || p.Usage.Models[0].ID != "zen-omni-30b" {
t.Fatalf("want ledger top model, got %+v", p.Usage.Models)
}
// Recent bucket over 1h interval: 36000 req / 3600s = 10 req/s.
if p.Usage.RequestsPerSec != 10 {
t.Fatalf("want recent-bucket rate 10 req/s, got %v", p.Usage.RequestsPerSec)
}
if p.Fleet == nil || p.Fleet.MachinesOnline != 2 || p.Fleet.Machines != 3 || p.Fleet.Gpus != 3 {
t.Fatalf("want live fleet 2/3 + 3 gpus, got %+v", p.Fleet)
}
if p.Fleet.ModelsServed != 2 {
t.Fatalf("want modelsServed=2, got %d", p.Fleet.ModelsServed)
}
}
// TestAIPulseSSEFrames: an EventSource-style request gets the typed usage/fleet/
// status frames in the first (immediate) emit.
func TestAIPulseSSEFrames(t *testing.T) {
up := aiPulseUpstream(t)
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "svc-super-admin")
t.Setenv("HANZO_API_BASE", up.URL)
ts := aiPulseServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/v1/world/ai-pulse", nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("sse request failed: %v", err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("want event-stream, got %q", ct)
}
seen := map[string]bool{}
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
var ev struct {
Type string `json:"type"`
State string `json:"state"`
}
if json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &ev) != nil {
continue
}
seen[ev.Type] = true
if ev.Type == "status" { // terminal frame of the first emit
if ev.State != "live" {
t.Fatalf("want status state=live, got %q", ev.State)
}
break
}
}
for _, want := range []string{"usage", "fleet", "status"} {
if !seen[want] {
t.Fatalf("missing %q frame (saw %v)", want, seen)
}
}
}
+168 -40
View File
@@ -125,9 +125,13 @@ func (s *Server) handleAnalyst(w http.ResponseWriter, r *http.Request) {
return
}
// Per-user billing: forward the signed-in caller's IAM token, never a shared
// key. Signed-out callers get a quiet prompt to sign in (never a 5xx).
bearer := s.ai.bearerFor(r)
// Paid usage: the analyst chat is metered to the signed-in user's org, so it
// requires the caller's OWN IAM bearer — never the funded service key. That key
// (a.key / HANZO_AI_KEY) backs ONLY the anonymous auto-insight endpoints
// (summarize/classify/country-intel via bearerFor); chat must not silently burn
// it, or "paid usage" would never actually meter. Signed-out callers get a quiet
// sign-in prompt (never a 5xx).
bearer := userBearer(r)
if bearer == "" {
writeJSON(w, http.StatusOK, "", map[string]any{
"reply": "", "actions": []any{}, "fallback": true, "skipped": true,
@@ -185,49 +189,147 @@ func (s *Server) handleAnalyst(w http.ResponseWriter, r *http.Request) {
full = append(full, chatMessage{Role: "system", Content: system})
full = append(full, msgs...)
// Agentic loop: each turn the model may (a) request data tools, which we run
// in-process and feed back, or (b) answer. It runs at most analystMaxToolRounds
// tool rounds before a final answer is forced. Tool traces accumulate for the
// chat UI. When the model needs no data, this collapses to a single call —
// byte-for-byte the prior behaviour.
var (
reply string
actions []map[string]any
traces = make([]map[string]any, 0, analystMaxToolRounds)
tokens int
)
for round := 0; ; round++ {
out, tk, err := s.ai.chatMessagesModel(ctx, s, bearer, model, full, 0.4, 700, extra)
if err != nil || out == "" {
// Surface the upstream reason honestly — the SPA renders it instead of a
// blank chat (e.g. a 401 when aud=hanzo-world isn't allow-listed upstream).
// Any tool traces gathered so far still ride along so partial work shows.
writeJSON(w, http.StatusOK, "", map[string]any{
"reply": "", "actions": []any{}, "fallback": true, "error": errStr(err), "traces": traces,
served := model
if served == "" {
served = s.ai.model
}
// Streaming variant: the SPA asks with Accept: text/event-stream and gets
// live SSE events (round / delta / tool / done) instead of one JSON body.
// The done event carries EXACTLY the JSON path's payload, so the client
// treats deltas as cosmetic and done as the source of truth.
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
if f, ok := w.(http.Flusher); ok {
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no")
setCORS(w, "POST, OPTIONS")
w.WriteHeader(http.StatusOK)
emit := func(v map[string]any) {
b, _ := json.Marshal(v)
_, _ = w.Write([]byte("data: "))
_, _ = w.Write(b)
_, _ = w.Write([]byte("\n\n"))
f.Flush()
}
reply, actions, traces, tokens, err := s.runAnalystLoop(ctx, bearer, model, full, allowed, toolNames, extra, emit)
if err != nil || reply == "" && len(actions) == 0 {
emit(addBillingCTA(map[string]any{
"type": "done", "reply": reply, "actions": emptyIfNil(actions), "fallback": err != nil,
"error": errStr(err), "traces": traces, "model": served,
}, err))
return
}
emit(map[string]any{
"type": "done", "reply": reply, "actions": emptyIfNil(actions),
"model": served, "tokens": tokens, "traces": traces,
})
return
}
}
reply, actions, traces, tokens, err := s.runAnalystLoop(ctx, bearer, model, full, allowed, toolNames, extra, nil)
if err != nil {
// Surface the upstream reason honestly — the SPA renders it instead of a
// blank chat (e.g. a 401 when aud=hanzo-world isn't allow-listed upstream).
// Any tool traces gathered so far still ride along so partial work shows.
writeJSON(w, http.StatusOK, "", addBillingCTA(map[string]any{
"reply": "", "actions": []any{}, "fallback": true, "error": errStr(err), "traces": traces,
}, err))
return
}
writeJSON(w, http.StatusOK, "", map[string]any{
"reply": reply, "actions": actions, "model": served, "tokens": tokens, "traces": traces,
})
}
// emptyIfNil keeps the wire contract's empty array (never null) for actions.
func emptyIfNil(a []map[string]any) any {
if a == nil {
return []any{}
}
return a
}
// billingURL / usageURL are the canonical Hanzo billing surfaces the analyst
// links to when a user's AI credits are exhausted. One contract, so every
// surface (world here, and console/commerce/cms via the same payload shape)
// renders the SAME wallet CTA instead of an opaque failure.
const (
billingURL = "https://console.hanzo.ai/billing"
usageURL = "https://console.hanzo.ai/billing/usage"
)
// addBillingCTA augments an analyst response payload in place when err signals an
// exhausted balance/quota (balanceErrorFrom), turning a dead chat into an
// actionable top-up prompt. The SPA keys off topup==true to render "You're out
// of AI credits → Add credits / View usage". Returns the same map for chaining.
func addBillingCTA(m map[string]any, err error) map[string]any {
if balanceErrorFrom(err) {
m["topup"] = true
m["error"] = "insufficient_balance"
m["billingUrl"] = billingURL
m["usageUrl"] = usageURL
m["reason"] = "You're out of AI credits"
}
return m
}
// runAnalystLoop is THE agentic loop (shared by the JSON and SSE paths): each
// turn the model may (a) request data tools, which run in-process and feed
// back, or (b) answer. At most analystMaxToolRounds tool rounds before a final
// answer is forced. When emit is non-nil, live events stream out as the loop
// runs: {"type":"round"} at each model turn (so the client resets any partial
// text from a prior tool round), {"type":"delta","text":…} for the growing
// "reply" value (via replyExtractor — tool rounds stream nothing), and
// {"type":"tool",…} per executed tool. When the model needs no data, this
// collapses to a single call — byte-for-byte the prior behaviour.
func (s *Server) runAnalystLoop(ctx context.Context, bearer, model string, full []chatMessage, allowed, toolNames map[string]bool, extra map[string]string, emit func(map[string]any)) (reply string, actions []map[string]any, traces []map[string]any, tokens int, err error) {
traces = make([]map[string]any, 0, analystMaxToolRounds)
for round := 0; ; round++ {
var out string
var tk int
if emit != nil {
emit(map[string]any{"type": "round", "n": round})
rx := newReplyExtractor(func(text string) {
emit(map[string]any{"type": "delta", "text": text})
})
rx.emitThink = func(text string) {
emit(map[string]any{"type": "think", "text": text})
}
out, tk, err = s.ai.chatMessagesModelStream(ctx, s, bearer, model, full, 0.4, 700, extra, rx.Feed)
} else {
out, tk, err = s.ai.chatMessagesModel(ctx, s, bearer, model, full, 0.4, 700, extra)
}
if err != nil || out == "" {
if err == nil {
err = fmt.Errorf("empty response")
}
return "", nil, traces, tokens, err
}
tokens += tk
r, acts, calls := parseAnalystTurn(out, allowed, toolNames)
if len(calls) == 0 || round >= analystMaxToolRounds {
reply, actions = r, acts
break
return r, acts, traces, tokens, nil
}
// Execute the requested tools in-process and feed the results back as the
// next turn's grounding. The model's raw request rides as the assistant turn
// so the transcript stays coherent.
var onTrace func(map[string]any)
if emit != nil {
onTrace = func(tr map[string]any) {
ev := map[string]any{"type": "tool"}
for k, v := range tr {
ev[k] = v
}
emit(ev)
}
}
full = append(full, chatMessage{Role: "assistant", Content: out})
last := round == analystMaxToolRounds-1
full = append(full, chatMessage{Role: "user", Content: s.runAnalystTools(ctx, calls, &traces, last)})
full = append(full, chatMessage{Role: "user", Content: s.runAnalystTools(ctx, calls, &traces, last, onTrace)})
}
served := model
if served == "" {
served = s.ai.model
}
writeJSON(w, http.StatusOK, "", map[string]any{
"reply": reply, "actions": actions, "model": served, "tokens": tokens, "traces": traces,
})
}
// runAnalystTools executes a round's tool calls IN-PROCESS through the mcp
@@ -235,7 +337,7 @@ func (s *Server) handleAnalyst(w http.ResponseWriter, r *http.Request) {
// trace per call for the chat UI, and returns the tool-results block injected back
// into the conversation as the next user turn. Results are capped on both the
// model-facing (analystToolResultCap) and UI-facing (analystTraceResultCap) sides.
func (s *Server) runAnalystTools(ctx context.Context, calls []analystToolCall, traces *[]map[string]any, lastRound bool) string {
func (s *Server) runAnalystTools(ctx context.Context, calls []analystToolCall, traces *[]map[string]any, lastRound bool, onTrace func(map[string]any)) string {
var b strings.Builder
b.WriteString("TOOL RESULTS (live data — ground your answer in these; do not repeat the same call):\n")
for _, c := range calls {
@@ -251,11 +353,15 @@ func (s *Server) runAnalystTools(ctx context.Context, calls []analystToolCall, t
}
b.WriteString(capString(body, analystToolResultCap))
b.WriteString("\n\n")
*traces = append(*traces, map[string]any{
tr := map[string]any{
"label": label,
"ok": ok,
"result": capString(body, analystTraceResultCap),
})
}
*traces = append(*traces, tr)
if onTrace != nil {
onTrace(tr) // live SSE trace — the chat shows the tool as it runs
}
}
if lastRound {
b.WriteString("This was the final tool round. Answer the user now in prose using the data above; do not request more tools.\n")
@@ -291,10 +397,12 @@ func toolNameSet(specs []mcp.ToolSpec) map[string]bool {
// ── Model / agent roster (drives the chat's model dropdown) ──────────────────
// zenRoster is the curated Zen family — always offered first so the dropdown
// works even before upstream /v1/models is reachable. Ids are the real served
// families (api.hanzo.ai). The default (ai.go model = "zen5") leads.
// zenRoster is the curated roster — always offered first so the dropdown
// works even before upstream /v1/models is reachable. "best" leads: it is the
// gateway's own routing alias and the only id guaranteed servable across
// catalog shifts (matches the ai.go default). Zen family ids follow.
var zenRoster = []map[string]string{
{"id": "best", "label": "Best (auto)", "group": "Auto"},
{"id": "zen5", "label": "Zen 5", "group": "Zen"},
{"id": "zen5-flash", "label": "Zen 5 Flash", "group": "Zen"},
{"id": "zen5-mini", "label": "Zen 5 Mini", "group": "Zen"},
@@ -336,7 +444,7 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
}
if id != "" && !seen[id] {
seen[id] = true
roster = append(roster, map[string]string{"id": id, "label": id, "group": "Models"})
roster = append(roster, map[string]string{"id": id, "label": id, "group": modelGroup(id)})
}
}
for _, ag := range s.ai.upstreamAgents(ctx, s, bearer, extra) {
@@ -358,6 +466,26 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, "no-store", map[string]any{"data": roster, "default": s.ai.model})
}
// modelGroup buckets a served model id into its family for the dropdown — the
// server-side mirror of the client's modelFamily (src/utils/model-marks.ts), so
// e.g. gpt-oss never files under Zen.
func modelGroup(id string) string {
m := strings.ToLower(id)
switch {
case m == "best":
return "Auto"
case strings.HasPrefix(m, "zen"):
return "Zen"
case strings.HasPrefix(m, "gpt"):
return "GPT"
case strings.HasPrefix(m, "llama") || strings.Contains(m, "llama"):
return "Llama"
case strings.HasPrefix(m, "claude") || strings.HasPrefix(m, "anthropic"):
return "Claude"
}
return "Models"
}
var modelIDRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,79}$`)
// sanitizeModel returns a syntactically-valid model id (or "" to use the default).
+54 -7
View File
@@ -237,7 +237,6 @@ func TestAnalystDataToolLoop(t *testing.T) {
s := NewServer()
s.ai.base = ai.URL
s.ai.key = "test-key" // no user token → keyed bearer, so the chat path runs
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
@@ -246,7 +245,13 @@ func TestAnalystDataToolLoop(t *testing.T) {
reqBody, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": "What is the state of global instability?"}},
})
resp, err := http.Post(ts.URL+"/v1/world/analyst", "application/json", strings.NewReader(string(reqBody)))
// A signed-in caller drives the chat: their IAM bearer is forwarded upstream and
// meters to their org. Chat requires a user bearer — the funded key backs only the
// anonymous auto-insight endpoints (see TestAnalystChatRequiresUserBearer).
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/world/analyst", strings.NewReader(string(reqBody)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
@@ -371,14 +376,17 @@ func TestHandleModelsCuratedRoster(t *testing.T) {
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Default != "zen5" {
t.Errorf("default = %q, want zen5", body.Default)
if body.Default != "best" {
t.Errorf("default = %q, want best", body.Default)
}
if len(body.Data) == 0 {
t.Fatal("empty model roster")
}
var hasZen5 bool
var hasBest, hasZen5 bool
for _, m := range body.Data {
if m.ID == "best" {
hasBest = true
}
if m.ID == "zen5" {
hasZen5 = true
}
@@ -386,7 +394,46 @@ func TestHandleModelsCuratedRoster(t *testing.T) {
t.Errorf("model entry missing label/group: %+v", m)
}
}
if !hasZen5 {
t.Error("roster missing zen5")
if !hasBest || !hasZen5 {
t.Error("roster missing best/zen5")
}
}
// TestAnalystChatRequiresUserBearer pins the pro-model contract: the analyst CHAT
// is paid usage, metered to the signed-in user's org via their OWN IAM bearer —
// NEVER the funded service key. The funded key (a.key / HANZO_AI_KEY) backs only
// the anonymous auto-insight endpoints (summarize/classify/country-intel). So even
// with a funded key configured, an unauthenticated chat call must be refused with a
// quiet sign-in prompt (200, skipped) — not silently answered on the shared key.
//
// Regression guard: if handleAnalyst reverts to bearerFor(r), the funded key would
// satisfy the bearer, the handler would fall through to a real upstream call, and
// this test would hang/fail instead of returning the deterministic skip below.
func TestAnalystChatRequiresUserBearer(t *testing.T) {
s := NewServer()
s.ai.key = "hk-test-funded" // funded key present (backs anonymous auto-insights only)
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
body := `{"messages":[{"role":"user","content":"hi"}],"context":""}`
resp, err := http.Post(ts.URL+"/v1/world/analyst", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (quiet sign-in prompt, never 5xx)", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out["skipped"] != true {
t.Fatalf("anonymous chat not refused — funded key leaked into paid chat path: %+v", out)
}
if reason, _ := out["reason"].(string); !strings.Contains(reason, "Sign in") {
t.Errorf("reason = %q, want a sign-in prompt", reason)
}
}
+901
View File
@@ -0,0 +1,901 @@
package world
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// China macro snapshot + official release calendar. A faithful Go port of the
// worldmonitor seed adapters (scripts/china-macro/{adapters,calendar}.mjs) and
// the merge in server/worldmonitor/economic/v1/get-china-macro-snapshot.ts.
//
// Every source parser is a small pure func over a raw body so it is unit-tested
// against the same fixtures as the TypeScript original. handleChinaMacro is the
// only impure piece: it fetches the live sources (<=2 sequential OECD requests,
// per OECD's "<60 downloads/hour" ask), builds both flows, and merges them into
// the exact upstream payload shape, cached aggressively (daily/monthly series).
const (
oecdCPIURL = "https://sdmx.oecd.org/public/rest/data/OECD.SDD.TPS,DSD_G20_PRICES@DF_G20_PRICES,1.0/CHN.M...PA...?startPeriod=2024-01&dimensionAtObservation=AllDimensions&format=csvfile"
oecdCLIURL = "https://sdmx.oecd.org/public/rest/data/OECD.SDD.STES,DSD_STES@DF_CLI,4.1/CHN.M.LI...AA...H?startPeriod=2024-01&dimensionAtObservation=AllDimensions&format=csvfile"
hkmaCNYURL = "https://api.hkma.gov.hk/public/market-data-and-statistics/monthly-statistical-bulletin/er-ir/er-eeri-daily?pagesize=2&fields=end_of_day,cny&sortby=end_of_day&sortorder=desc"
fredDEXCHUSURL = "https://api.stlouisfed.org/fred/series/observations?series_id=DEXCHUS&file_type=json&sort_order=desc&limit=2"
bisPolicyCacheKey = "economic:bis:policy:v1"
nbsCalendarIndexURL = "https://www.stats.gov.cn/english/PressRelease/ReleaseCalendar/"
chinaMoneyLPRURL = "https://www.chinamoney.com.cn/chinese/bklpr/?tab=2"
chinaMoneyLPRNoticeAPI = "https://www.chinamoney.com.cn/ags/ms/cm-s-notice-query/contentsinshorttime"
chinaMoneyLPRChannelID = "3686"
)
// chinaIndicator is one macro series in the snapshot. Value/PriorValue are
// pointers so an unavailable observation marshals as JSON null (never a fake 0),
// matching the upstream `value: null` contract.
type chinaIndicator struct {
ID string `json:"id"`
Label string `json:"label"`
Category string `json:"category"`
Value *float64 `json:"value"`
PriorValue *float64 `json:"priorValue"`
Unit string `json:"unit"`
ObservationDate string `json:"observationDate"`
Source string `json:"source"`
SourceURL string `json:"sourceUrl"`
Stale bool `json:"stale"`
UnavailableReason string `json:"unavailableReason"`
ContextOnly bool `json:"contextOnly"`
}
type chinaSourceDecision struct {
Source string `json:"source"`
Host string `json:"host"`
Status string `json:"status"`
Reason string `json:"reason"`
CheckedAt string `json:"checkedAt"`
Optional bool `json:"optional"`
RequestCount int `json:"requestCount"`
}
type chinaReleaseEvent struct {
ID string `json:"id"`
Event string `json:"event"`
CountryCode string `json:"countryCode"`
ReleaseDate string `json:"releaseDate"`
ReleaseTime string `json:"releaseTime"`
Timezone string `json:"timezone"`
Kind string `json:"kind"`
Status string `json:"status"`
Source string `json:"source"`
SourceURL string `json:"sourceUrl"`
}
// chinaMacroSnapshot is the merged payload the SPA consumes. Empty slices are
// initialized so they marshal as [] rather than null.
type chinaMacroSnapshot struct {
CountryCode string `json:"countryCode"`
GeneratedAt string `json:"generatedAt"`
Status string `json:"status"`
LaunchReady bool `json:"launchReady"`
ContentObservationDate string `json:"contentObservationDate"`
LatestObservationDate string `json:"latestObservationDate"`
Indicators []chinaIndicator `json:"indicators"`
SourceDecisions []chinaSourceDecision `json:"sourceDecisions"`
ReleaseEvents []chinaReleaseEvent `json:"releaseEvents"`
Unavailable bool `json:"unavailable"`
}
// indicatorDef is the static description of a series (labels, source, staleness
// horizon) shared by the complete/unavailable builders.
type indicatorDef struct {
id, label, category, unit, source, sourceURL string
maxAgeDays int
}
var chinaRequiredCategories = []string{"price", "activity", "policy", "fx"}
// ── observation health ───────────────────────────────────────────────────────
var (
reObsMonth = regexp.MustCompile(`^(\d{4})-(\d{2})$`)
reObsDay = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
reStatusCode = regexp.MustCompile(`status (\d+)`)
)
// observationTime resolves a source timestamp to the instant used for staleness.
// Monthly values ('2026-05') anchor at month-end 23:59:59 UTC so a fresh FX tick
// never makes stale monthly content look current; daily values anchor at day-end.
func observationTime(v string) (time.Time, bool) {
if v == "" {
return time.Time{}, false
}
if m := reObsMonth.FindStringSubmatch(v); m != nil {
y, _ := strconv.Atoi(m[1])
mo, _ := strconv.Atoi(m[2])
// day 0 of month mo+1 == last day of month mo.
return time.Date(y, time.Month(mo)+1, 0, 23, 59, 59, 0, time.UTC), true
}
if reObsDay.MatchString(v) {
if t, err := time.Parse("2006-01-02", v); err == nil {
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC), true
}
}
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, true
}
return time.Time{}, false
}
func isStale(obsDate string, maxAgeDays int, now time.Time) bool {
t, ok := observationTime(obsDate)
if !ok {
return true
}
return now.Sub(t) > time.Duration(maxAgeDays)*24*time.Hour
}
func unavailableIndicator(def indicatorDef, reason string, contextOnly bool) chinaIndicator {
if reason == "" {
reason = "SOURCE_UNAVAILABLE"
}
return chinaIndicator{
ID: def.id, Label: def.label, Category: def.category, Unit: def.unit,
Source: def.source, SourceURL: def.sourceURL,
UnavailableReason: reason, ContextOnly: contextOnly,
}
}
func completeIndicator(def indicatorDef, value float64, prior *float64, obsDate string, now time.Time, contextOnly bool) chinaIndicator {
stale := isStale(obsDate, def.maxAgeDays, now)
reason := ""
if stale {
reason = "STALE_OBSERVATION"
}
v := value
return chinaIndicator{
ID: def.id, Label: def.label, Category: def.category, Value: &v, PriorValue: prior,
Unit: def.unit, ObservationDate: obsDate, Source: def.source, SourceURL: def.sourceURL,
Stale: stale, UnavailableReason: reason, ContextOnly: contextOnly,
}
}
// ── source parsers (pure) ────────────────────────────────────────────────────
type dateValue struct {
date string
value float64
}
func latestTwo(rows []dateValue) (dateValue, *float64) {
sort.SliceStable(rows, func(i, j int) bool { return rows[i].date < rows[j].date })
latest := rows[len(rows)-1]
if len(rows) >= 2 {
p := rows[len(rows)-2].value
return latest, &p
}
return latest, nil
}
func csvColumn(header []string, names ...string) int {
for i, h := range header {
for _, n := range names {
if h == n {
return i
}
}
}
return -1
}
// parseOecdCsvIndicator reads an OECD SDMX CSV (CPI YoY from DF_G20_PRICES or CLI
// from DF_CLI), keeping only mainland-China (REF_AREA=CHN) observations and
// returning the latest value with the prior for change context.
func parseOecdCsvIndicator(body []byte, def indicatorDef, now time.Time) chinaIndicator {
r := csv.NewReader(strings.NewReader(string(body)))
r.FieldsPerRecord = -1
recs, _ := r.ReadAll()
if len(recs) == 0 {
return unavailableIndicator(def, "MALFORMED_RESPONSE", false)
}
header := recs[0]
area := csvColumn(header, "REF_AREA", "ReferenceArea")
date := csvColumn(header, "TIME_PERIOD", "TimePeriod")
val := csvColumn(header, "OBS_VALUE", "ObservationValue")
if area < 0 || date < 0 || val < 0 {
return unavailableIndicator(def, "MALFORMED_RESPONSE", false)
}
var rows []dateValue
for _, rec := range recs[1:] {
if area >= len(rec) || date >= len(rec) || val >= len(rec) {
continue
}
if strings.ToUpper(rec[area]) != "CHN" || rec[date] == "" {
continue
}
v, err := strconv.ParseFloat(rec[val], 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
continue
}
rows = append(rows, dateValue{rec[date], v})
}
if len(rows) == 0 {
return unavailableIndicator(def, "NO_CHINA_OBSERVATIONS", false)
}
latest, prior := latestTwo(rows)
return completeIndicator(def, latest.value, prior, latest.date, now, false)
}
var bisPolicyDef = indicatorDef{
id: "policy_rate", label: "Policy Rate", category: "policy", unit: "%",
source: "BIS (mainland China policy rate)", sourceURL: "https://stats.bis.org/api/v1/data/WS_CBPOL", maxAgeDays: 75,
}
// parseBisPolicy reads the seeded BIS central-bank policy-rate cache shape and
// returns China's latest rate. Prior falls back to the row's previousRate when
// only one observation is present.
func parseBisPolicy(body []byte, now time.Time) chinaIndicator {
var payload struct {
Rates []struct {
CountryCode string `json:"countryCode"`
Rate *float64 `json:"rate"`
PreviousRate *float64 `json:"previousRate"`
Date string `json:"date"`
} `json:"rates"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return unavailableIndicator(bisPolicyDef, "NO_CHINA_POLICY_RATE", false)
}
type row struct {
date string
rate float64
previousRate *float64
}
var matches []row
for _, r := range payload.Rates {
if r.CountryCode != "CN" || r.Rate == nil || r.Date == "" || math.IsNaN(*r.Rate) {
continue
}
matches = append(matches, row{r.Date, *r.Rate, r.PreviousRate})
}
if len(matches) == 0 {
return unavailableIndicator(bisPolicyDef, "NO_CHINA_POLICY_RATE", false)
}
sort.SliceStable(matches, func(i, j int) bool { return matches[i].date < matches[j].date })
latest := matches[len(matches)-1]
var prior *float64
if len(matches) > 1 {
p := matches[len(matches)-2].rate
prior = &p
} else if latest.previousRate != nil && !math.IsNaN(*latest.previousRate) {
p := *latest.previousRate
prior = &p
}
return completeIndicator(bisPolicyDef, latest.rate, prior, latest.date, now, false)
}
var fredUsdCnyDef = indicatorDef{
id: "usd_cny", label: "USD/CNY", category: "fx", unit: "CNY per USD",
source: "FRED DEXCHUS (Federal Reserve H.10)", sourceURL: "https://fred.stlouisfed.org/series/DEXCHUS", maxAgeDays: 10,
}
// parseFredUsdCny reads FRED DEXCHUS observations (values are strings, '.' for
// missing) and returns the latest USD/CNY fixing.
func parseFredUsdCny(body []byte, now time.Time) chinaIndicator {
var payload struct {
Observations []struct {
Date string `json:"date"`
Value string `json:"value"`
} `json:"observations"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return unavailableIndicator(fredUsdCnyDef, "NO_CURRENT_DEXCHUS", false)
}
var rows []dateValue
for _, o := range payload.Observations {
v, err := strconv.ParseFloat(o.Value, 64)
if o.Date == "" || err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
continue
}
rows = append(rows, dateValue{o.Date, v})
}
if len(rows) == 0 {
return unavailableIndicator(fredUsdCnyDef, "NO_CURRENT_DEXCHUS", false)
}
latest, prior := latestTwo(rows)
return completeIndicator(fredUsdCnyDef, latest.value, prior, latest.date, now, false)
}
var hkmaCnyDef = indicatorDef{
id: "cnh_context", label: "CNY/HKD Context", category: "context", unit: "HKD per CNY",
source: "HKMA (Hong Kong/CNH context)",
sourceURL: "https://apidocs.hkma.gov.hk/documentation/market-data-and-statistics/monthly-statistical-bulletin/er-ir/er-eeri-daily/",
maxAgeDays: 10,
}
// parseHkmaCnyContext reads the optional HKMA daily CNY/HKD context series. It is
// context-only: it never gates the launch decision.
func parseHkmaCnyContext(body []byte, now time.Time) chinaIndicator {
var payload struct {
Result struct {
Records []struct {
EndOfDay string `json:"end_of_day"`
CNY *float64 `json:"cny"`
} `json:"records"`
} `json:"result"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return unavailableIndicator(hkmaCnyDef, "NO_HKMA_CNY_CONTEXT", true)
}
var rows []dateValue
for _, rec := range payload.Result.Records {
if rec.EndOfDay == "" || rec.CNY == nil || math.IsNaN(*rec.CNY) {
continue
}
rows = append(rows, dateValue{rec.EndOfDay, *rec.CNY})
}
if len(rows) == 0 {
return unavailableIndicator(hkmaCnyDef, "NO_HKMA_CNY_CONTEXT", true)
}
latest, prior := latestTwo(rows)
return completeIndicator(hkmaCnyDef, latest.value, prior, latest.date, now, true)
}
// ── release calendar (pure) ──────────────────────────────────────────────────
var (
reNbsRow = regexp.MustCompile(`(?is)<tr\b[^>]*>(.*?)</tr>`)
reNbsCell = regexp.MustCompile(`(?is)<t[dh]\b[^>]*>(.*?)</t[dh]>`)
reNbsBr = regexp.MustCompile(`(?i)<br\s*/?\s*>`)
reNbsTag = regexp.MustCompile(`<[^>]+>`)
reNbsSpaces = regexp.MustCompile(`[ \t]+`)
reNbsNumeric = regexp.MustCompile(`^\d+$`)
reNbsEllipsis = regexp.MustCompile(`^(\x{2026}+|\.{3,})$`)
reNbsDay = regexp.MustCompile(`(^|\s)(\d{1,2})\s*/[A-Za-z]+`)
reNbsTime = regexp.MustCompile(`\b(\d{1,2}:\d{2})\b`)
reNbsWS = regexp.MustCompile(`\s`)
)
func stripHTML(v string) string {
v = reNbsBr.ReplaceAllString(v, "\n")
v = reNbsTag.ReplaceAllString(v, " ")
v = strings.ReplaceAll(v, "&nbsp;", " ")
v = strings.ReplaceAll(v, "&#160;", " ")
v = strings.ReplaceAll(v, "&amp;", "&")
v = strings.ReplaceAll(v, "&#39;", "'")
v = strings.ReplaceAll(v, "&apos;", "'")
v = strings.ReplaceAll(v, "&quot;", "\"")
v = reNbsSpaces.ReplaceAllString(v, " ")
return strings.TrimSpace(v)
}
func nbsCells(row string) []string {
matches := reNbsCell.FindAllStringSubmatch(row, -1)
cells := make([]string, 0, len(matches))
for _, m := range matches {
cells = append(cells, stripHTML(m[1]))
}
return cells
}
func isoDate(year, month, day int) string {
return fmt.Sprintf("%04d-%02d-%02d", year, month, day)
}
// parseNbsReleaseCalendar scrapes the National Bureau of Statistics HTML release
// grid (one row per statistic, one column per month) into dated release events.
// Blank/ellipsis month cells stay empty; a cell may carry several days (e.g. the
// Spring-Festival-shifted PMI) and each becomes its own event.
func parseNbsReleaseCalendar(html []byte, year int, sourceURL string) []chinaReleaseEvent {
var events []chinaReleaseEvent
for _, rowMatch := range reNbsRow.FindAllStringSubmatch(string(html), -1) {
cells := nbsCells(rowMatch[1])
if len(cells) < 14 || !reNbsNumeric.MatchString(cells[0]) {
continue
}
event := cells[1]
rowNo, _ := strconv.Atoi(cells[0])
for month := 1; month <= 12; month++ {
cell := ""
if month+1 < len(cells) {
cell = cells[month+1]
}
if cell == "" || reNbsEllipsis.MatchString(reNbsWS.ReplaceAllString(cell, "")) {
continue
}
releaseTime := "09:30"
if m := reNbsTime.FindStringSubmatch(cell); m != nil {
releaseTime = m[1]
}
for _, dm := range reNbsDay.FindAllStringSubmatch(cell, -1) {
day, _ := strconv.Atoi(dm[2])
releaseDate := isoDate(year, month, day)
events = append(events, chinaReleaseEvent{
ID: fmt.Sprintf("nbs-%02d-%s", rowNo, releaseDate),
Event: event,
CountryCode: "CN",
ReleaseDate: releaseDate,
ReleaseTime: releaseTime,
Timezone: "Asia/Shanghai",
Kind: "nbs",
Status: "scheduled",
Source: "National Bureau of Statistics of China",
SourceURL: sourceURL,
})
}
}
}
sortReleaseEvents(events)
return events
}
func sortReleaseEvents(events []chinaReleaseEvent) {
sort.SliceStable(events, func(i, j int) bool {
if events[i].ReleaseDate != events[j].ReleaseDate {
return events[i].ReleaseDate < events[j].ReleaseDate
}
return events[i].Event < events[j].Event
})
}
// chinaBusinessCalendar is the official mainland-China holiday + adjusted-workday
// set for a year. LPR candidate dates are rolled forward over these.
type chinaBusinessCalendar struct {
holidays map[string]bool
adjustedWorkdays map[string]bool
}
func toSet(days ...string) map[string]bool {
m := make(map[string]bool, len(days))
for _, d := range days {
m[d] = true
}
return m
}
// chinaBusinessCalendars is the hardcoded official calendar. 2027 must be added
// here before January 2027, or buildLprCandidates fails closed for that year.
var chinaBusinessCalendars = map[int]chinaBusinessCalendar{
2026: {
holidays: toSet(
"2026-01-01", "2026-01-02", "2026-01-03",
"2026-02-15", "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19", "2026-02-20", "2026-02-21", "2026-02-22", "2026-02-23",
"2026-04-04", "2026-04-05", "2026-04-06",
"2026-05-01", "2026-05-02", "2026-05-03", "2026-05-04", "2026-05-05",
"2026-06-19", "2026-06-20", "2026-06-21",
"2026-09-25", "2026-09-26", "2026-09-27",
"2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04", "2026-10-05", "2026-10-06", "2026-10-07",
),
adjustedWorkdays: toSet("2026-01-04", "2026-02-14", "2026-02-28", "2026-05-09", "2026-09-20", "2026-10-10"),
},
}
func businessCalendar(year int) (chinaBusinessCalendar, error) {
if cal, ok := chinaBusinessCalendars[year]; ok {
return cal, nil
}
return chinaBusinessCalendar{}, fmt.Errorf("CHINA_HOLIDAY_CALENDAR_UNAVAILABLE:%d", year)
}
func isChinaBusinessDay(d time.Time, cal chinaBusinessCalendar) bool {
iso := d.UTC().Format("2006-01-02")
if cal.adjustedWorkdays[iso] {
return true
}
if cal.holidays[iso] {
return false
}
wd := d.UTC().Weekday()
return wd != time.Sunday && wd != time.Saturday
}
// buildLprCandidates derives the provisional PBoC Loan Prime Rate release dates:
// the 20th of each month rolled forward to the next mainland-China business day.
// Realized dates are confirmed later via ChinaMoney (mergeVerifiedLprDates).
func buildLprCandidates(year int) ([]chinaReleaseEvent, error) {
cal, err := businessCalendar(year)
if err != nil {
return nil, err
}
events := make([]chinaReleaseEvent, 0, 12)
for month := 1; month <= 12; month++ {
d := time.Date(year, time.Month(month), 20, 0, 0, 0, 0, time.UTC)
for !isChinaBusinessDay(d, cal) {
d = d.AddDate(0, 0, 1)
}
releaseDate := d.UTC().Format("2006-01-02")
events = append(events, chinaReleaseEvent{
ID: "pboc-lpr-" + releaseDate[:7],
Event: "Loan Prime Rate (LPR)",
CountryCode: "CN",
ReleaseDate: releaseDate,
ReleaseTime: "09:00",
Timezone: "Asia/Shanghai",
Kind: "pboc_lpr",
Status: "provisional",
Source: "PBoC rule; realized date verified by ChinaMoney/CFETS",
SourceURL: chinaMoneyLPRURL,
})
}
return events, nil
}
var (
reLprNoticeTitle = regexp.MustCompile(`受权公布贷款市场报价利率.*LPR`)
reLprNoticeDate = regexp.MustCompile(`^20\d{2}-\d{2}-\d{2}$`)
)
// parseChinaMoneyLprNotices extracts the realized LPR announcement dates from the
// ChinaMoney notice feed, keeping only genuine rate-publication notices.
func parseChinaMoneyLprNotices(body []byte) []string {
var payload struct {
Records []struct {
Title string `json:"title"`
ReleaseDate string `json:"releaseDate"`
} `json:"records"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil
}
seen := map[string]bool{}
var dates []string
for _, rec := range payload.Records {
if !reLprNoticeTitle.MatchString(rec.Title) {
continue
}
d := rec.ReleaseDate
if len(d) > 10 {
d = d[:10]
}
if !reLprNoticeDate.MatchString(d) || seen[d] {
continue
}
seen[d] = true
dates = append(dates, d)
}
sort.Strings(dates)
return dates
}
// mergeVerifiedLprDates promotes each provisional candidate to a verified date
// when ChinaMoney published a realized announcement in the same month.
func mergeVerifiedLprDates(candidates []chinaReleaseEvent, realizedDates []string) []chinaReleaseEvent {
realizedByMonth := map[string]string{}
for _, d := range realizedDates {
if len(d) >= 7 {
realizedByMonth[d[:7]] = d
}
}
out := make([]chinaReleaseEvent, len(candidates))
for i, c := range candidates {
out[i] = c
if realized, ok := realizedByMonth[c.ReleaseDate[:7]]; ok {
out[i].ReleaseDate = realized
out[i].Status = "verified"
out[i].ID = "pboc-lpr-" + realized[:7]
}
}
return out
}
// currentCalendarLink resolves the year's NBS calendar page from the index,
// refusing any link that is not on the trusted www.stats.gov.cn release-calendar
// path so a tampered index can never redirect the scrape off-origin.
func currentCalendarLink(indexHTML string, year int) (string, error) {
re := regexp.MustCompile(`href=["']([^"']+)["'][^>]*>[^<]*` + strconv.Itoa(year) + `[^<]*<`)
m := re.FindStringSubmatch(indexHTML)
if m == nil {
return nbsCalendarIndexURL, nil
}
base, err := url.Parse(nbsCalendarIndexURL)
if err != nil {
return "", fmt.Errorf("NBS_CALENDAR_LINK_REJECTED:UNTRUSTED_NBS_CALENDAR_URL")
}
ref, err := base.Parse(m[1])
if err != nil {
return "", fmt.Errorf("NBS_CALENDAR_LINK_REJECTED:UNTRUSTED_NBS_CALENDAR_URL")
}
trustedOrigin := ref.Scheme == "https" && ref.Host == "www.stats.gov.cn"
trustedPath := strings.HasPrefix(ref.Path, "/english/PressRelease/ReleaseCalendar/")
if !trustedOrigin || !trustedPath {
return "", fmt.Errorf("NBS_CALENDAR_LINK_REJECTED:UNTRUSTED_NBS_CALENDAR_URL")
}
return ref.String(), nil
}
// ── merge ────────────────────────────────────────────────────────────────────
func requiredIndicator(indicators []chinaIndicator, category string) *chinaIndicator {
for i := range indicators {
if indicators[i].Category == category && !indicators[i].ContextOnly {
return &indicators[i]
}
}
return nil
}
// buildChinaMacroSnapshot applies the launch gate: launchReady only when all four
// required categories (price/activity/policy/fx) carry a current, non-stale
// value. contentObservationDate is the OLDEST required observation — the anchor
// that keeps a fresh FX tick from masking stale CPI/activity content.
func buildChinaMacroSnapshot(indicators []chinaIndicator, decisions []chinaSourceDecision, generatedAt string) chinaMacroSnapshot {
launchReady := true
var requiredDates []string
for _, cat := range chinaRequiredCategories {
ind := requiredIndicator(indicators, cat)
if ind == nil || ind.Value == nil || ind.Stale || ind.UnavailableReason != "" {
launchReady = false
}
if ind != nil && ind.ObservationDate != "" {
requiredDates = append(requiredDates, ind.ObservationDate)
}
}
sort.Strings(requiredDates)
anyValue := false
for i := range indicators {
if indicators[i].Value != nil {
anyValue = true
break
}
}
status := "unavailable"
switch {
case launchReady:
status = "ready"
case anyValue:
status = "degraded"
}
content := ""
if launchReady && len(requiredDates) == len(chinaRequiredCategories) {
content = requiredDates[0]
}
latest := ""
if len(requiredDates) > 0 {
latest = requiredDates[len(requiredDates)-1]
}
return chinaMacroSnapshot{
CountryCode: "CN", GeneratedAt: generatedAt, Status: status, LaunchReady: launchReady,
ContentObservationDate: content, LatestObservationDate: latest,
Indicators: indicators, SourceDecisions: decisions,
ReleaseEvents: []chinaReleaseEvent{},
}
}
func chinaDecision(source, host, status, reason, checkedAt string, optional bool, requestCount int) chinaSourceDecision {
return chinaSourceDecision{Source: source, Host: host, Status: status, Reason: reason, CheckedAt: checkedAt, Optional: optional, RequestCount: requestCount}
}
func chinaUnavailable() chinaMacroSnapshot {
return chinaMacroSnapshot{
CountryCode: "CN", Status: "unavailable",
Indicators: []chinaIndicator{}, SourceDecisions: []chinaSourceDecision{}, ReleaseEvents: []chinaReleaseEvent{},
Unavailable: true,
}
}
// ── handler ──────────────────────────────────────────────────────────────────
// handleChinaMacro serves GET /v1/world/china-macro: the merged China macro
// snapshot + official release calendar. Cached 30m fresh / 6h stale (these are
// daily/monthly series); on a required-source outage it serves last-good stale,
// else an honest unavailable payload.
func (s *Server) handleChinaMacro(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "china:macro:v1",
"public, max-age=1800, s-maxage=1800, stale-while-revalidate=600",
30*time.Minute, 6*time.Hour,
func(ctx context.Context) (any, error) { return s.chinaMacro(ctx) },
func(w http.ResponseWriter, _ error) { writeJSON(w, http.StatusOK, "", chinaUnavailable()) })
}
// oecdHeaders carries the Accept-Language the OECD CLI endpoint requires (it
// answers 500 to language-less clients even where curl succeeds).
var oecdHeaders = map[string]string{
"Accept": "text/csv, text/plain;q=0.9, */*;q=0.1",
"Accept-Language": "en",
"User-Agent": browserUA,
}
func chinaReasonFor(err error) string {
if err == nil {
return "OK"
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") {
return "TIMEOUT"
}
if m := reStatusCode.FindStringSubmatch(msg); m != nil {
return "HTTP_" + m[1]
}
return "FETCH_FAILED"
}
func (s *Server) chinaMacro(ctx context.Context) (any, error) {
now := time.Now().UTC()
checkedAt := now.Format(time.RFC3339)
var decisions []chinaSourceDecision
// 1. OECD — required. Two sequential dataset requests, no retry: OECD asks
// consumers to stay under 60 downloads/hour, so a failure preserves last-good
// rather than replaying the flow.
cpiCSV, err := s.getText(ctx, oecdCPIURL, oecdHeaders)
reqCount := 1
if err == nil {
var cliCSV string
cliCSV, err = s.getText(ctx, oecdCLIURL, oecdHeaders)
reqCount = 2
if err == nil {
decisions = append(decisions, chinaDecision("OECD Data Explorer", "sdmx.oecd.org", "accepted", "OK", checkedAt, false, reqCount))
return s.chinaMerge(ctx, now, checkedAt, decisions, []byte(cpiCSV), []byte(cliCSV))
}
}
reason := chinaReasonFor(err)
decisions = append(decisions, chinaDecision("OECD Data Explorer", "sdmx.oecd.org", "blocked", reason, checkedAt, false, reqCount))
return nil, fmt.Errorf("OECD_REQUIRED_SOURCE_UNAVAILABLE:%s", reason)
}
func (s *Server) chinaMerge(ctx context.Context, now time.Time, checkedAt string, decisions []chinaSourceDecision, cpiCSV, cliCSV []byte) (any, error) {
indicators := []chinaIndicator{
parseOecdCsvIndicator(cpiCSV, indicatorDef{id: "cpi_yoy", label: "CPI (YoY)", category: "price", unit: "%", source: "OECD Data Explorer", sourceURL: oecdCPIURL, maxAgeDays: 120}, now),
parseOecdCsvIndicator(cliCSV, indicatorDef{id: "activity_cli", label: "Composite Leading Indicator", category: "activity", unit: "index", source: "OECD Data Explorer", sourceURL: oecdCLIURL, maxAgeDays: 120}, now),
}
// 2. BIS policy rate — read from the seed cache key the worldmonitor BIS job
// populates. Our fork ships no such job, so the key is empty and the indicator
// is honestly not_configured; parseBisPolicy lights up the moment it is seeded.
if b, ok := s.kvGet(ctx, bisPolicyCacheKey); ok {
ind := parseBisPolicy(b, now)
indicators = append(indicators, ind)
decisions = append(decisions, chinaDecision("BIS seed cache", "hanzo-kv", statusFor(ind), reasonOrOK(ind), checkedAt, false, 1))
} else {
indicators = append(indicators, unavailableIndicator(bisPolicyDef, "not_configured", false))
decisions = append(decisions, chinaDecision("BIS seed cache", "hanzo-kv", "blocked", "not_configured", checkedAt, false, 0))
}
// 3. FRED DEXCHUS — reuse the FRED_API_KEY env path the fred-data handler uses.
if key := env("FRED_API_KEY"); key != "" {
b, status, err := s.get(ctx, fredDEXCHUSURL+"&api_key="+key, map[string]string{"Accept": "application/json"})
if err == nil && status >= 200 && status < 300 {
ind := parseFredUsdCny(b, now)
indicators = append(indicators, ind)
decisions = append(decisions, chinaDecision("FRED DEXCHUS", "api.stlouisfed.org", statusFor(ind), reasonOrOK(ind), checkedAt, false, 1))
} else {
reason := chinaReasonFor(orStatusErr(err, status))
indicators = append(indicators, unavailableIndicator(fredUsdCnyDef, reason, false))
decisions = append(decisions, chinaDecision("FRED DEXCHUS", "api.stlouisfed.org", "blocked", reason, checkedAt, false, 1))
}
} else {
indicators = append(indicators, unavailableIndicator(fredUsdCnyDef, "not_configured", false))
decisions = append(decisions, chinaDecision("FRED DEXCHUS", "api.stlouisfed.org", "blocked", "not_configured", checkedAt, false, 0))
}
// 4. HKMA CNY/HKD context — optional.
if b, status, err := s.get(ctx, hkmaCNYURL, map[string]string{"Accept": "application/json", "User-Agent": browserUA}); err == nil && status >= 200 && status < 300 {
ind := parseHkmaCnyContext(b, now)
indicators = append(indicators, ind)
decisions = append(decisions, chinaDecision("HKMA CNY context", "api.hkma.gov.hk", statusFor(ind), reasonOrOK(ind), checkedAt, true, 1))
} else {
reason := chinaReasonFor(orStatusErr(err, status))
indicators = append(indicators, unavailableIndicator(hkmaCnyDef, reason, true))
decisions = append(decisions, chinaDecision("HKMA CNY context", "api.hkma.gov.hk", "blocked", reason, checkedAt, true, 1))
}
snapshot := buildChinaMacroSnapshot(indicators, decisions, checkedAt)
// Calendar is required for the merged product (mirrors get-china-macro-
// snapshot.ts, which returns unavailable when either indicators or events are
// empty). On outage, return an error so cachedJSON serves last-good stale.
events, calDecisions, calErr := s.chinaCalendar(ctx, now, checkedAt)
snapshot.SourceDecisions = append(snapshot.SourceDecisions, calDecisions...)
if calErr != nil || len(events) == 0 {
return nil, fmt.Errorf("CHINA_CALENDAR_UNAVAILABLE")
}
snapshot.ReleaseEvents = events
return snapshot, nil
}
func (s *Server) chinaCalendar(ctx context.Context, now time.Time, checkedAt string) ([]chinaReleaseEvent, []chinaSourceDecision, error) {
year := now.Year()
var decisions []chinaSourceDecision
htmlHeaders := map[string]string{"Accept": "text/html,application/xhtml+xml", "User-Agent": browserUA}
indexHTML, err := s.getText(ctx, nbsCalendarIndexURL, htmlHeaders)
nbsReq := 1
if err != nil {
decisions = append(decisions, chinaDecision("NBS release calendar", "www.stats.gov.cn", "blocked", chinaReasonFor(err), checkedAt, false, nbsReq))
return nil, decisions, err
}
calURL, err := currentCalendarLink(indexHTML, year)
if err != nil {
decisions = append(decisions, chinaDecision("NBS release calendar", "www.stats.gov.cn", "blocked", "UNTRUSTED_NBS_CALENDAR_URL", checkedAt, false, nbsReq))
return nil, decisions, err
}
calHTML := indexHTML
if calURL != nbsCalendarIndexURL {
nbsReq = 2
calHTML, err = s.getText(ctx, calURL, htmlHeaders)
if err != nil {
decisions = append(decisions, chinaDecision("NBS release calendar", "www.stats.gov.cn", "blocked", chinaReasonFor(err), checkedAt, false, nbsReq))
return nil, decisions, err
}
}
nbsEvents := parseNbsReleaseCalendar([]byte(calHTML), year, calURL)
if len(nbsEvents) == 0 {
decisions = append(decisions, chinaDecision("NBS release calendar", "www.stats.gov.cn", "blocked", "NO_NBS_EVENTS", checkedAt, false, nbsReq))
return nil, decisions, fmt.Errorf("NBS_REQUIRED_SOURCE_UNAVAILABLE:NO_NBS_EVENTS")
}
decisions = append(decisions, chinaDecision("NBS release calendar", "www.stats.gov.cn", "accepted", "OK", checkedAt, false, nbsReq))
lpr, err := buildLprCandidates(year)
if err != nil {
decisions = append(decisions, chinaDecision("PBoC/ChinaMoney LPR verification", "www.chinamoney.com.cn", "blocked", "CHINA_HOLIDAY_CALENDAR_UNAVAILABLE", checkedAt, false, 0))
return nil, decisions, err
}
if notices, err := s.chinaMoneyNotices(ctx); err == nil {
lpr = mergeVerifiedLprDates(lpr, parseChinaMoneyLprNotices(notices))
decisions = append(decisions, chinaDecision("PBoC/ChinaMoney LPR verification", "www.chinamoney.com.cn", "accepted", "OK", checkedAt, false, 1))
} else {
decisions = append(decisions, chinaDecision("PBoC/ChinaMoney LPR verification", "www.chinamoney.com.cn", "blocked", chinaReasonFor(err), checkedAt, false, 1))
}
events := append(nbsEvents, lpr...)
sortReleaseEvents(events)
return events, decisions, nil
}
func (s *Server) chinaMoneyNotices(ctx context.Context) ([]byte, error) {
body := []byte(url.Values{"channelId": {chinaMoneyLPRChannelID}, "pageSize": {"24"}, "pageNo": {"1"}}.Encode())
b, status, err := s.do(ctx, http.MethodPost, chinaMoneyLPRNoticeAPI, map[string]string{
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"User-Agent": browserUA,
}, body)
if err != nil {
return nil, err
}
if status < 200 || status >= 300 {
return nil, httpErr(status)
}
return b, nil
}
// kvGet reads a raw value from the shared hot cache, reporting absence (or a
// disabled cache) as ok=false so callers degrade to not_configured.
func (s *Server) kvGet(ctx context.Context, key string) ([]byte, bool) {
if s.kv == nil {
return nil, false
}
b, ok := s.kv.GetBytes(ctx, key)
return b, ok && len(b) > 0
}
func statusFor(ind chinaIndicator) string {
if ind.Value == nil {
return "blocked"
}
return "accepted"
}
func reasonOrOK(ind chinaIndicator) string {
if ind.UnavailableReason != "" {
return ind.UnavailableReason
}
return "OK"
}
func orStatusErr(err error, status int) error {
if err != nil {
return err
}
return httpErr(status)
}
+244
View File
@@ -0,0 +1,244 @@
package world
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func chinaFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "china-macro", name))
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return b
}
var chinaNow = time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC)
func deref(t *testing.T, p *float64) float64 {
t.Helper()
if p == nil {
t.Fatalf("expected a value, got nil")
}
return *p
}
// Parity with the TypeScript adapters: each source parser normalizes to
// independent value / prior / date / source / staleness fields.
func TestChinaSourceParsers(t *testing.T) {
cpi := parseOecdCsvIndicator(chinaFixture(t, "oecd-cpi.csv"),
indicatorDef{id: "cpi_yoy", label: "CPI (YoY)", category: "price", unit: "%", source: "OECD Data Explorer", maxAgeDays: 120}, chinaNow)
if deref(t, cpi.Value) != 0.6 || deref(t, cpi.PriorValue) != 0.3 || cpi.ObservationDate != "2026-05" ||
cpi.Source != "OECD Data Explorer" || cpi.Stale || cpi.UnavailableReason != "" {
t.Fatalf("cpi mismatch: %+v", cpi)
}
cli := parseOecdCsvIndicator(chinaFixture(t, "oecd-cli.csv"),
indicatorDef{id: "activity_cli", label: "Composite Leading Indicator", category: "activity", unit: "index", source: "OECD Data Explorer", maxAgeDays: 120}, chinaNow)
if deref(t, cli.Value) != 99.58 {
t.Fatalf("cli value = %v, want 99.58", cli.Value)
}
policy := parseBisPolicy(chinaFixture(t, "bis-policy.json"), chinaNow)
if deref(t, policy.Value) != 3 || deref(t, policy.PriorValue) != 3.1 {
t.Fatalf("policy mismatch: value=%v prior=%v", policy.Value, policy.PriorValue)
}
fx := parseFredUsdCny(chinaFixture(t, "fred-dexchus.json"), chinaNow)
if deref(t, fx.Value) != 7.1842 {
t.Fatalf("fx value = %v, want 7.1842", fx.Value)
}
hkma := parseHkmaCnyContext(chinaFixture(t, "hkma-cny.json"), chinaNow)
if !hkma.ContextOnly || hkma.Source != "HKMA (Hong Kong/CNH context)" || deref(t, hkma.Value) != 1.0881 {
t.Fatalf("hkma mismatch: %+v", hkma)
}
}
// An old observation is stale even when the fetch itself is fresh.
func TestChinaStaleObservation(t *testing.T) {
stale := parseOecdCsvIndicator(chinaFixture(t, "oecd-cpi.csv"),
indicatorDef{id: "cpi_yoy", label: "CPI (YoY)", category: "price", unit: "%", source: "OECD Data Explorer", maxAgeDays: 30},
time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC))
if !stale.Stale || stale.UnavailableReason != "STALE_OBSERVATION" {
t.Fatalf("expected stale STALE_OBSERVATION, got stale=%v reason=%q", stale.Stale, stale.UnavailableReason)
}
}
// The launch gate needs current price, activity, policy, and FX; the oldest
// required observation anchors contentObservationDate. Optional context states
// are retained and never gate launch.
func TestChinaLaunchGate(t *testing.T) {
indicators := []chinaIndicator{
parseOecdCsvIndicator(chinaFixture(t, "oecd-cpi.csv"), indicatorDef{id: "cpi_yoy", category: "price", unit: "%", source: "OECD Data Explorer", maxAgeDays: 120}, chinaNow),
parseOecdCsvIndicator(chinaFixture(t, "oecd-cli.csv"), indicatorDef{id: "activity_cli", category: "activity", unit: "index", source: "OECD Data Explorer", maxAgeDays: 120}, chinaNow),
parseBisPolicy(chinaFixture(t, "bis-policy.json"), chinaNow),
parseFredUsdCny(chinaFixture(t, "fred-dexchus.json"), chinaNow),
unavailableIndicator(hkmaCnyDef, "HOST_BLOCKED", true),
}
snap := buildChinaMacroSnapshot(indicators, nil, chinaNow.Format(time.RFC3339))
if !snap.LaunchReady || snap.Status != "ready" {
t.Fatalf("expected ready/launch, got status=%q launchReady=%v", snap.Status, snap.LaunchReady)
}
if snap.ContentObservationDate != "2026-05" {
t.Fatalf("contentObservationDate = %q, want oldest required 2026-05", snap.ContentObservationDate)
}
if snap.LatestObservationDate != "2026-07-10" {
t.Fatalf("latestObservationDate = %q, want 2026-07-10", snap.LatestObservationDate)
}
if last := snap.Indicators[len(snap.Indicators)-1]; last.UnavailableReason != "HOST_BLOCKED" {
t.Fatalf("optional context state not retained: %+v", last)
}
// A stale required category drops launch readiness to degraded.
indicators[1].Stale = true
degraded := buildChinaMacroSnapshot(indicators, nil, chinaNow.Format(time.RFC3339))
if degraded.LaunchReady || degraded.Status != "degraded" || degraded.ContentObservationDate != "" {
t.Fatalf("expected degraded with blank content date, got %+v", degraded)
}
}
// A missing policy source (our fork has no BIS feed) is honestly not_configured
// and holds launch back rather than faking readiness.
func TestChinaNotConfiguredPolicyBlocksLaunch(t *testing.T) {
indicators := []chinaIndicator{
parseOecdCsvIndicator(chinaFixture(t, "oecd-cpi.csv"), indicatorDef{id: "cpi_yoy", category: "price", source: "OECD Data Explorer", maxAgeDays: 120}, chinaNow),
parseOecdCsvIndicator(chinaFixture(t, "oecd-cli.csv"), indicatorDef{id: "activity_cli", category: "activity", source: "OECD Data Explorer", maxAgeDays: 120}, chinaNow),
unavailableIndicator(bisPolicyDef, "not_configured", false),
parseFredUsdCny(chinaFixture(t, "fred-dexchus.json"), chinaNow),
}
snap := buildChinaMacroSnapshot(indicators, nil, chinaNow.Format(time.RFC3339))
if snap.LaunchReady || snap.Status != "degraded" {
t.Fatalf("not_configured policy should degrade, got status=%q launchReady=%v", snap.Status, snap.LaunchReady)
}
if got := requiredIndicator(snap.Indicators, "policy"); got == nil || got.UnavailableReason != "not_configured" {
t.Fatalf("policy indicator not marked not_configured: %+v", got)
}
}
// The NBS grid keeps blank months empty and captures quarterly + Spring-Festival
// shifted releases.
func TestChinaParseNbsReleaseCalendar(t *testing.T) {
events := parseNbsReleaseCalendar(chinaFixture(t, "nbs-calendar.html"), 2026,
"https://www.stats.gov.cn/english/PressRelease/ReleaseCalendar/202512/t20251226_1962154.html")
for _, e := range events {
if e.Event == "National Economic Performance" && len(e.ReleaseDate) >= 7 && e.ReleaseDate[:7] == "2026-02" {
t.Fatalf("blank February month should stay empty, got %+v", e)
}
}
var prelim []string
for _, e := range events {
if strings.HasPrefix(e.Event, "Preliminary Accounting") {
prelim = append(prelim, e.ReleaseDate)
}
}
want := []string{"2026-01-20", "2026-04-17", "2026-07-16", "2026-10-20"}
if len(prelim) != len(want) {
t.Fatalf("preliminary accounting dates = %v, want %v", prelim, want)
}
for i := range want {
if prelim[i] != want[i] {
t.Fatalf("preliminary accounting dates = %v, want %v", prelim, want)
}
}
if !hasReleaseOn(events, "Purchasing Managers", "2026-03-04") || !hasReleaseOn(events, "Purchasing Managers", "2026-03-31") {
t.Fatalf("PMI Spring-Festival-shifted dual release not captured")
}
}
func hasReleaseOn(events []chinaReleaseEvent, eventSubstr, date string) bool {
for _, e := range events {
if e.ReleaseDate == date && strings.Contains(e.Event, eventSubstr) {
return true
}
}
return false
}
// LPR candidates roll over weekends and official holidays; only realized months
// are promoted to verified.
func TestChinaLprCandidates(t *testing.T) {
candidates, err := buildLprCandidates(2026)
if err != nil {
t.Fatalf("buildLprCandidates(2026): %v", err)
}
if got := monthDate(candidates, "2026-02"); got != "2026-02-24" {
t.Fatalf("Feb LPR candidate = %q, want 2026-02-24 (Spring Festival roll-forward)", got)
}
if got := monthDate(candidates, "2026-06"); got != "2026-06-22" {
t.Fatalf("Jun LPR candidate = %q, want 2026-06-22 (Dragon Boat roll-forward)", got)
}
for _, c := range candidates {
if c.Status != "provisional" {
t.Fatalf("candidate %s not provisional: %q", c.ReleaseDate, c.Status)
}
}
realized := parseChinaMoneyLprNotices(chinaFixture(t, "chinamoney-lpr.json"))
merged := mergeVerifiedLprDates(candidates, realized)
if statusOf(merged, "2026-02-24") != "verified" || statusOf(merged, "2026-06-22") != "verified" {
t.Fatalf("realized LPR dates not verified: %v", merged)
}
if statusOf(merged, "2026-07-20") != "provisional" {
t.Fatalf("unrealized July LPR should stay provisional")
}
}
// Fails closed when the official holiday calendar is not configured for a year.
func TestChinaLprCalendarUnconfiguredYear(t *testing.T) {
if _, err := buildLprCandidates(2027); err == nil {
t.Fatalf("expected CHINA_HOLIDAY_CALENDAR_UNAVAILABLE for unconfigured 2027")
}
}
// The trusted-origin guard resolves same-origin links and refuses off-origin ones.
func TestChinaCurrentCalendarLink(t *testing.T) {
got, err := currentCalendarLink(`<a href="calendar.html">2026 release calendar</a>`, 2026)
if err != nil || got != "https://www.stats.gov.cn/english/PressRelease/ReleaseCalendar/calendar.html" {
t.Fatalf("trusted relative link resolution failed: got=%q err=%v", got, err)
}
if _, err := currentCalendarLink(`<a href="https://attacker.example/calendar.html">2026 release calendar</a>`, 2026); err == nil {
t.Fatalf("off-origin NBS link must be rejected")
}
got, err = currentCalendarLink(`<p>no link here</p>`, 2026)
if err != nil || got != nbsCalendarIndexURL {
t.Fatalf("missing link should fall back to index URL, got=%q err=%v", got, err)
}
}
// The handler exposes the merged shape and degrades to an honest unavailable
// payload (never a 5xx) — the fallback branch of the endpoint.
func TestChinaUnavailableShape(t *testing.T) {
u := chinaUnavailable()
if u.CountryCode != "CN" || !u.Unavailable || u.Status != "unavailable" ||
u.Indicators == nil || u.ReleaseEvents == nil || u.SourceDecisions == nil {
t.Fatalf("unavailable fallback shape wrong: %+v", u)
}
}
func monthDate(events []chinaReleaseEvent, month string) string {
for _, e := range events {
if len(e.ReleaseDate) >= 7 && e.ReleaseDate[:7] == month {
return e.ReleaseDate
}
}
return ""
}
func statusOf(events []chinaReleaseEvent, date string) string {
for _, e := range events {
if e.ReleaseDate == date {
return e.Status
}
}
return ""
}
+70 -22
View File
@@ -19,11 +19,14 @@ import (
// world data). So by default this route returns a clearly-labeled DEMO
// dataset with demo:true — the flag travels in the payload and the UI shows a
// "demo data" note. We never fake platform numbers silently.
// - When an operator wires a service token (HANZO_CLOUD_PULSE_TOKEN, from KMS),
// we make SERVICE-side calls to the real cloud for non-sensitive COUNTS only
// (models served, node/region/GPU counts) and set demo:false. Request/token
// VOLUME still has no aggregate source, so it stays modeled and is flagged
// volumeModeled:true — again, never silently faked.
// - When an operator wires the service token (serviceToken, from KMS), we make
// SERVICE-side calls to the real cloud for non-sensitive COUNTS (models
// served, node/region/GPU counts) and set demo:false. With a super-admin
// token we also read the platform-wide 24h request/token VOLUME from the
// ClickHouse-backed usage ledger (get-cloud-usages ?org=all) and clear
// volumeModeled. If only the counts resolve (e.g. a non-admin token), volume
// stays modeled and is flagged volumeModeled:true — again, never silently
// faked.
//
// Signed-in, org-scoped drill-down (the user's own fleet / models / bill) does
// NOT come through here — those panels call api.hanzo.ai directly with the
@@ -99,21 +102,21 @@ func (s *Server) handleCloudPulse(w http.ResponseWriter, r *http.Request) {
)
}
// tryServicePulse overlays REAL non-sensitive counts onto the demo scaffold when
// a service token is configured (HANZO_CLOUD_PULSE_TOKEN, KMS-injected). It
// returns ok=false — leaving the honest demo dataset in place — when the token is
// absent or any required call fails. Only counts are read; no spend, no names.
// tryServicePulse overlays REAL platform data onto the demo scaffold when a
// service token is configured (serviceToken, KMS-injected). It returns ok=false —
// leaving the honest demo dataset in place — when the token is absent or the core
// counts calls fail. It reads two honest sources: non-sensitive COUNTS (models
// served, node/region/GPU counts from the ai catalog + visor) and, when the
// super-admin usage ledger is reachable, the platform-wide 24h request/token
// VOLUME (get-cloud-usages ?org=all). When volume is real, demo:false AND
// volumeModeled:false; when only counts are real, volume stays modeled and
// volumeModeled:true — never silently faked.
func (s *Server) tryServicePulse(ctx context.Context, base cloudPulse) (cloudPulse, bool) {
tok := env("HANZO_CLOUD_PULSE_TOKEN")
if tok == "" {
hdr := serviceAuth()
if hdr == nil {
return cloudPulse{}, false
}
apiBase := env("HANZO_API_BASE", "HANZO_AI_BASE")
if apiBase == "" {
apiBase = "https://api.hanzo.ai"
}
apiBase = trimSlash(apiBase)
hdr := map[string]string{"Authorization": "Bearer " + tok}
host := apiHost()
// Models served (ai gateway, OpenAI-compatible list).
var models struct {
@@ -121,7 +124,7 @@ func (s *Server) tryServicePulse(ctx context.Context, base cloudPulse) (cloudPul
ID string `json:"id"`
} `json:"data"`
}
if err := s.getJSON(ctx, apiBase+"/v1/models", hdr, &models); err != nil || len(models.Data) == 0 {
if err := s.getJSON(ctx, host+"/v1/models", hdr, &models); err != nil || len(models.Data) == 0 {
return cloudPulse{}, false
}
@@ -132,7 +135,7 @@ func (s *Server) tryServicePulse(ctx context.Context, base cloudPulse) (cloudPul
Status string `json:"status"`
} `json:"machines"`
}
if err := s.getJSON(ctx, apiBase+"/v1/machines", hdr, &machines); err != nil {
if err := s.getJSON(ctx, host+"/v1/machines", hdr, &machines); err != nil {
return cloudPulse{}, false
}
var gpus struct {
@@ -141,7 +144,7 @@ func (s *Server) tryServicePulse(ctx context.Context, base cloudPulse) (cloudPul
} `json:"gpus"`
}
// GPUs are a bonus count; a failure here should not sink real machine data.
_ = s.getJSON(ctx, apiBase+"/v1/gpus", hdr, &gpus)
_ = s.getJSON(ctx, host+"/v1/gpus", hdr, &gpus)
regionSet := map[string]int{}
online := 0
@@ -156,9 +159,7 @@ func (s *Server) tryServicePulse(ctx context.Context, base cloudPulse) (cloudPul
p := base
p.Demo = false
p.VolumeModeled = true
p.Source = "service"
p.Note = "Live counts from Hanzo Cloud (models/nodes/regions). Request & token volume is modeled — no aggregate volume endpoint is exposed."
p.Overview.ModelsServed = len(models.Data)
p.Overview.NodesTotal = len(machines.Machines)
p.Overview.NodesOnline = online
@@ -166,9 +167,56 @@ func (s *Server) tryServicePulse(ctx context.Context, base cloudPulse) (cloudPul
if len(regionSet) > 0 {
p.Overview.Regions = len(regionSet)
}
// Real platform VOLUME from the ClickHouse-backed usage ledger (all orgs).
// When it lands, it replaces the modeled headline/series/top-models and clears
// volumeModeled; when the read fails (e.g. a non-super-admin token, or the
// ledger is unreachable) the modeled volume stays and the flag stays true.
if ov, err := s.fetchCloudUsage(ctx, "24h"); err == nil && ov.Totals.Requests > 0 {
applyUsageToPulse(&p, ov)
} else {
p.VolumeModeled = true
p.Note = "Live counts from Hanzo Cloud (models/nodes/regions). Request & token volume is modeled — the platform usage ledger was not reachable with this token."
}
return p, true
}
// applyUsageToPulse folds the real platform-wide usage overview into p: the
// headline rate (recent series bucket, else 24h average), 24h totals, the real
// hourly request/token series, and the top models by real spend. It clears
// volumeModeled — these are measured, not modeled.
func applyUsageToPulse(p *cloudPulse, ov *cloudUsageOverview) {
p.VolumeModeled = false
p.Window = ov.Range
if p.Window == "" {
p.Window = "24h"
}
p.Overview.Requests24h = ov.Totals.Requests
p.Overview.Tokens24h = ov.Totals.Tokens
// Headline rate: the most recent complete bucket is the freshest honest rate;
// fall back to the 24h average when there is no usable interval.
p.Overview.RequestsPerSec = round1(usageRate(ov.Totals.Requests, ov.Series, ov.Interval, seriesRequests))
// Real hourly buckets (chronological) drive both sparklines.
if n := len(ov.Series); n > 0 {
reqs := make([]int64, n)
toks := make([]int64, n)
for i, pt := range ov.Series {
reqs[i] = pt.Requests
toks[i] = pt.Tokens
}
p.RequestSeries = reqs
p.TokenSeries = toks
}
// Top models by real spend/volume (ledger byModel items, already ranked).
if m := topModelsFromUsage(ov); m != nil {
p.Models = m
}
p.Note = "Live platform aggregate from Hanzo Cloud — models, fleet, and measured 24h request/token volume across all orgs."
}
func machineOnline(status string) bool {
switch status {
case "active", "running", "online", "ready", "healthy", "":
+2 -2
View File
@@ -449,7 +449,7 @@ func (s *Server) handleCloudBYOGPU(w http.ResponseWriter, r *http.Request) {
// Returns ok=false (→ demo) when the token is absent, both sources fail, or no GPU
// maps to a known region. Only non-sensitive fields (region/model/status) are read.
func (s *Server) tryRealGPUs(ctx context.Context) ([]gpuCluster, bool) {
tok := env("HANZO_CLOUD_PULSE_TOKEN")
tok := serviceToken()
if tok == "" {
return nil, false
}
@@ -597,7 +597,7 @@ func (s *Server) handleCloudTraffic(w http.ResponseWriter, r *http.Request) {
// demo) when tokenless, unreachable, or no country data. Reuses mergeMetric so the
// analytics fan-out lives in exactly one place.
func (s *Server) tryRealTraffic(ctx context.Context) ([]trafficArc, bool) {
tok := env("HANZO_CLOUD_PULSE_TOKEN")
tok := serviceToken()
if tok == "" {
return nil, false
}
+79
View File
@@ -52,3 +52,82 @@ func TestCloudPulseDemoFlag(t *testing.T) {
t.Fatalf("want 24-bucket series, got req=%d tok=%d", len(p.RequestSeries), len(p.TokenSeries))
}
}
// TestCloudPulseServiceVolume proves the real path: with a service token and a
// reachable super-admin usage ledger, /v1/world/cloud-pulse folds MEASURED
// platform volume (get-cloud-usages ?org=all) + visor counts, drops demo:true AND
// volumeModeled:true, and surfaces the ledger's top models — never modeled.
func TestCloudPulseServiceVolume(t *testing.T) {
upstream := http.NewServeMux()
upstream.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"data":[{"id":"zen-1"},{"id":"zen-omni-30b"},{"id":"qwen3-235b"}]}`))
})
upstream.HandleFunc("/v1/machines", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"machines":[{"region":"nyc","status":"active"},{"region":"sfo","status":"active"},{"region":"nyc","status":"stopped"}]}`))
})
upstream.HandleFunc("/v1/gpus", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"gpus":[{"region":"nyc"},{"region":"nyc"},{"region":"sfo"}]}`))
})
upstream.HandleFunc("/v1/get-cloud-usages", func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("org"); got != "all" {
t.Errorf("want org=all (platform-wide), got %q", got)
}
_, _ = w.Write([]byte(`{
"range":"24h","interval":"1h",
"totals":{"tokens":1180000000,"requests":1000000,"spendCents":42000,"models":3},
"series":[{"t":"2026-07-16T00:00:00Z","tokens":40000000,"requests":34000},
{"t":"2026-07-16T01:00:00Z","tokens":41000000,"requests":35000}],
"byModel":{"items":[
{"model":"zen-omni-30b","spendCents":24000,"tokens":600000000,"requests":520000,"pct":57.1},
{"model":"zen-1","spendCents":12000,"tokens":300000000,"requests":300000,"pct":28.6}]}
}`))
})
up := httptest.NewServer(upstream)
t.Cleanup(up.Close)
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "svc-super-admin")
t.Setenv("HANZO_API_BASE", up.URL)
s := NewServer()
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
resp, err := http.Get(ts.URL + "/v1/world/cloud-pulse")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var p cloudPulse
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
t.Fatalf("decode: %v", err)
}
if p.Demo {
t.Fatalf("service path must set demo:false")
}
if p.VolumeModeled {
t.Fatalf("measured usage must clear volumeModeled")
}
if p.Source != "service" {
t.Fatalf("want source=service, got %q", p.Source)
}
if p.Overview.Requests24h != 1_000_000 || p.Overview.Tokens24h != 1_180_000_000 {
t.Fatalf("want measured 24h volume, got req=%d tok=%d", p.Overview.Requests24h, p.Overview.Tokens24h)
}
if p.Overview.ModelsServed != 3 {
t.Fatalf("want modelsServed=3 (ai catalog), got %d", p.Overview.ModelsServed)
}
if p.Overview.NodesTotal != 3 || p.Overview.NodesOnline != 2 {
t.Fatalf("want nodes 2/3 online, got %d/%d", p.Overview.NodesOnline, p.Overview.NodesTotal)
}
if p.Overview.GpusOnline != 3 {
t.Fatalf("want gpusOnline=3, got %d", p.Overview.GpusOnline)
}
if len(p.Models) != 2 || p.Models[0].ID != "zen-omni-30b" {
t.Fatalf("want ledger top models, got %+v", p.Models)
}
if len(p.RequestSeries) != 2 || p.RequestSeries[1] != 35000 {
t.Fatalf("want measured series buckets, got %v", p.RequestSeries)
}
}
+404
View File
@@ -0,0 +1,404 @@
package world
import (
"context"
_ "embed"
"encoding/json"
"math"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
// Enso flywheel — the router's self-improvement loop, made visible for the AI
// variant. It folds THREE honest sources into one event-typed payload:
//
// 1. the routing-decision ledger tail (export-routing-ledger ?since=, super-admin
// JSONL): how many decisions, the engine-vs-heuristic mix, a confidence
// histogram, and the task / routed-model distribution;
// 2. the reward tail (export-routing-rewards ?since=, JSONL): how many decisions
// have been scored and their mean reward — the per-request training labels;
// 3. the latest enso-bench eval scores (an embedded snapshot of the enso-bench
// results summary, or a live ENSO_BENCH_URL when configured).
//
// The result is deliberately event-typed (Events[]) so future retrain / deploy
// milestones slot into the same timeline. Ledger + rewards need the super-admin
// service token; the eval scores are always available (embedded), so the panel
// is useful even signed-out — state says which sources are live.
// ensoBenchSummary is a committed snapshot of enso-bench results/summary.json
// (refresh it when enso-bench reruns; set ENSO_BENCH_URL to override it live). It
// is the "latest eval scores" source and needs no network or token.
//
//go:embed ensodata/summary.json
var ensoBenchSummary []byte
const (
ensoWindowLabel = "24h"
ensoLedgerWindow = 24 * time.Hour
)
// ── wire shapes ───────────────────────────────────────────────────────────────
type ensoBucket struct {
Label string `json:"label"`
Count int `json:"count"`
}
type ensoCount struct {
Name string `json:"name"`
Count int `json:"count"`
}
type ensoLedgerStats struct {
Available bool `json:"available"` // false ⇒ ledger upstream unreachable
Total int `json:"total"` // decisions in the window
Engine int `json:"engine"`
Heuristic int `json:"heuristic"`
EnginePct float64 `json:"enginePct"`
Rewarded int `json:"rewarded"`
AvgReward float64 `json:"avgReward"`
AvgConfidence float64 `json:"avgConfidence"`
Confidence []ensoBucket `json:"confidence"`
Tasks []ensoCount `json:"tasks"`
Models []ensoCount `json:"models"`
}
type ensoEvalRow struct {
System string `json:"system"`
AccuracyPct float64 `json:"accuracyPct"`
StderrPct float64 `json:"stderrPct"`
N int `json:"n"`
UsdEst float64 `json:"usdEst"`
}
type ensoEvals struct {
Bench string `json:"bench"`
Source string `json:"source"` // "embedded" | "live"
Systems []ensoEvalRow `json:"systems"`
}
// ensoEvent is one entry in the flywheel timeline. Type is "eval" | "ledger" |
// "reward" today; retrain / deploy events slot in unchanged.
type ensoEvent struct {
Type string `json:"type"`
At string `json:"at"`
Label string `json:"label"`
Value float64 `json:"value,omitempty"`
}
type ensoTraining struct {
State string `json:"state"` // "live" | "partial" | "demo"
UpdatedAt string `json:"updatedAt"`
Window string `json:"window"`
Since string `json:"since"` // RFC3339 ledger cursor
Ledger ensoLedgerStats `json:"ledger"`
Evals ensoEvals `json:"evals"`
Events []ensoEvent `json:"events"`
}
// ── handler ───────────────────────────────────────────────────────────────────
// handleEnsoTraining serves the flywheel fold. It never 5xxes: produce always
// yields at least the embedded eval scores, and onError degrades to those.
func (s *Server) handleEnsoTraining(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
s.cachedJSON(w, "enso-training", "public, max-age=30, s-maxage=30, stale-while-revalidate=120",
60*time.Second, 10*time.Minute,
func(ctx context.Context) (any, error) { return s.produceEnsoTraining(ctx), nil },
func(w http.ResponseWriter, _ error) { writeJSON(w, http.StatusOK, "", s.ensoEvalsOnly()) },
)
}
// produceEnsoTraining folds the ledger + rewards + evals. State reflects which
// live sources resolved: "live" (ledger folded), "partial" (token present but
// ledger unreachable), or "demo" (no token — evals only).
func (s *Server) produceEnsoTraining(ctx context.Context) ensoTraining {
now := time.Now().UTC()
since := now.Add(-ensoLedgerWindow).Format(time.RFC3339)
evals := s.ensoEvals(ctx)
ledger, ok := s.foldRoutingLedger(ctx, since)
state := "demo"
if serviceToken() != "" {
if ok {
state = "live"
} else {
state = "partial"
}
}
return ensoTraining{
State: state,
UpdatedAt: now.Format(time.RFC3339),
Window: ensoWindowLabel,
Since: since,
Ledger: ledger,
Evals: evals,
Events: ensoEventsFrom(evals, ledger, now),
}
}
// ensoEvalsOnly is the degrade payload: embedded eval scores, no ledger.
func (s *Server) ensoEvalsOnly() ensoTraining {
now := time.Now().UTC()
evals := parseEnsoEvals(ensoBenchSummary, "embedded")
return ensoTraining{
State: "demo",
UpdatedAt: now.Format(time.RFC3339),
Window: ensoWindowLabel,
Since: now.Add(-ensoLedgerWindow).Format(time.RFC3339),
Ledger: ensoLedgerStats{Available: false},
Evals: evals,
Events: ensoEventsFrom(evals, ensoLedgerStats{}, now),
}
}
// ── routing ledger + rewards fold ─────────────────────────────────────────────
type ledgerRow struct {
Task string `json:"task"`
RoutedModel string `json:"routed_model"`
Confidence float64 `json:"confidence"`
Source string `json:"source"`
}
type rewardRow struct {
Reward float64 `json:"reward"`
}
// foldRoutingLedger streams the ledger (and, best-effort, rewards) JSONL and
// reduces it to the panel's aggregates. ok=false (no token / upstream down /
// non-admin) leaves an Available:false zero value so the panel degrades honestly.
func (s *Server) foldRoutingLedger(ctx context.Context, since string) (ensoLedgerStats, bool) {
hdr := serviceAuth()
if hdr == nil {
return ensoLedgerStats{Available: false}, false
}
host := apiHost()
body, err := s.getText(ctx, host+"/v1/export-routing-ledger?since="+url.QueryEscape(since), hdr)
if err != nil {
return ensoLedgerStats{Available: false}, false
}
stats := ensoLedgerStats{Available: true}
conf := make([]int, len(confLabels))
tasks := map[string]int{}
models := map[string]int{}
var confSum float64
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var row ledgerRow
if json.Unmarshal([]byte(line), &row) != nil {
continue
}
stats.Total++
switch row.Source {
case "engine":
stats.Engine++
case "heuristic":
stats.Heuristic++
}
confSum += row.Confidence
conf[confBucketIdx(row.Confidence)]++
if row.Task != "" {
tasks[row.Task]++
}
if row.RoutedModel != "" {
models[row.RoutedModel]++
}
}
if stats.Total > 0 {
stats.AvgConfidence = round2(confSum / float64(stats.Total))
stats.EnginePct = round1(float64(stats.Engine) / float64(stats.Total) * 100)
}
stats.Confidence = bucketsToHistogram(conf)
stats.Tasks = topCounts(tasks, 6)
stats.Models = topCounts(models, 6)
// Rewards are a bonus signal; their absence must not sink the ledger stats.
if rbody, rerr := s.getText(ctx, host+"/v1/export-routing-rewards?since="+url.QueryEscape(since), hdr); rerr == nil {
var sum float64
n := 0
for _, line := range strings.Split(rbody, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var rr rewardRow
if json.Unmarshal([]byte(line), &rr) != nil {
continue
}
n++
sum += rr.Reward
}
stats.Rewarded = n
if n > 0 {
stats.AvgReward = round2(sum / float64(n))
}
}
return stats, true
}
var confLabels = []string{"020%", "2040%", "4060%", "6080%", "80100%"}
// confBucketIdx maps a 0..1 confidence to one of five equal bins (clamped).
func confBucketIdx(c float64) int {
switch {
case c < 0.2:
return 0
case c < 0.4:
return 1
case c < 0.6:
return 2
case c < 0.8:
return 3
default:
return 4
}
}
func bucketsToHistogram(counts []int) []ensoBucket {
out := make([]ensoBucket, len(counts))
for i, c := range counts {
out[i] = ensoBucket{Label: confLabels[i], Count: c}
}
return out
}
// topCounts returns the n most frequent entries, ties broken by name for a stable
// order.
func topCounts(m map[string]int, n int) []ensoCount {
out := make([]ensoCount, 0, len(m))
for k, v := range m {
out = append(out, ensoCount{Name: k, Count: v})
}
sort.Slice(out, func(a, b int) bool {
if out[a].Count != out[b].Count {
return out[a].Count > out[b].Count
}
return out[a].Name < out[b].Name
})
if len(out) > n {
out = out[:n]
}
return out
}
// ── enso-bench eval scores ────────────────────────────────────────────────────
type ensoBenchFile struct {
Measured map[string]map[string]ensoBenchRow `json:"measured"`
}
type ensoBenchRow struct {
AccuracyPct float64 `json:"accuracy_pct"`
StderrPct float64 `json:"stderr_pct"`
N int `json:"n"`
UsdEst float64 `json:"usd_est"`
}
// ensoEvals returns the latest eval scores: a live ENSO_BENCH_URL when configured
// and reachable, else the embedded snapshot.
func (s *Server) ensoEvals(ctx context.Context) ensoEvals {
if u := env("ENSO_BENCH_URL"); u != "" {
if body, err := s.getText(ctx, u, nil); err == nil {
if ev := parseEnsoEvals([]byte(body), "live"); len(ev.Systems) > 0 {
return ev
}
}
}
return parseEnsoEvals(ensoBenchSummary, "embedded")
}
// parseEnsoEvals reduces an enso-bench summary to the ranked per-system scores of
// its primary benchmark (gpqa_diamond when present).
func parseEnsoEvals(data []byte, source string) ensoEvals {
var f ensoBenchFile
if json.Unmarshal(data, &f) != nil {
return ensoEvals{Source: source}
}
bench := pickBench(f.Measured)
out := ensoEvals{Bench: bench, Source: source}
for sys, r := range f.Measured[bench] {
out.Systems = append(out.Systems, ensoEvalRow{
System: sys,
AccuracyPct: r.AccuracyPct,
StderrPct: r.StderrPct,
N: r.N,
UsdEst: r.UsdEst,
})
}
sort.Slice(out.Systems, func(a, b int) bool {
if out.Systems[a].AccuracyPct != out.Systems[b].AccuracyPct {
return out.Systems[a].AccuracyPct > out.Systems[b].AccuracyPct
}
return out.Systems[a].System < out.Systems[b].System
})
return out
}
// pickBench prefers gpqa_diamond, else the lexicographically-first bench (stable).
func pickBench(m map[string]map[string]ensoBenchRow) string {
if _, ok := m["gpqa_diamond"]; ok {
return "gpqa_diamond"
}
best := ""
for k := range m {
if best == "" || k < best {
best = k
}
}
return best
}
// ── event timeline ────────────────────────────────────────────────────────────
// ensoEventsFrom builds the typed flywheel timeline. Today it surfaces the
// enso eval score and the ledger/reward counts; retrain / deploy events append
// with the same shape.
func ensoEventsFrom(evals ensoEvals, ledger ensoLedgerStats, now time.Time) []ensoEvent {
ts := now.Format(time.RFC3339)
events := make([]ensoEvent, 0, 4)
// The enso system's headline score (or the top system when enso isn't present).
if row, ok := ensoHeadlineRow(evals); ok {
events = append(events, ensoEvent{
Type: "eval",
At: ts,
Label: row.System + " · " + evals.Bench,
Value: row.AccuracyPct,
})
}
if ledger.Available {
events = append(events, ensoEvent{Type: "ledger", At: ts, Label: "routing decisions folded", Value: float64(ledger.Total)})
if ledger.Rewarded > 0 {
events = append(events, ensoEvent{Type: "reward", At: ts, Label: "decisions rewarded", Value: float64(ledger.Rewarded)})
}
}
return events
}
// ensoHeadlineRow picks the "enso" system if the eval carries it, else the top
// (already-sorted) system.
func ensoHeadlineRow(evals ensoEvals) (ensoEvalRow, bool) {
for _, r := range evals.Systems {
if r.System == "enso" {
return r, true
}
}
if len(evals.Systems) > 0 {
return evals.Systems[0], true
}
return ensoEvalRow{}, false
}
func round2(f float64) float64 { return math.Round(f*100) / 100 }
+137
View File
@@ -0,0 +1,137 @@
package world
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestEnsoTrainingEvalsOnly: with no service token the ledger is unreachable, but
// the embedded enso-bench eval scores are always served — state "demo", real
// eval rows, ledger.available:false. The panel is useful signed-out.
func TestEnsoTrainingEvalsOnly(t *testing.T) {
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "")
t.Setenv("ENSO_BENCH_URL", "")
ts := aiPulseServer(t)
resp, err := http.Get(ts.URL + "/v1/world/enso-training")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var et ensoTraining
if err := json.NewDecoder(resp.Body).Decode(&et); err != nil {
t.Fatalf("decode: %v", err)
}
if et.State != "demo" {
t.Fatalf("want state=demo without token, got %q", et.State)
}
if et.Ledger.Available {
t.Fatalf("ledger must be unavailable without a token")
}
if et.Evals.Bench != "gpqa_diamond" || len(et.Evals.Systems) == 0 {
t.Fatalf("embedded evals must load, got bench=%q systems=%d", et.Evals.Bench, len(et.Evals.Systems))
}
if et.Evals.Source != "embedded" {
t.Fatalf("want embedded eval source, got %q", et.Evals.Source)
}
// Systems are ranked by accuracy desc.
for i := 1; i < len(et.Evals.Systems); i++ {
if et.Evals.Systems[i-1].AccuracyPct < et.Evals.Systems[i].AccuracyPct {
t.Fatalf("evals not sorted by accuracy desc: %+v", et.Evals.Systems)
}
}
if !hasEventType(et.Events, "eval") {
t.Fatalf("timeline must carry an eval event, got %+v", et.Events)
}
}
// TestEnsoTrainingLiveFold: with a token + reachable exports, the ledger + reward
// JSONL fold into the mix, confidence histogram, and reward stats.
func TestEnsoTrainingLiveFold(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/export-routing-ledger", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("since") == "" {
t.Errorf("ledger export must carry a since cursor")
}
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(
`{"task":"code","routed_model":"zen-coder","confidence":0.92,"source":"engine"}` + "\n" +
`{"task":"chat","routed_model":"zen-omni","confidence":0.55,"source":"heuristic"}` + "\n" +
`{"task":"code","routed_model":"zen-coder","confidence":0.88,"source":"engine"}` + "\n" +
`{"task":"math","routed_model":"zen-1","confidence":0.15,"source":"heuristic"}` + "\n"))
})
mux.HandleFunc("/v1/export-routing-rewards", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
_, _ = w.Write([]byte(
`{"model":"zen-coder","task":"code","reward":0.8,"at":"2026-07-16T00:00:00Z"}` + "\n" +
`{"model":"zen-1","task":"math","reward":0.6,"at":"2026-07-16T01:00:00Z"}` + "\n"))
})
up := httptest.NewServer(mux)
t.Cleanup(up.Close)
t.Setenv("HANZO_CLOUD_PULSE_TOKEN", "svc-super-admin")
t.Setenv("HANZO_API_BASE", up.URL)
t.Setenv("ENSO_BENCH_URL", "")
ts := aiPulseServer(t)
resp, err := http.Get(ts.URL + "/v1/world/enso-training")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var et ensoTraining
if err := json.NewDecoder(resp.Body).Decode(&et); err != nil {
t.Fatalf("decode: %v", err)
}
if et.State != "live" {
t.Fatalf("want state=live, got %q", et.State)
}
l := et.Ledger
if !l.Available || l.Total != 4 || l.Engine != 2 || l.Heuristic != 2 || l.EnginePct != 50 {
t.Fatalf("bad ledger mix: %+v", l)
}
if l.AvgConfidence != 0.63 {
t.Fatalf("want avgConfidence 0.63, got %v", l.AvgConfidence)
}
if bucketCount(l.Confidence, "80100%") != 2 || bucketCount(l.Confidence, "020%") != 1 {
t.Fatalf("bad confidence histogram: %+v", l.Confidence)
}
if countByName(l.Tasks, "code") != 2 || countByName(l.Models, "zen-coder") != 2 {
t.Fatalf("bad task/model distribution: tasks=%+v models=%+v", l.Tasks, l.Models)
}
if l.Rewarded != 2 || l.AvgReward != 0.7 {
t.Fatalf("want rewarded=2 avgReward=0.7, got %d / %v", l.Rewarded, l.AvgReward)
}
if !hasEventType(et.Events, "ledger") || !hasEventType(et.Events, "reward") {
t.Fatalf("timeline must carry ledger + reward events, got %+v", et.Events)
}
}
func hasEventType(events []ensoEvent, typ string) bool {
for _, e := range events {
if e.Type == typ {
return true
}
}
return false
}
func bucketCount(buckets []ensoBucket, label string) int {
for _, b := range buckets {
if b.Label == label {
return b.Count
}
}
return -1
}
func countByName(counts []ensoCount, name string) int {
for _, c := range counts {
if c.Name == name {
return c.Count
}
}
return -1
}
+58 -23
View File
@@ -32,6 +32,38 @@ type feedBatchItem struct {
Link string `json:"link"`
PubDate string `json:"pubDate,omitempty"` // RFC3339 when parseable, else ""
Tickers []string `json:"tickers,omitempty"` // stock/crypto tickers in the title
// Enrichment computed HERE, once, instead of on every client on every
// render (see enrich.go — proven identical to the old browser code).
Threat *ThreatClassification `json:"threat,omitempty"`
Geo *feedItemGeo `json:"geo,omitempty"`
}
// feedItemGeo is the top inferred hub, already resolved to coordinates so the
// client needs no hub table of its own.
type feedItemGeo struct {
HubID string `json:"hubId"`
Name string `json:"name"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Confidence float64 `json:"confidence"`
}
// enrichFeedItems classifies + geo-locates parsed items. Kept separate from
// parseFeedItems: parsing is about XML, enrichment is about meaning.
func enrichFeedItems(items []feedBatchItem, variant string) []feedBatchItem {
for i := range items {
c := ClassifyByKeyword(items[i].Title, variant)
items[i].Threat = &c
if m := InferGeoHubs(items[i].Title); len(m) > 0 {
if hub := GeoHubByID(m[0].HubID); hub != nil {
items[i].Geo = &feedItemGeo{
HubID: hub.ID, Name: hub.Name, Lat: hub.Lat, Lon: hub.Lon,
Confidence: m[0].Confidence,
}
}
}
}
return items
}
type feedBatchResult struct {
@@ -49,7 +81,8 @@ func (s *Server) handleFeedsBatch(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
URLs []string `json:"urls"`
URLs []string `json:"urls"`
Variant string `json:"variant,omitempty"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil || len(req.URLs) == 0 {
writeError(w, http.StatusBadRequest, "Body must be {\"urls\":[...]}")
@@ -58,6 +91,12 @@ func (s *Server) handleFeedsBatch(w http.ResponseWriter, r *http.Request) {
if len(req.URLs) > feedsBatchMaxURLs {
req.URLs = req.URLs[:feedsBatchMaxURLs]
}
// Only 'tech' unlocks the tech keyword tiers; every other variant classifies
// against the base tables (same rule the frontend used).
variant := req.Variant
if variant == "" {
variant = "full"
}
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
@@ -77,9 +116,12 @@ func (s *Server) handleFeedsBatch(w http.ResponseWriter, r *http.Request) {
go func() {
defer wg.Done()
defer func() { <-sem }()
if body, ok := s.feedXML(ctx, feedURL); ok {
if body, ok, fresh := s.feedXML(ctx, feedURL); ok {
results[i].OK = true
results[i].Items = parseFeedItems(body, feedsBatchMaxItems)
results[i].Items = enrichFeedItems(parseFeedItems(body, feedsBatchMaxItems), variant)
if fresh {
s.ingestFeedItems(feedURL, body) // fold a cold-miss fetch into the lake
}
}
}()
}
@@ -91,29 +133,22 @@ func (s *Server) handleFeedsBatch(w http.ResponseWriter, r *http.Request) {
})
}
// feedXML returns the raw feed body, sharing handleRSSProxy's cache keys and
// stale-fallback behavior.
func (s *Server) feedXML(ctx context.Context, feedURL string) ([]byte, bool) {
key := "rss:" + feedURL
if v, ok := s.cache.Get(key); ok {
return v.([]byte), true
// feedXML returns a feed body from the shared warm cache (instant, never blocks
// on upstream), falling through to a bounded live fetch only on a true cold miss
// and write-through-ing the result. fresh reports whether the body came from that
// live fetch (so the caller folds it into the lake exactly once). The warm cache
// is the same one handleRSSProxy reads, so either path warms the other.
func (s *Server) feedXML(ctx context.Context, feedURL string) (body []byte, ok, fresh bool) {
if b, _, hit := s.feeds.Get(ctx, feedURL); hit {
return b, true, false // any cached copy → serve instantly (stale-while-revalidate)
}
ctx, cancel := context.WithTimeout(ctx, feedsBatchFetchTimeout)
fctx, cancel := context.WithTimeout(ctx, feedsBatchFetchTimeout)
defer cancel()
body, status, err := s.getAllowlisted(ctx, feedURL, allowedRSSDomains, map[string]string{
"User-Agent": browserUA,
"Accept": "application/rss+xml, application/xml, text/xml, */*",
})
// A blank 200 is a failure, not content: never cache it (it would poison the
// shared "rss:" key handleRSSProxy reads too). Fall back to last-good stale.
if err != nil || status < 200 || status >= 300 || isBlankBody(body) {
if v, ok := s.cache.GetStale(key); ok {
return v.([]byte), true
}
return nil, false
if b, okk := s.fetchFeedBody(fctx, feedURL); okk {
s.feeds.Put(feedURL, b)
return b, true, true
}
s.cache.Set(key, body, 5*time.Minute, 15*time.Minute)
return body, true
return nil, false, false
}
// parseFeedItems handles RSS 2.0 (channel>item), RDF/RSS 1.0 (root-level
+1 -1
View File
@@ -420,7 +420,7 @@ func quoteOf(closes []float64) map[string]any {
"r1d": round2s(pctChange(last, prev)),
"r5d": ptr2(rateOfChange(closes, 5)),
"r1m": ptr2(rateOfChange(closes, minInt(len(closes)-1, 21))),
"sparkline": tailN(closes, 30),
"sparkline": sparkline(closes, 30),
}
}
+17 -6
View File
@@ -232,17 +232,17 @@ func (s *Server) computeMacroSignals(ctx context.Context) (any, error) {
return map[string]any{
"timestamp": nowISO(), "verdict": verdict, "bullishCount": bullish, "totalCount": total,
"signals": map[string]any{
"liquidity": map[string]any{"status": liquidity, "value": ptr2(jpyRoc30), "sparkline": tailN(jpyP, 30)},
"liquidity": map[string]any{"status": liquidity, "value": ptr2(jpyRoc30), "sparkline": sparkline(jpyP, 30)},
"flowStructure": map[string]any{"status": flow, "btcReturn5": ptr2(btcRet5), "qqqReturn5": ptr2(qqqRet5)},
"macroRegime": map[string]any{"status": regime, "qqqRoc20": ptr2(qqqRoc20), "xlpRoc20": ptr2(xlpRoc20)},
"technicalTrend": map[string]any{"status": trend, "btcPrice": ptrf(btcCur),
"sma50": ptr0(btcSma50), "sma200": ptr0(btcSma200), "vwap30d": ptrf(btcVwap),
"mayerMultiple": ptrf(mayer), "sparkline": tailN(btcP, 30)},
"mayerMultiple": ptrf(mayer), "sparkline": sparkline(btcP, 30)},
"hashRate": map[string]any{"status": hashStatus, "change30d": ptrf(hashChange)},
"miningCost": map[string]any{"status": mining},
"fearGreed": map[string]any{"status": fgLabel, "value": ptri(fgValue), "history": fgHistory},
},
"meta": map[string]any{"qqqSparkline": tailN(qqqP, 30)},
"meta": map[string]any{"qqqSparkline": sparkline(qqqP, 30)},
}, nil
}
@@ -290,11 +290,22 @@ func sma(prices []float64, period int) *float64 {
return &v
}
func tailN(a []float64, n int) []float64 {
if len(a) <= n {
// sparkline returns the last n closes rounded to 7 significant digits — the wire
// form emitted to clients. Scalars (price/change) are computed from the raw
// closes upstream of this call, so they keep full precision; only the emitted
// curve is rounded. A nil input passes through as nil.
func sparkline(a []float64, n int) []float64 {
if len(a) == 0 {
return a
}
return a[len(a)-n:]
if len(a) > n {
a = a[len(a)-n:]
}
out := make([]float64, len(a))
for i, v := range a {
out[i] = roundSig(v)
}
return out
}
func ptr2(p *float64) any {
+16
View File
@@ -6,6 +6,7 @@ import (
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -412,6 +413,21 @@ func compact(in []*float64) []float64 {
return out
}
// roundSig rounds v to 7 significant digits — the wire precision for emitted
// sparkline closes. Yahoo returns float32-widened closes (17.209999084472656
// for 17.21); serializing that noise verbatim ~doubles the sparkline payload
// for a curve the renderer draws identically. Significant digits, not fixed
// decimals, so a sub-1.0 FX rate keeps its precision. Non-finite values pass
// through unchanged. Applied only where close ARRAYS ship (see sparkline);
// price/change scalars are derived from the raw closes and stay exact.
func roundSig(v float64) float64 {
if math.IsNaN(v) || math.IsInf(v, 0) {
return v
}
f, _ := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 7, 64), 64)
return f
}
// ── Stock index (country → weekly % change) ──────────────────────────────────
type indexInfo struct{ symbol, name string }
+245
View File
@@ -0,0 +1,245 @@
package world
import (
"context"
"encoding/json"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/hanzoai/world/internal/world/store"
)
// Go-native keyword monitors.
//
// Monitors used to live entirely in the browser: the keyword list in
// localStorage, the matching done client-side over whatever headlines that tab
// had loaded. That means a monitor only ever saw one client's slice of the
// corpus, and it stopped existing the moment you closed the tab or switched
// device.
//
// Here they are server state: the list is persisted per identity (the same
// per-identity store the dashboard settings use, namespaced 'monitors'), and
// matching runs against the LAKE — every item the backend has ingested, not just
// what a browser fetched. Anonymous callers get 401 and keep their local
// monitors; nothing about the signed-out experience changes.
//
// GET /v1/world/monitors → { monitors: [...] }
// PUT /v1/world/monitors → { ok: true } (body: { monitors: [...] })
// GET /v1/world/monitors/matches → { matches: [...] } (matched against the lake)
const (
monitorsDoc = "monitors" // per-identity store namespace
monitorMatchWindow = 48 * time.Hour
monitorMatchLimit = 60
maxMonitors = 50
maxKeywordsEach = 25
)
// Monitor is one keyword watch. Mirrors the frontend Monitor type.
type Monitor struct {
ID string `json:"id"`
Keywords []string `json:"keywords"`
Color string `json:"color,omitempty"`
}
// MonitorMatch is a lake item that tripped a monitor.
type MonitorMatch struct {
MonitorID string `json:"monitorId"`
Color string `json:"color,omitempty"`
Keyword string `json:"keyword"`
Title string `json:"title"`
Link string `json:"link"`
Source string `json:"source"`
TS time.Time `json:"ts"`
}
// identityFor resolves the caller, or writes 401 and reports false.
func (s *Server) identityFor(w http.ResponseWriter, r *http.Request) (store.Identity, bool) {
bearer := userBearer(r)
if bearer == "" {
writeError(w, http.StatusUnauthorized, "Sign in required")
return store.Identity{}, false
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := s.introspectIdentity(ctx, bearer)
if err != nil || id.Sub == "" {
writeError(w, http.StatusUnauthorized, "Sign in required")
return store.Identity{}, false
}
return store.Identity{Org: id.Org, UserSub: id.Sub, Project: monitorsDoc}, true
}
func (s *Server) handleMonitors(w http.ResponseWriter, r *http.Request) {
setCORS(w, "GET, PUT, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "GET or PUT")
return
}
ident, ok := s.identityFor(w, r)
if !ok {
return
}
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{"monitors": s.loadMonitors(ident)})
return
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil {
writeError(w, http.StatusBadRequest, "Body too large")
return
}
var body struct {
Monitors []Monitor `json:"monitors"`
}
if err := json.Unmarshal(raw, &body); err != nil {
writeError(w, http.StatusBadRequest, "Body must be {\"monitors\":[...]}")
return
}
clean := sanitizeMonitors(body.Monitors)
blob, err := json.Marshal(map[string]any{"monitors": clean})
if err != nil {
writeError(w, http.StatusBadRequest, "Unserializable monitors")
return
}
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{
"ok": s.store.Settings.Put(ident, blob),
"monitors": clean,
})
}
// handleMonitorMatches runs the caller's monitors against the lake — the whole
// ingested corpus, not one browser's slice of it.
func (s *Server) handleMonitorMatches(w http.ResponseWriter, r *http.Request) {
setCORS(w, "GET, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "GET")
return
}
ident, ok := s.identityFor(w, r)
if !ok {
return
}
monitors := s.loadMonitors(ident)
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{
"matches": s.matchMonitors(monitors),
})
}
func (s *Server) loadMonitors(ident store.Identity) []Monitor {
blob, ok := s.store.Settings.Get(ident)
if !ok {
return []Monitor{}
}
var doc struct {
Monitors []Monitor `json:"monitors"`
}
if err := json.Unmarshal(blob, &doc); err != nil || doc.Monitors == nil {
return []Monitor{}
}
return doc.Monitors
}
// matchMonitors searches the lake once per keyword and folds the hits together,
// deduped by (monitor, item) so one item can trip two different monitors but
// never the same one twice. Word-boundary re-check mirrors the frontend rule:
// FTS tokenises, but "ai" must not match "train".
func (s *Server) matchMonitors(monitors []Monitor) []MonitorMatch {
out := []MonitorMatch{}
if s.store == nil || len(monitors) == 0 {
return out
}
since := time.Now().Add(-monitorMatchWindow)
seen := map[string]bool{}
for _, m := range monitors {
for _, kw := range m.Keywords {
kw = strings.ToLower(strings.TrimSpace(kw))
if kw == "" {
continue
}
re := wordBoundary(kw)
items := s.store.Lake.Search(store.SearchQuery{
Q: kw, Kind: "news", Since: since, Limit: monitorMatchLimit,
})
for _, it := range items {
if !re.MatchString(strings.ToLower(it.Title + " " + it.Text)) {
continue // FTS stemming can over-match; the boundary rule decides
}
key := m.ID + "\x00" + it.ID
if seen[key] {
continue
}
seen[key] = true
out = append(out, MonitorMatch{
MonitorID: m.ID, Color: m.Color, Keyword: kw,
Title: it.Title, Link: itemLink(it), Source: it.Source, TS: it.TS,
})
}
}
}
return out
}
// itemLink pulls the canonical link out of a lake item's payload, falling back
// to its id (feed items are keyed by link upstream).
func itemLink(it store.Item) string {
var p struct {
Link string `json:"link"`
}
if it.Payload != "" && json.Unmarshal([]byte(it.Payload), &p) == nil && p.Link != "" {
return p.Link
}
if strings.HasPrefix(it.ID, "http") {
return it.ID
}
return ""
}
var wbCache sync.Map // keyword → *regexp.Regexp
func wordBoundary(kw string) *regexp.Regexp {
if v, ok := wbCache.Load(kw); ok {
return v.(*regexp.Regexp)
}
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(kw) + `\b`)
wbCache.Store(kw, re)
return re
}
// sanitizeMonitors bounds what a client can store: no unbounded lists, no empty
// keywords, everything lowercased so matching is case-insensitive by construction.
func sanitizeMonitors(in []Monitor) []Monitor {
out := make([]Monitor, 0, len(in))
for _, m := range in {
if len(out) >= maxMonitors {
break
}
kws := make([]string, 0, len(m.Keywords))
for _, k := range m.Keywords {
k = strings.ToLower(strings.TrimSpace(k))
if k == "" || len(kws) >= maxKeywordsEach {
continue
}
kws = append(kws, k)
}
if len(kws) == 0 || strings.TrimSpace(m.ID) == "" {
continue
}
out = append(out, Monitor{ID: m.ID, Keywords: kws, Color: m.Color})
}
return out
}
+119
View File
@@ -0,0 +1,119 @@
package world
import (
"testing"
"time"
"github.com/hanzoai/world/internal/world/store"
)
// Monitors are server state now, so the two things that MUST hold are:
// 1. a monitor list round-trips through the per-identity store, and
// 2. matching runs against the lake — the whole ingested corpus — while still
// honouring the word-boundary rule the browser used ("ai" ≠ "train").
//
// The store is opened against a temp dir (WORLD_DATA_DIR), so this is the real
// SQLite lake + settings table, not a mock.
func testServer(t *testing.T) *Server {
t.Helper()
t.Setenv("WORLD_DATA_DIR", t.TempDir())
s := NewServer()
t.Cleanup(s.Close)
if s.store == nil || !s.store.Enabled() {
t.Skip("store unavailable in this environment")
}
return s
}
func seedNews(t *testing.T, s *Server, items ...store.Item) {
t.Helper()
for _, it := range items {
if it.Kind == "" {
it.Kind = "news"
}
if it.TS.IsZero() {
it.TS = time.Now()
}
s.store.Lake.Add(it)
}
// Add() is write-behind; flush so the search sees the rows.
s.store.Lake.Flush()
}
func TestMonitorsRoundTripPerIdentity(t *testing.T) {
s := testServer(t)
ident := store.Identity{Org: "acme", UserSub: "user-1", Project: monitorsDoc}
other := store.Identity{Org: "acme", UserSub: "user-2", Project: monitorsDoc}
blob := []byte(`{"monitors":[{"id":"m1","keywords":["nvidia","gpu"],"color":"#f00"}]}`)
if !s.store.Settings.Put(ident, blob) {
t.Fatal("put monitors failed")
}
got := s.loadMonitors(ident)
if len(got) != 1 || got[0].ID != "m1" || len(got[0].Keywords) != 2 {
t.Fatalf("round-trip mismatch: %+v", got)
}
// Isolation: another identity must not see them.
if n := len(s.loadMonitors(other)); n != 0 {
t.Fatalf("identity leak: user-2 sees %d monitors", n)
}
}
func TestMonitorMatchesRunAgainstTheLake(t *testing.T) {
s := testServer(t)
seedNews(t, s,
store.Item{ID: "https://x.test/a", Source: "BBC", Title: "Nvidia unveils a new GPU for data centres"},
store.Item{ID: "https://x.test/b", Source: "FT", Title: "Markets rally on strong earnings"},
store.Item{ID: "https://x.test/c", Source: "AP", Title: "Train derails outside the city"},
)
monitors := []Monitor{{ID: "m1", Keywords: []string{"nvidia"}, Color: "#f00"}}
matches := s.matchMonitors(monitors)
if len(matches) != 1 {
t.Fatalf("want 1 match, got %d (%+v)", len(matches), matches)
}
m := matches[0]
if m.MonitorID != "m1" || m.Keyword != "nvidia" || m.Source != "BBC" {
t.Fatalf("unexpected match: %+v", m)
}
if m.Link != "https://x.test/a" {
t.Errorf("link = %q, want the item's canonical link", m.Link)
}
}
// The rule FTS alone would get wrong: a short keyword must not match inside a
// longer word. "ai" must never trip on "train".
func TestMonitorMatchesHonourWordBoundaries(t *testing.T) {
s := testServer(t)
seedNews(t, s,
store.Item{ID: "https://x.test/train", Source: "AP", Title: "Train derails outside the city"},
store.Item{ID: "https://x.test/ai", Source: "Wired", Title: "AI models get cheaper to run"},
)
matches := s.matchMonitors([]Monitor{{ID: "m1", Keywords: []string{"ai"}}})
for _, m := range matches {
if m.Link == "https://x.test/train" {
t.Fatalf(`"ai" matched inside "Train" — word boundary not enforced`)
}
}
if len(matches) != 1 || matches[0].Link != "https://x.test/ai" {
t.Fatalf("want only the AI story, got %+v", matches)
}
}
func TestSanitizeMonitorsBoundsInput(t *testing.T) {
in := []Monitor{
{ID: "ok", Keywords: []string{" Nvidia ", "", "GPU"}},
{ID: "", Keywords: []string{"dropped: no id"}},
{ID: "empty", Keywords: []string{" "}},
}
out := sanitizeMonitors(in)
if len(out) != 1 {
t.Fatalf("want 1 surviving monitor, got %d (%+v)", len(out), out)
}
if out[0].Keywords[0] != "nvidia" || out[0].Keywords[1] != "gpu" {
t.Fatalf("keywords not normalised: %+v", out[0].Keywords)
}
}
+26
View File
@@ -0,0 +1,26 @@
package world
import (
"net/http"
"time"
)
// ── HKO tropical-cyclone warnings ────────────────────────────────────────────
// handleHKOWarnings proxies the Hong Kong Observatory weather-warning summary
// (verbatim JSON). Browsers can't reach data.weather.gov.hk directly (no CORS),
// so the Western-Pacific cyclone attribution stream folds it in through here as
// an HKO agency observation. Degrades to {} so the client parser simply yields
// no warnings. Follows handleGDELTGeo's passthrough shape.
func (s *Server) handleHKOWarnings(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
const upstream = "https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=warnsum&lang=en"
s.passthrough(w, "hko-warnings", upstream, "application/json",
"public, max-age=300, s-maxage=300, stale-while-revalidate=60",
nil, 5*time.Minute, 15*time.Minute,
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{})
})
}
+56 -38
View File
@@ -14,6 +14,28 @@ import (
// ── GDELT ────────────────────────────────────────────────────────────────────
// Shared GDELT cache policy + key/URL construction — defined ONCE so the doc and
// geo handlers and the boot warmer (cache_warmer.go) all read/write the exact
// same cache keys and hit the same upstream. 5m fresh / 15m stale.
const (
gdeltCC = "public, max-age=300, s-maxage=300, stale-while-revalidate=60"
gdeltTTL = 5 * time.Minute
gdeltStale = 15 * time.Minute
)
func gdeltDocKey(query string, maxrecords int, timespan string) string {
return "gdelt-doc:" + query + ":" + itoa(maxrecords) + ":" + timespan
}
func gdeltGeoKey(query, format string, maxrecords int, timespan string) string {
return "gdelt-geo:" + query + ":" + format + ":" + itoa(maxrecords) + ":" + timespan
}
func gdeltGeoURL(query, format string, maxrecords int, timespan string) string {
return "https://api.gdeltproject.org/api/v2/geo/geo?query=" + urlQueryEscape(query) +
"&format=" + format + "&maxrecords=" + itoa(maxrecords) + "&timespan=" + timespan
}
// handleGDELTDoc queries the GDELT DOC 2.0 article list and maps it to the
// compact {articles,query} shape. Ported from api/gdelt-doc.js.
func (s *Server) handleGDELTDoc(w http.ResponseWriter, r *http.Request) {
@@ -31,28 +53,30 @@ func (s *Server) handleGDELTDoc(w http.ResponseWriter, r *http.Request) {
if timespan == "" {
timespan = "72h"
}
key := "gdelt-doc:" + query + ":" + itoa(maxrecords) + ":" + timespan
s.cachedJSON(w, key, "public, max-age=300, s-maxage=300, stale-while-revalidate=60",
5*time.Minute, 15*time.Minute,
func(ctx context.Context) (any, error) {
arts, err := s.fetchGDELTArticles(ctx, query, timespan, maxrecords)
if err != nil {
return nil, err
}
articles := make([]map[string]any, 0, len(arts))
for _, a := range arts {
articles = append(articles, map[string]any{
"title": a.Title, "url": a.URL, "source": a.Domain,
"date": a.SeenDate, "image": a.SocialImage, "language": a.Language, "tone": a.Tone,
})
}
return map[string]any{"articles": articles, "query": query}, nil
},
s.cachedJSON(w, gdeltDocKey(query, maxrecords, timespan), gdeltCC, gdeltTTL, gdeltStale,
func(ctx context.Context) (any, error) { return s.produceGDELTDoc(ctx, query, timespan, maxrecords) },
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{"error": err.Error(), "articles": []any{}})
})
}
// produceGDELTDoc fetches and shapes the gdelt-doc payload — the single produce
// path shared by the handler and the boot warmer, so the transform lives once.
func (s *Server) produceGDELTDoc(ctx context.Context, query, timespan string, maxrecords int) (any, error) {
arts, err := s.fetchGDELTArticles(ctx, query, timespan, maxrecords)
if err != nil {
return nil, err
}
articles := make([]map[string]any, 0, len(arts))
for _, a := range arts {
articles = append(articles, map[string]any{
"title": a.Title, "url": a.URL, "source": a.Domain,
"date": a.SeenDate, "image": a.SocialImage, "language": a.Language, "tone": a.Tone,
})
}
return map[string]any{"articles": articles, "query": query}, nil
}
// gdeltArticle is one GDELT DOC 2.0 artlist row. The shared decode target for
// both the /gdelt-doc endpoint and the world-model news source.
type gdeltArticle struct {
@@ -103,11 +127,9 @@ func (s *Server) handleGDELTGeo(w http.ResponseWriter, r *http.Request) {
if format == "csv" {
ct = "text/csv"
}
upstream := "https://api.gdeltproject.org/api/v2/geo/geo?query=" + urlQueryEscape(query) +
"&format=" + format + "&maxrecords=" + itoa(maxrecords) + "&timespan=" + timespan
key := "gdelt-geo:" + query + ":" + format + ":" + itoa(maxrecords) + ":" + timespan
s.passthrough(w, key, upstream, ct, "public, max-age=300, s-maxage=300, stale-while-revalidate=60",
nil, 5*time.Minute, 15*time.Minute,
s.passthrough(w, gdeltGeoKey(query, format, maxrecords, timespan),
gdeltGeoURL(query, format, maxrecords, timespan), ct, gdeltCC,
nil, gdeltTTL, gdeltStale,
func(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusOK, "", map[string]any{"error": "upstream unavailable", "data": []any{}})
})
@@ -160,30 +182,26 @@ func (s *Server) handleRSSProxy(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Domain not allowed")
return
}
key := "rss:" + feedURL
if v, ok := s.cache.Get(key); ok {
writeBytes(w, http.StatusOK, "application/xml", "public, max-age=300, s-maxage=300, stale-while-revalidate=60", v.([]byte))
const cc = "public, max-age=300, s-maxage=300, stale-while-revalidate=60"
// Warm cache first (per-pod L1 → shared hanzo-kv L2): instant, and never blocks
// on the upstream while ANY cached copy exists (stale-while-revalidate; the
// background warmer does the revalidation).
if body, _, ok := s.feeds.Get(r.Context(), feedURL); ok {
writeBytes(w, http.StatusOK, "application/xml", cc, body)
return
}
// True cold miss: bounded live fetch, write-through to the warm cache + lake.
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
body, status, err := s.getAllowlisted(ctx, feedURL, allowedRSSDomains, map[string]string{
"User-Agent": browserUA,
"Accept": "application/rss+xml, application/xml, text/xml, */*",
})
// A blank 200 is a failure, not content: never cache it (it would poison the
// shared "rss:" key feedXML reads too). Fall back to last-good stale.
if err != nil || status < 200 || status >= 300 || isBlankBody(body) {
if v, ok := s.cache.GetStale(key); ok {
writeBytes(w, http.StatusOK, "application/xml", "public, max-age=300, s-maxage=300, stale-while-revalidate=60", v.([]byte))
return
}
body, ok := s.fetchFeedBody(ctx, feedURL)
if !ok {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, "", map[string]any{"error": "upstream unavailable", "items": []any{}})
return
}
s.cache.Set(key, body, 5*time.Minute, 15*time.Minute)
writeBytes(w, http.StatusOK, "application/xml", "public, max-age=300, s-maxage=300, stale-while-revalidate=60", body)
s.feeds.Put(feedURL, body)
s.ingestFeedItems(feedURL, body)
writeBytes(w, http.StatusOK, "application/xml", cc, body)
}
// ── Hacker News ──────────────────────────────────────────────────────────────
+94
View File
@@ -0,0 +1,94 @@
package world
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/hanzoai/world/internal/world/store"
)
// The search + analytics endpoints over the ingested-data LAKE — the CTO's "one
// place to query everything". Every ingested item (news, model observations, …)
// is a row; search ranks them (FTS bm25 when q is present, recency otherwise),
// analytics summarizes them. Both degrade to empty results, never 5xx.
// handleSearch serves GET /v1/world/search?q=&kind=&since=&country=&ticker=&limit= .
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
q := r.URL.Query()
results := s.store.Lake.Search(store.SearchQuery{
Q: q.Get("q"),
Kind: strings.TrimSpace(q.Get("kind")),
Country: strings.TrimSpace(q.Get("country")),
Ticker: strings.TrimSpace(q.Get("ticker")),
Since: parseSince(q.Get("since")),
Limit: atoiDefault(q.Get("limit"), 30),
})
out := make([]map[string]any, 0, len(results))
for _, it := range results {
m := map[string]any{
"id": it.ID, "kind": it.Kind, "source": it.Source,
"ts": it.TS.Format(time.RFC3339), "title": it.Title,
}
if it.Text != "" {
m["text"] = it.Text
}
if len(it.Tickers) > 0 {
m["tickers"] = it.Tickers
}
if it.Country != "" {
m["country"] = it.Country
}
if it.HasGeo {
m["lat"], m["lon"] = it.Lat, it.Lon
}
if it.Payload != "" {
m["payload"] = json.RawMessage(it.Payload)
}
out = append(out, m)
}
writeJSON(w, http.StatusOK, "public, max-age=15, s-maxage=15, stale-while-revalidate=60", map[string]any{
"query": q.Get("q"), "count": len(out), "results": out, "updatedAt": nowRFC(),
})
}
// handleAnalytics serves GET /v1/world/analytics?hours= — a cross-cutting
// summary of the lake: totals and breakdowns by kind, source, and top tickers.
func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") || methodNotGet(w, r) {
return
}
hours := clampInt(r.URL.Query().Get("hours"), 24, 1, 720)
writeJSON(w, http.StatusOK, "public, max-age=30, s-maxage=30, stale-while-revalidate=120",
s.store.Lake.Analytics(hours))
}
// parseSince accepts an absolute RFC3339 timestamp, a "<n>d" day window, a Go
// duration ("24h","90m"), or a bare integer of hours. Unparseable/empty → no
// lower bound.
func parseSince(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return t
}
if strings.HasSuffix(raw, "d") {
if n, err := strconv.Atoi(strings.TrimSuffix(raw, "d")); err == nil && n > 0 {
return time.Now().Add(-time.Duration(n) * 24 * time.Hour)
}
}
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
return time.Now().Add(-d)
}
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return time.Now().Add(-time.Duration(n) * time.Hour)
}
return time.Time{}
}
+95
View File
@@ -0,0 +1,95 @@
package world
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// serve drives one handler with an in-memory request/response and returns the
// decoded JSON body plus status.
func serve(t *testing.T, h http.HandlerFunc, method, target, bearer, body string) (map[string]any, int) {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, target, strings.NewReader(body))
} else {
r = httptest.NewRequest(method, target, nil)
}
if bearer != "" {
r.Header.Set("Authorization", "Bearer "+bearer)
}
rec := httptest.NewRecorder()
h(rec, r)
res := rec.Result()
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var m map[string]any
if len(raw) > 0 {
_ = json.Unmarshal(raw, &m)
}
return m, res.StatusCode
}
func TestSearchEndpointEmptyNever5xx(t *testing.T) {
s := newTestServer(t)
m, code := serve(t, s.handleSearch, http.MethodGet, "/v1/world/search", "", "")
if code != http.StatusOK {
t.Fatalf("status = %d, want 200", code)
}
if m["count"].(float64) != 0 {
t.Fatalf("empty lake count = %v, want 0", m["count"])
}
if _, ok := m["results"].([]any); !ok {
t.Fatalf("results not an array: %v", m["results"])
}
}
func TestSearchAndAnalyticsReturnIngested(t *testing.T) {
s := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.store.Lake.Run(ctx) // write-behind consumer
// Ingest via the same path the feed handlers use.
s.ingestFeedItems("https://cointelegraph.com/rss", []byte(stubRSS))
// Poll until the write-behind flush lands (≤1s tick).
var m map[string]any
deadline := time.Now().Add(4 * time.Second)
for time.Now().Before(deadline) {
var code int
m, code = serve(t, s.handleSearch, http.MethodGet, "/v1/world/search?q=bitcoin", "", "")
if code != http.StatusOK {
t.Fatalf("search status = %d", code)
}
if m["count"].(float64) >= 1 {
break
}
time.Sleep(40 * time.Millisecond)
}
if m["count"].(float64) < 1 {
t.Fatalf("ingested news not searchable: %v", m)
}
first := m["results"].([]any)[0].(map[string]any)
if !strings.Contains(strings.ToLower(first["title"].(string)), "bitcoin") {
t.Fatalf("top result title = %v, want a Bitcoin item", first["title"])
}
if first["kind"] != "news" {
t.Fatalf("kind = %v, want news", first["kind"])
}
// Analytics reflects the same ingest.
a, code := serve(t, s.handleAnalytics, http.MethodGet, "/v1/world/analytics", "", "")
if code != http.StatusOK {
t.Fatalf("analytics status = %d", code)
}
if a["total"].(float64) < 1 {
t.Fatalf("analytics total = %v, want ≥1", a["total"])
}
}
+112
View File
@@ -0,0 +1,112 @@
package world
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"time"
"github.com/hanzoai/world/internal/world/store"
)
// Per-identity settings: server-side dashboard sync for signed-in users.
//
// The frontend layout engine persists panel geometry / layout mode / preferences
// to localStorage today. These endpoints let a follow-up sync a SIGNED-IN user's
// dashboard across devices: the blob is keyed by (org, user_sub, project) from
// the IAM bearer, so it is isolated per identity. Anonymous callers get 401 and
// keep using localStorage — no server state for them.
//
// FRONTEND HOOK (another agent owns src/): after the layout store mutates, if the
// user is signed in, PUT the same JSON it writes to localStorage to
// /v1/world/settings (Authorization: Bearer <token>); on load, if signed in, GET
// /v1/world/settings and prefer a non-empty server blob over localStorage. One
// debounced PUT on change, one GET on boot — the endpoints below are the whole API.
// wIdentity is the caller's IAM identity resolved from userinfo.
type wIdentity struct {
Org string `json:"owner"`
Sub string `json:"sub"`
}
// introspectIdentity resolves the caller's org (owner claim) + subject from IAM
// userinfo, memoized by token hash for a short TTL. It is world's ONE identity
// path — the admin gate (requireAdmin) and per-identity settings both resolve
// through here, so a token's userinfo is fetched and cached once. Authoritative,
// IAM-signed identity — never a client-supplied header.
func (s *Server) introspectIdentity(ctx context.Context, bearer string) (wIdentity, error) {
sum := sha256.Sum256([]byte(bearer))
key := "identity:" + hex.EncodeToString(sum[:12])
if v, ok := s.cache.Get(key); ok {
return v.(wIdentity), nil
}
var id wIdentity
if err := s.getJSON(ctx, iamIssuer()+"/v1/iam/oauth/userinfo",
map[string]string{"Authorization": bearer}, &id); err != nil {
return wIdentity{}, err
}
s.cache.Set(key, id, 60*time.Second, 60*time.Second)
return id, nil
}
// handleSettings serves GET (read this identity's blob) and PUT (upsert it),
// both bearer-gated. Never 5xx: a degraded store returns {} on GET and ok:false
// on PUT so the client falls back to localStorage.
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
setCORS(w, "GET, PUT, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPut {
writeError(w, http.StatusMethodNotAllowed, "GET or PUT")
return
}
bearer := userBearer(r)
if bearer == "" {
writeError(w, http.StatusUnauthorized, "Sign in required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := s.introspectIdentity(ctx, bearer)
if err != nil || id.Sub == "" {
writeError(w, http.StatusUnauthorized, "Sign in required")
return
}
ident := store.Identity{Org: id.Org, UserSub: id.Sub, Project: r.URL.Query().Get("project")}
if r.Method == http.MethodGet {
blob, ok := s.store.Settings.Get(ident)
if !ok {
blob = json.RawMessage(`{}`)
}
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{"settings": blob})
return
}
// PUT: validate the body is a JSON object at the boundary, then upsert.
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10))
if err != nil {
writeError(w, http.StatusBadRequest, "Body too large")
return
}
if !json.Valid(raw) || !isJSONObject(raw) {
writeError(w, http.StatusBadRequest, "Body must be a JSON object")
return
}
writeJSON(w, http.StatusOK, "private, no-store", map[string]any{
"ok": s.store.Settings.Put(ident, json.RawMessage(raw)),
})
}
// isJSONObject reports whether raw is a JSON object ({...}), not an array or
// scalar — settings are always an object blob.
func isJSONObject(raw []byte) bool {
raw = bytes.TrimSpace(raw)
return len(raw) > 0 && raw[0] == '{'
}
+115
View File
@@ -0,0 +1,115 @@
package world
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// stubIAM stands up a userinfo endpoint that maps a bearer token to an IAM
// identity, and points the server's issuer at it. This exercises the real
// introspectIdentity path hermetically (no live IAM).
func stubIAM(t *testing.T, tokenToIdentity map[string]wIdentity) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/iam/oauth/userinfo" {
http.NotFound(w, r)
return
}
tok := r.Header.Get("Authorization")
id, ok := tokenToIdentity[tok]
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(id)
}))
t.Cleanup(srv.Close)
t.Setenv("HANZO_IAM_ISSUER", srv.URL)
}
func TestSettingsAnonymousUnauthorized(t *testing.T) {
s := newTestServer(t)
_, code := serve(t, s.handleSettings, http.MethodGet, "/v1/world/settings", "", "")
if code != http.StatusUnauthorized {
t.Fatalf("anonymous GET status = %d, want 401", code)
}
_, code = serve(t, s.handleSettings, http.MethodPut, "/v1/world/settings", "", `{"a":1}`)
if code != http.StatusUnauthorized {
t.Fatalf("anonymous PUT status = %d, want 401", code)
}
}
func TestSettingsUpsertGetPerIdentity(t *testing.T) {
s := newTestServer(t)
stubIAM(t, map[string]wIdentity{
"Bearer alice": {Org: "acme", Sub: "alice"},
"Bearer bob": {Org: "acme", Sub: "bob"},
})
// Alice stores her dashboard.
m, code := serve(t, s.handleSettings, http.MethodPut, "/v1/world/settings", "alice", `{"layout":"grid","cell":40}`)
if code != http.StatusOK || m["ok"] != true {
t.Fatalf("alice PUT = %d %v", code, m)
}
// Bob stores a different one.
if m, code := serve(t, s.handleSettings, http.MethodPut, "/v1/world/settings", "bob", `{"layout":"list"}`); code != http.StatusOK || m["ok"] != true {
t.Fatalf("bob PUT = %d %v", code, m)
}
// Each reads back exactly their own — server-side, cross-device, isolated.
m, code = serve(t, s.handleSettings, http.MethodGet, "/v1/world/settings", "alice", "")
if code != http.StatusOK {
t.Fatalf("alice GET status = %d", code)
}
if got, want := jsonStr(t, m["settings"]), normJSON(t, `{"layout":"grid","cell":40}`); got != want {
t.Fatalf("alice settings = %s, want %s", got, want)
}
m, _ = serve(t, s.handleSettings, http.MethodGet, "/v1/world/settings", "bob", "")
if got, want := jsonStr(t, m["settings"]), normJSON(t, `{"layout":"list"}`); got != want {
t.Fatalf("bob settings = %s, want %s (identity isolation broken)", got, want)
}
}
func TestSettingsRejectsNonObject(t *testing.T) {
s := newTestServer(t)
stubIAM(t, map[string]wIdentity{"Bearer alice": {Org: "acme", Sub: "alice"}})
_, code := serve(t, s.handleSettings, http.MethodPut, "/v1/world/settings", "alice", `[1,2,3]`)
if code != http.StatusBadRequest {
t.Fatalf("array body status = %d, want 400", code)
}
}
func TestSettingsMissingIsEmptyObject(t *testing.T) {
s := newTestServer(t)
stubIAM(t, map[string]wIdentity{"Bearer newuser": {Org: "acme", Sub: "newuser"}})
m, code := serve(t, s.handleSettings, http.MethodGet, "/v1/world/settings", "newuser", "")
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if got := jsonStr(t, m["settings"]); got != `{}` {
t.Fatalf("absent settings = %s, want {}", got)
}
}
func jsonStr(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(b)
}
// normJSON round-trips a JSON literal through decode+encode so map key ordering
// matches jsonStr's output (Go marshals map keys sorted) — order-independent
// comparison.
func normJSON(t *testing.T, s string) string {
t.Helper()
var v any
if err := json.Unmarshal([]byte(s), &v); err != nil {
t.Fatalf("unmarshal %q: %v", s, err)
}
return jsonStr(t, v)
}
+158
View File
@@ -0,0 +1,158 @@
package world
import (
"context"
"encoding/json"
"html"
"net/http"
"regexp"
"strings"
"time"
)
// handleYouTubeSearch resolves a free-text query (e.g. "Milken Institute Jensen
// Huang 2025") to a ranked list of non-live YouTube videos, so the analyst — or
// the watch-queue search box — can turn "queue that talk" into a real video id.
// Uses the YouTube Data API (same key as youtube/live); degrades to an empty,
// honest result set when no key is configured rather than faking hits.
func (s *Server) handleYouTubeSearch(w http.ResponseWriter, r *http.Request) {
if preflight(w, r, "GET, OPTIONS") {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeError(w, http.StatusBadRequest, "Missing q parameter")
return
}
cacheKey := "youtube-search:" + strings.ToLower(q)
if v, ok := s.cache.Get(cacheKey); ok {
writeJSON(w, http.StatusOK, "public, max-age=600, s-maxage=600", v)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// No key required: scrape the results page, exactly as handleYouTubeLive
// scrapes /live (same UA + consent cookies to dodge the interstitial served
// to datacenter IPs). The Data API is only a faster path when a key exists.
key := env("YOUTUBE_API_KEY", "YT_API_KEY")
if key == "" {
results := s.youtubeSearchScrape(ctx, q)
out := map[string]any{"results": results}
if len(results) > 0 {
s.cache.Set(cacheKey, out, 10*time.Minute, 60*time.Minute)
}
writeJSON(w, http.StatusOK, "public, max-age=600, s-maxage=600", out)
return
}
var resp struct {
Items []struct {
ID struct {
VideoID string `json:"videoId"`
} `json:"id"`
Snippet struct {
Title string `json:"title"`
ChannelTitle string `json:"channelTitle"`
PublishedAt string `json:"publishedAt"`
Thumbnails struct {
Medium struct {
URL string `json:"url"`
} `json:"medium"`
} `json:"thumbnails"`
} `json:"snippet"`
} `json:"items"`
}
u := "https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&maxResults=12&q=" +
urlQueryEscape(q) + "&key=" + key
if err := s.getJSON(ctx, u, nil, &resp); err != nil {
writeError(w, http.StatusBadGateway, "youtube search failed")
return
}
results := make([]map[string]any, 0, len(resp.Items))
for _, it := range resp.Items {
if it.ID.VideoID == "" {
continue
}
results = append(results, map[string]any{
"id": it.ID.VideoID,
// The Data API returns titles HTML-escaped (&amp;, &#39;) — unescape
// so the client renders plain text and re-escapes once itself.
"title": html.UnescapeString(it.Snippet.Title),
"channel": html.UnescapeString(it.Snippet.ChannelTitle),
"thumbnail": it.Snippet.Thumbnails.Medium.URL,
"publishedAt": it.Snippet.PublishedAt,
})
}
out := map[string]any{"results": results}
s.cache.Set(cacheKey, out, 10*time.Minute, 60*time.Minute)
writeJSON(w, http.StatusOK, "public, max-age=600, s-maxage=600", out)
}
// ytRendererRe finds each video result block in the results-page ytInitialData.
var ytRendererRe = regexp.MustCompile(`"videoRenderer":\{"videoId":"([A-Za-z0-9_-]{11})"`)
// ytTitleRe / ytOwnerRe pull the title and channel out of one renderer block.
var ytTitleRe = regexp.MustCompile(`"title":\{"runs":\[\{"text":"((?:[^"\\]|\\.)*)"`)
var ytOwnerRe = regexp.MustCompile(`"ownerText":\{"runs":\[\{"text":"((?:[^"\\]|\\.)*)"`)
// youtubeSearchScrape resolves a query to videos WITHOUT an API key by reading
// the results page. Returns an empty slice (never an error) when YouTube shape-
// shifts — the caller then reports an honest empty result rather than a fake one.
func (s *Server) youtubeSearchScrape(ctx context.Context, q string) []map[string]any {
// sp=EgIQAQ%3D%3D → filter to type:video (drops channels/playlists/shorts).
u := "https://www.youtube.com/results?search_query=" + urlQueryEscape(q) +
"&sp=EgIQAQ%3D%3D&hl=en&gl=US"
page, err := s.getText(ctx, u, map[string]string{
"User-Agent": browserUA,
"Accept-Language": "en-US,en;q=0.9",
"Cookie": "SOCS=CAISNQgDEitib3FfaWRlbnRpdHlfMjAyNDA4MjcuMDFfcDAaAmVuIAEaBgiA_LyxBg; CONSENT=YES+",
})
if err != nil {
return []map[string]any{}
}
locs := ytRendererRe.FindAllStringSubmatchIndex(page, 20)
results := make([]map[string]any, 0, len(locs))
seen := map[string]bool{}
for i, loc := range locs {
id := page[loc[2]:loc[3]]
if seen[id] {
continue
}
seen[id] = true
// Scope title/channel lookup to THIS renderer block, so a later result's
// title can never be attributed to an earlier video.
end := len(page)
if i+1 < len(locs) {
end = locs[i+1][0]
}
block := page[loc[1]:end]
title := ytUnquote(ytTitleRe, block)
if title == "" {
continue
}
results = append(results, map[string]any{
"id": id,
"title": title,
"channel": ytUnquote(ytOwnerRe, block),
"thumbnail": "https://i.ytimg.com/vi/" + id + "/mqdefault.jpg",
"publishedAt": "",
})
}
return results
}
// ytUnquote applies re to block and JSON-unescapes the captured text (the page
// embeds &, \" etc.).
func ytUnquote(re *regexp.Regexp, block string) string {
m := re.FindStringSubmatch(block)
if m == nil {
return ""
}
var out string
if err := json.Unmarshal([]byte(`"`+m[1]+`"`), &out); err != nil {
return html.UnescapeString(m[1])
}
return out
}
+136
View File
@@ -0,0 +1,136 @@
// Package kv is world's thin, graceful-degrade client for hanzo-kv — the shared
// Valkey/Redis hot cache (k8s Service hanzo-kv:6379, ns hanzo). It exists so the
// feed/response warm cache is SHARED across all world pods and survives a pod
// restart: warming once benefits the whole fleet, and a restarted pod reads a
// still-warm cache instead of cold-starting.
//
// Every method degrades cleanly — a nil/unconfigured client, an unreachable
// server, or any transport error yields a clean miss / no-op, never a blocking
// call or an error the caller must handle. A tiny circuit breaker parks a
// downed server for a cooldown so the hot path is not repeatedly stalled dialing
// a dead host. The queryable data lake lives in SQLite (package store); this is
// only the speed layer in front of feeds.
package kv
import (
"context"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
// breakerCooldown parks a failing server: after an error, ops short-circuit to
// "miss" for this long instead of re-dialing on every request.
const breakerCooldown = 15 * time.Second
// Client wraps a go-redis client. A zero/disabled Client (r == nil) is valid and
// behaves as a permanent clean miss, so local dev and CI need no Redis.
type Client struct {
r *redis.Client
downUntil atomic.Int64 // unix-nano; server parked until then
}
// Open builds a client for addr (e.g. "hanzo-kv:6379"). An empty addr returns a
// disabled client (pure miss) — the correct behavior for environments without
// hanzo-kv. Timeouts are short so a slow/dead server degrades fast; retries are
// disabled because we fail over to the embedded/in-mem cache, not by retrying.
func Open(addr, password string) *Client {
if addr == "" {
return &Client{}
}
return &Client{r: redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DialTimeout: 2 * time.Second,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
PoolSize: 8,
MaxRetries: -1, // fail fast; degrade rather than retry
})}
}
// Enabled reports whether a server is configured (not whether it is currently
// reachable).
func (c *Client) Enabled() bool { return c != nil && c.r != nil }
func (c *Client) available() bool {
if c == nil || c.r == nil {
return false
}
return time.Now().UnixNano() >= c.downUntil.Load()
}
// trip parks the server for the cooldown after a transport failure.
func (c *Client) trip() { c.downUntil.Store(time.Now().Add(breakerCooldown).UnixNano()) }
// GetBytes returns the value for key, or (nil,false) on miss/failure. A real
// cache miss (redis.Nil) does not trip the breaker; a transport error does.
func (c *Client) GetBytes(ctx context.Context, key string) ([]byte, bool) {
if !c.available() {
return nil, false
}
b, err := c.r.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, false
}
if err != nil {
c.trip()
return nil, false
}
return b, true
}
// SetBytes writes key=val with a TTL. Best-effort: failures trip the breaker and
// are otherwise ignored (the value is still cached in the per-pod mirror).
func (c *Client) SetBytes(ctx context.Context, key string, val []byte, ttl time.Duration) {
if !c.available() {
return
}
if err := c.r.Set(ctx, key, val, ttl).Err(); err != nil {
c.trip()
}
}
// SAdd adds members to a set (the fleet-wide warm-URL registry). Best-effort.
func (c *Client) SAdd(ctx context.Context, key string, members ...string) {
if !c.available() || len(members) == 0 {
return
}
vals := make([]any, len(members))
for i, m := range members {
vals[i] = m
}
if err := c.r.SAdd(ctx, key, vals...).Err(); err != nil {
c.trip()
}
}
// SMembers returns the set members, or nil on miss/failure.
func (c *Client) SMembers(ctx context.Context, key string) []string {
if !c.available() {
return nil
}
v, err := c.r.SMembers(ctx, key).Result()
if err != nil {
c.trip()
return nil
}
return v
}
// Ping checks reachability (used once at boot to log status). Returns an error
// when disabled or unreachable.
func (c *Client) Ping(ctx context.Context) error {
if c == nil || c.r == nil {
return redis.ErrClosed
}
return c.r.Ping(ctx).Err()
}
// Close releases the connection pool. Safe on a disabled client.
func (c *Client) Close() {
if c != nil && c.r != nil {
_ = c.r.Close()
}
}
-7
View File
@@ -272,13 +272,6 @@ func toolResult(body []byte, isErr bool) map[string]any {
return res
}
func toolError(msg string) map[string]any {
return map[string]any{
"content": []any{map[string]any{"type": "text", "text": msg}},
"isError": true,
}
}
// ── http helpers ─────────────────────────────────────────────────────────────
// setMCPCORS mirrors the world backend's wildcard policy (public data, no
+13 -46
View File
@@ -25,9 +25,19 @@ type Engine struct {
snapPath string
history *History
// sink, when set, receives every cycle's raw observations after they fold.
// It lets an owner (package world) dump observations into the queryable data
// lake WITHOUT this package knowing anything about storage — the engine stays
// decomplected from the datastore; it just calls a value-in hook.
sink func([]Observation)
startOnce sync.Once
}
// SetObservationSink registers a hook called with each cycle's observations after
// they are folded. Set once before Start; nil (the default) is a no-op.
func (e *Engine) SetObservationSink(fn func([]Observation)) { e.sink = fn }
// New builds an engine. dataDir is where the warm-start snapshot and the history
// ring live; interval<=0 uses DefaultInterval.
func New(sources []Source, dataDir string, interval time.Duration) *Engine {
@@ -43,9 +53,6 @@ func New(sources []Source, dataDir string, interval time.Duration) *Engine {
}
}
// History exposes the durable snapshot ring (queried by the /history handler).
func (e *Engine) History() *History { return e.history }
// Store exposes the state store to the API handlers.
func (e *Engine) Store() *Store { return e.store }
@@ -114,6 +121,9 @@ func (e *Engine) IngestOnce(ctx context.Context) {
wg.Wait()
changes := e.store.Apply(all, time.Now().UTC())
log.Printf("world-model: ingest folded %d observations, %d changes", len(all), len(changes))
if e.sink != nil && len(all) > 0 {
e.sink(all)
}
}
// ── snapshot ─────────────────────────────────────────────────────────────────
@@ -173,41 +183,6 @@ func (e *Engine) save() {
// ── AI grounding (ModelContext) ──────────────────────────────────────────────
// Context returns a compact plain-text briefing of the current world state —
// the top movers and highest-instability entities — for grounding AI answers in
// the model instead of raw feeds. One helper, called by the AI handlers.
func (e *Engine) Context() string {
top := e.store.Top(KindCountry, MetricInstability, 8)
movers := e.store.Top(KindCountry, "velocity", 6)
theaters := e.store.Top(KindTheater, MetricInstability, 4)
asOf := e.store.AsOf()
var b strings.Builder
b.WriteString("WORLD MODEL (as of ")
b.WriteString(asOf.Format(time.RFC3339))
b.WriteString(")\nHighest instability:\n")
for _, ent := range top {
b.WriteString(" - " + ent.Name + " (" + ent.ID + "): instability " +
ftoa(ent.Metrics[MetricInstability]) + " [" + ent.Level + "]" + toneNote(ent) + "\n")
}
b.WriteString("Biggest news movers:\n")
for _, ent := range movers {
v := ent.Metrics[MetricNewsVelocity]
if v == 0 {
continue
}
b.WriteString(" - " + ent.Name + ": news velocity " + signed(v) + "\n")
}
if len(theaters) > 0 {
b.WriteString("Active theaters:\n")
for _, ent := range theaters {
b.WriteString(" - " + ent.Name + ": " + ent.Level + " (" +
ftoa(ent.Metrics[MetricMilitaryActivity]) + " mil. aircraft)\n")
}
}
return strings.TrimRight(b.String(), "\n")
}
// CountryContext returns the state vector for one country (ISO alpha-2) as a
// JSON-friendly map, or false if the model has no such entity. AI country
// briefs merge this so the narrative matches the numbers.
@@ -223,14 +198,6 @@ func (e *Engine) CountryContext(iso string) (map[string]any, bool) {
}, true
}
func toneNote(e *Entity) string {
t, ok := e.Metrics[MetricSentiment]
if !ok {
return ""
}
return ", tone " + ftoa(t)
}
func ftoa(f float64) string {
b, _ := json.Marshal(round2(f))
return string(b)
@@ -0,0 +1,174 @@
package world
// RED adversarial test (NOT part of Blue's commits — orchestrator may keep or delete).
//
// Proves the unified IAM-introspection admin gate (introspectIdentity → requireAdmin)
// is fail-closed across the full matrix Blue left "network-bound"/untested:
// - no token → 401
// - non-admin owner → 403
// - empty/missing owner → 403 (NOT admin, even though isAdminOrg is not env-driven)
// - IAM 401 / 500 / non-JSON 200 → 403 (introspection failure)
// - admin / built-in owner → gate PASSES (not 401/403)
// - owner with surrounding whitespace → trimmed, PASSES
// - shared identity cache poisoning → a settings-populated non-admin identity
// cannot elevate the admin gate
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// iamStub serves /v1/iam/oauth/userinfo with a fixed status + body.
func iamStub(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/iam/oauth/userinfo" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
// apiStub returns 200 {} for any path — a passing gate must reach here and 200.
func apiStub(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
t.Cleanup(srv.Close)
return srv
}
func TestRedAdminGateMatrix(t *testing.T) {
const adminRoute = "/v1/world/cloud/fleet"
cases := []struct {
name string
iamStatus int
iamBody string
bearer string // "" = send no Authorization header
wantStatus int // exact status when deterministic
gatePass bool // true = assert NOT 401 and NOT 403 (downstream may vary)
}{
{name: "no token → 401", bearer: "", wantStatus: http.StatusUnauthorized},
{name: "non-admin owner → 403", iamStatus: 200, iamBody: `{"owner":"acme","sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "empty owner → 403", iamStatus: 200, iamBody: `{"owner":"","sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "missing owner claim → 403", iamStatus: 200, iamBody: `{"sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "owner=null → 403", iamStatus: 200, iamBody: `{"owner":null,"sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "IAM 401 (bad token) → 403", iamStatus: 401, iamBody: `{"error":"invalid_token"}`, bearer: "Bearer bad", wantStatus: http.StatusForbidden},
{name: "IAM 500 → 403", iamStatus: 500, iamBody: `oops`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "IAM 200 non-JSON → 403", iamStatus: 200, iamBody: `<html>not json</html>`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "IAM 200 owner as number → 403", iamStatus: 200, iamBody: `{"owner":123,"sub":"u1"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "admin owner → gate passes", iamStatus: 200, iamBody: `{"owner":"admin","sub":"z"}`, bearer: "Bearer good", gatePass: true},
{name: "built-in owner → gate passes", iamStatus: 200, iamBody: `{"owner":"built-in","sub":"z"}`, bearer: "Bearer good", gatePass: true},
{name: "admin owner padded whitespace → gate passes", iamStatus: 200, iamBody: `{"owner":" admin ","sub":"z"}`, bearer: "Bearer good", gatePass: true},
{name: "near-miss 'administrator' → 403", iamStatus: 200, iamBody: `{"owner":"administrator","sub":"z"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
{name: "case-variant 'Admin' → 403", iamStatus: 200, iamBody: `{"owner":"Admin","sub":"z"}`, bearer: "Bearer good", wantStatus: http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
iam := iamStub(t, tc.iamStatus, tc.iamBody)
api := apiStub(t)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("HANZO_API_BASE", api.URL)
s := NewServer()
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
req, _ := http.NewRequest(http.MethodGet, ts.URL+adminRoute, nil)
if tc.bearer != "" {
req.Header.Set("Authorization", tc.bearer)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if tc.gatePass {
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
t.Fatalf("admin owner was REJECTED (%d); gate must pass", resp.StatusCode)
}
return
}
if resp.StatusCode != tc.wantStatus {
t.Fatalf("got %d, want %d", resp.StatusCode, tc.wantStatus)
}
})
}
}
// TestRedSharedCacheNoElevation proves the unification's shared "identity:" cache
// cannot elevate: an identity resolved for the SETTINGS path (non-admin owner) is
// the very same cache entry requireAdmin reads — and it still 403s. If the gate
// ever trusted a settings-populated entry as admin, this fails.
func TestRedSharedCacheNoElevation(t *testing.T) {
iam := iamStub(t, 200, `{"owner":"acme","sub":"u1"}`)
api := apiStub(t)
t.Setenv("HANZO_IAM_ISSUER", iam.URL)
t.Setenv("HANZO_API_BASE", api.URL)
s := NewServer()
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
bearer := "Bearer settings-first"
// 1) Populate the shared identity cache exactly as the settings path would.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
id, err := s.introspectIdentity(ctx, bearer)
if err != nil || id.Org != "acme" || id.Sub != "u1" {
t.Fatalf("cache priming failed: id=%+v err=%v", id, err)
}
// 2) Now hit the admin route with the SAME token. The gate reads the SAME cache
// entry (no second IAM call) and MUST still reject a non-admin owner.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/world/cloud/fleet", nil)
req.Header.Set("Authorization", bearer)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("shared-cache elevation: non-admin token got %d on admin route, want 403", resp.StatusCode)
}
}
// TestRedIsAdminOrgExact locks the admin-org predicate: only the two hardcoded
// orgs (trimmed) qualify; empty never does. This is the property that makes the
// "empty ADMIN_ORG == empty owner" class of bug structurally impossible — there
// is no env-driven admin org to leave unset.
func TestRedIsAdminOrgExact(t *testing.T) {
admit := map[string]bool{
"admin": true, "built-in": true,
" admin ": true, "\tbuilt-in\n": true,
}
deny := []string{"", " ", "Admin", "ADMIN", "administrator", "built_in", "builtin", "admins", "acme", "\x00admin"}
for in, want := range admit {
if isAdminOrg(in) != want {
t.Fatalf("isAdminOrg(%q) = %v, want %v", in, !want, want)
}
}
for _, in := range deny {
if isAdminOrg(in) {
t.Fatalf("isAdminOrg(%q) = true, want false", in)
}
}
}
+24
View File
@@ -48,6 +48,7 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/ais-snapshot", s.handleAISSnapshot)
mux.HandleFunc("/v1/world/firms-fires", s.handleFIRMS)
mux.HandleFunc("/v1/world/earthquakes", s.handleEarthquakes)
mux.HandleFunc("/v1/world/hko-warnings", s.handleHKOWarnings)
mux.HandleFunc("/v1/world/climate-anomalies", s.handleClimate)
mux.HandleFunc("/v1/world/wingbits", s.handleWingbits)
mux.HandleFunc("/v1/world/wingbits/", s.handleWingbits)
@@ -64,9 +65,22 @@ func (s *Server) mount(mux registrar) {
mux.HandleFunc("/v1/world/fwdstart", s.handleFwdstart)
mux.HandleFunc("/v1/world/youtube/live", s.handleYouTubeLive)
mux.HandleFunc("/v1/world/youtube/embed", s.handleYouTubeEmbed)
mux.HandleFunc("/v1/world/youtube/search", s.handleYouTubeSearch)
mux.HandleFunc("/v1/world/monitors", s.handleMonitors)
mux.HandleFunc("/v1/world/monitors/matches", s.handleMonitorMatches)
// ingested-data lake — the "one place to query everything" (search +
// analytics across ALL ingested items: news, model observations, …).
mux.HandleFunc("/v1/world/search", s.handleSearch)
mux.HandleFunc("/v1/world/analytics", s.handleAnalytics)
// per-identity settings — server-side dashboard sync for signed-in users
// (bearer-gated; anonymous keeps localStorage).
mux.HandleFunc("/v1/world/settings", s.handleSettings)
// econ / humanitarian
mux.HandleFunc("/v1/world/fred-data", s.handleFRED)
mux.HandleFunc("/v1/world/china-macro", s.handleChinaMacro)
mux.HandleFunc("/v1/world/worldbank", s.handleWorldBank)
mux.HandleFunc("/v1/world/eia", s.handleEIA)
mux.HandleFunc("/v1/world/eia/", s.handleEIA)
@@ -92,6 +106,16 @@ func (s *Server) mount(mux registrar) {
// configured. Org-scoped drill-down goes straight to api.hanzo.ai, not here.
mux.HandleFunc("/v1/world/cloud-pulse", s.handleCloudPulse)
// AI Compute pulse (AI variant): live inference volume + serving fleet, pushed
// over SSE (EventSource) with a plain-GET JSON snapshot as the poll fallback.
// Same honest platform aggregate as cloud-pulse; "unavailable" without a token.
mux.HandleFunc("/v1/world/ai-pulse", s.handleAIPulse)
// Enso flywheel (AI variant): the router self-improvement loop — routing-ledger
// tail + reward tail (super-admin) folded with the latest enso-bench eval
// scores (embedded snapshot / ENSO_BENCH_URL). Event-typed; evals-only degrade.
mux.HandleFunc("/v1/world/enso-training", s.handleEnsoTraining)
// Cloud console. PUBLIC excitement layer (real, non-sensitive):
mux.HandleFunc("/v1/world/cloud/models", s.handleCloudModels)
// PUBLIC map layers (real telemetry when reachable; modeled/demo carries a flag):
+154 -29
View File
@@ -19,10 +19,13 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hanzoai/world/internal/world/kv"
"github.com/hanzoai/world/internal/world/mcp"
"github.com/hanzoai/world/internal/world/model"
"github.com/hanzoai/world/internal/world/store"
)
const (
@@ -39,21 +42,32 @@ const (
type Server struct {
client *http.Client
cache *Cache
flight *flightGroup
ai *AIClient
worldModel *model.Engine
mcp *mcp.Server
// Datastore layer (see datastore.go): kv is the shared hanzo-kv hot cache,
// feeds is the two-tier warm feed-body cache in front of it, and store is the
// embedded SQLite lake + per-identity settings.
kv *kv.Client
store *store.DB
feeds *FeedCache
}
// NewServer constructs the backend and its world-model engine (built from the
// feed sources in model_sources.go). Call StartModel to begin ingest.
// NewServer constructs the backend, its world-model engine (built from the feed
// sources in model_sources.go), and the datastore layer. Call StartModel and
// StartDatastore to begin the background loops.
func NewServer() *Server {
s := &Server{
client: &http.Client{Timeout: 25 * time.Second},
cache: NewCache(4096),
flight: newFlightGroup(),
ai: newAIClient(),
mcp: mcp.New(),
}
s.worldModel = model.New(s.modelSources(), modelDataDir(), modelInterval())
s.initDatastore()
return s
}
@@ -216,10 +230,80 @@ func methodNotGet(w http.ResponseWriter, r *http.Request) bool {
// ── caching helpers ─────────────────────────────────────────────────────────
// cachedJSON serves a JSON endpoint through the shared cache. produce returns
// the value to cache and return; on error, a stale cached value is served if
// available, otherwise onError decides the response. This captures the dominant
// "check cache → fetch → transform → cache → fallback" pattern once.
// flightGroup coalesces concurrent work on a cache key so N simultaneous misses
// cause ONE upstream fetch. It is the single-flight guard shared by cachedJSON
// and passthrough: a blocking caller (do) leads or waits-and-shares; a
// background revalidation (tryGo) leads or skips when a leader is already
// running. Keyed by the cache key, so different endpoints never collide.
type flightGroup struct {
mu sync.Mutex
m map[string]*flightCall
}
type flightCall struct {
done chan struct{}
val any
err error
}
func newFlightGroup() *flightGroup { return &flightGroup{m: map[string]*flightCall{}} }
// do runs fn for key, coalescing concurrent callers: the first (leader) runs fn
// once; the rest block on it and share its (val, err).
func (g *flightGroup) do(key string, fn func() (any, error)) (any, error) {
g.mu.Lock()
if c, ok := g.m[key]; ok {
g.mu.Unlock()
<-c.done
return c.val, c.err
}
c := &flightCall{done: make(chan struct{})}
g.m[key] = c
g.mu.Unlock()
g.run(key, c, fn)
return c.val, c.err
}
// tryGo runs fn in the background for key unless a call is already in flight,
// so concurrent misses don't stack refreshes. Reports whether it started one.
func (g *flightGroup) tryGo(key string, fn func() (any, error)) bool {
g.mu.Lock()
if _, ok := g.m[key]; ok {
g.mu.Unlock()
return false
}
c := &flightCall{done: make(chan struct{})}
g.m[key] = c
g.mu.Unlock()
go g.run(key, c, fn)
return true
}
// run executes fn, records its result, then releases the key and wakes waiters.
// A panic in fn is converted to an error (never crashes the background refresh
// goroutine) so a failed produce degrades cleanly like any other error.
func (g *flightGroup) run(key string, c *flightCall, fn func() (any, error)) {
defer func() {
if r := recover(); r != nil {
c.err = fmt.Errorf("panic: %v", r)
}
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
close(c.done)
}()
c.val, c.err = fn()
}
// cachedJSON serves a JSON endpoint through the shared cache with
// stale-while-revalidate. produce returns the value to cache and return.
// - Fresh hit: served immediately.
// - Stale hit (TTL lapsed, still within the stale window): the stale value is
// served INSTANTLY and a single coalesced background refresh is kicked, so a
// lapsed TTL never blocks a request on the ~10s upstream.
// - True cold miss: blocks on produce, but single-flighted so N cold callers
// cause one upstream fetch; on error a stale value is served if one appeared,
// otherwise onError decides the response.
func (s *Server) cachedJSON(
w http.ResponseWriter,
key, cacheControl string,
@@ -231,9 +315,22 @@ func (s *Server) cachedJSON(
writeJSON(w, http.StatusOK, cacheControl, v)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
v, err := produce(ctx)
fetch := func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
v, err := produce(ctx)
if err != nil {
return nil, err
}
s.cache.Set(key, v, ttl, staleFor)
return v, nil
}
if stale, ok := s.cache.GetStale(key); ok {
s.flight.tryGo(key, fetch)
writeJSON(w, http.StatusOK, cacheControl, stale)
return
}
v, err := s.flight.do(key, fetch)
if err != nil {
if stale, ok := s.cache.GetStale(key); ok {
writeJSON(w, http.StatusOK, cacheControl, stale)
@@ -242,7 +339,6 @@ func (s *Server) cachedJSON(
onError(w, err)
return
}
s.cache.Set(key, v, ttl, staleFor)
writeJSON(w, http.StatusOK, cacheControl, v)
}
@@ -257,11 +353,39 @@ const negativeTTL = 30 * time.Second
// it must never become the cached value.
func isBlankBody(body []byte) bool { return len(bytes.TrimSpace(body)) == 0 }
// fetchAndCache fetches upstream for key and applies the pass-through caching
// POLICY once: a good body is cached fresh (Set); a blank 200 or non-2xx is a
// failure that is never cached (it would poison good data) and instead sets a
// short negative marker so a flapping source is not re-hit every request. Shared
// by passthrough and the boot warmer so the policy lives in exactly one place.
func (s *Server) fetchAndCache(ctx context.Context, key, upstream string, headers map[string]string, ttl, staleFor time.Duration) ([]byte, error) {
body, status, err := s.get(ctx, upstream, headers)
if err != nil || status < 200 || status >= 300 || isBlankBody(body) {
if err == nil {
if status < 200 || status >= 300 {
err = fmt.Errorf("upstream status %d", status)
} else {
err = fmt.Errorf("upstream returned empty body")
}
}
s.cache.SetNegative(key, negativeTTL)
return nil, err
}
s.cache.Set(key, body, ttl, staleFor)
return body, nil
}
// passthrough proxies a fixed upstream URL, returning its body verbatim with a
// short in-memory TTL cache. Used by the pure pass-through endpoints. A blank
// 200 is treated as a failure (never cached — it would poison good data for the
// whole TTL). On any failure a stale body is served if present, else the
// upstream is negative-cached briefly and degraded is written no-store.
// short in-memory TTL cache and stale-while-revalidate. Used by the pure
// pass-through endpoints.
// - Fresh hit: served immediately.
// - Recent failure (negative marker): serve last-good stale or degrade — the
// upstream is not re-hit.
// - Stale hit: the stale body is served INSTANTLY and a single coalesced
// background refresh is kicked, so a lapsed TTL never blocks the request.
// - Cold miss: blocks on the fetch, single-flighted so N cold callers cause one
// upstream hit; on failure a stale body is served if present, else the
// upstream is negative-cached briefly and degraded is written no-store.
func (s *Server) passthrough(
w http.ResponseWriter,
key, upstream, contentType, cacheControl string,
@@ -273,29 +397,30 @@ func (s *Server) passthrough(
writeBytes(w, http.StatusOK, contentType, cacheControl, v.([]byte))
return
}
// A recent blank/failed fetch: don't re-hit the upstream, serve last-good
// stale or degrade cleanly.
if s.cache.Negative(key) {
s.degradeBytes(w, key, contentType, cacheControl, degraded, fmt.Errorf("upstream recently failed"))
return
}
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
body, status, err := s.get(ctx, upstream, headers)
if err != nil || status < 200 || status >= 300 || isBlankBody(body) {
if err == nil {
if status < 200 || status >= 300 {
err = fmt.Errorf("upstream status %d", status)
} else {
err = fmt.Errorf("upstream returned empty body")
}
fetch := func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 24*time.Second)
defer cancel()
body, err := s.fetchAndCache(ctx, key, upstream, headers, ttl, staleFor)
if err != nil {
return nil, err
}
s.cache.SetNegative(key, negativeTTL)
return body, nil
}
if v, ok := s.cache.GetStale(key); ok {
s.flight.tryGo(key, fetch)
writeBytes(w, http.StatusOK, contentType, cacheControl, v.([]byte))
return
}
v, err := s.flight.do(key, fetch)
if err != nil {
s.degradeBytes(w, key, contentType, cacheControl, degraded, err)
return
}
s.cache.Set(key, body, ttl, staleFor)
writeBytes(w, http.StatusOK, contentType, cacheControl, body)
writeBytes(w, http.StatusOK, contentType, cacheControl, v.([]byte))
}
// degradeBytes serves the last-good stale body when present, else the handler's
+113
View File
@@ -0,0 +1,113 @@
package world
import (
"encoding/json"
"math"
"reflect"
"strings"
"testing"
)
// Yahoo returns chart closes as float32 widened to float64, so JSON re-serializes
// the conversion noise verbatim — 17.209999084472656, 18 characters for 17.21.
// roundSig strips that to 7 significant digits (significant digits, NOT fixed
// decimals, so a sub-1.0 FX rate keeps its precision). Values verified against the
// upstream toPrecision(7) semantics in tests/sparkline-precision.test.mjs.
func TestRoundSig(t *testing.T) {
cases := []struct {
in, want float64
}{
{17.209999084472656, 17.21}, // the canonical float32 noise → clean
{17.25, 17.25}, // already clean, unchanged
{16.829999923706055, 16.83},
{0.0071349999, 0.007135}, // sub-1.0: 7 SIG DIGITS kept, not flattened to 0.01
{-0.0071349999, -0.007135}, // sign preserved
{0.6917064189910889, 0.6917064}, // real FX rate — the reason it is sig digits
{0.6908955574035645, 0.6908956}, // rounds up correctly at the 7th digit
{123456.789, 123456.8}, // large: 7 sig < 2dp here — why we never round price scalars
{8675309, 8675309}, // 7-digit integer is exact
{42000, 42000},
{-42.005, -42.005},
{0, 0}, // zero passes through
}
for _, c := range cases {
if got := roundSig(c.in); got != c.want {
t.Errorf("roundSig(%v) = %v, want %v", c.in, got, c.want)
}
}
// A fixed 2dp round would flatten the FX rate to 0.01 and destroy the chart;
// significant digits keep the meaningful precision at any magnitude.
if v := roundSig(0.0071349999); v <= 0.007 || v >= 0.008 {
t.Errorf("FX precision lost: roundSig(0.0071349999) = %v, want ~0.007135", v)
}
// Non-finite values must survive rather than become null/0 and dent the curve.
if v := roundSig(math.NaN()); !math.IsNaN(v) {
t.Errorf("roundSig(NaN) = %v, want NaN", v)
}
if v := roundSig(math.Inf(1)); !math.IsInf(v, 1) {
t.Errorf("roundSig(+Inf) = %v, want +Inf", v)
}
if v := roundSig(math.Inf(-1)); !math.IsInf(v, -1) {
t.Errorf("roundSig(-Inf) = %v, want -Inf", v)
}
}
// The real emit path a client sees: Yahoo Close ([]*float64, nulls and all) →
// compact() drops the nulls → sparkline() rounds the last n for the wire. This
// pins that the shipped array is rounded and noise-free, and — load-bearing —
// that the source closes are NOT mutated, so the price/change scalars derived
// from them upstream stay exact.
func TestSparklinePathRoundsAndPreservesSource(t *testing.T) {
f := func(v float64) *float64 { return &v }
// Real float64 noise copied from market:commodities-bootstrap:v1, with a null
// spliced in (Yahoo emits nulls for missing sessions) that compact must drop.
raw := []*float64{
f(17.209999084472656), f(17.219999313354492), f(17.239999771118164),
nil, f(17.190000534057617), f(17.170000076293945), f(17.25), f(16.829999923706055),
}
closes := compact(raw)
got := sparkline(closes, 30)
want := []float64{17.21, 17.22, 17.24, 17.19, 17.17, 17.25, 16.83}
if !reflect.DeepEqual(got, want) {
t.Fatalf("sparkline(compact(raw)) = %v, want %v", got, want)
}
// The serialized array must carry none of the float32 noise.
b, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), "17.209999") {
t.Errorf("noise reached the wire: %s", b)
}
// It must be materially smaller — this is the entire point (>50% on this series).
before, _ := json.Marshal(closes)
if len(b) >= len(before) {
t.Errorf("no shrink: raw %d bytes, rounded %d bytes", len(before), len(b))
}
// The scalar path reads `closes` directly (price = round2s(last)); sparkline
// must not have mutated it, or a 6-figure price would lose precision.
if closes[0] != 17.209999084472656 {
t.Errorf("source closes mutated: closes[0] = %v, want the raw noisy value", closes[0])
}
}
// sparkline is the one way an emitted close array is built: last n only, nil in →
// nil out (so a failed fetch degrades to JSON null exactly as before, not []).
func TestSparklineTailAndNil(t *testing.T) {
if got := sparkline(nil, 30); got != nil {
t.Errorf("sparkline(nil) = %v, want nil (preserve null shape)", got)
}
long := make([]float64, 50)
for i := range long {
long[i] = float64(i)
}
got := sparkline(long, 30)
if len(got) != 30 || got[0] != 20 || got[29] != 49 {
t.Errorf("sparkline tail: got len=%d first=%v last=%v, want last 30 (20..49)", len(got), got[0], got[29])
}
}
+420
View File
@@ -0,0 +1,420 @@
package store
import (
"context"
"database/sql"
"sort"
"strings"
"time"
)
// Item is one normalized, queryable record in the ingested-data lake. Every
// upstream — news/feed items, events, indicators, market snapshots, model
// observations — folds into this ONE shape so everything is searchable and
// countable together. ID is the stable dedupe key: re-ingesting the same item
// upserts (no duplicates), so the warmer re-touching a feed every few minutes
// keeps the lake fresh without growing it.
type Item struct {
ID string `json:"id"`
Kind string `json:"kind"` // news | event | indicator | market | observation
Source string `json:"source"` // feed host, provider, or model source
TS time.Time `json:"ts"` // the item's own timestamp
Title string `json:"title"`
Text string `json:"text,omitempty"`
Tickers []string `json:"tickers,omitempty"`
Country string `json:"country,omitempty"` // ISO code or ""
Lat float64 `json:"lat,omitempty"`
Lon float64 `json:"lon,omitempty"`
HasGeo bool `json:"-"`
Payload string `json:"payload,omitempty"` // compact original JSON
}
// Lake is the write-behind ingest sink and the query surface (search +
// analytics) over the items table. Writes are buffered and flushed in batched
// transactions by Run so the request path never blocks on disk; reads go
// straight to SQLite (single serialized connection, sub-ms).
type Lake struct {
db *sql.DB // nil in degraded mode
ch chan Item
retention time.Duration
}
// lakeBuffer bounds the write-behind queue. Full → drop (the lake is a
// derived cache of the feeds/model, never the source of truth).
const lakeBuffer = 8192
func newLake(db *sql.DB, retention time.Duration) *Lake {
return &Lake{db: db, ch: make(chan Item, lakeBuffer), retention: retention}
}
// Add enqueues an item for write-behind persistence. Non-blocking: if the
// buffer is full (writer stalled) the item is dropped rather than slowing the
// caller. No-op in degraded mode or without an ID.
func (l *Lake) Add(it Item) {
if l == nil || l.db == nil || it.ID == "" {
return
}
select {
case l.ch <- it:
default: // buffer full — drop; the source will re-emit on its next cycle
}
}
// Run is the write-behind consumer + retention prune loop. It batches queued
// items into periodic transactions and prunes expired rows hourly, until ctx is
// cancelled (final flush on the way out). Start once from the server lifecycle.
func (l *Lake) Run(ctx context.Context) {
if l == nil || l.db == nil {
return
}
batch := make([]Item, 0, 256)
flush := func() {
if len(batch) == 0 {
return
}
if err := l.insert(batch); err != nil {
logStore("lake insert (%d items): %v", len(batch), err)
}
batch = batch[:0]
}
tick := time.NewTicker(time.Second)
defer tick.Stop()
prune := time.NewTicker(time.Hour)
defer prune.Stop()
// Prune once shortly after boot so a restart trims yesterday's backlog.
l.Prune(l.retention)
for {
select {
case <-ctx.Done():
flush()
return
case it := <-l.ch:
batch = append(batch, it)
if len(batch) >= 256 {
flush()
}
case <-tick.C:
flush()
case <-prune.C:
l.Prune(l.retention)
}
}
}
// Flush drains everything queued by Add and writes it synchronously, reusing the
// same insert path Run uses. Run() is the steady-state consumer; Flush exists for
// the callers that need the write to be VISIBLE before they continue — tests, and
// a shutdown that wants the buffer on disk.
func (l *Lake) Flush() {
if l == nil || l.db == nil {
return
}
batch := make([]Item, 0, 256)
for {
select {
case it := <-l.ch:
batch = append(batch, it)
continue
default:
}
break
}
if len(batch) == 0 {
return
}
if err := l.insert(batch); err != nil {
logStore("lake flush (%d items): %v", len(batch), err)
}
}
// insert upserts a batch in one transaction. On conflict it refreshes the
// mutable fields but preserves created_at (first-seen) so retention measures
// true age. The FTS index follows via triggers.
func (l *Lake) insert(batch []Item) error {
tx, err := l.db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT INTO items
(id, kind, source, ts, title, text, tickers, country, lat, lon, payload, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
kind=excluded.kind, source=excluded.source, ts=excluded.ts,
title=excluded.title, text=excluded.text, tickers=excluded.tickers,
country=excluded.country, lat=excluded.lat, lon=excluded.lon,
payload=excluded.payload`)
if err != nil {
_ = tx.Rollback()
return err
}
defer func() { _ = stmt.Close() }()
now := time.Now().Unix()
for _, it := range batch {
var lat, lon any
if it.HasGeo {
lat, lon = it.Lat, it.Lon
}
if _, err := stmt.Exec(it.ID, it.Kind, it.Source, it.TS.Unix(), it.Title, it.Text,
packTickers(it.Tickers), it.Country, lat, lon, it.Payload, now); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
// SearchQuery is a query across the whole lake. Empty Q browses by recency;
// non-empty Q ranks by FTS bm25. All filters are optional and compose.
type SearchQuery struct {
Q string
Kind string
Country string
Ticker string
Since time.Time
Limit int
}
// Search returns matching items, ranked by relevance (with Q) or recency
// (without). Never errors out: any failure yields an empty slice (never-5xx).
func (l *Lake) Search(q SearchQuery) []Item {
if l == nil || l.db == nil {
return []Item{}
}
limit := q.Limit
if limit <= 0 || limit > 100 {
limit = 30
}
// The items table is ALWAYS aliased `i` (bare browse or FTS join), so every
// filter and the projection reference i.<col> uniformly — no per-branch column
// rewriting. Free text goes through the FTS index (bm25 ranked); its absence
// browses by recency. Both share the same filter + projection code below.
const cols = "i.id,i.kind,i.source,i.ts,i.title,i.text,i.tickers,i.country,i.lat,i.lon,i.payload"
var (
sb strings.Builder
args []any
)
fts := ftsQuery(q.Q)
if fts != "" {
sb.WriteString("SELECT " + cols + " FROM items_fts f JOIN items i ON i.rowid=f.rowid WHERE items_fts MATCH ?")
args = append(args, fts)
} else {
sb.WriteString("SELECT " + cols + " FROM items i WHERE 1=1")
}
if q.Kind != "" {
sb.WriteString(" AND i.kind=?")
args = append(args, q.Kind)
}
if q.Country != "" {
sb.WriteString(" AND i.country=?")
args = append(args, strings.ToUpper(q.Country))
}
if !q.Since.IsZero() {
sb.WriteString(" AND i.ts>=?")
args = append(args, q.Since.Unix())
}
if t := strings.ToLower(strings.TrimSpace(q.Ticker)); t != "" {
sb.WriteString(" AND i.tickers LIKE ?")
args = append(args, "% "+t+" %")
}
if fts != "" {
sb.WriteString(" ORDER BY bm25(items_fts) LIMIT ?")
} else {
sb.WriteString(" ORDER BY i.ts DESC LIMIT ?")
}
args = append(args, limit)
rows, err := l.db.Query(sb.String(), args...)
if err != nil {
logStore("search: %v", err)
return []Item{}
}
defer func() { _ = rows.Close() }()
return scanItems(rows)
}
// scanItems reads item rows into a slice.
func scanItems(rows *sql.Rows) []Item {
out := []Item{}
for rows.Next() {
var (
it Item
ts int64
tickers string
lat, lon sql.NullFloat64
)
if err := rows.Scan(&it.ID, &it.Kind, &it.Source, &ts, &it.Title, &it.Text,
&tickers, &it.Country, &lat, &lon, &it.Payload); err != nil {
logStore("scan: %v", err)
continue
}
it.TS = time.Unix(ts, 0).UTC()
it.Tickers = unpackTickers(tickers)
if lat.Valid && lon.Valid {
it.Lat, it.Lon, it.HasGeo = lat.Float64, lon.Float64, true
}
out = append(out, it)
}
return out
}
// Count is one bucket of the analytics summary.
type Count struct {
Key string `json:"key"`
Count int `json:"count"`
}
// AnalyticsSummary is the cross-cutting "what's in the lake" view over the last
// window: totals, breakdown by kind and source, and the top tickers seen.
type AnalyticsSummary struct {
WindowHours int `json:"windowHours"`
Total int `json:"total"`
ByKind []Count `json:"byKind"`
BySource []Count `json:"bySource"`
TopTickers []Count `json:"topTickers"`
Since string `json:"since"`
}
// Analytics summarizes the lake over the last `hours`. Degrades to zeros.
func (l *Lake) Analytics(hours int) AnalyticsSummary {
if hours <= 0 {
hours = 24
}
out := AnalyticsSummary{WindowHours: hours, ByKind: []Count{}, BySource: []Count{}, TopTickers: []Count{}}
if l == nil || l.db == nil {
return out
}
since := time.Now().Add(-time.Duration(hours) * time.Hour)
out.Since = since.UTC().Format(time.RFC3339)
sinceUnix := since.Unix()
_ = l.db.QueryRow(`SELECT COUNT(*) FROM items WHERE ts>=?`, sinceUnix).Scan(&out.Total)
out.ByKind = groupCount(l.db, `SELECT kind, COUNT(*) c FROM items WHERE ts>=? GROUP BY kind ORDER BY c DESC`, sinceUnix)
out.BySource = groupCount(l.db, `SELECT source, COUNT(*) c FROM items WHERE ts>=? AND source!='' GROUP BY source ORDER BY c DESC LIMIT 15`, sinceUnix)
out.TopTickers = topTickers(l.db, sinceUnix, 15)
return out
}
func groupCount(db *sql.DB, query string, args ...any) []Count {
out := []Count{}
rows, err := db.Query(query, args...)
if err != nil {
return out
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var c Count
if err := rows.Scan(&c.Key, &c.Count); err == nil {
out = append(out, c)
}
}
return out
}
// topTickers aggregates the packed tickers column in Go — cheap over a bounded
// recent window and avoids a second normalized table for a summary stat.
func topTickers(db *sql.DB, sinceUnix int64, n int) []Count {
rows, err := db.Query(`SELECT tickers FROM items WHERE ts>=? AND tickers!='' LIMIT 20000`, sinceUnix)
if err != nil {
return []Count{}
}
defer func() { _ = rows.Close() }()
freq := map[string]int{}
for rows.Next() {
var packed string
if err := rows.Scan(&packed); err != nil {
continue
}
for _, t := range unpackTickers(packed) {
freq[t]++
}
}
out := make([]Count, 0, len(freq))
for k, v := range freq {
out = append(out, Count{Key: k, Count: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Key < out[j].Key
})
if len(out) > n {
out = out[:n]
}
return out
}
// Prune deletes items older than the retention window (by first-seen). The FTS
// index is cleaned by the delete trigger. Returns rows removed.
func (l *Lake) Prune(retention time.Duration) int64 {
if l == nil || l.db == nil {
return 0
}
if retention <= 0 {
retention = DefaultRetention
}
cutoff := time.Now().Add(-retention).Unix()
res, err := l.db.Exec(`DELETE FROM items WHERE created_at < ?`, cutoff)
if err != nil {
logStore("prune: %v", err)
return 0
}
n, _ := res.RowsAffected()
if n > 0 {
logStore("pruned %d expired items", n)
}
return n
}
// ── ticker packing ───────────────────────────────────────────────────────────
// packTickers stores tickers as a lowercase, space-padded token string
// (" aapl btc ") so a token filter is an exact LIKE '% aapl %' — no false
// substring hits, no separate table.
func packTickers(tickers []string) string {
if len(tickers) == 0 {
return ""
}
seen := map[string]bool{}
var b strings.Builder
b.WriteByte(' ')
for _, t := range tickers {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] {
continue
}
seen[t] = true
b.WriteString(t)
b.WriteByte(' ')
}
if b.Len() == 1 {
return ""
}
return b.String()
}
func unpackTickers(packed string) []string {
f := strings.Fields(packed)
if len(f) == 0 {
return nil
}
return f
}
// ftsQuery turns free user text into a SAFE FTS5 MATCH expression: alphanumeric
// tokens, each double-quoted (so FTS control chars can never cause a syntax
// error), AND-ed together. Empty when there are no usable tokens (caller then
// browses by recency). This is the SSRF-equivalent boundary for FTS — untrusted
// input can never reach the query grammar unescaped.
func ftsQuery(q string) string {
var tokens []string
for _, f := range strings.FieldsFunc(q, func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9')
}) {
if len(tokens) >= 12 {
break
}
tokens = append(tokens, `"`+f+`"`)
}
return strings.Join(tokens, " ")
}
+161
View File
@@ -0,0 +1,161 @@
package store
import (
"testing"
"time"
)
func TestLakeIngestSearchRoundTrip(t *testing.T) {
db := openTestDB(t)
now := time.Now()
items := []Item{
{ID: "n1", Kind: "news", Source: "coindesk.com", TS: now, Title: "Bitcoin ETF approved by regulators", Tickers: []string{"BTC"}},
{ID: "n2", Kind: "news", Source: "sec.gov", TS: now, Title: "SEC announces new disclosure rules"},
{ID: "o1", Kind: "observation", Source: "gdelt", TS: now, Title: "Ukraine", Country: "UA"},
}
if err := db.Lake.insert(items); err != nil {
t.Fatalf("insert: %v", err)
}
// Full-text: "regulators" hits only n1.
if got := db.Lake.Search(SearchQuery{Q: "regulators"}); len(got) != 1 || got[0].ID != "n1" {
t.Fatalf("search regulators = %+v, want [n1]", ids(got))
}
// Empty query browses by recency across ALL kinds.
if got := db.Lake.Search(SearchQuery{}); len(got) != 3 {
t.Fatalf("browse all = %v, want 3 rows", ids(got))
}
// Tickers survive the round-trip.
got := db.Lake.Search(SearchQuery{Q: "bitcoin"})
if len(got) != 1 || len(got[0].Tickers) != 1 || got[0].Tickers[0] != "btc" {
t.Fatalf("tickers round-trip = %+v", got)
}
}
func TestSearchFTSRanking(t *testing.T) {
db := openTestDB(t)
now := time.Now()
// a mentions the term twice in the title, b once in the body → a ranks first.
if err := db.Lake.insert([]Item{
{ID: "a", Kind: "news", TS: now, Title: "Bitcoin Bitcoin rally continues"},
{ID: "b", Kind: "news", TS: now, Title: "Markets update", Text: "a passing bitcoin mention"},
}); err != nil {
t.Fatalf("insert: %v", err)
}
got := db.Lake.Search(SearchQuery{Q: "bitcoin"})
if len(got) != 2 {
t.Fatalf("want 2 hits, got %v", ids(got))
}
if got[0].ID != "a" {
t.Fatalf("bm25 ranking = %v, want a ranked first", ids(got))
}
}
func TestSearchFilters(t *testing.T) {
db := openTestDB(t)
now := time.Now()
old := now.Add(-48 * time.Hour)
if err := db.Lake.insert([]Item{
{ID: "fresh", Kind: "news", Source: "cnbc.com", TS: now, Title: "Fed rate decision", Country: "US", Tickers: []string{"SPY"}},
{ID: "stale", Kind: "news", Source: "cnbc.com", TS: old, Title: "Fed rate decision old", Country: "US"},
{ID: "obs", Kind: "observation", TS: now, Title: "Fed watch", Country: "US"},
}); err != nil {
t.Fatalf("insert: %v", err)
}
// kind filter
if got := db.Lake.Search(SearchQuery{Q: "fed", Kind: "observation"}); len(got) != 1 || got[0].ID != "obs" {
t.Fatalf("kind filter = %v", ids(got))
}
// since filter drops the 48h-old row
if got := db.Lake.Search(SearchQuery{Q: "fed", Kind: "news", Since: now.Add(-24 * time.Hour)}); len(got) != 1 || got[0].ID != "fresh" {
t.Fatalf("since filter = %v", ids(got))
}
// ticker filter (exact token, no substring false-hit)
if got := db.Lake.Search(SearchQuery{Ticker: "SPY"}); len(got) != 1 || got[0].ID != "fresh" {
t.Fatalf("ticker filter = %v", ids(got))
}
if got := db.Lake.Search(SearchQuery{Ticker: "SP"}); len(got) != 0 {
t.Fatalf("ticker substring must not match, got %v", ids(got))
}
// country filter
if got := db.Lake.Search(SearchQuery{Country: "US"}); len(got) != 3 {
t.Fatalf("country filter = %v, want all 3 US rows", ids(got))
}
}
func TestRetentionPrune(t *testing.T) {
db := openTestDB(t)
now := time.Now()
// Insert one expired and one fresh row with explicit created_at (white-box).
mustExec(t, db, `INSERT INTO items(id,kind,source,ts,title,text,tickers,country,lat,lon,payload,created_at)
VALUES ('old','news','s',?, 'old title','','','',NULL,NULL,'', ?)`,
now.Unix(), now.Add(-10*24*time.Hour).Unix())
mustExec(t, db, `INSERT INTO items(id,kind,source,ts,title,text,tickers,country,lat,lon,payload,created_at)
VALUES ('new','news','s',?, 'new title','','','',NULL,NULL,'', ?)`,
now.Unix(), now.Unix())
if n := db.Lake.Prune(7 * 24 * time.Hour); n != 1 {
t.Fatalf("prune removed %d, want 1", n)
}
// The FTS index must follow the delete (trigger) — the pruned row is unsearchable.
if got := db.Lake.Search(SearchQuery{Q: "old"}); len(got) != 0 {
t.Fatalf("pruned row still searchable via FTS: %v", ids(got))
}
if got := db.Lake.Search(SearchQuery{Q: "new"}); len(got) != 1 {
t.Fatalf("fresh row lost after prune: %v", ids(got))
}
}
func TestAnalyticsSummary(t *testing.T) {
db := openTestDB(t)
now := time.Now()
if err := db.Lake.insert([]Item{
{ID: "1", Kind: "news", Source: "coindesk.com", TS: now, Title: "a", Tickers: []string{"BTC", "ETH"}},
{ID: "2", Kind: "news", Source: "coindesk.com", TS: now, Title: "b", Tickers: []string{"BTC"}},
{ID: "3", Kind: "observation", Source: "gdelt", TS: now, Title: "c"},
}); err != nil {
t.Fatalf("insert: %v", err)
}
a := db.Lake.Analytics(24)
if a.Total != 3 {
t.Fatalf("total = %d, want 3", a.Total)
}
if kind := countFor(a.ByKind, "news"); kind != 2 {
t.Fatalf("byKind news = %d, want 2", kind)
}
if src := countFor(a.BySource, "coindesk.com"); src != 2 {
t.Fatalf("bySource coindesk = %d, want 2", src)
}
if btc := countFor(a.TopTickers, "btc"); btc != 2 {
t.Fatalf("topTickers btc = %d, want 2", btc)
}
if len(a.TopTickers) == 0 || a.TopTickers[0].Key != "btc" {
t.Fatalf("top ticker = %+v, want btc first", a.TopTickers)
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func ids(items []Item) []string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.ID
}
return out
}
func countFor(cs []Count, key string) int {
for _, c := range cs {
if c.Key == key {
return c.Count
}
}
return -1
}
func mustExec(t *testing.T, db *DB, query string, args ...any) {
t.Helper()
if _, err := db.sql.Exec(query, args...); err != nil {
t.Fatalf("exec %q: %v", query, err)
}
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"database/sql"
"encoding/json"
"strings"
"time"
)
// Settings persists a per-identity JSON blob (dashboard layout, panel geometry,
// preferences) SERVER-SIDE, keyed by (org, user_sub, project) from the IAM
// bearer, so a signed-in user's dashboard syncs across devices. It is the "one
// driver" companion to the lake — same SQLite DB, its own table, orthogonal
// concern. Anonymous callers never reach here (the handler gates on the bearer);
// they keep using localStorage.
type Settings struct {
db *sql.DB // nil in degraded mode
}
// Identity keys a settings row. Project defaults to "default" upstream so a
// user's single dashboard has a stable key.
type Identity struct {
Org string
UserSub string
Project string
}
func (id Identity) normalize() Identity {
id.Org = strings.TrimSpace(id.Org)
id.UserSub = strings.TrimSpace(id.UserSub)
id.Project = strings.TrimSpace(id.Project)
if id.Project == "" {
id.Project = "default"
}
return id
}
// Get returns the stored settings blob for an identity, or (nil,false) when
// absent or degraded. The blob is returned verbatim (already valid JSON).
func (s *Settings) Get(id Identity) (json.RawMessage, bool) {
if s == nil || s.db == nil {
return nil, false
}
id = id.normalize()
if id.UserSub == "" {
return nil, false
}
var blob string
err := s.db.QueryRow(
`SELECT blob FROM settings WHERE org=? AND user_sub=? AND project=?`,
id.Org, id.UserSub, id.Project).Scan(&blob)
if err != nil {
return nil, false
}
return json.RawMessage(blob), true
}
// Put upserts an identity's settings blob. blob must be valid JSON (validated by
// the caller at the boundary). Reports whether it was stored (false = degraded).
func (s *Settings) Put(id Identity, blob json.RawMessage) bool {
if s == nil || s.db == nil {
return false
}
id = id.normalize()
if id.UserSub == "" {
return false
}
_, err := s.db.Exec(`INSERT INTO settings (org, user_sub, project, blob, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(org, user_sub, project) DO UPDATE SET blob=excluded.blob, updated_at=excluded.updated_at`,
id.Org, id.UserSub, id.Project, string(blob), time.Now().Unix())
if err != nil {
logStore("settings put: %v", err)
return false
}
return true
}
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"encoding/json"
"testing"
)
func TestSettingsUpsertAndIdentityIsolation(t *testing.T) {
db := openTestDB(t)
alice := Identity{Org: "acme", UserSub: "alice", Project: "default"}
bob := Identity{Org: "acme", UserSub: "bob", Project: "default"}
if !db.Settings.Put(alice, json.RawMessage(`{"layout":"grid"}`)) {
t.Fatal("put alice failed")
}
if !db.Settings.Put(bob, json.RawMessage(`{"layout":"list"}`)) {
t.Fatal("put bob failed")
}
// Each identity reads back its OWN blob — no cross-talk.
if got, ok := db.Settings.Get(alice); !ok || string(got) != `{"layout":"grid"}` {
t.Fatalf("get alice = %q ok=%v", got, ok)
}
if got, ok := db.Settings.Get(bob); !ok || string(got) != `{"layout":"list"}` {
t.Fatalf("get bob = %q ok=%v", got, ok)
}
// Upsert replaces alice's blob and leaves bob untouched.
if !db.Settings.Put(alice, json.RawMessage(`{"layout":"masonry","cell":42}`)) {
t.Fatal("upsert alice failed")
}
if got, _ := db.Settings.Get(alice); string(got) != `{"layout":"masonry","cell":42}` {
t.Fatalf("upsert alice = %q", got)
}
if got, _ := db.Settings.Get(bob); string(got) != `{"layout":"list"}` {
t.Fatalf("bob mutated by alice upsert: %q", got)
}
}
func TestSettingsProjectIsolationAndDefault(t *testing.T) {
db := openTestDB(t)
base := Identity{Org: "acme", UserSub: "alice"} // Project "" → "default"
proj := Identity{Org: "acme", UserSub: "alice", Project: "trading"}
if !db.Settings.Put(base, json.RawMessage(`{"p":"default"}`)) {
t.Fatal("put default failed")
}
if !db.Settings.Put(proj, json.RawMessage(`{"p":"trading"}`)) {
t.Fatal("put trading failed")
}
// Same user, different project → different blobs.
if got, _ := db.Settings.Get(Identity{Org: "acme", UserSub: "alice", Project: "default"}); string(got) != `{"p":"default"}` {
t.Fatalf("default project = %q", got)
}
if got, _ := db.Settings.Get(proj); string(got) != `{"p":"trading"}` {
t.Fatalf("trading project = %q", got)
}
// Empty project normalizes to "default".
if got, _ := db.Settings.Get(base); string(got) != `{"p":"default"}` {
t.Fatalf("empty project did not normalize to default: %q", got)
}
}
func TestSettingsMissingAndNoSub(t *testing.T) {
db := openTestDB(t)
if _, ok := db.Settings.Get(Identity{Org: "acme", UserSub: "ghost"}); ok {
t.Fatal("get for absent identity reported ok")
}
// A missing subject is not a valid identity — never store or read.
if db.Settings.Put(Identity{Org: "acme", UserSub: ""}, json.RawMessage(`{}`)) {
t.Fatal("put with empty sub reported stored")
}
}
+139
View File
@@ -0,0 +1,139 @@
// Package store is world's embedded, CGO-free datastore: the queryable
// ingested-data LAKE (full-text searchable) and per-identity SETTINGS, both in
// ONE SQLite database (modernc.org/sqlite — pure Go, FTS5 built in).
//
// It is decomplected from HTTP and from the shared feed hot-cache: this package
// only holds durable, queryable rows and answers value queries. The instant
// feed body cache lives in package kv (hanzo-kv, shared across pods); SQLite is
// the "one place to query everything" — search, analytics, and signed-in
// settings. One driver, two logical stores.
//
// Never-5xx: if the database cannot be opened the returned *DB is still usable —
// every method degrades to an empty/no-op result rather than failing. A single
// serialized connection (SetMaxOpenConns(1)) makes all access deterministic and
// free of SQLITE_BUSY, which is correct for a per-pod cache/lake where every
// operation is sub-millisecond.
package store
import (
"database/sql"
"log"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
// DB owns the embedded SQLite handle and the two logical stores layered on it.
// sql is nil only when the database could not be opened (degraded mode).
type DB struct {
sql *sql.DB
Lake *Lake
Settings *Settings
}
// dbFile is the single embedded database filename under the data dir.
const dbFile = "world.db"
// schema is the full DDL, applied idempotently on Open. The lake is an external
// content FTS5 index (title+text) kept in sync by triggers, so bm25 ranking and
// prune-driven deletes stay correct with no manual FTS bookkeeping.
var schema = []string{
`CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
ts INTEGER NOT NULL, -- item's own time (unix seconds)
title TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
tickers TEXT NOT NULL DEFAULT '', -- space-delimited, lowercase, padded ' a b '
country TEXT NOT NULL DEFAULT '',
lat REAL,
lon REAL,
payload TEXT NOT NULL DEFAULT '', -- compact original item JSON
created_at INTEGER NOT NULL -- first-seen (unix seconds); drives retention
)`,
`CREATE INDEX IF NOT EXISTS items_kind_ts ON items(kind, ts)`,
`CREATE INDEX IF NOT EXISTS items_ts ON items(ts)`,
`CREATE INDEX IF NOT EXISTS items_created ON items(created_at)`,
`CREATE INDEX IF NOT EXISTS items_country ON items(country)`,
`CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
title, text, content='items', content_rowid='rowid'
)`,
`CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
INSERT INTO items_fts(rowid, title, text) VALUES (new.rowid, new.title, new.text);
END`,
`CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, title, text) VALUES ('delete', old.rowid, old.title, old.text);
END`,
`CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, title, text) VALUES ('delete', old.rowid, old.title, old.text);
INSERT INTO items_fts(rowid, title, text) VALUES (new.rowid, new.title, new.text);
END`,
`CREATE TABLE IF NOT EXISTS settings (
org TEXT NOT NULL,
user_sub TEXT NOT NULL,
project TEXT NOT NULL,
blob TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (org, user_sub, project)
)`,
}
// DefaultRetention is how long ingested items are kept before the prune job
// deletes them (rolling window). Overridable via the caller.
const DefaultRetention = 7 * 24 * time.Hour
// Open opens (creating if needed) the embedded database under dir and applies
// the schema. It ALWAYS returns a usable *DB: on any failure it logs, returns a
// degraded DB (empty results, no-op writes) plus the error, so callers never
// have to nil-check and the service never 5xxes over storage.
func Open(dir string, retention time.Duration) (*DB, error) {
if retention <= 0 {
retention = DefaultRetention
}
degraded := &DB{Lake: newLake(nil, retention), Settings: &Settings{}}
if err := os.MkdirAll(dir, 0o755); err != nil {
return degraded, err
}
dsn := "file:" + filepath.Join(dir, dbFile) +
"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)"
sqldb, err := sql.Open("sqlite", dsn)
if err != nil {
return degraded, err
}
// One connection: all access serialized in-process — deterministic, no
// SQLITE_BUSY. Every op is sub-ms so serialization costs nothing here.
sqldb.SetMaxOpenConns(1)
if err := sqldb.Ping(); err != nil {
_ = sqldb.Close()
return degraded, err
}
for _, ddl := range schema {
if _, err := sqldb.Exec(ddl); err != nil {
_ = sqldb.Close()
return degraded, err
}
}
return &DB{
sql: sqldb,
Lake: newLake(sqldb, retention),
Settings: &Settings{db: sqldb},
}, nil
}
// Enabled reports whether the database opened successfully (durable mode).
func (d *DB) Enabled() bool { return d != nil && d.sql != nil }
// Close flushes and closes the database. Safe on a degraded DB.
func (d *DB) Close() error {
if d == nil || d.sql == nil {
return nil
}
return d.sql.Close()
}
// logStore namespaces the store's best-effort warnings.
func logStore(format string, args ...any) { log.Printf("world-store: "+format, args...) }
+73
View File
@@ -0,0 +1,73 @@
package store
import (
"os"
"path/filepath"
"testing"
"time"
)
// openTestDB opens a fresh embedded datastore in a temp dir. The mere fact this
// succeeds under `CGO_ENABLED=0 go test` is the CGO-free assertion: modernc's
// pure-Go SQLite (with FTS5) links and runs without a C toolchain.
func openTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(t.TempDir(), time.Hour)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !db.Enabled() {
t.Fatal("Open returned a disabled DB")
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestOpenAppliesSchemaAndFTS5(t *testing.T) {
db := openTestDB(t)
// FTS5 must be compiled into modernc's SQLite — a MATCH query over the virtual
// table would error otherwise. Ingest one row and match it.
if err := db.Lake.insert([]Item{{ID: "x", Kind: "news", Title: "Bitcoin surges today"}}); err != nil {
t.Fatalf("insert: %v", err)
}
got := db.Lake.Search(SearchQuery{Q: "bitcoin"})
if len(got) != 1 || got[0].ID != "x" {
t.Fatalf("FTS5 search = %+v, want the one bitcoin row", got)
}
}
func TestOpenDegradedIsUsableNeverNil(t *testing.T) {
// A path under a regular file cannot be a directory → Open fails, but must
// still return a usable degraded DB (never-5xx contract at the storage layer).
f := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
db, err := Open(filepath.Join(f, "sub"), time.Hour)
if err == nil {
t.Fatal("expected an error opening under a file")
}
if db == nil {
t.Fatal("Open returned nil DB on error")
}
if db.Enabled() {
t.Fatal("degraded DB reports Enabled")
}
// Every method must no-op / empty rather than panic.
if got := db.Lake.Search(SearchQuery{Q: "x"}); len(got) != 0 {
t.Fatalf("degraded Search = %v, want empty", got)
}
db.Lake.Add(Item{ID: "a", Kind: "news", Title: "t"})
if n := db.Lake.Prune(time.Hour); n != 0 {
t.Fatalf("degraded Prune = %d, want 0", n)
}
if _, ok := db.Settings.Get(Identity{UserSub: "u"}); ok {
t.Fatal("degraded Settings.Get returned ok")
}
if db.Settings.Put(Identity{UserSub: "u"}, []byte(`{}`)) {
t.Fatal("degraded Settings.Put reported stored")
}
if a := db.Lake.Analytics(24); a.Total != 0 {
t.Fatalf("degraded Analytics total = %d, want 0", a.Total)
}
}
+14 -15
View File
@@ -9,21 +9,20 @@ import (
"strings"
)
func itoa(n int) string { return strconv.Itoa(n) }
func urlQueryEscape(s string) string { return url.QueryEscape(s) }
func httpErr(status int) error { return fmt.Errorf("upstream status %d", status) }
func lower(s string) string { return strings.ToLower(s) }
func contains(s, sub string) bool { return strings.Contains(s, sub) }
func base64Std(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
func trimSpace(s string) string { return strings.TrimSpace(s) }
func hasPrefix(s, p string) bool { return strings.HasPrefix(s, p) }
func hasSuffix(s, p string) bool { return strings.HasSuffix(s, p) }
func trimPrefix(s, p string) string { return strings.TrimPrefix(s, p) }
func splitComma(s string) []string { return strings.Split(s, ",") }
func joinComma(parts []string) string { return strings.Join(parts, ",") }
func upper(s string) string { return strings.ToUpper(s) }
func replaceAll(s, o, n string) string { return strings.ReplaceAll(s, o, n) }
func fieldsCollapse(s string) string { return strings.Join(strings.Fields(s), " ") }
func itoa(n int) string { return strconv.Itoa(n) }
func urlQueryEscape(s string) string { return url.QueryEscape(s) }
func httpErr(status int) error { return fmt.Errorf("upstream status %d", status) }
func lower(s string) string { return strings.ToLower(s) }
func contains(s, sub string) bool { return strings.Contains(s, sub) }
func base64Std(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
func trimSpace(s string) string { return strings.TrimSpace(s) }
func hasPrefix(s, p string) bool { return strings.HasPrefix(s, p) }
func hasSuffix(s, p string) bool { return strings.HasSuffix(s, p) }
func trimPrefix(s, p string) string { return strings.TrimPrefix(s, p) }
func splitComma(s string) []string { return strings.Split(s, ",") }
func joinComma(parts []string) string { return strings.Join(parts, ",") }
func upper(s string) string { return strings.ToUpper(s) }
func replaceAll(s, o, n string) string { return strings.ReplaceAll(s, o, n) }
// truncate limits s to at most n bytes (matching JS substring semantics closely
// enough for the ASCII-dominant fields the upstreams emit).
+106
View File
@@ -0,0 +1,106 @@
package world
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestCachedJSONServesStaleAndRefreshes: on a lapsed TTL with a stale value
// present, cachedJSON must serve the stale value INSTANTLY (not block on the
// slow produce) and refresh the cache in the background.
func TestCachedJSONServesStaleAndRefreshes(t *testing.T) {
s := NewServer()
const key = "swr-stale"
// Stale-but-not-fresh: Get misses, GetStale hits (fresh horizon already past).
s.cache.Set(key, map[string]any{"v": "old"}, -time.Second, time.Minute)
var calls int32
release := make(chan struct{})
produce := func(ctx context.Context) (any, error) {
atomic.AddInt32(&calls, 1)
<-release // hold the refresh open so the request path can't wait on it
return map[string]any{"v": "new"}, nil
}
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
s.cachedJSON(rec, key, "cc", time.Minute, time.Minute, produce,
func(w http.ResponseWriter, err error) { t.Errorf("onError fired with stale present: %v", err) })
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
close(release)
t.Fatal("cachedJSON blocked on the background refresh instead of serving stale instantly")
}
if body := rec.Body.String(); !strings.Contains(body, `"old"`) {
t.Fatalf("served body = %q, want the stale value", body)
}
// Let the background refresh finish; the fresh value must land in the cache.
close(release)
deadline := time.Now().Add(2 * time.Second)
for {
if v, ok := s.cache.Get(key); ok {
if m, _ := v.(map[string]any); m != nil && m["v"] == "new" {
break
}
}
if time.Now().After(deadline) {
t.Fatal("background refresh did not update the cache with the fresh value")
}
time.Sleep(5 * time.Millisecond)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("produce called %d times, want 1", got)
}
}
// TestCachedJSONColdMissSingleFlight: N concurrent COLD callers (no stale) must
// coalesce to ONE produce; every caller gets the leader's value.
func TestCachedJSONColdMissSingleFlight(t *testing.T) {
s := NewServer()
const key = "swr-cold"
var calls int32
produce := func(ctx context.Context) (any, error) {
atomic.AddInt32(&calls, 1)
time.Sleep(100 * time.Millisecond) // hold the leader so followers coalesce
return map[string]any{"v": "fresh"}, nil
}
const n = 12
bodies := make([]string, n)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
rec := httptest.NewRecorder()
s.cachedJSON(rec, key, "cc", time.Minute, time.Minute, produce,
func(w http.ResponseWriter, err error) { t.Errorf("onError fired: %v", err) })
bodies[i] = rec.Body.String()
}(i)
}
close(start)
wg.Wait()
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("produce called %d times, want 1 (cold misses must single-flight)", got)
}
for i, b := range bodies {
if !strings.Contains(b, `"fresh"`) {
t.Fatalf("caller %d body = %q, want the coalesced fresh value", i, b)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"rates": [
{ "countryCode": "CN", "country": "China", "rate": 3.0, "previousRate": 3.1, "date": "2026-06-01" }
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"head": { "rep_code": "200" },
"data": { "total": 3, "pageTotalSize": 1 },
"records": [
{
"title": "2026年6月22日全国银行间同业拆借中心受权公布贷款市场报价利率(LPR)公告",
"releaseDate": "2026-06-22",
"channelId": "3686"
},
{
"title": "全国银行间同业拆借中心受权发布贷款市场报价利率报价行名单",
"releaseDate": "2026-03-31",
"channelId": "3686"
},
{
"title": "2026年2月24日全国银行间同业拆借中心受权公布贷款市场报价利率(LPR)公告",
"releaseDate": "2026-02-24",
"channelId": "3686"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"observations": [
{ "date": "2026-07-09", "value": "7.1710" },
{ "date": "2026-07-10", "value": "7.1842" }
]
}
+9
View File
@@ -0,0 +1,9 @@
{
"result": {
"datasize": 2,
"records": [
{ "end_of_day": "2026-07-10", "cny": 1.0881 },
{ "end_of_day": "2026-07-09", "cny": 1.0872 }
]
}
}
+6
View File
@@ -0,0 +1,6 @@
<table>
<tr><th>No.</th><th>Content</th><th>Jan.</th><th>Feb.</th><th>Mar.</th><th>Apr.</th><th>May</th><th>Jun.</th><th>Jul.</th><th>Aug.</th><th>Sep.</th><th>Oct.</th><th>Nov.</th><th>Dec.</th></tr>
<tr><td>1</td><td>National Economic Performance</td><td>19/Mon<br>10:00</td><td>……</td><td>16/Mon<br>10:00</td><td>16/Thu<br>10:00</td><td>18/Mon<br>10:00</td><td>16/Tue<br>10:00</td><td>15/Wed<br>10:00</td><td>17/Mon<br>10:00</td><td>15/Tue<br>10:00</td><td>19/Mon<br>10:00</td><td>16/Mon<br>10:00</td><td>15/Tue<br>10:00</td></tr>
<tr><td>3</td><td>Preliminary Accounting Report on Quarterly Value Added of Major Industries</td><td>20/Tue<br>9:30</td><td>……</td><td>……</td><td>17/Fri<br>9:30</td><td>……</td><td>……</td><td>16/Thu<br>9:30</td><td>……</td><td>……</td><td>20/Tue<br>9:30</td><td>……</td><td>……</td></tr>
<tr><td>4</td><td>Monthly Report on Purchasing Managers' Index (PMI)</td><td>31/Sat<br>9:30</td><td>……</td><td>4/Wed Note5<br>31/Tue<br>9:30</td><td>30/Thu<br>9:30</td><td>31/Sun<br>9:30</td><td>30/Tue<br>9:30</td><td>31/Fri<br>9:30</td><td>31/Mon<br>9:30</td><td>30/Wed<br>9:30</td><td>31/Sat<br>9:30</td><td>30/Mon<br>9:30</td><td>31/Thu<br>9:30</td></tr>
</table>
+3
View File
@@ -0,0 +1,3 @@
REF_AREA,FREQ,MEASURE,UNIT_MEASURE,TIME_PERIOD,OBS_VALUE
CHN,M,LI,IX,2026-04,99.41
CHN,M,LI,IX,2026-05,99.58
1 REF_AREA FREQ MEASURE UNIT_MEASURE TIME_PERIOD OBS_VALUE
2 CHN M LI IX 2026-04 99.41
3 CHN M LI IX 2026-05 99.58
+3
View File
@@ -0,0 +1,3 @@
REF_AREA,FREQ,MEASURE,UNIT_MEASURE,TIME_PERIOD,OBS_VALUE
CHN,M,PA,PT_CH_PR_YOY,2026-04,0.3
CHN,M,PA,PT_CH_PR_YOY,2026-05,0.6
1 REF_AREA FREQ MEASURE UNIT_MEASURE TIME_PERIOD OBS_VALUE
2 CHN M PA PT_CH_PR_YOY 2026-04 0.3
3 CHN M PA PT_CH_PR_YOY 2026-05 0.6
File diff suppressed because it is too large Load Diff
+1 -69
View File
@@ -15,7 +15,6 @@
"@deck.gl/mapbox": "^9.2.6",
"@sentry/browser": "^10.39.0",
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@xenova/transformers": "^2.17.2",
"d3": "^7.9.0",
"deck.gl": "^9.2.6",
@@ -24,8 +23,7 @@
"mapbox-gl": "^3.26.0",
"maplibre-gl": "^5.16.0",
"onnxruntime-web": "^1.23.2",
"topojson-client": "^3.1.0",
"youtubei.js": "^16.0.1"
"topojson-client": "^3.1.0"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
@@ -1722,12 +1720,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@carto/api-client": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@carto/api-client/-/api-client-0.5.24.tgz",
@@ -4919,44 +4911,6 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@vercel/analytics": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz",
"integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
"license": "MPL-2.0",
"peerDependencies": {
"@remix-run/react": "^2",
"@sveltejs/kit": "^1 || ^2",
"next": ">= 13",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
}
},
"node_modules/@webcomponents/shadycss": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.11.2.tgz",
@@ -8787,15 +8741,6 @@
"node": ">= 8"
}
},
"node_modules/meriyah": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
"integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==",
"license": "ISC",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -12361,19 +12306,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/youtubei.js": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-16.0.1.tgz",
"integrity": "sha512-3802bCAGkBc2/G5WUTc0l/bO5mPYJbQAHL04d9hE9PnrDHoBUT8MN721Yqt4RCNncAXdHcfee9VdJy3Fhq1r5g==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"meriyah": "^6.1.4"
}
},
"node_modules/zstd-codec": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz",
+2 -4
View File
@@ -1,7 +1,7 @@
{
"name": "world-monitor",
"private": true,
"version": "2.4.0",
"version": "2.4.6",
"type": "module",
"scripts": {
"lint:md": "markdownlint-cli2 '**/*.md'",
@@ -63,7 +63,6 @@
"@deck.gl/mapbox": "^9.2.6",
"@sentry/browser": "^10.39.0",
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@xenova/transformers": "^2.17.2",
"d3": "^7.9.0",
"deck.gl": "^9.2.6",
@@ -72,7 +71,6 @@
"mapbox-gl": "^3.26.0",
"maplibre-gl": "^5.16.0",
"onnxruntime-web": "^1.23.2",
"topojson-client": "^3.1.0",
"youtubei.js": "^16.0.1"
"topojson-client": "^3.1.0"
}
}
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
// Private config for the layout-engine work: runs on port 4273 so it never
// collides with the concurrent responsive-fix agent's server on 4173.
// 1440x900 viewport per the gate.
export default defineConfig({
testDir: './e2e',
workers: 1,
timeout: 90000,
expect: { timeout: 30000 },
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4273',
viewport: { width: 1440, height: 900 },
colorScheme: 'dark',
locale: 'en-US',
timezoneId: 'UTC',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'off',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
launchOptions: { args: ['--use-angle=swiftshader', '--use-gl=swiftshader'] },
},
},
],
snapshotPathTemplate: '{testDir}/{testFileName}-snapshots/{arg}{ext}',
webServer: {
command: 'VITE_E2E=1 npm run dev -- --host 127.0.0.1 --port 4273',
url: 'http://127.0.0.1:4273/tests/panel-drag-harness.html',
reuseExistingServer: true,
timeout: 120000,
},
});
+541 -42
View File
@@ -1,3 +1,4 @@
import './styles/try-hanzo.css';
import type { NewsItem, Monitor, PanelConfig, MapLayers, RelatedAsset, InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, CyberThreat } from '@/types';
import {
FEEDS,
@@ -10,13 +11,15 @@ import {
MOBILE_DEFAULT_MAP_LAYERS,
STORAGE_KEYS,
SITE_VARIANT,
MONITOR_COLORS,
} from '@/config';
import { BETA_MODE } from '@/config/beta';
import { fetchCategoryFeeds, getFeedFailures, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics, fetchCyberThreats, drainTrendingSignals } from '@/services';
import { fetchCategoryFeeds, getFeedFailures, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics, fetchChinaMacro, fetchCyberThreats, drainTrendingSignals } from '@/services';
import { fetchCountryMarkets } from '@/services/polymarket';
import { mlWorker } from '@/services/ml-worker';
import { attachPanelDrag, attachPanelResize, attachPanelColResize } from '@/services/panel-drag';
import { installPanelContextMenu } from '@/services/panel-menu';
import { installPanelContextMenu, registerSummarizePort } from '@/services/panel-menu';
import { loadMonitors as loadUserMonitors, saveMonitors as saveUserMonitors, fetchMonitorMatches } from '@/services/monitors';
import { ImmersiveController, type ImmersiveBackground, type ImmersiveState } from '@/services/immersive';
import { loadPanelSpans, savePanelSpan, currentSpan, setSpanClass } from '@/components/Panel';
import { clusterNewsHybrid } from '@/services/clustering';
@@ -25,6 +28,9 @@ import { signalAggregator } from '@/services/signal-aggregator';
import { updateAndCheck } from '@/services/temporal-baseline';
import { fetchAllFires, flattenFires, computeRegionStats } from '@/services/firms-satellite';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { WatchQueuePanel } from '@/components/WatchQueuePanel';
import { watchQueue } from '@/services/watch-queue';
import { searchYouTube } from '@/services/youtube-search';
import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresence, foreignPresenceToSignal, type TheaterPostureSummary } from '@/services/military-surge';
import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture';
import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, startLearning, isInLearningMode, calculateCII, getCountryData, TIER1_COUNTRIES } from '@/services/country-instability';
@@ -37,7 +43,7 @@ import { fetchUcdpEvents, deduplicateAgainstAcled } from '@/services/ucdp-events
import { fetchUnhcrPopulation } from '@/services/unhcr';
import { fetchClimateAnomalies } from '@/services/climate';
import { enrichEventsWithExposure } from '@/services/population-exposure';
import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage, ExportPanel, getCircuitBreakerCooldownInfo, isMobileDevice, setTheme, getCurrentTheme } from '@/utils';
import { buildMapUrl, debounce, loadFromStorage, parseMapUrlState, saveToStorage, ExportPanel, getCircuitBreakerCooldownInfo, isMobileDevice, setTheme, getCurrentTheme, generateId, getCSSColor } from '@/utils';
import { reverseGeocode } from '@/utils/reverse-geocode';
import { CountryBriefPage } from '@/components/CountryBriefPage';
import { CountryTimeline, type TimelineEvent } from '@/components/CountryTimeline';
@@ -63,7 +69,6 @@ import {
StatusPanel,
EconomicPanel,
SearchModal,
MobileWarningModal,
PizzIntIndicator,
GdeltIntelPanel,
LiveNewsPanel,
@@ -103,6 +108,8 @@ import {
CloudAnalyticsPanel,
LlmUsagePanel,
BlockchainPanel,
AiComputePanel,
EnsoFlywheelPanel,
} from '@/components';
import { isAdmin, isAuthenticated, listOrgs, setActiveOrg } from '@/services/iam';
import type { SearchResult } from '@/components/SearchModal';
@@ -123,7 +130,7 @@ import { STOCK_EXCHANGES, FINANCIAL_CENTERS, CENTRAL_BANKS, COMMODITY_HUBS } fro
import { isDesktopRuntime, canConfigureKeys } from '@/services/runtime';
import { isFeatureAvailable } from '@/services/runtime-config';
import { getCountryAtCoordinates, hasCountryGeometry, isCoordinateInCountry, preloadCountryGeometry } from '@/services/country-geometry';
import { initI18n, t, changeLanguage } from '@/services/i18n';
import { initI18n, t, changeLanguage, getCurrentLanguage, LANGUAGES } from '@/services/i18n';
import type { PredictionMarket, MarketData, ClusteredEvent } from '@/types';
@@ -173,7 +180,6 @@ export class App {
private analystOrgs: Array<{ id: string; name: string }> = [];
private adminCloudMounted = false;
private searchModal: SearchModal | null = null;
private mobileWarningModal: MobileWarningModal | null = null;
private pizzintIndicator: PizzIntIndicator | null = null;
private latestPredictions: PredictionMarket[] = [];
private latestMarkets: MarketData[] = [];
@@ -368,6 +374,7 @@ export class App {
this.setupPizzIntIndicator();
this.setupExportPanel();
this.setupLanguageSelector();
this.setupTryHanzoMenu();
this.setupAccountMenu();
this.setupSearchModal();
this.setupMapLayerHandlers();
@@ -423,7 +430,6 @@ export class App {
},
});
document.getElementById('immersiveToggle')?.addEventListener('click', () => this.immersive?.toggle());
document.getElementById('immersiveCollapse')?.addEventListener('click', () => this.immersive?.toggleCollapsed());
document.querySelectorAll('#immersiveBgSelect .ibg-btn').forEach((btn) => {
btn.addEventListener('click', () =>
@@ -441,15 +447,182 @@ export class App {
}
private reflectImmersiveUi(state: ImmersiveState): void {
const toggle = document.getElementById('immersiveToggle');
toggle?.classList.toggle('active', state.enabled);
toggle?.setAttribute('aria-pressed', String(state.enabled));
const collapse = document.getElementById('immersiveCollapse');
collapse?.classList.toggle('active', state.collapsed);
collapse?.setAttribute('aria-pressed', String(state.collapsed));
document.querySelectorAll('#immersiveBgSelect .ibg-btn').forEach((btn) => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.bg === state.background);
});
// Keep the single mode dropdown in sync — immersive wins as the shown value,
// otherwise it reflects the snap mode (grid/free). Covers Escape-to-exit too.
this.syncModeSelect();
}
// The dock's one mode control shows the layout MODE as a 3-way choice over two
// orthogonal states: background (normal vs immersive) and snap (grid vs free).
// Grid → normal background, snap to grid
// Free → normal background, free-form pixels
// Immersive → map fills the viewport, panels float freestyle over it
private syncModeSelect(): void {
const sel = document.getElementById('dockModeSelect') as HTMLSelectElement | null;
if (!sel) return;
const mode = this.immersive?.getState().enabled ? 'immersive' : this.gridApi().getLayoutMode();
if (sel.value !== mode) sel.value = mode;
}
// Apply a 3-way mode pick, decomplecting background from snap. Entering immersive
// defaults the floating panels to freestyle (free snap); leaving it drops back to
// the normal background at whatever snap the user last had.
private setDockMode(mode: 'grid' | 'free' | 'immersive'): void {
const grid = this.gridApi();
if (mode === 'immersive') {
grid.setLayoutMode('free');
this.immersive?.setEnabled(true);
} else {
this.immersive?.setEnabled(false);
grid.setLayoutMode(mode);
}
this.syncModeSelect();
}
// Bottom toolbar dock wiring. Buttons moved here from the header (Panels,
// Sources, Copy link, Fullscreen, Immersive, region) keep their ids so their
// existing handlers still bind; this only wires the dock-native controls:
// Layers toggle, layout-mode, widget-size, "+ Add widget", and collapse.
private setupDock(): void {
// Layers: toggle the map's floating layer panel (hidden by default).
const layersBtn = document.getElementById('dockLayersBtn');
layersBtn?.addEventListener('click', () => {
const open = this.map?.toggleLayerPanel() ?? false;
layersBtn.classList.toggle('active', open);
layersBtn.setAttribute('aria-pressed', String(open));
});
// Collapse the dock to a slim edge (persisted for the session).
const collapse = document.getElementById('dockCollapse');
const dock = document.getElementById('worldDock');
const collapsed = localStorage.getItem('hanzo-world-dock-collapsed') === '1';
if (collapsed) dock?.classList.add('collapsed');
if (collapse) {
collapse.setAttribute('aria-expanded', String(!collapsed));
collapse.addEventListener('click', () => {
const isCollapsed = dock?.classList.toggle('collapsed') ?? false;
collapse.setAttribute('aria-expanded', String(!isCollapsed));
try { localStorage.setItem('hanzo-world-dock-collapsed', isCollapsed ? '1' : '0'); } catch { /* ignore */ }
requestAnimationFrame(() => this.map?.render());
});
}
// Layout mode (grid / free / immersive) + widget size. The one dropdown drives
// the layout engine (window.worldGrid) for snap and the immersive controller for
// the background — see setDockMode. It stays in sync via syncModeSelect (also on
// Escape-to-exit immersive and programmatic grid-config changes).
const grid = this.gridApi();
const modeSelect = document.getElementById('dockModeSelect') as HTMLSelectElement | null;
if (modeSelect) {
modeSelect.addEventListener('change', () =>
this.setDockMode(modeSelect.value as 'grid' | 'free' | 'immersive'));
// grid-config broadcasts programmatic snap changes (analyst / reset); reflect them.
document.addEventListener('layout-mode-change', () => this.syncModeSelect());
this.syncModeSelect();
}
const sizeInput = document.getElementById('dockGridSize') as HTMLInputElement | null;
if (sizeInput) {
sizeInput.value = String(grid.getCellSize());
sizeInput.addEventListener('input', () => grid.setCellSize(parseInt(sizeInput.value, 10)));
}
// "+ Add widget" — a searchable palette over the panel registry.
document.getElementById('dockAddWidget')?.addEventListener('click', () => this.openAddWidget());
document.getElementById('addWidgetClose')?.addEventListener('click', () => this.closeAddWidget());
document.getElementById('addWidgetModal')?.addEventListener('click', (e) => {
if (e.target === e.currentTarget) this.closeAddWidget();
});
const search = document.getElementById('addWidgetSearch') as HTMLInputElement | null;
search?.addEventListener('input', () => this.renderAddWidgetGrid(search.value));
}
// Layout-config adapter. Prefers the world-layoutengine API exposed at
// window.worldGrid; falls back to a minimal self-contained effect (a body data
// attribute for mode, a CSS var for the grid column floor) so the dock controls
// work standalone. See src/services/grid-config.ts (world-layoutengine).
private gridApi(): {
setLayoutMode: (m: 'grid' | 'free') => void;
getLayoutMode: () => 'grid' | 'free';
setCellSize: (px: number) => void;
getCellSize: () => number;
} {
const g = (window as unknown as { worldGrid?: Partial<ReturnType<App['gridApi']>> }).worldGrid;
return {
setLayoutMode: (m) => {
if (g?.setLayoutMode) { g.setLayoutMode(m); return; }
document.body.dataset.layoutMode = m;
},
getLayoutMode: () => {
if (g?.getLayoutMode) return g.getLayoutMode();
return document.body.dataset.layoutMode === 'free' ? 'free' : 'grid';
},
setCellSize: (px) => {
const v = Math.max(140, Math.min(360, px));
if (g?.setCellSize) { g.setCellSize(v); return; }
document.documentElement.style.setProperty('--panel-col-min', `${v}px`);
try { localStorage.setItem('hanzo-world-grid-size', String(v)); } catch { /* ignore */ }
},
getCellSize: () => {
if (g?.getCellSize) return g.getCellSize();
const saved = parseInt(localStorage.getItem('hanzo-world-grid-size') ?? '', 10);
if (Number.isFinite(saved)) {
document.documentElement.style.setProperty('--panel-col-min', `${saved}px`);
return saved;
}
return 160;
},
};
}
// Searchable widget palette: lists the panel registry (same source the Panels
// menu toggles); clicking one shows it via the one show/hide path. Works in both
// grid and immersive layouts (immersive adds it to the floating column).
private openAddWidget(): void {
const modal = document.getElementById('addWidgetModal');
if (!modal) return;
modal.classList.add('active');
const search = document.getElementById('addWidgetSearch') as HTMLInputElement | null;
if (search) { search.value = ''; }
this.renderAddWidgetGrid('');
setTimeout(() => search?.focus(), 30);
}
private closeAddWidget(): void {
document.getElementById('addWidgetModal')?.classList.remove('active');
}
private renderAddWidgetGrid(filter: string): void {
const grid = document.getElementById('addWidgetGrid');
if (!grid) return;
const q = filter.trim().toLowerCase();
const entries = Object.entries(this.panelSettings)
.filter(([key]) => key !== 'map')
.filter(([key, cfg]) => !q || cfg.name.toLowerCase().includes(q) || key.toLowerCase().includes(q))
.sort((a, b) => a[1].name.localeCompare(b[1].name));
grid.innerHTML = entries.length === 0
? `<div class="add-widget-empty">No widgets match “${filter}”.</div>`
: entries.map(([key, cfg]) => `
<button class="add-widget-item ${cfg.enabled ? 'is-on' : ''}" data-key="${key}">
<span class="add-widget-name">${cfg.name}</span>
<span class="add-widget-state">${cfg.enabled ? 'Shown' : 'Add'}</span>
</button>`).join('');
grid.querySelectorAll<HTMLButtonElement>('.add-widget-item').forEach((btn) => {
btn.addEventListener('click', () => {
const key = btn.dataset.key!;
this.setPanelEnabled(key, true);
this.closeAddWidget();
const el = this.panels[key]?.getElement();
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
el?.classList.add('flash-new');
setTimeout(() => el?.classList.remove('flash-new'), 1200);
});
});
}
private handleDeepLinks(): void {
@@ -517,11 +690,30 @@ export class App {
setInterval(tick, 1000);
}
// The mobile-view onboarding modal is retired: the layout is now genuinely
// responsive down to 390px (single-column stack, scrollable dock), so an
// interstitial warning is noise. Intentionally a no-op — one way, no modal.
private setupMobileWarning(): void {
if (MobileWarningModal.shouldShow()) {
this.mobileWarningModal = new MobileWarningModal();
this.mobileWarningModal.show();
}
this.applyMapLogoFlag();
}
// Basemap wordmark visibility. Shown by default (ToS); ?maplogo=0 or the stored
// preference hides it (a compact "ⓘ" attribution stays, so ToS still holds).
private applyMapLogoFlag(): void {
let hide = false;
try {
const url = new URLSearchParams(window.location.search).get('maplogo');
if (url === '0' || url === 'off' || url === 'false') {
hide = true;
localStorage.setItem('hanzo-world-maplogo', '0');
} else if (url === '1' || url === 'on' || url === 'true') {
hide = false;
localStorage.setItem('hanzo-world-maplogo', '1');
} else {
hide = localStorage.getItem('hanzo-world-maplogo') === '0';
}
} catch { /* private mode: default to shown */ }
document.body.classList.toggle('hide-maplogo', hide);
}
private setupStatusPanel(): void {
@@ -604,6 +796,111 @@ export class App {
}
}
// "Try Hanzo" product switcher — the hanzo.ai "Try" menu, rebuilt for the World
// header. A white .hz-cta pill (the site's one primary-action style) that drops a
// monochrome menu of Hanzo products; the current product (world.hanzo.ai) is
// highlighted with a "Current" chip. Opens below the pill, closes on click-away
// or Escape. The menu is portaled to <body> and position:fixed so it never widens
// the header/page and is never clipped by the header's mobile overflow.
private setupTryHanzoMenu(): void {
const headerRight = this.container.querySelector('.header-right');
if (!headerRight) return;
const products: ReadonlyArray<{ name: string; desc: string; url: string; current?: boolean }> = [
{ name: 'Hanzo World', desc: 'Real-time world intelligence', url: 'https://world.hanzo.ai', current: true },
{ name: 'Hanzo Chat', desc: 'AI chat', url: 'https://hanzo.chat' },
{ name: 'Hanzo Dev', desc: 'AI coding', url: 'https://hanzo.ai/code' },
{ name: 'Hanzo App', desc: 'Build with AI', url: 'https://hanzo.app' },
{ name: 'Hanzo Cloud', desc: 'Console & infra', url: 'https://console.hanzo.ai' },
{ name: 'Hanzo Desktop', desc: 'Desktop app', url: 'https://hanzo.ai/desktop' },
];
const esc = (v: string): string =>
v.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]!));
const wrap = document.createElement('div');
wrap.className = 'try-hanzo';
wrap.innerHTML = `
<button class="hz-cta try-hanzo-trigger" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="tryHanzoMenu">
<svg class="try-hanzo-ico" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 4h4v4H4V4zm6 0h4v4h-4V4zm6 0h4v4h-4V4zM4 10h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4zM4 16h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4z"/></svg>
<span class="try-hanzo-label">Try Hanzo</span>
<span class="try-hanzo-caret" aria-hidden="true"></span>
</button>
<div class="try-hanzo-menu" id="tryHanzoMenu" role="menu">
${products
.map(
(p) => `<a class="try-hanzo-item${p.current ? ' is-current' : ''}" role="menuitem" href="${esc(p.url)}"${
p.current ? ' aria-current="page"' : ' target="_blank" rel="noopener noreferrer"'
}>
<span class="try-hanzo-item-body">
<span class="try-hanzo-name">${esc(p.name)}${p.current ? '<span class="try-hanzo-chip">Current</span>' : ''}</span>
<span class="try-hanzo-desc">${esc(p.desc)}</span>
</span>
</a>`,
)
.join('')}
</div>
`;
// "Try Hanzo" is an ACQUISITION CTA — it only makes sense to a visitor who
// hasn't signed in. Once identity resolves as signed-in, it disappears (and
// its portaled menu closes with it). Driven by the one 'hanzo:auth' signal.
const syncCta = (authed: boolean): void => {
wrap.hidden = authed;
if (authed) menu.classList.remove('open');
};
const trigger = wrap.querySelector<HTMLButtonElement>('.try-hanzo-trigger')!;
const menu = wrap.querySelector<HTMLElement>('.try-hanzo-menu')!;
// Portal the menu to <body>: the mobile header sets overflow-y:hidden for its
// horizontal tab scroller, which would clip a menu nested inside it. As a body
// child the fixed menu is anchored to the viewport and never clipped.
document.body.appendChild(menu);
syncCta(isAuthenticated()); // instant paint; refined when identity resolves
document.addEventListener('hanzo:auth', (e) => {
const authed = !!(e as CustomEvent<{ authed: boolean }>).detail?.authed;
syncCta(authed);
// Identity resolved → pull this user's server-side monitors.
if (authed) void this.syncMonitorsFromServer();
});
const isOpen = (): boolean => menu.classList.contains('open');
const setOpen = (open: boolean): void => {
if (open) {
// Anchor the fixed menu under the trigger, right-aligned to it.
const r = trigger.getBoundingClientRect();
menu.style.top = `${Math.round(r.bottom + 8)}px`;
menu.style.right = `${Math.round(Math.max(8, window.innerWidth - r.right))}px`;
}
menu.classList.toggle('open', open);
wrap.classList.toggle('open', open); // caret rotation
trigger.setAttribute('aria-expanded', String(open));
};
trigger.addEventListener('click', (e) => {
e.stopPropagation();
setOpen(!isOpen());
});
menu.addEventListener('click', (e) => {
const item = (e.target as HTMLElement).closest('.try-hanzo-item');
if (!item) return;
if (item.classList.contains('is-current')) e.preventDefault(); // already here
setOpen(false);
});
document.addEventListener('click', (e) => {
const target = e.target as Node;
if (isOpen() && !wrap.contains(target) && !menu.contains(target)) setOpen(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) {
setOpen(false);
trigger.focus();
}
});
// Trigger sits to the left of the account/identity control (appended right after).
headerRight.appendChild(wrap);
}
// Hanzo IAM: show the logged-in user + org/project switcher (or "Sign in").
// Rightmost in the header so identity is always visible. Desktop builds keep
// it too — the same hanzo.id session backs the Tauri app.
@@ -1790,39 +2087,14 @@ export class App {
<span class="status-dot"></span>
<span>${t('header.live')}</span>
</div>
<div class="region-selector">
<select id="regionSelect" class="region-select">
<option value="global">${t('components.deckgl.views.global')}</option>
<option value="america">${t('components.deckgl.views.americas')}</option>
<option value="mena">${t('components.deckgl.views.mena')}</option>
<option value="eu">${t('components.deckgl.views.europe')}</option>
<option value="asia">${t('components.deckgl.views.asia')}</option>
<option value="latam">${t('components.deckgl.views.latam')}</option>
<option value="africa">${t('components.deckgl.views.africa')}</option>
<option value="oceania">${t('components.deckgl.views.oceania')}</option>
</select>
</div>
</div>
<div class="header-right">
<button class="search-btn" id="searchBtn"><kbd>K</kbd> ${t('header.search')}</button>
${this.isDesktopApp ? '' : `<button class="copy-link-btn" id="copyLinkBtn">${t('header.copyLink')}</button>`}
<button class="search-btn" id="searchBtn"><kbd>K</kbd> <span class="btn-label">${t('header.search')}</span></button>
<button class="theme-toggle-btn" id="headerThemeToggle" title="${t('header.toggleTheme')}">
${getCurrentTheme() === 'dark'
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>'}
</button>
${this.isDesktopApp ? '' : `<button class="fullscreen-btn" id="fullscreenBtn" title="${t('header.fullscreen')}">⛶</button>`}
${this.isMobile ? '' : `
<div class="immersive-controls" id="immersiveControls">
<button class="immersive-toggle-btn" id="immersiveToggle" title="${t('header.immersive', { defaultValue: 'Immersive layout — map fills the background, panels float on top' })}" aria-pressed="false"> ${t('header.immersive', { defaultValue: 'Immersive' })}</button>
<div class="immersive-bg-select" id="immersiveBgSelect" role="group" aria-label="Background">
<button class="ibg-btn" data-bg="map" title="${t('header.bgMap', { defaultValue: 'Map background' })}">${t('header.bgMap', { defaultValue: 'Map' })}</button>
<button class="ibg-btn" data-bg="video" title="${t('header.bgVideo', { defaultValue: 'Live video background' })}">${t('header.bgVideo', { defaultValue: 'Video' })}</button>
</div>
<button class="immersive-collapse-btn" id="immersiveCollapse" title="${t('header.collapsePanels', { defaultValue: 'Collapse panels to the edge' })}" aria-pressed="false"></button>
</div>`}
<button class="settings-btn" id="settingsBtn"> ${t('header.settings')}</button>
<button class="sources-btn" id="sourcesBtn">📡 ${t('header.sources')}</button>
<div class="header-account" id="headerAccount"></div>
</div>
</div>
@@ -1840,6 +2112,7 @@ export class App {
</div>
</div>
</div>
${this.renderDock()}
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
@@ -1870,10 +2143,90 @@ export class App {
</div>
</div>
</div>
<div class="modal-overlay" id="addWidgetModal">
<div class="modal add-widget-modal">
<div class="modal-header">
<span class="modal-title">Add widget</span>
<button class="modal-close" id="addWidgetClose">×</button>
</div>
<div class="add-widget-search">
<input type="text" id="addWidgetSearch" placeholder="Search widgets…" autocomplete="off" />
</div>
<div class="add-widget-grid" id="addWidgetGrid"></div>
</div>
</div>
`;
this.createPanels();
this.renderPanelToggles();
this.setupDock();
}
// Bottom toolbar dock — the single home for operational controls, split out of
// the (now identity-only) header. The map mounts its 2D/3D toggle, basemap style
// switcher and time-range pills into #dockMapControls; the rest are dock chrome.
// Monochrome, sentence-case, collapsible; horizontally scrollable when narrow so
// the PAGE never overflows. Layout labels stay literal (dock design language,
// matching the codebase's literal control strings).
private renderDock(): string {
const regionOptions = `
<option value="global">${t('components.deckgl.views.global')}</option>
<option value="america">${t('components.deckgl.views.americas')}</option>
<option value="mena">${t('components.deckgl.views.mena')}</option>
<option value="eu">${t('components.deckgl.views.europe')}</option>
<option value="asia">${t('components.deckgl.views.asia')}</option>
<option value="latam">${t('components.deckgl.views.latam')}</option>
<option value="africa">${t('components.deckgl.views.africa')}</option>
<option value="oceania">${t('components.deckgl.views.oceania')}</option>`;
const immersive = this.isMobile ? '' : `
<div class="dock-group immersive-controls" id="immersiveControls">
<span class="dock-sublabel">Background</span>
<div class="immersive-bg-select" id="immersiveBgSelect" role="group" aria-label="Immersive background">
<button class="ibg-btn" data-bg="map" title="Map background">Map</button>
<button class="ibg-btn" data-bg="video" title="Live video background">Video</button>
</div>
<button class="dock-btn immersive-collapse-btn" id="immersiveCollapse" title="Collapse panels to the edge" aria-pressed="false"></button>
</div>`;
const share = this.isDesktopApp ? '' : `
<div class="dock-group">
<button class="dock-btn copy-link-btn" id="copyLinkBtn" title="Copy a shareable link"><span class="dock-ico">🔗</span> <span class="btn-label">Copy link</span></button>
<button class="dock-btn fullscreen-btn" id="fullscreenBtn" title="Fullscreen"><span class="dock-ico"></span></button>
</div>`;
return `
<footer class="world-dock" id="worldDock" aria-label="Toolbar">
<button class="dock-collapse" id="dockCollapse" title="Collapse toolbar" aria-expanded="true"></button>
<div class="dock-scroll" id="dockScroll">
<div class="dock-group dock-map-controls" id="dockMapControls"></div>
<div class="dock-group">
<select id="regionSelect" class="region-select" title="Map region" aria-label="Map region">${regionOptions}</select>
</div>
<div class="dock-group">
<button class="dock-btn" id="dockLayersBtn" aria-pressed="false" title="Show or hide map layers"><span class="dock-ico"></span> <span class="btn-label">Layers</span></button>
</div>
<div class="dock-group dock-layout">
<label class="dock-select-wrap" title="Layout mode — Grid snaps widgets to the grid, Free is pixel-perfect, Immersive floats them over a full-screen map">
<select id="dockModeSelect" class="dock-select" aria-label="Layout mode">
<option value="grid">Grid</option>
<option value="free">Free</option>
<option value="immersive">Immersive</option>
</select>
</label>
<label class="dock-slider" title="Widget size">
<span class="dock-ico"></span>
<input type="range" id="dockGridSize" min="140" max="360" step="20" value="160" aria-label="Widget size" />
</label>
</div>
${immersive}
<div class="dock-group">
<button class="dock-btn dock-add" id="dockAddWidget" title="Add a widget"><span class="dock-ico"></span> <span class="btn-label">Add widget</span></button>
</div>
<div class="dock-group">
<button class="dock-btn" id="settingsBtn" title="Show or hide panels"><span class="dock-ico"></span> <span class="btn-label">Panels</span></button>
<button class="dock-btn" id="sourcesBtn" title="Data sources"><span class="dock-ico">📡</span> <span class="btn-label">Sources</span></button>
</div>
${share}
</div>
</footer>`;
}
/**
@@ -2027,6 +2380,10 @@ export class App {
layers: this.mapLayers,
timeRange: '7d',
mode: this.resolveInitialMapMode(),
// Mount the 2D/3D toggle, basemap switcher and time-range pills into the
// bottom toolbar dock instead of overlaying the map. Falls back to the map
// container if the dock isn't present (e.g. harness).
controlsHost: document.getElementById('dockMapControls') ?? undefined,
});
// Initialize escalation service with data getters
@@ -2059,10 +2416,12 @@ export class App {
this.panels['monitors'] = monitorPanel;
monitorPanel.onChanged((monitors) => {
this.monitors = monitors;
saveToStorage(STORAGE_KEYS.monitors, monitors);
void saveUserMonitors(monitors); // localStorage + the Go backend when signed in
this.updateMonitorResults();
});
this.panels['watch'] = new WatchQueuePanel();
const commoditiesPanel = new CommoditiesPanel();
this.panels['commodities'] = commoditiesPanel;
@@ -2296,6 +2655,13 @@ export class App {
this.panels['hanzo-status'] = new HanzoStatusPanel();
}
// AI variant — live Hanzo inference telemetry: AI Compute (ai-pulse SSE) +
// Enso Flywheel (routing ledger + evals).
if (SITE_VARIANT === 'ai') {
this.panels['ai-compute'] = new AiComputePanel();
this.panels['enso-flywheel'] = new EnsoFlywheelPanel();
}
// Chains widget — live block heights + peers (saas + crypto variants).
if (SITE_VARIANT === 'saas' || SITE_VARIANT === 'crypto') {
this.panels['chains'] = new BlockchainPanel();
@@ -2407,6 +2773,14 @@ export class App {
if (!this.analystDock) {
this.analystDock = new AiAnalystDock(this.buildAnalystHost());
this.analystDock.attach();
// Right-click → "Summarize with AI" on any headline routes into the SAME
// analyst dock (no second AI path, no bespoke summarizer).
registerSummarizePort((headline, url) => {
const q = url
? `Summarize this story and why it matters: "${headline}" (${url})`
: `Summarize this story and why it matters: "${headline}"`;
this.analystDock?.askInDock(q);
});
}
// Cloud tab: the deep operator panels are admin-org only (server enforces
@@ -2552,6 +2926,15 @@ export class App {
region: this.map?.getState().view,
theme: getCurrentTheme(),
authed: isAuthenticated(),
layoutMode: this.immersive?.getState().enabled ? 'immersive' : this.gridApi().getLayoutMode(),
immersiveBg: this.immersive?.getState().background,
language: getCurrentLanguage(),
monitors: this.monitors.map((m) => ({ id: m.id, keywords: m.keywords })),
queue: {
total: watchQueue.length,
unwatched: watchQueue.unwatchedCount(),
current: watchQueue.current()?.title,
},
}),
listPanels: () =>
Object.entries(this.panelSettings)
@@ -2573,6 +2956,20 @@ export class App {
setTheme: (theme) => this.setAppTheme(theme),
search: (query) => this.runSearch(query),
resetLayout: () => this.resetPanelLayout(),
queueVideo: (query) => this.queueVideoToWatch(query),
setLayoutMode: (mode) => this.setLayoutModeFromCommand(mode),
setImmersiveBackground: (bg) => this.setImmersiveBackgroundFromCommand(bg),
setLanguage: (code) => this.setLanguageFromCommand(code),
addMonitor: (keywords) => this.addMonitorFromCommand(keywords),
removeMonitor: (id) => this.removeMonitorFromCommand(id),
queueNext: () => {
if (!watchQueue.length) return { ok: false };
return { ok: true, title: watchQueue.next()?.title };
},
queuePrev: () => {
if (!watchQueue.length) return { ok: false };
return { ok: true, title: watchQueue.prev()?.title };
},
addFeedPanel: (name, url) => this.addCustomFeedPanel(name, url),
removeCustomPanel: (name) => this.removeCustomFeedPanel(name),
switchOrg: (org) => this.switchActiveOrg(org),
@@ -2581,6 +2978,77 @@ export class App {
// ── New analyst capabilities (drive existing public APIs; no new plumbing) ──
// Resolve a free-text query to a video and put it in the persistent Watch
// Queue (survives reload — the whole point), then show the panel and play it.
private async queueVideoToWatch(query: string): Promise<{ ok: boolean; note?: string; title?: string }> {
try {
const [hit] = await searchYouTube(query);
if (!hit) return { ok: false, note: 'no video found' };
watchQueue.enqueue({
id: `yt:${hit.id}`,
kind: 'video',
title: hit.title,
source: hit.channel,
ref: hit.id,
thumbnail: hit.thumbnail,
link: `https://www.youtube.com/watch?v=${hit.id}`,
});
watchQueue.select(`yt:${hit.id}`);
this.setPanelEnabled('watch', true);
return { ok: true, title: hit.title };
} catch {
return { ok: false, note: 'search is unavailable' };
}
}
// Layout mode: the analyst drives the SAME setDockMode the dock select drives,
// so the select and the AI can never disagree about the mode.
private setLayoutModeFromCommand(mode: 'grid' | 'free' | 'immersive'): boolean {
this.setDockMode(mode);
this.syncModeSelect();
const now = this.immersive?.getState().enabled ? 'immersive' : this.gridApi().getLayoutMode();
return now === mode;
}
private setImmersiveBackgroundFromCommand(bg: 'map' | 'video'): boolean {
if (!this.immersive?.getState().enabled) return false; // honest: immersive is off
this.immersive.setBackground(bg);
return this.immersive.getState().background === bg;
}
private setLanguageFromCommand(code: string): boolean {
if (!LANGUAGES.some((l) => l.code === code)) return false;
changeLanguage(code);
return true;
}
// "Watch for X" — the same path the Monitors panel takes, so a monitor added by
// the analyst is persisted (and matched server-side) exactly like a typed one.
private addMonitorFromCommand(keywords: string): { ok: boolean; id?: string } {
const list = keywords.split(',').map((k) => k.trim().toLowerCase()).filter(Boolean);
if (!list.length) return { ok: false };
const monitor: Monitor = {
id: generateId(),
keywords: list,
color: MONITOR_COLORS[this.monitors.length % MONITOR_COLORS.length] ?? getCSSColor('--status-live'),
};
this.monitors = [...this.monitors, monitor];
(this.panels['monitors'] as MonitorPanel | undefined)?.setMonitors(this.monitors);
void saveUserMonitors(this.monitors);
this.updateMonitorResults();
return { ok: true, id: monitor.id };
}
private removeMonitorFromCommand(id: string): boolean {
const next = this.monitors.filter((m) => m.id !== id);
if (next.length === this.monitors.length) return false;
this.monitors = next;
(this.panels['monitors'] as MonitorPanel | undefined)?.setMonitors(next);
void saveUserMonitors(next);
this.updateMonitorResults();
return true;
}
private resizePanelInGrid(key: string, span: number): boolean {
const s = Math.max(1, Math.min(4, Math.round(span)));
const el = key === 'map' ? document.getElementById('mapSection') : this.panels[key]?.getElement();
@@ -3455,6 +3923,7 @@ export class App {
{ name: 'fred', task: runGuarded('fred', () => this.loadFredData()) },
{ name: 'oil', task: runGuarded('oil', () => this.loadOilAnalytics()) },
{ name: 'spending', task: runGuarded('spending', () => this.loadGovernmentSpending()) },
{ name: 'china', task: runGuarded('china', () => this.loadChinaMacro()) },
];
// Load intelligence signals for CII calculation (protests, military, outages)
@@ -4708,9 +5177,38 @@ export class App {
}
}
private async loadChinaMacro(): Promise<void> {
const economicPanel = this.panels['economic'] as EconomicPanel;
try {
const data = await fetchChinaMacro();
economicPanel?.updateChina(data);
this.statusPanel?.updateApi('ChinaMacro', { status: data.unavailable ? 'error' : 'ok' });
} catch (e) {
console.error('[App] China macro failed:', e);
this.statusPanel?.updateApi('ChinaMacro', { status: 'error' });
}
}
private updateMonitorResults(): void {
const monitorPanel = this.panels['monitors'] as MonitorPanel;
monitorPanel.renderResults(this.allNews);
if (!monitorPanel) return;
// Signed in, the Go backend matches against the whole lake — every item it
// ingested, not just the headlines this tab loaded. Signed out (or server
// unreachable), match locally exactly as before.
void fetchMonitorMatches().then((matches) => {
if (matches) monitorPanel.renderServerMatches(matches);
else monitorPanel.renderResults(this.allNews);
});
}
// Adopt the signed-in user's server-side monitors once identity resolves, so a
// second device shows the monitors you made on the first.
private async syncMonitorsFromServer(): Promise<void> {
const monitors = await loadUserMonitors();
if (!monitors.length && !this.monitors.length) return;
this.monitors = monitors;
(this.panels['monitors'] as MonitorPanel | undefined)?.setMonitors(monitors);
this.updateMonitorResults();
}
private async runCorrelationAnalysis(): Promise<void> {
@@ -4865,6 +5363,7 @@ export class App {
this.scheduleRefresh('fred', () => this.loadFredData(), 30 * 60 * 1000);
this.scheduleRefresh('oil', () => this.loadOilAnalytics(), 30 * 60 * 1000);
this.scheduleRefresh('spending', () => this.loadGovernmentSpending(), 60 * 60 * 1000);
this.scheduleRefresh('china', () => this.loadChinaMacro(), 30 * 60 * 1000);
// Refresh intelligence signals for CII (geopolitical variant only)
// This handles outages, protests, military - updates map when layers enabled
+29
View File
@@ -0,0 +1,29 @@
// Pure state derivation for the China summary groups. Kept free of service/DOM
// imports so the four-state contract (available/partial/stale/unavailable) stays
// unit-testable. Ported verbatim from worldmonitor src/app/china-summary-state.ts;
// the signal/group types are defined locally because this fork surfaces China in
// the Economic Indicators panel rather than the upstream CountryBriefPanel.
export type ChinaCountrySummaryState = 'loading' | 'available' | 'partial' | 'stale' | 'unavailable';
export interface ChinaCountrySummarySignal {
label?: string;
value?: string;
source?: string;
observedAt?: string;
stale: boolean;
}
export function chinaSummaryState(signals: ChinaCountrySummarySignal[], expectedSignals: number): ChinaCountrySummaryState {
if (signals.length === 0) return 'unavailable';
if (signals.every((signal) => signal.stale)) return 'stale';
return signals.length < expectedSignals || signals.some((signal) => signal.stale) ? 'partial' : 'available';
}
// Source-provided timestamps are dates or months ('2026-06', '2025-Q4');
// retrieval timestamps are full ISO strings. Trim the latter to the date part
// so the attribution row never shows a millisecond-precision machine string.
export function toObservedDate(timestamp: string): string {
const tIndex = timestamp.indexOf('T');
return tIndex > 0 ? timestamp.slice(0, tIndex) : timestamp;
}
+23 -4
View File
@@ -42,15 +42,25 @@ export class AccountMenu {
this.user = null;
this.scope = null;
this.renderSignedOut();
this.announce(false);
return;
}
this.user = await getUser();
if (!this.user) {
this.renderSignedOut();
this.announce(false);
return;
}
this.scope = await resolveScope();
this.render();
this.announce(true);
}
// The ONE signal that identity resolved. Anything that depends on signed-in
// state (e.g. hiding the "Try Hanzo" acquisition CTA) listens for this rather
// than polling isAuthenticated().
private announce(authed: boolean): void {
document.dispatchEvent(new CustomEvent('hanzo:auth', { detail: { authed } }));
}
private close(): void {
@@ -60,10 +70,11 @@ export class AccountMenu {
}
private renderSignedOut(): void {
// Primary CTA (shared .hz-cta pill): signed-out users see one clear
// "Try Hanzo World" action that runs the same OIDC login the account chip uses.
this.element.innerHTML = `<button class="am-cta hz-cta" type="button">Try Hanzo World</button>`;
this.element.querySelector('.am-cta')?.addEventListener('click', () => void login());
// Secondary identity control: a "Sign in" button. The primary header CTA is now
// the "Try Hanzo" product switcher, so this stays understated and runs the same
// hanzo.id OIDC login the account chip uses.
this.element.innerHTML = `<button class="am-signin" type="button">Sign in</button>`;
this.element.querySelector('.am-signin')?.addEventListener('click', () => void login());
}
private render(): void {
@@ -200,6 +211,14 @@ function injectStyles(): void {
cursor: pointer; transition: border-color 0.15s, background 0.15s;
}
.am-trigger:hover { border-color: var(--border-strong, rgba(255,255,255,0.24)); background: var(--surface-hover, rgba(255,255,255,0.08)); }
.am-signin {
display: inline-flex; align-items: center;
height: 30px; padding: 0 14px; font-size: 13px; font-weight: 500;
color: inherit; background: var(--surface, rgba(255,255,255,0.04));
border: 1px solid var(--border, rgba(255,255,255,0.14)); border-radius: 6px;
transition: border-color 0.15s, background 0.15s;
}
.am-signin:hover { border-color: var(--border-strong, rgba(255,255,255,0.24)); background: var(--surface-hover, rgba(255,255,255,0.08)); }
.am-scope { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.am-caret { opacity: 0.6; font-size: 10px; }
.am-avatar { border-radius: 50%; object-fit: cover; flex: none; background: var(--accent, #6366f1); }

Some files were not shown because too many files have changed in this diff Show More