Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee04c5c89 | ||
|
|
7cd8035964 | ||
|
|
4ef1b36e45 | ||
|
|
7bbfe0f5a7 | ||
|
|
426f918286 | ||
|
|
322248b98c | ||
|
|
27e52c0ea2 | ||
|
|
5b3f257a78 | ||
|
|
9ff7367f47 | ||
|
|
b2c86b60f5 | ||
|
|
ca6ab46c0a | ||
|
|
9e56b6768f | ||
|
|
df125ac864 | ||
|
|
8f024d9227 | ||
|
|
f4d5827263 | ||
|
|
8511a46d38 | ||
|
|
014080d3e8 | ||
|
|
bd5aec5e12 | ||
|
|
7f58503cf6 | ||
|
|
baac10392f | ||
|
|
881c464e08 | ||
|
|
b03f317986 | ||
|
|
0ad40998e1 | ||
|
|
a452df5282 | ||
|
|
959138ac28 | ||
|
|
329a741409 | ||
|
|
a8485d11af | ||
|
|
abfbd61d1d | ||
|
|
cb67e2bbec | ||
|
|
44ad7190cc | ||
|
|
bc11f1111e | ||
|
|
c75b442523 | ||
|
|
6bf39c2ebd | ||
|
|
2351a1e5bb | ||
|
|
6461b9f7eb | ||
|
|
9f6ae73148 | ||
|
|
43bfcd9463 | ||
|
|
71c53613e4 | ||
|
|
d5cccfa026 | ||
|
|
fb5b277927 | ||
|
|
c90e39c018 | ||
|
|
a5d6da77ae | ||
|
|
217f13a582 | ||
|
|
73bd9d78f8 | ||
|
|
5dc7232cda | ||
|
|
9119ebbf3c | ||
|
|
966882d9b7 | ||
|
|
56c8f3770a | ||
|
|
897a9e1280 | ||
|
|
c953754197 | ||
|
|
490acbb1c5 | ||
|
|
da5be4b051 | ||
|
|
678bd10d0d | ||
|
|
a338fc440b | ||
|
|
6fdfc0d3a6 | ||
|
|
1043182671 | ||
|
|
68a28f2eda | ||
|
|
a8af187b99 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 416 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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") + "."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, " ", " ")
|
||||
v = strings.ReplaceAll(v, " ", " ")
|
||||
v = strings.ReplaceAll(v, "&", "&")
|
||||
v = strings.ReplaceAll(v, "'", "'")
|
||||
v = strings.ReplaceAll(v, "'", "'")
|
||||
v = strings.ReplaceAll(v, """, "\"")
|
||||
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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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", "":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{"0–20%", "20–40%", "40–60%", "60–80%", "80–100%"}
|
||||
|
||||
// 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 }
|
||||
@@ -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, "80–100%") != 2 || bucketCount(l.Confidence, "0–20%") != 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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) + "×pan=" + 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) + "×pan=" + 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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] == '{'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (&, ') — 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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...) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rates": [
|
||||
{ "countryCode": "CN", "country": "China", "rate": 3.0, "previousRate": 3.1, "date": "2026-06-01" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"observations": [
|
||||
{ "date": "2026-07-09", "value": "7.1710" },
|
||||
{ "date": "2026-07-10", "value": "7.1842" }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||