From db3e0806404bb0ec47001cd27c96e55715c737c7 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Sat, 27 Jun 2026 01:44:11 -0400 Subject: [PATCH] Upgrade request analysis routing --- docs/kb/README.md | 12 +- docs/kb/modules.json | 96 ++++ docs/kb/modules/chunk-and-world-ops.md | 13 + docs/kb/modules/content-assets-and-imports.md | 12 + docs/kb/modules/graphics-painter-features.md | 15 + docs/kb/modules/launcher-and-site.md | 14 + .../kb/modules/persistence-history-and-api.md | 12 + docs/kb/modules/rendering-runtime-bridge.md | 12 + docs/kb/modules/request-board-operations.md | 15 + docs/kb/modules/request-routing-playbook.md | 39 ++ .../kb/modules/terminology-engine-language.md | 20 + docs/kb/modules/tile-canvas-tools.md | 14 + docs/kb/queue-workflow.md | 19 +- docs/kb/request-analysis-schema.json | 64 +++ docs/kb/terminology.json | 66 +++ scripts/request-analysis-worker.mjs | 515 +++++++++++++++++- server.js | 263 ++++++++- src/WorldshaperLauncher.tsx | 356 +++++++++++- src/index.css | 29 + 19 files changed, 1520 insertions(+), 66 deletions(-) create mode 100644 docs/kb/modules.json create mode 100644 docs/kb/modules/chunk-and-world-ops.md create mode 100644 docs/kb/modules/content-assets-and-imports.md create mode 100644 docs/kb/modules/graphics-painter-features.md create mode 100644 docs/kb/modules/launcher-and-site.md create mode 100644 docs/kb/modules/persistence-history-and-api.md create mode 100644 docs/kb/modules/rendering-runtime-bridge.md create mode 100644 docs/kb/modules/request-board-operations.md create mode 100644 docs/kb/modules/request-routing-playbook.md create mode 100644 docs/kb/modules/terminology-engine-language.md create mode 100644 docs/kb/modules/tile-canvas-tools.md create mode 100644 docs/kb/terminology.json diff --git a/docs/kb/README.md b/docs/kb/README.md index e971ada..258fcf6 100644 --- a/docs/kb/README.md +++ b/docs/kb/README.md @@ -18,12 +18,18 @@ The goal is to give request-processing tools stable system summaries instead of - JSON schema for structured request parsing output. - `tags.json` - Standardized request tags for queue analysis and admin review. +- `terminology.json` + - Canonical editor terminology and user-language aliases. +- `modules.json` + - Machine-readable index of granular KB slices for targeted retrieval. - `queue-workflow.md` - First-pass automation and review flow. - `editor-capabilities.md` - Shorthand capability matrix for common request-analysis questions. - `systems/*.md` - Human-readable system notes, organized by editor subsystem. +- `modules/*.md` + - Smaller topical docs that can be mixed into model context without sending the whole KB. ## Current System Coverage @@ -40,14 +46,15 @@ The scaffold currently covers: - Persistence and save pipeline - Floating windows and popout shell - Content catalog and record APIs +- Routing terminology and modular retrieval slices ## How To Use This For Request Analysis Suggested queue flow: 1. Pull a raw request submission from the launcher request store. -2. Retrieve likely systems from `systems.json` using tags, aliases, and file names. -3. Provide the matching `systems/*.md` files to the local model as context. +2. Run a routing pass using `systems.json`, `modules.json`, and `terminology.json`. +3. Retrieve only the most relevant `systems/*.md` and `modules/*.md` files. 4. Require the model to use only tags from `tags.json`. 5. Ask the model to split the submission into atomic requests. 6. Require output that matches `request-analysis-schema.json`. @@ -56,6 +63,7 @@ Suggested queue flow: ## Recommended Next Additions +- Keep expanding modules until every major tool and runtime handoff has at least one focused doc. - Add concrete file-level notes for major controllers as they evolve. - Add a small extractor script that refreshes endpoint lists from `server.js`. - Add examples of well-tagged historical requests for few-shot prompting. diff --git a/docs/kb/modules.json b/docs/kb/modules.json new file mode 100644 index 0000000..7f6ebe8 --- /dev/null +++ b/docs/kb/modules.json @@ -0,0 +1,96 @@ +{ + "schemaVersion": 1, + "generatedFromRepoDate": "2026-06-27", + "modules": [ + { + "id": "request-routing-playbook", + "name": "Request Routing Playbook", + "docPath": "docs/kb/modules/request-routing-playbook.md", + "tags": ["Request Board", "Wiki", "General"], + "systemIds": ["request-board", "launcher-home"], + "aliases": ["triage", "routing", "request parsing", "review flow"], + "priority": "high" + }, + { + "id": "terminology-engine-language", + "name": "Terminology And Engine Language", + "docPath": "docs/kb/modules/terminology-engine-language.md", + "tags": ["General", "Wiki", "Content", "Graphics Painter", "Tiling", "Rendering", "Worlds"], + "systemIds": ["graphics-painter", "layers-tile-editing", "rendering-viewport", "content-catalog-records", "world-bootstrap"], + "aliases": ["synonyms", "naming", "engine terminology", "language map"], + "priority": "high" + }, + { + "id": "launcher-and-site", + "name": "Launcher And Website Surface", + "docPath": "docs/kb/modules/launcher-and-site.md", + "tags": ["Launcher", "Website", "Polish", "Request Board"], + "systemIds": ["launcher-home", "request-board"], + "aliases": ["site", "homepage", "launcher", "main page"], + "priority": "medium" + }, + { + "id": "request-board-operations", + "name": "Request Board Operations", + "docPath": "docs/kb/modules/request-board-operations.md", + "tags": ["Request Board", "Website", "UI / Workflow", "Wiki"], + "systemIds": ["request-board"], + "aliases": ["request board", "admin review", "queue worker", "moderation"], + "priority": "high" + }, + { + "id": "tile-canvas-tools", + "name": "Tile Canvas Tools", + "docPath": "docs/kb/modules/tile-canvas-tools.md", + "tags": ["Tiling", "Layers", "Chunks", "UI / Workflow"], + "systemIds": ["layers-tile-editing", "chunk-storage-streaming", "content-catalog-records"], + "aliases": ["map painting", "rectangle tool", "brush", "eraser", "grid editing"], + "priority": "high" + }, + { + "id": "graphics-painter-features", + "name": "Graphics Painter Features", + "docPath": "docs/kb/modules/graphics-painter-features.md", + "tags": ["Graphics Painter", "Animation", "Content", "Windows"], + "systemIds": ["graphics-painter", "content-catalog-records", "floating-window-shell"], + "aliases": ["sprite editor", "art editor", "painting tool", "recoloring", "frame editing"], + "priority": "high" + }, + { + "id": "chunk-and-world-ops", + "name": "Chunk And World Operations", + "docPath": "docs/kb/modules/chunk-and-world-ops.md", + "tags": ["Chunks", "World Overview", "Worlds", "Performance"], + "systemIds": ["chunk-storage-streaming", "world-overview", "world-bootstrap"], + "aliases": ["chunk ops", "world overview", "world map", "chunk movement", "streaming"], + "priority": "high" + }, + { + "id": "rendering-runtime-bridge", + "name": "Rendering And Runtime Bridge", + "docPath": "docs/kb/modules/rendering-runtime-bridge.md", + "tags": ["Rendering", "Performance", "Animation", "Worlds"], + "systemIds": ["rendering-viewport", "world-bootstrap", "chunk-storage-streaming"], + "aliases": ["runtime", "game engine", "renderer", "viewport", "scene draw"], + "priority": "medium" + }, + { + "id": "content-assets-and-imports", + "name": "Content Assets And Imports", + "docPath": "docs/kb/modules/content-assets-and-imports.md", + "tags": ["Content", "Graphics Painter", "Tiling", "Persistence"], + "systemIds": ["content-catalog-records", "graphics-painter", "persistence-save-pipeline"], + "aliases": ["assets", "tile catalog", "sprite catalog", "imports", "images"], + "priority": "medium" + }, + { + "id": "persistence-history-and-api", + "name": "Persistence, History, And API Writes", + "docPath": "docs/kb/modules/persistence-history-and-api.md", + "tags": ["Persistence", "Chunks", "Content", "UI / Workflow"], + "systemIds": ["persistence-save-pipeline", "chunk-storage-streaming", "content-catalog-records"], + "aliases": ["saving", "undo", "redo", "history", "api writes"], + "priority": "medium" + } + ] +} diff --git a/docs/kb/modules/chunk-and-world-ops.md b/docs/kb/modules/chunk-and-world-ops.md new file mode 100644 index 0000000..529c2b5 --- /dev/null +++ b/docs/kb/modules/chunk-and-world-ops.md @@ -0,0 +1,13 @@ +# Chunk And World Operations + +## What This Module Covers + +This module handles world-scale navigation and chunk-backed operations rather than single-asset editing. + +## Existing Capabilities + +- neighborhood chunk loading around the current viewport +- chunk move, duplicate, rotate, flip, and delete flows +- world overview interactions +- bookmark and point-of-interest navigation +- batch save paths for chunk edits diff --git a/docs/kb/modules/content-assets-and-imports.md b/docs/kb/modules/content-assets-and-imports.md new file mode 100644 index 0000000..a60dae6 --- /dev/null +++ b/docs/kb/modules/content-assets-and-imports.md @@ -0,0 +1,12 @@ +# Content Assets And Imports + +## What This Module Covers + +This module tracks how tiles, sprites, images, and related metadata are stored, imported, previewed, and connected to editor features. + +## Existing Capabilities + +- content records for tiles, sprites, and images +- imports and source-image handling +- preview generation for art assets +- tile and sprite catalog usage across editor tools diff --git a/docs/kb/modules/graphics-painter-features.md b/docs/kb/modules/graphics-painter-features.md new file mode 100644 index 0000000..aa92f40 --- /dev/null +++ b/docs/kb/modules/graphics-painter-features.md @@ -0,0 +1,15 @@ +# Graphics Painter Features + +## What This Module Covers + +This module is the detailed quick-reference for the asset art editor. It is where requests about painting graphics, recoloring sprites, frame editing, and art-side tooling should route. + +## Existing Capabilities + +- rectangle, circle, triangle, and line tools +- outline, fill, and combined shape modes +- shape erasers +- animation timeline editing +- frame duplication and ordering workflows +- preview background support +- animation preview window diff --git a/docs/kb/modules/launcher-and-site.md b/docs/kb/modules/launcher-and-site.md new file mode 100644 index 0000000..932e037 --- /dev/null +++ b/docs/kb/modules/launcher-and-site.md @@ -0,0 +1,14 @@ +# Launcher And Website Surface + +## What This Module Covers + +This module focuses on the public-facing launcher, its presentation, its navigation, and the supporting request/news board behavior shown outside the main editor runtime. + +## Features + +- floating-window launch flow for the studio +- branded landing presentation +- launcher background art treatment +- request/news tab switching +- repo link and launch affordances +- admin-window entry point from the public board diff --git a/docs/kb/modules/persistence-history-and-api.md b/docs/kb/modules/persistence-history-and-api.md new file mode 100644 index 0000000..1b986f2 --- /dev/null +++ b/docs/kb/modules/persistence-history-and-api.md @@ -0,0 +1,12 @@ +# Persistence, History, And API Writes + +## What This Module Covers + +This module explains how edits become saved data, how history interacts with those edits, and where API writes sit in the workflow. + +## Existing Capabilities + +- save pipeline for chunk data +- save pipeline for content records +- history-aware editor state +- server endpoints for persisted editor data diff --git a/docs/kb/modules/rendering-runtime-bridge.md b/docs/kb/modules/rendering-runtime-bridge.md new file mode 100644 index 0000000..2f5c2e7 --- /dev/null +++ b/docs/kb/modules/rendering-runtime-bridge.md @@ -0,0 +1,12 @@ +# Rendering And Runtime Bridge + +## What This Module Covers + +This module explains the handoff between authored editor data and runtime-facing map rendering behavior. + +## What It Usually Means + +- how edited data appears in the viewport +- whether animations actually play in live rendering +- how runtime entities or assets would consume editor-authored records +- how gameplay requests may imply editor-side metadata diff --git a/docs/kb/modules/request-board-operations.md b/docs/kb/modules/request-board-operations.md new file mode 100644 index 0000000..4d6e0f8 --- /dev/null +++ b/docs/kb/modules/request-board-operations.md @@ -0,0 +1,15 @@ +# Request Board Operations + +## What This Module Covers + +This module describes the request intake, queue automation, admin review experience, deletion flow, and protected moderation tools. + +## Core Capabilities + +- public request submission storage +- pending versus active request state +- structured review metadata +- admin-protected editing and deletion +- queue worker launch and rerun flows +- recent log viewing +- popup review details for long-form analysis diff --git a/docs/kb/modules/request-routing-playbook.md b/docs/kb/modules/request-routing-playbook.md new file mode 100644 index 0000000..7d63dbd --- /dev/null +++ b/docs/kb/modules/request-routing-playbook.md @@ -0,0 +1,39 @@ +# Request Routing Playbook + +## Purpose + +This module exists to help the queue worker turn messy, broad, or slang-heavy requests into a useful first interpretation before the deeper implementation pass begins. + +## Routing Rules + +- Prefer existing Worldshaper terminology when a user uses nearby language. +- Split one submission into multiple request items only when the text clearly asks for separate changes. +- If a request is broad, still produce a useful interpretation instead of returning an empty or dismissive review. +- Low confidence should change the recommendation and review notes, not erase the attempt to help. + +## Ambiguous Request Handling + +- "Make the game better" is not zero-information. Treat it as a broad improvement request and map it to `General` plus the most likely systems. +- "Add horses" is a content-plus-runtime request unless the text narrows it down. It may imply: + - visual assets + - placement on maps + - runtime entity support +- "Fix painting" should first determine whether the user means map tile painting or the Graphics Painter. + +## What The First Pass Should Produce + +- matched terms and likely meanings +- candidate tags +- likely systems +- ambiguity level +- a short rationale safe for admin review +- possible directions if the user intent could branch + +## What The Second Pass Should Produce + +- clean title +- primary category +- standardized tags +- parsed interpretation +- implementation approach +- review rationale and options when the request is still uncertain diff --git a/docs/kb/modules/terminology-engine-language.md b/docs/kb/modules/terminology-engine-language.md new file mode 100644 index 0000000..9811c53 --- /dev/null +++ b/docs/kb/modules/terminology-engine-language.md @@ -0,0 +1,20 @@ +# Terminology And Engine Language + +## Purpose + +Users often describe systems with game-development slang instead of the editor's exact names. This module helps map that language back into Worldshaper concepts. + +## Common Term Bridges + +- "sprite editor", "painting tool", and "recoloring tool" usually point toward `Graphics Painter`. +- "map painting", "brush", and "grid editing" usually point toward `Tile Canvas Tools` and `Layers And Tile Editing`. +- "engine", "runtime", or "actual game" usually point toward runtime-facing systems, not just editor windows. +- "characters", "actors", and "entities" often cross `Content`, `Rendering`, and `Worlds`. +- "map", "grid", and "tiles" can refer to chunk-backed tile placement rather than image editing. + +## Meaning Differences That Matter + +- Painting on the map is not the same as painting an asset. +- A tile is usually a world-placement record. A sprite is usually an art asset or entity-facing image record. +- A chunk is the storage and streaming unit, not just a visible square on screen. +- A runtime request can require editor metadata even if the user only talks about gameplay. diff --git a/docs/kb/modules/tile-canvas-tools.md b/docs/kb/modules/tile-canvas-tools.md new file mode 100644 index 0000000..0208ddf --- /dev/null +++ b/docs/kb/modules/tile-canvas-tools.md @@ -0,0 +1,14 @@ +# Tile Canvas Tools + +## What This Module Covers + +This module is about painting and editing the live map grid, not editing the source art for tiles or sprites. + +## Existing Capabilities + +- freehand paint on the active layer +- rectangle, circle, and line-based placement workflows +- erasing on active layers +- height-aware sparse paint behavior +- background tile and background-hole modes +- grid-backed placement within chunk data diff --git a/docs/kb/queue-workflow.md b/docs/kb/queue-workflow.md index 2f1424f..c1a4cca 100644 --- a/docs/kb/queue-workflow.md +++ b/docs/kb/queue-workflow.md @@ -15,13 +15,14 @@ Turn raw launcher submissions into structured request items that are: ## Recommended Pipeline 1. Read one pending submission. -2. Split it into candidate atomic requests. +2. Run a light routing pass that maps slang and broad language onto likely systems and tags. 3. Retrieve likely systems from `docs/kb/systems.json`. -4. Load the matching `docs/kb/systems/*.md` files. -5. Load the standardized request tags from `docs/kb/tags.json`. -6. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`. -7. Validate the JSON. -8. Promote high-confidence items and hold low-confidence items for review. +4. Retrieve likely focused modules from `docs/kb/modules.json`. +5. Load only the matching `docs/kb/systems/*.md` and `docs/kb/modules/*.md` files. +6. Load the standardized request tags from `docs/kb/tags.json`. +7. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`. +8. Validate the JSON. +9. Promote high-confidence items and hold low-confidence items for review. ## Suggested Confidence Rules @@ -41,7 +42,9 @@ Search `systems.json` using: - aliases - past tags from similar requests -If a submission touches multiple subsystems, feed the model the top two to four matching docs instead of the whole KB. +If a submission touches multiple subsystems, feed the model the top two to four matching system docs plus the smallest helpful focused modules instead of the whole KB. + +Use `terminology.json` during the routing pass so phrases like "sprite editor", "painting tool", "recoloring", and "engine" still resolve to the intended Worldshaper concepts. ## Prompt Skeleton @@ -67,6 +70,7 @@ If you are unsure, lower confidence and use statusRecommendation = "needs_review Then provide: - the raw submission +- the routing summary - the JSON schema - the retrieved KB docs @@ -121,6 +125,7 @@ Behavior: - high-confidence all-active results replace the pending submission with active request rows - mixed or lower-confidence results stay on the pending submission as review metadata - failures are stored as analysis errors instead of silently disappearing +- broad or ambiguous requests should still receive a useful interpretation, likely systems, and possible directions instead of a dead-end review ## What Not To Automate First diff --git a/docs/kb/request-analysis-schema.json b/docs/kb/request-analysis-schema.json index a47541d..98303c3 100644 --- a/docs/kb/request-analysis-schema.json +++ b/docs/kb/request-analysis-schema.json @@ -18,6 +18,70 @@ "type": "string", "minLength": 1 }, + "routing": { + "type": "object", + "additionalProperties": false, + "properties": { + "summary": { + "type": "string" + }, + "ambiguity": { + "type": "string", + "enum": ["low", "medium", "high"] + }, + "matchedTerms": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "suggestedTags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "suggestedSystems": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "suggestedModules": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "rationale": { + "type": "string" + }, + "possibleDirections": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "kbSections": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, "items": { "type": "array", "minItems": 1, diff --git a/docs/kb/terminology.json b/docs/kb/terminology.json new file mode 100644 index 0000000..57b6829 --- /dev/null +++ b/docs/kb/terminology.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "generatedFromRepoDate": "2026-06-27", + "terms": [ + { + "canonical": "Graphics Painter", + "aliases": ["graphic painter", "sprite editor", "art editor", "painting tool", "pixel editor", "recoloring tool"], + "tags": ["Graphics Painter", "Content", "Animation"], + "systemIds": ["graphics-painter", "content-catalog-records"] + }, + { + "canonical": "Tile Canvas", + "aliases": ["map canvas", "map paint", "tile brush", "grid painter", "rectangle tile tool", "tile tool"], + "tags": ["Tiling", "Layers", "Chunks"], + "systemIds": ["layers-tile-editing", "chunk-storage-streaming"] + }, + { + "canonical": "Chunk", + "aliases": ["map section", "region", "chunk cell", "chunk area"], + "tags": ["Chunks", "World Overview", "Worlds"], + "systemIds": ["chunk-storage-streaming", "world-overview"] + }, + { + "canonical": "World Overview", + "aliases": ["overview map", "chunk overview", "world map", "big map"], + "tags": ["World Overview", "Chunks", "Worlds"], + "systemIds": ["world-overview", "chunk-storage-streaming"] + }, + { + "canonical": "Renderer", + "aliases": ["runtime renderer", "viewport", "draw system", "scene draw", "engine view"], + "tags": ["Rendering", "Performance", "Animation"], + "systemIds": ["rendering-viewport"] + }, + { + "canonical": "Runtime", + "aliases": ["engine", "game runtime", "play mode", "actual game"], + "tags": ["Worlds", "Rendering", "Performance"], + "systemIds": ["world-bootstrap", "rendering-viewport"] + }, + { + "canonical": "Character Entity", + "aliases": ["character", "npc", "actor", "unit", "entity"], + "tags": ["Content", "Rendering", "Worlds"], + "systemIds": ["content-catalog-records", "rendering-viewport", "world-bootstrap"] + }, + { + "canonical": "Grid", + "aliases": ["tile grid", "map grid", "cells", "squares"], + "tags": ["Tiling", "Chunks", "Layers"], + "systemIds": ["layers-tile-editing", "chunk-storage-streaming"] + }, + { + "canonical": "Asset Catalog", + "aliases": ["sprite list", "tile list", "catalog", "content records", "asset database"], + "tags": ["Content", "Persistence"], + "systemIds": ["content-catalog-records", "persistence-save-pipeline"] + }, + { + "canonical": "Launcher Request Board", + "aliases": ["request board", "requests", "admin review", "pending queue"], + "tags": ["Request Board", "Website", "Wiki"], + "systemIds": ["request-board", "launcher-home"] + } + ] +} diff --git a/scripts/request-analysis-worker.mjs b/scripts/request-analysis-worker.mjs index c98b8b2..2c6d135 100644 --- a/scripts/request-analysis-worker.mjs +++ b/scripts/request-analysis-worker.mjs @@ -7,6 +7,10 @@ const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const kbRoot = path.join(repoRoot, "docs", "kb"); const requestTagCatalogPath = path.join(kbRoot, "tags.json"); +const terminologyCatalogPath = path.join(kbRoot, "terminology.json"); +const moduleIndexPath = path.join(kbRoot, "modules.json"); +const editorCapabilitiesPath = path.join(kbRoot, "editor-capabilities.md"); +const queueWorkflowPath = path.join(kbRoot, "queue-workflow.md"); const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"; const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash"; const DEFAULT_PROVIDER = process.env.REQUEST_ANALYZER_PROVIDER @@ -253,6 +257,45 @@ async function loadRequestTagCatalog() { return tags; } +async function loadTerminologyCatalog() { + const payload = JSON.parse(await fs.readFile(terminologyCatalogPath, "utf8")); + const terms = Array.isArray(payload?.terms) + ? payload.terms + .map((entry) => ({ + canonical: String(entry?.canonical || "").trim(), + aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [], + tags: Array.isArray(entry?.tags) ? entry.tags.map((tag) => String(tag || "").trim()).filter(Boolean) : [], + systemIds: Array.isArray(entry?.systemIds) ? entry.systemIds.map((systemId) => String(systemId || "").trim()).filter(Boolean) : [], + })) + .filter((entry) => entry.canonical) + : []; + return { + schemaVersion: Number(payload?.schemaVersion) || 1, + terms, + }; +} + +async function loadModuleIndex() { + const payload = JSON.parse(await fs.readFile(moduleIndexPath, "utf8")); + const modules = Array.isArray(payload?.modules) + ? payload.modules + .map((entry) => ({ + id: String(entry?.id || "").trim(), + name: String(entry?.name || "").trim(), + docPath: String(entry?.docPath || "").trim(), + tags: Array.isArray(entry?.tags) ? entry.tags.map((tag) => String(tag || "").trim()).filter(Boolean) : [], + systemIds: Array.isArray(entry?.systemIds) ? entry.systemIds.map((systemId) => String(systemId || "").trim()).filter(Boolean) : [], + aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [], + priority: String(entry?.priority || "medium").trim().toLowerCase(), + })) + .filter((entry) => entry.id && entry.name && entry.docPath) + : []; + return { + schemaVersion: Number(payload?.schemaVersion) || 1, + modules, + }; +} + function buildRequestTagLookup(tagDefinitions) { return new Map( (Array.isArray(tagDefinitions) ? tagDefinitions : []).flatMap((entry) => [ @@ -388,21 +431,68 @@ async function loadKnowledgeBase() { const systemsIndex = JSON.parse(await fs.readFile(systemsIndexPath, "utf8")); const requestSchema = JSON.parse(await fs.readFile(requestSchemaPath, "utf8")); const requestTagDefinitions = await loadRequestTagCatalog(); + const terminology = await loadTerminologyCatalog(); + const moduleIndex = await loadModuleIndex(); const docsById = new Map(); + const moduleDocsById = new Map(); for (const system of Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) { const docPath = path.join(repoRoot, String(system.docPath || "").replace(/\//g, path.sep)); const docText = await fs.readFile(docPath, "utf8").catch(() => ""); docsById.set(String(system.id || ""), docText); } + for (const moduleEntry of Array.isArray(moduleIndex.modules) ? moduleIndex.modules : []) { + const docPath = path.join(repoRoot, String(moduleEntry.docPath || "").replace(/\//g, path.sep)); + const docText = await fs.readFile(docPath, "utf8").catch(() => ""); + moduleDocsById.set(String(moduleEntry.id || ""), docText); + } return { systemsIndex, requestSchema, requestTagDefinitions, requestTagLookup: buildRequestTagLookup(requestTagDefinitions), + terminology, + moduleIndex, docsById, + moduleDocsById, + editorCapabilitiesText: await fs.readFile(editorCapabilitiesPath, "utf8").catch(() => ""), + queueWorkflowText: await fs.readFile(queueWorkflowPath, "utf8").catch(() => ""), }; } +function normalizeAmbiguityLevel(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "low" || normalized === "medium" || normalized === "high") { + return normalized; + } + return "medium"; +} + +function normalizeSystemIds(values, kb, fallback = []) { + const knownIds = new Set((Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).map((system) => String(system.id || "").trim())); + const requested = uniqueStrings(Array.isArray(values) ? values : []) + .map((entry) => String(entry || "").trim()) + .filter((entry) => knownIds.has(entry)); + if (requested.length > 0) { + return requested; + } + return uniqueStrings(Array.isArray(fallback) ? fallback : []) + .map((entry) => String(entry || "").trim()) + .filter((entry) => knownIds.has(entry)); +} + +function normalizeModuleIds(values, kb, fallback = []) { + const knownIds = new Set((Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []).map((moduleEntry) => String(moduleEntry.id || "").trim())); + const requested = uniqueStrings(Array.isArray(values) ? values : []) + .map((entry) => String(entry || "").trim()) + .filter((entry) => knownIds.has(entry)); + if (requested.length > 0) { + return requested; + } + return uniqueStrings(Array.isArray(fallback) ? fallback : []) + .map((entry) => String(entry || "").trim()) + .filter((entry) => knownIds.has(entry)); +} + function buildSystemSearchText(system, docText) { const parts = [ system?.id, @@ -417,15 +507,52 @@ function buildSystemSearchText(system, docText) { return parts.join(" ").toLowerCase(); } +function buildModuleSearchText(moduleEntry, docText) { + const parts = [ + moduleEntry?.id, + moduleEntry?.name, + ...(Array.isArray(moduleEntry?.aliases) ? moduleEntry.aliases : []), + ...(Array.isArray(moduleEntry?.tags) ? moduleEntry.tags : []), + ...(Array.isArray(moduleEntry?.systemIds) ? moduleEntry.systemIds : []), + docText, + ]; + return parts.join(" ").toLowerCase(); +} + +function collectTerminologyMatches(kb, requestText) { + const requestLower = String(requestText || "").toLowerCase(); + return (Array.isArray(kb?.terminology?.terms) ? kb.terminology.terms : []) + .map((term) => { + const phrases = [term.canonical, ...(Array.isArray(term.aliases) ? term.aliases : [])] + .map((entry) => String(entry || "").trim().toLowerCase()) + .filter(Boolean); + const matched = phrases.filter((phrase) => requestLower.includes(phrase)); + return matched.length > 0 + ? { + term, + matched, + } + : null; + }) + .filter(Boolean); +} + function pickRelevantSystems(kb, requestText, limit = 4) { const systems = Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []; const queryTokens = tokenize(requestText); const requestLower = String(requestText || "").toLowerCase(); + const terminologyMatches = collectTerminologyMatches(kb, requestText); + const terminologyBoostBySystemId = new Map(); + terminologyMatches.forEach(({ term, matched }) => { + (Array.isArray(term.systemIds) ? term.systemIds : []).forEach((systemId) => { + terminologyBoostBySystemId.set(systemId, (terminologyBoostBySystemId.get(systemId) || 0) + (matched.length * 4)); + }); + }); const ranked = systems .map((system) => { const docText = kb.docsById.get(String(system.id || "")) || ""; const corpus = buildSystemSearchText(system, docText); - let score = 0; + let score = terminologyBoostBySystemId.get(String(system.id || "").trim()) || 0; for (const token of queryTokens) { if (corpus.includes(token)) { score += 2; @@ -454,30 +581,280 @@ function pickRelevantSystems(kb, requestText, limit = 4) { return ranked.slice(0, limit); } -function buildPrompt(request, relevantSystems, schema, kb) { +function pickRelevantModules(kb, requestText, routing, limit = 4) { + const modules = Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []; + const queryTokens = tokenize(requestText); + const requestLower = String(requestText || "").toLowerCase(); + const suggestedTags = new Set(Array.isArray(routing?.suggestedTags) ? routing.suggestedTags : []); + const suggestedSystems = new Set(Array.isArray(routing?.suggestedSystems) ? routing.suggestedSystems : []); + const ranked = modules + .map((moduleEntry) => { + const docText = kb.moduleDocsById.get(String(moduleEntry.id || "")) || ""; + const corpus = buildModuleSearchText(moduleEntry, docText); + let score = 0; + for (const token of queryTokens) { + if (corpus.includes(token)) { + score += 2; + } + } + for (const alias of Array.isArray(moduleEntry.aliases) ? moduleEntry.aliases : []) { + const normalizedAlias = String(alias || "").trim().toLowerCase(); + if (normalizedAlias && requestLower.includes(normalizedAlias)) { + score += 5; + } + } + for (const tag of Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []) { + if (suggestedTags.has(tag)) { + score += 4; + } + } + for (const systemId of Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []) { + if (suggestedSystems.has(systemId)) { + score += 4; + } + } + if (String(moduleEntry.priority || "medium") === "high") { + score += 1; + } + return { + moduleEntry, + docText, + score, + }; + }) + .sort((left, right) => right.score - left.score || String(left.moduleEntry?.name || "").localeCompare(String(right.moduleEntry?.name || ""))); + const positive = ranked.filter((entry) => entry.score > 0).slice(0, limit); + if (positive.length > 0) { + return positive; + } + return ranked.slice(0, limit); +} + +function deriveHeuristicRouting(kb, requestText) { + const terminologyMatches = collectTerminologyMatches(kb, requestText); + const relevantSystems = pickRelevantSystems(kb, requestText, 4); + const matchedTerms = uniqueStrings( + terminologyMatches.flatMap(({ matched }) => matched), + ); + const suggestedTags = uniqueStrings([ + ...terminologyMatches.flatMap(({ term }) => Array.isArray(term.tags) ? term.tags : []), + ...relevantSystems.flatMap(({ system }) => Array.isArray(system.tags) ? system.tags : []), + ]); + const suggestedSystems = uniqueStrings([ + ...terminologyMatches.flatMap(({ term }) => Array.isArray(term.systemIds) ? term.systemIds : []), + ...relevantSystems.map(({ system }) => String(system.id || "").trim()), + ]); + const suggestedModules = pickRelevantModules(kb, requestText, { + suggestedTags, + suggestedSystems, + }, 4).map(({ moduleEntry }) => String(moduleEntry.id || "").trim()); + const normalizedText = String(requestText || "").replace(/\s+/g, " ").trim(); + const tokenCount = tokenize(normalizedText).length; + const genericPatterns = [ + /\b(make|improve|fix|upgrade)\b.+\b(game|editor|worldshaper|site|launcher|it)\b/i, + /^\s*(better|more|faster|cooler|good)\s*!?\s*$/i, + /^\s*add\s+[a-z0-9_-]+\s*!?\s*$/i, + ]; + const isBroad = genericPatterns.some((pattern) => pattern.test(normalizedText)) || tokenCount <= 4; + const ambiguity = isBroad + ? "high" + : (matchedTerms.length === 0 || suggestedSystems.length > 3 ? "medium" : "low"); + const displaySystemNames = suggestedSystems + .map((systemId) => (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).find((system) => String(system.id || "").trim() === systemId)?.name || systemId) + .slice(0, 4); + const possibleDirections = uniqueStrings([ + suggestedTags.includes("Graphics Painter") ? "Treat it as an asset-editing request inside the Graphics Painter." : "", + suggestedTags.includes("Tiling") ? "Treat it as map-grid painting or placement behavior on the tile canvas." : "", + suggestedTags.includes("Content") ? "Treat it as a new asset or content-record request that needs catalog support." : "", + suggestedTags.includes("Rendering") || suggestedTags.includes("Worlds") ? "Treat it as a runtime-facing request that may need renderer or world metadata support." : "", + suggestedTags.includes("Chunks") ? "Treat it as a chunk or world-scale placement workflow." : "", + ]).slice(0, 4); + return { + summary: displaySystemNames.length > 0 + ? `Likely touches ${displaySystemNames.join(", ")}.` + : "Broad request with no single obvious subsystem match yet.", + ambiguity, + matchedTerms, + suggestedTags: suggestedTags.length > 0 ? suggestedTags.slice(0, 6) : ["General"], + suggestedSystems: suggestedSystems.slice(0, 4), + suggestedModules, + rationale: matchedTerms.length > 0 + ? `Matched terminology: ${matchedTerms.join(", ")}.` + : "Used broad alias and system matching because the submission did not name an exact editor surface.", + possibleDirections: possibleDirections.length > 0 + ? possibleDirections + : ["Clarify which editor surface or runtime behavior the request should target first."], + }; +} + +function normalizeRoutingResult(rawResult, kb, requestText, fallbackRouting) { + const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult) + ? rawResult + : {}; + const suggestedTags = normalizeRequestTags( + Array.isArray(source.suggestedTags) ? source.suggestedTags : source.tags, + kb?.requestTagLookup || new Map(), + fallbackRouting?.suggestedTags || ["General"], + ); + const suggestedSystems = normalizeSystemIds( + Array.isArray(source.suggestedSystems) ? source.suggestedSystems : source.suggestedSystemIds, + kb, + fallbackRouting?.suggestedSystems || [], + ); + const suggestedModules = normalizeModuleIds( + Array.isArray(source.suggestedModules) ? source.suggestedModules : source.suggestedModuleIds, + kb, + fallbackRouting?.suggestedModules || [], + ); + const matchedTerms = uniqueStrings(source.matchedTerms || source.terms || fallbackRouting?.matchedTerms || []); + const possibleDirections = uniqueStrings(source.possibleDirections || fallbackRouting?.possibleDirections || []); + const summary = String(source.summary || source.routingSummary || fallbackRouting?.summary || "").trim() + || `Likely touches ${suggestedTags.join(", ")}.`; + const rationale = String(source.rationale || source.reviewRationale || fallbackRouting?.rationale || "").trim() + || "Routing used terminology and KB alias matching."; + const ambiguity = normalizeAmbiguityLevel(source.ambiguity || fallbackRouting?.ambiguity || ""); + const kbSections = uniqueStrings([ + ...suggestedSystems.map((systemId) => { + const system = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).find((entry) => String(entry.id || "").trim() === systemId); + return String(system?.docPath || "").trim(); + }), + ...suggestedModules.map((moduleId) => { + const moduleEntry = (Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []).find((entry) => String(entry.id || "").trim() === moduleId); + return String(moduleEntry?.docPath || "").trim(); + }), + ].filter(Boolean)); + return { + summary, + ambiguity, + matchedTerms, + suggestedTags: suggestedTags.length > 0 ? suggestedTags : ["General"], + suggestedSystems, + suggestedModules, + rationale, + possibleDirections: possibleDirections.length > 0 + ? possibleDirections + : (fallbackRouting?.possibleDirections || ["Clarify which part of the editor or runtime should own this request."]), + kbSections, + sourceText: String(requestText || "").trim(), + }; +} + +function buildRoutingPrompt(request, kb, heuristicRouting) { + const systemSummaries = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []) + .map((system) => `- ${system.id}: ${system.name} | tags=${(Array.isArray(system.tags) ? system.tags : []).join(", ")} | aliases=${(Array.isArray(system.aliases) ? system.aliases : []).join(", ")}`) + .join("\n"); + const moduleSummaries = (Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []) + .map((moduleEntry) => `- ${moduleEntry.id}: ${moduleEntry.name} | tags=${(Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []).join(", ")} | systems=${(Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []).join(", ")}`) + .join("\n"); + const terminologySummary = (Array.isArray(kb?.terminology?.terms) ? kb.terminology.terms : []) + .map((term) => `- ${term.canonical}: aliases=${(Array.isArray(term.aliases) ? term.aliases : []).join(", ")} | tags=${(Array.isArray(term.tags) ? term.tags : []).join(", ")} | systems=${(Array.isArray(term.systemIds) ? term.systemIds : []).join(", ")}`) + .join("\n"); + return [ + { + role: "system", + content: [ + "You are the routing pass for Worldshaper request analysis.", + "Map the submission onto likely systems, modules, and standardized tags before the deeper analysis pass runs.", + "Prefer Worldshaper terminology when the user uses adjacent language such as sprite editor, painting tool, recoloring, engine, runtime, map, grid, or chunk.", + "Broad requests must still receive a useful interpretation and possible directions.", + "Do not expose or simulate hidden chain-of-thought.", + "Return only valid JSON with keys: summary, ambiguity, matchedTerms, suggestedTags, suggestedSystems, suggestedModules, rationale, possibleDirections.", + ].join("\n"), + }, + { + role: "user", + content: [ + `Submission id: ${request.id}`, + "Raw submission:", + request.sourceText, + "", + "Standardized tags:", + (Array.isArray(kb?.requestTagDefinitions) ? kb.requestTagDefinitions : []).map((entry) => `- ${entry.label}: ${entry.description || ""}`).join("\n"), + "", + "System index:", + systemSummaries, + "", + "Focused modules:", + moduleSummaries, + "", + "Terminology map:", + terminologySummary, + "", + "Heuristic seed:", + JSON.stringify(heuristicRouting, null, 2), + ].join("\n"), + }, + ]; +} + +function buildKbContext(kb, requestText, routing) { + const relevantSystems = uniqueStrings([ + ...normalizeSystemIds(routing?.suggestedSystems, kb, []), + ...pickRelevantSystems(kb, requestText, 4).map(({ system }) => String(system.id || "").trim()), + ]) + .slice(0, 4) + .map((systemId) => { + const system = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []).find((entry) => String(entry.id || "").trim() === systemId); + return system + ? { + system, + docText: kb.docsById.get(systemId) || "", + } + : null; + }) + .filter(Boolean); + const relevantModules = uniqueStrings([ + ...normalizeModuleIds(routing?.suggestedModules, kb, []), + ...pickRelevantModules(kb, requestText, routing, 5).map(({ moduleEntry }) => String(moduleEntry.id || "").trim()), + ]) + .slice(0, 5) + .map((moduleId) => { + const moduleEntry = (Array.isArray(kb?.moduleIndex?.modules) ? kb.moduleIndex.modules : []).find((entry) => String(entry.id || "").trim() === moduleId); + return moduleEntry + ? { + moduleEntry, + docText: kb.moduleDocsById.get(moduleId) || "", + } + : null; + }) + .filter(Boolean); + return { + systems: relevantSystems, + modules: relevantModules, + editorCapabilitiesText: String(kb?.editorCapabilitiesText || "").trim(), + }; +} + +function buildAnalysisPrompt(request, routing, kbContext, schema, kb) { const schemaSummary = JSON.stringify(schema, null, 2); const tagCatalogSummary = Array.isArray(kb?.requestTagDefinitions) ? kb.requestTagDefinitions.map((entry) => `- ${entry.label}: ${entry.description || ""}`.trim()).join("\n") : ""; - const systemDocs = relevantSystems.map(({ system, docText }) => { - return [ - `System: ${system.name}`, - `System ID: ${system.id}`, - `Tags: ${(Array.isArray(system.tags) ? system.tags : []).join(", ")}`, - `Key files: ${(Array.isArray(system.keyFiles) ? system.keyFiles : []).join(", ")}`, - `API endpoints: ${(Array.isArray(system.apiEndpoints) ? system.apiEndpoints : []).join(", ") || "(none)"}`, - docText, - ].join("\n"); - }).join("\n\n---\n\n"); + const systemDocs = kbContext.systems.map(({ system, docText }) => [ + `System: ${system.name}`, + `System ID: ${system.id}`, + `Tags: ${(Array.isArray(system.tags) ? system.tags : []).join(", ")}`, + `Key files: ${(Array.isArray(system.keyFiles) ? system.keyFiles : []).join(", ")}`, + `API endpoints: ${(Array.isArray(system.apiEndpoints) ? system.apiEndpoints : []).join(", ") || "(none)"}`, + docText, + ].join("\n")).join("\n\n---\n\n"); + const moduleDocs = kbContext.modules.map(({ moduleEntry, docText }) => [ + `Module: ${moduleEntry.name}`, + `Module ID: ${moduleEntry.id}`, + `Tags: ${(Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []).join(", ")}`, + `System IDs: ${(Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []).join(", ")}`, + docText, + ].join("\n")).join("\n\n---\n\n"); return [ { role: "system", content: [ "You are processing Worldshaper editor requests.", "Split a submission into one or more atomic requests.", - "Ground your decisions in the provided KB systems only.", + "Ground your decisions in the provided KB systems and modules only.", "Use only the standardized tags listed in the provided tag catalog.", "Do not expose or simulate hidden chain-of-thought. Provide short structured review rationale instead.", + "If a request is broad or ambiguous, still provide the most useful likely interpretation and concrete implementation path you can.", "Return only valid JSON.", "Do not wrap the JSON in markdown fences.", "If you are unsure, lower confidence and use statusRecommendation = \"needs_review\".", @@ -490,14 +867,23 @@ function buildPrompt(request, relevantSystems, schema, kb) { "Raw submission:", request.sourceText, "", + "Routing summary:", + JSON.stringify(routing, null, 2), + "", "Return JSON matching this schema:", schemaSummary, "", "Standardized tags you may use:", tagCatalogSummary, "", + "Capability shorthand:", + kbContext.editorCapabilitiesText || "(none)", + "", "Relevant KB systems:", - systemDocs, + systemDocs || "(none)", + "", + "Relevant KB modules:", + moduleDocs || "(none)", ].join("\n"), }, ]; @@ -541,7 +927,7 @@ function readMessageContent(messageContent) { return ""; } -async function callModelApi(config, messages) { +async function callModelApi(config, messages, options = {}) { const headers = { "Content-Type": "application/json", }; @@ -550,8 +936,8 @@ async function callModelApi(config, messages) { } const requestBody = { model: config.model, - temperature: 0.2, - max_tokens: config.maxTokens, + temperature: Number.isFinite(Number(options.temperature)) ? Number(options.temperature) : 0.2, + max_tokens: Math.max(256, Math.floor(Number(options.maxTokens) || config.maxTokens)), messages, }; if (config.provider === "deepseek") { @@ -592,11 +978,13 @@ async function processLauncherRequestAnalysis(config, requestId, body) { } function shouldPromoteAnalysis(result, config) { - return result.items.length > 0 && result.items.every((item) => ( + return result.items.length > 0 + && String(result?.routing?.ambiguity || "").trim().toLowerCase() !== "high" + && result.items.every((item) => ( item.statusRecommendation === "active" && Number.isFinite(item.confidence) && item.confidence >= config.promoteThreshold - )); + )); } async function markRequestProcessing(config, request) { @@ -635,6 +1023,7 @@ async function markRequestReview(config, request, result) { submissionId: request.id, sourceTextSnapshot: request.sourceText, confidence: result.confidence, + routing: result.routing, items: result.items, }, }); @@ -648,22 +1037,100 @@ async function promoteRequest(config, request, result) { submissionId: request.id, sourceTextSnapshot: request.sourceText, confidence: result.confidence, + routing: result.routing, items: result.items, }, }); } +function buildFallbackAnalysisResult(request, routing, kb) { + const primaryTag = Array.isArray(routing?.suggestedTags) && routing.suggestedTags.length > 0 + ? routing.suggestedTags[0] + : "General"; + const primarySystemId = Array.isArray(routing?.suggestedSystems) && routing.suggestedSystems.length > 0 + ? routing.suggestedSystems[0] + : ""; + const primarySystem = (Array.isArray(kb?.systemsIndex?.systems) ? kb.systemsIndex.systems : []) + .find((system) => String(system.id || "").trim() === primarySystemId); + const title = buildFallbackTitle( + request.sourceText, + routing?.ambiguity === "high" ? "Broader editor improvement request" : "Pending request", + ); + const parsedInterpretation = routing?.ambiguity === "high" + ? `This submission is broad, but it most likely points toward ${primaryTag} work${primarySystem?.name ? ` touching ${primarySystem.name}` : ""}. It needs a narrower decision before it can be promoted cleanly.` + : `This request most likely targets ${primarySystem?.name || primaryTag}, based on the submission language and the matched KB terminology.`; + const implementationApproach = routing?.ambiguity === "high" + ? `Turn the request into a smaller scoped follow-up by choosing one direction first: ${(Array.isArray(routing?.possibleDirections) && routing.possibleDirections.length > 0 ? routing.possibleDirections : ["clarify whether this should be content, map editing, or runtime work"]).join(" ")}`.trim() + : `Start from ${primarySystem?.name || primaryTag}, audit the existing workflow there, and then scope the change so the request can be broken into editor-facing behavior, saved data changes, and any runtime handoff that may be required.`; + const reviewOptions = Array.isArray(routing?.possibleDirections) && routing.possibleDirections.length > 0 + ? routing.possibleDirections + : ["Clarify which part of the editor or runtime should own this request first."]; + return { + submissionId: request.id, + sourceText: request.sourceText, + confidence: 0.38, + minimumConfidence: 0.38, + routing, + items: [ + { + title, + primaryCategory: primaryTag, + tags: Array.isArray(routing?.suggestedTags) && routing.suggestedTags.length > 0 ? routing.suggestedTags : ["General"], + statusRecommendation: "needs_review", + parsedInterpretation, + implementationApproach, + affectedSystems: primarySystem?.name ? [primarySystem.name] : [], + affectedFiles: [], + problemType: "feature", + rawExcerpt: String(request.sourceText || "").trim(), + confidence: 0.38, + reviewRationale: String(routing?.rationale || "").trim() || "The request is still too broad to auto-promote safely.", + reviewOptions, + notes: String(routing?.summary || "").trim(), + }, + ], + }; +} + async function analyzeRequest(config, kb, request) { - const relevantSystems = pickRelevantSystems(kb, request.sourceText, 4); - const systemNames = relevantSystems.map(({ system }) => system.name); + const heuristicRouting = deriveHeuristicRouting(kb, request.sourceText); + const routingPrompt = buildRoutingPrompt(request, kb, heuristicRouting); + let routing = heuristicRouting; + try { + const routingResult = await callModelApi(config, routingPrompt, { + maxTokens: Math.min(config.maxTokens, 900), + temperature: 0.1, + }); + routing = normalizeRoutingResult(routingResult, kb, request.sourceText, heuristicRouting); + } catch (error) { + console.warn(` Routing pass fallback for ${request.id}: ${String(error)}`); + routing = normalizeRoutingResult({}, kb, request.sourceText, heuristicRouting); + } + const kbContext = buildKbContext(kb, request.sourceText, routing); + const systemNames = kbContext.systems.map(({ system }) => system.name); console.log(`Analyzing ${request.id}: ${request.title}`); console.log(` Systems: ${systemNames.join(", ")}`); if (!config.dryRun) { await markRequestProcessing(config, request); } - const prompt = buildPrompt(request, relevantSystems, kb.requestSchema, kb); - const modelResult = await callModelApi(config, prompt); - const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system), kb); + const prompt = buildAnalysisPrompt(request, routing, kbContext, kb.requestSchema, kb); + let normalizedResult; + try { + const modelResult = await callModelApi(config, prompt, { + maxTokens: config.maxTokens, + temperature: 0.2, + }); + normalizedResult = normalizeAnalysisResult( + modelResult, + request, + kbContext.systems.map((entry) => entry.system), + kb, + ); + } catch (error) { + console.warn(` Analysis pass fallback for ${request.id}: ${String(error)}`); + normalizedResult = buildFallbackAnalysisResult(request, routing, kb); + } + normalizedResult.routing = routing; const action = shouldPromoteAnalysis(normalizedResult, config) ? "promote" : "review"; console.log(` Result: ${normalizedResult.items.length} item(s), action=${action}, confidence=${normalizedResult.confidence ?? "n/a"}`); if (config.dryRun) { diff --git a/server.js b/server.js index bf8d222..656aba0 100644 --- a/server.js +++ b/server.js @@ -101,6 +101,9 @@ const editorSettingsPath = path.join(dataRoot, "editor_settings.json"); const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json"); const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs"); const requestTagCatalogPath = path.join(__dirname, "docs", "kb", "tags.json"); +const kbSystemsIndexPath = path.join(__dirname, "docs", "kb", "systems.json"); +const kbModulesIndexPath = path.join(__dirname, "docs", "kb", "modules.json"); +const kbTerminologyPath = path.join(__dirname, "docs", "kb", "terminology.json"); const requestAnalysisRunState = { child: null, restartTimer: null, @@ -492,6 +495,41 @@ function normalizeLauncherRequestAnalysisTags(value, fallback = []) { return normalizeLauncherRequestTags(Array.isArray(fallback) ? fallback : []); } +function normalizeLauncherRequestAnalysisRouting(value) { + const source = value && typeof value === "object" && !Array.isArray(value) + ? value + : null; + if (!source) { + return undefined; + } + const ambiguityRaw = String(source.ambiguity || "").trim().toLowerCase(); + const ambiguity = ambiguityRaw === "low" || ambiguityRaw === "medium" || ambiguityRaw === "high" + ? ambiguityRaw + : "medium"; + const summary = String(source.summary || source.routingSummary || "").trim(); + const rationale = String(source.rationale || "").trim(); + const matchedTerms = normalizeLauncherRequestAnalysisStringList(source.matchedTerms || source.terms); + const suggestedTags = normalizeLauncherRequestAnalysisTags(source.suggestedTags || source.tags); + const suggestedSystems = normalizeLauncherRequestAnalysisStringList(source.suggestedSystems || source.suggestedSystemIds); + const suggestedModules = normalizeLauncherRequestAnalysisStringList(source.suggestedModules || source.suggestedModuleIds); + const possibleDirections = normalizeLauncherRequestAnalysisStringList(source.possibleDirections); + const kbSections = normalizeLauncherRequestAnalysisStringList(source.kbSections); + if (!summary && !rationale && matchedTerms.length === 0 && suggestedTags.length === 0 && suggestedSystems.length === 0 && suggestedModules.length === 0 && possibleDirections.length === 0 && kbSections.length === 0) { + return undefined; + } + return { + summary: summary || undefined, + ambiguity, + matchedTerms, + suggestedTags, + suggestedSystems, + suggestedModules, + rationale: rationale || undefined, + possibleDirections, + kbSections, + }; +} + function normalizeLauncherRequestAnalysisItem(item, index = 0) { const source = item && typeof item === "object" && !Array.isArray(item) ? item @@ -567,7 +605,8 @@ function normalizeLauncherRequestAnalysis(value) { const updatedAt = String(source.updatedAt || "").trim() || createdAt; const submissionId = String(source.submissionId || "").trim(); const sourceTextSnapshot = String(source.sourceTextSnapshot || source.sourceText || "").trim(); - if (!model && !error && !submissionId && !sourceTextSnapshot && normalizedItems.length === 0 && state === "unprocessed") { + const routing = normalizeLauncherRequestAnalysisRouting(source.routing); + if (!model && !error && !submissionId && !sourceTextSnapshot && normalizedItems.length === 0 && state === "unprocessed" && !routing) { return undefined; } return { @@ -579,6 +618,7 @@ function normalizeLauncherRequestAnalysis(value) { error: error || undefined, submissionId: submissionId || undefined, sourceTextSnapshot: sourceTextSnapshot || undefined, + routing, itemCount: normalizedItems.length, items: normalizedItems, }; @@ -698,10 +738,16 @@ function isLauncherRequestAutoQueueEligible(entry) { && (!analysisState || analysisState === "unprocessed"); } -function getQueuedPendingLauncherRequestCount() { +function getQueuedPendingLauncherRequestCount(requestId = "") { try { const payload = readLauncherRequestsPayload(); - return payload.requests.filter((entry) => isLauncherRequestAutoQueueEligible(entry)).length; + const normalizedRequestId = String(requestId || "").trim(); + return payload.requests.filter((entry) => { + if (!isLauncherRequestAutoQueueEligible(entry)) { + return false; + } + return !normalizedRequestId || String(entry?.id || "").trim() === normalizedRequestId; + }).length; } catch { return 0; } @@ -734,13 +780,14 @@ function scheduleQueuedRequestAnalysis(reason = "queued-pending-requests", delay return true; } -function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { +function launchQueuedRequestAnalysis(reason = "queued-pending-requests", options = {}) { + const requestId = String(options?.requestId || "").trim(); if (!isRequestAnalysisConfigured()) { return { launched: false, reason: "request-analysis-not-configured" }; } - const queuedPendingCount = getQueuedPendingLauncherRequestCount(); + const queuedPendingCount = getQueuedPendingLauncherRequestCount(requestId); if (queuedPendingCount <= 0) { - return { launched: false, reason: "no-pending-requests" }; + return { launched: false, reason: requestId ? "request-not-queued" : "no-pending-requests" }; } if (requestAnalysisRunState.child && !requestAnalysisRunState.child.killed) { return { launched: false, reason: "request-analysis-already-running" }; @@ -755,6 +802,9 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { "--limit", String(Math.max(1, Math.floor(Number(process.env.REQUEST_ANALYZER_AUTORUN_LIMIT) || 5))), ]; + if (requestId) { + args.push("--request-id", requestId); + } const child = spawn(process.execPath, args, { cwd: __dirname, env: { @@ -770,6 +820,7 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { provider, reason, queuedPendingCount, + requestId, pid: child.pid, }); child.stdout?.on("data", (chunk) => { @@ -792,6 +843,7 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { provider, reason, queuedPendingCount: getQueuedPendingLauncherRequestCount(), + requestId, code: Number.isFinite(Number(code)) ? Number(code) : null, signal: signal || "", }); @@ -806,6 +858,7 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { type: "launcher-request-analysis-launch-error", provider, reason, + requestId, error: String(error), }); }); @@ -814,6 +867,7 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") { launched: true, reason, queuedPendingCount, + requestId, pid: child.pid, }; } @@ -2240,6 +2294,33 @@ function loadRequestTagCatalog() { return DEFAULT_REQUEST_TAG_DEFINITIONS; } +function readKbIndexFile(filePath, fallbackKey) { + try { + const payload = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + return payload; + } + } catch { + // Ignore KB read failures and fall back to empty payloads. + } + return { + schemaVersion: 1, + [fallbackKey]: [], + }; +} + +function readKbSystemsIndex() { + return readKbIndexFile(kbSystemsIndexPath, "systems"); +} + +function readKbModulesIndex() { + return readKbIndexFile(kbModulesIndexPath, "modules"); +} + +function readKbTerminologyIndex() { + return readKbIndexFile(kbTerminologyPath, "terms"); +} + function normalizeRequestTag(value) { const normalizedValue = normalizeRequestTagLookupValue(value); if (!normalizedValue) { @@ -3029,6 +3110,76 @@ app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => { } }); +app.post("/api/launcher-requests/:requestId/requeue-analysis", (req, res) => { + const requestId = String(req.params.requestId || "").trim(); + if (!requireLauncherAdminAccess(req, res)) { + return; + } + try { + const payload = readLauncherRequestsPayload(); + const index = payload.requests.findIndex((entry) => entry.id === requestId); + if (index < 0) { + res.status(404).json({ error: "Request not found." }); + return; + } + const existing = payload.requests[index]; + if (existing.status !== "pending") { + res.status(400).json({ error: "Only pending requests can be re-reviewed." }); + return; + } + const mode = String(req.body?.mode || "saved").trim().toLowerCase() === "draft" ? "draft" : "saved"; + const now = new Date().toISOString(); + const draftSource = mode === "draft" && req.body?.request && typeof req.body.request === "object" && !Array.isArray(req.body.request) + ? req.body.request + : {}; + const nextRequest = normalizeLauncherRequestEntry({ + ...existing, + title: mode === "draft" ? draftSource.title ?? existing.title : existing.title, + category: mode === "draft" ? draftSource.category ?? existing.category : existing.category, + tags: mode === "draft" && Array.isArray(draftSource.tags) ? draftSource.tags : existing.tags, + sourceText: mode === "draft" ? draftSource.sourceText ?? existing.sourceText : existing.sourceText, + summary: mode === "draft" ? draftSource.summary ?? existing.summary : existing.summary, + implementationNotes: mode === "draft" ? draftSource.implementationNotes ?? existing.implementationNotes : existing.implementationNotes, + analysis: { + ...(existing.analysis && typeof existing.analysis === "object" && !Array.isArray(existing.analysis) ? existing.analysis : {}), + state: "unprocessed", + confidence: null, + updatedAt: now, + createdAt: existing.analysis?.createdAt || now, + submissionId: requestId, + sourceTextSnapshot: mode === "draft" + ? String((draftSource.sourceText ?? existing.sourceText) || "").trim() + : String(existing.sourceText || "").trim(), + error: "", + routing: undefined, + items: [], + }, + updatedAt: now, + }, index); + const nextRequests = [...payload.requests]; + nextRequests[index] = nextRequest; + writeLauncherRequestsPayload({ + schemaVersion: payload.schemaVersion, + requests: nextRequests, + }); + recordSaveEvent({ + type: "launcher-request-analysis-requeue", + requestId, + reason: mode === "draft" ? "draft-resubmitted" : "saved-request-rerun", + textPreview: String(nextRequest.sourceText || "").slice(0, 80), + }); + const launchResult = launchQueuedRequestAnalysis("manual-request-rerun", { requestId }); + res.json({ + ok: true, + request: nextRequest, + requests: nextRequests, + ...launchResult, + }); + } catch (err) { + res.status(500).json({ error: `Failed to requeue launcher request analysis: ${String(err)}` }); + } +}); + app.post("/api/launcher-requests/process-pending", (req, res) => { if (!requireLauncherAdminAccess(req, res)) { return; @@ -3047,6 +3198,106 @@ app.post("/api/launcher-requests/process-pending", (req, res) => { } }); +app.post("/api/admin/kb/query", (req, res) => { + if (!requireLauncherAdminAccess(req, res)) { + return; + } + try { + const systemsIndex = readKbSystemsIndex(); + const modulesIndex = readKbModulesIndex(); + const terminologyIndex = readKbTerminologyIndex(); + const requestedTags = normalizeLauncherRequestTags(req.body?.tags || []); + const requestedSystems = normalizeLauncherRequestAnalysisStringList(req.body?.systems || req.body?.systemIds); + const requestedModules = normalizeLauncherRequestAnalysisStringList(req.body?.modules || req.body?.moduleIds); + const searchTerms = normalizeLauncherRequestAnalysisStringList(req.body?.searchTerms || req.body?.terms); + const limit = Math.max(1, Math.min(12, Math.floor(Number(req.body?.limit) || 6))); + const searchNeedles = [ + ...requestedTags, + ...requestedSystems, + ...requestedModules, + ...searchTerms, + ].map((entry) => String(entry || "").trim().toLowerCase()).filter(Boolean); + const rankEntry = (entry, extraText = "") => { + const corpus = [ + entry?.id, + entry?.name, + entry?.label, + ...(Array.isArray(entry?.aliases) ? entry.aliases : []), + ...(Array.isArray(entry?.tags) ? entry.tags : []), + ...(Array.isArray(entry?.systemIds) ? entry.systemIds : []), + extraText, + ].join(" ").toLowerCase(); + return searchNeedles.reduce((score, needle) => (corpus.includes(needle) ? score + 2 : score), 0); + }; + const matchedSystems = (Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) + .map((system) => ({ + system, + score: rankEntry(system, [system.docPath, ...(Array.isArray(system.uiSurfaces) ? system.uiSurfaces : [])].join(" ")), + })) + .filter(({ system, score }) => { + if (requestedSystems.length > 0 && requestedSystems.includes(String(system.id || "").trim())) { + return true; + } + if (requestedTags.length > 0 && (Array.isArray(system.tags) ? system.tags : []).some((tag) => requestedTags.includes(String(tag || "").trim()))) { + return true; + } + return score > 0; + }) + .sort((left, right) => right.score - left.score) + .slice(0, limit) + .map(({ system }) => ({ + ...system, + docText: fs.readFileSync(path.join(__dirname, String(system.docPath || "").replace(/\//g, path.sep)), "utf8"), + })); + const matchedModules = (Array.isArray(modulesIndex.modules) ? modulesIndex.modules : []) + .map((moduleEntry) => ({ + moduleEntry, + score: rankEntry(moduleEntry, moduleEntry.docPath), + })) + .filter(({ moduleEntry, score }) => { + if (requestedModules.length > 0 && requestedModules.includes(String(moduleEntry.id || "").trim())) { + return true; + } + if (requestedSystems.length > 0 && (Array.isArray(moduleEntry.systemIds) ? moduleEntry.systemIds : []).some((systemId) => requestedSystems.includes(String(systemId || "").trim()))) { + return true; + } + if (requestedTags.length > 0 && (Array.isArray(moduleEntry.tags) ? moduleEntry.tags : []).some((tag) => requestedTags.includes(String(tag || "").trim()))) { + return true; + } + return score > 0; + }) + .sort((left, right) => right.score - left.score) + .slice(0, limit) + .map(({ moduleEntry }) => ({ + ...moduleEntry, + docText: fs.readFileSync(path.join(__dirname, String(moduleEntry.docPath || "").replace(/\//g, path.sep)), "utf8"), + })); + const matchedTerms = (Array.isArray(terminologyIndex.terms) ? terminologyIndex.terms : []) + .filter((term) => { + const corpus = [ + term?.canonical, + ...(Array.isArray(term?.aliases) ? term.aliases : []), + ...(Array.isArray(term?.tags) ? term.tags : []), + ...(Array.isArray(term?.systemIds) ? term.systemIds : []), + ].join(" ").toLowerCase(); + return searchNeedles.length === 0 || searchNeedles.some((needle) => corpus.includes(needle)); + }) + .slice(0, limit); + res.json({ + ok: true, + requestedTags, + requestedSystems, + requestedModules, + searchTerms, + systems: matchedSystems, + modules: matchedModules, + terminology: matchedTerms, + }); + } catch (err) { + res.status(500).json({ error: `Failed to query KB: ${String(err)}` }); + } +}); + app.get("/api/world-default", (_req, res) => { try { const indexPayload = readWorldIndexPayload(); diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index 4760265..ea96cb9 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -20,6 +20,19 @@ type WorldDefaultPayload = { type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error"; type BoardTab = "news" | "requests"; +type LauncherWindowMode = "public" | "admin"; + +type LauncherRequestAnalysisRouting = { + summary?: string; + ambiguity?: "low" | "medium" | "high"; + matchedTerms?: string[]; + suggestedTags?: string[]; + suggestedSystems?: string[]; + suggestedModules?: string[]; + rationale?: string; + possibleDirections?: string[]; + kbSections?: string[]; +}; type LauncherRequest = { id: string; @@ -40,6 +53,7 @@ type LauncherRequest = { error?: string; submissionId?: string; sourceTextSnapshot?: string; + routing?: LauncherRequestAnalysisRouting; itemCount?: number; items?: Array<{ title?: string; @@ -99,6 +113,17 @@ type ProcessPendingPayload = { pid?: number; }; +type RequeueAnalysisPayload = { + ok?: boolean; + launched?: boolean; + reason?: string; + request?: LauncherRequest; + requests?: LauncherRequest[]; + requestId?: string; + queuedPendingCount?: number; + pid?: number; +}; + type LauncherRequestMetaPayload = { allowedTags?: string[]; }; @@ -112,6 +137,14 @@ type AdminAuthPayload = { const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; +function readLauncherWindowMode(): LauncherWindowMode { + if (typeof window === "undefined") { + return "public"; + } + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get("admin") === "requests" ? "admin" : "public"; +} + async function resolveDefaultWorldId(): Promise { const response = await fetch("/api/world-default"); if (!response.ok) { @@ -186,6 +219,7 @@ function openReviewDetailsPopup(requestEntry: LauncherRequest): void { if (!popup) { return; } + const routing = requestEntry.analysis?.routing; const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items : []; const renderedItems = items.map((item, index) => { const reviewOptions = Array.isArray(item?.reviewOptions) ? item.reviewOptions : []; @@ -251,6 +285,38 @@ function openReviewDetailsPopup(requestEntry: LauncherRequest): void {

${escapePopupHtml(requestEntry.title)}

${escapePopupHtml(requestEntry.sourceText)}

+ ${routing ? ` +
+
Routing Summary
+

${escapePopupHtml(String(routing.summary || "KB routing context"))}

+
+ Ambiguity: ${escapePopupHtml(String(routing.ambiguity || "medium"))} + ${(Array.isArray(routing.suggestedTags) ? routing.suggestedTags : []).map((tag) => `${escapePopupHtml(tag)}`).join("")} +
+
+

Rationale

+

${escapePopupHtml(String(routing.rationale || "No routing rationale was stored."))}

+
+
+

Matched Terms

+ ${(Array.isArray(routing.matchedTerms) && routing.matchedTerms.length > 0) + ? `
    ${routing.matchedTerms.map((term) => `
  • ${escapePopupHtml(String(term || ""))}
  • `).join("")}
` + : "

No explicit terminology matches were stored.

"} +
+
+

Likely Systems

+ ${(Array.isArray(routing.suggestedSystems) && routing.suggestedSystems.length > 0) + ? `
    ${routing.suggestedSystems.map((systemId) => `
  • ${escapePopupHtml(String(systemId || ""))}
  • `).join("")}
` + : "

No likely systems were stored.

"} +
+
+

Possible Directions

+ ${(Array.isArray(routing.possibleDirections) && routing.possibleDirections.length > 0) + ? `
    ${routing.possibleDirections.map((direction) => `
  • ${escapePopupHtml(String(direction || ""))}
  • `).join("")}
` + : "

No alternate directions were stored.

"} +
+
+ ` : ""} ${renderedItems || '

No Review Items

This request does not have structured review details yet.

'} @@ -369,6 +435,8 @@ function formatEventLabel(event: RecentSaveEvent): string { return "Queue worker finished"; case "launcher-request-analysis-launch-error": return "Queue worker launch error"; + case "launcher-request-analysis-requeue": + return "Request requeued for review"; default: return String(event.type || "Event"); } @@ -401,12 +469,25 @@ function openRepo(): void { window.location.assign("https://repo.andraxion.net/"); } +function openAdminPanelWindow(): boolean { + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set("admin", "requests"); + nextUrl.searchParams.set("tab", "requests"); + const popup = window.open(nextUrl.toString(), "worldshaper-admin-panel", "popup=yes,width=1620,height=980,resizable=yes,scrollbars=yes"); + if (popup) { + popup.focus(); + } + return Boolean(popup); +} + function WorldshaperLauncher() { + const launcherWindowMode = readLauncherWindowMode(); + const adminWindowMode = launcherWindowMode === "admin"; const [launchState, setLaunchState] = useState("ready"); const [status, setStatus] = useState("Launch Worldshaper Studio in its floating window."); const [error, setError] = useState(""); const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK); - const [activeBoardTab, setActiveBoardTab] = useState("news"); + const [activeBoardTab, setActiveBoardTab] = useState(adminWindowMode ? "requests" : "news"); const [requests, setRequests] = useState([]); const [requestsLoading, setRequestsLoading] = useState(true); const [requestsError, setRequestsError] = useState(""); @@ -417,7 +498,6 @@ function WorldshaperLauncher() { const [requestFilter, setRequestFilter] = useState("all"); const [allowedRequestTags, setAllowedRequestTags] = useState([]); const [expandedRequestIds, setExpandedRequestIds] = useState([]); - const [adminPanelOpen, setAdminPanelOpen] = useState(false); const [adminAccessGranted, setAdminAccessGranted] = useState(false); const [adminPassword, setAdminPassword] = useState(""); const [adminPasswordDraft, setAdminPasswordDraft] = useState(""); @@ -430,7 +510,9 @@ function WorldshaperLauncher() { const [logsLoading, setLogsLoading] = useState(false); const [logsError, setLogsError] = useState(""); const [queueTriggering, setQueueTriggering] = useState(false); + const [requeueingMode, setRequeueingMode] = useState<"" | "saved" | "draft">(""); const [adminNotice, setAdminNotice] = useState(""); + const adminPanelOpen = adminWindowMode; useEffect(() => { let cancelled = false; @@ -650,15 +732,11 @@ function WorldshaperLauncher() { setRequestDraftOpen(false); setRequestsError(""); setLogsError(""); - if (adminPanelOpen) { - setAdminPanelOpen(false); - setAdminNotice(""); + if (adminWindowMode) { return; } - setAdminPanelOpen(true); - setAdminPasswordError(""); - if (adminAccessGranted && adminPassword) { - await refreshAdminData({ includeLogs: true, silentRequests: true }); + if (!openAdminPanelWindow()) { + setAdminNotice("Allow popups to open the admin review window."); } } @@ -831,6 +909,69 @@ function WorldshaperLauncher() { } } + async function handleRequeueAnalysis(mode: "saved" | "draft"): Promise { + if (!adminEditorDraft) { + return; + } + setRequeueingMode(mode); + setLogsError(""); + try { + const payload = await fetchJsonOrThrow( + `/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}/requeue-analysis`, + { + method: "POST", + headers: buildAdminHeaders(adminPassword, { + "Content-Type": "application/json", + }), + body: JSON.stringify({ + mode, + request: mode === "draft" + ? { + title: adminEditorDraft.title, + category: adminEditorDraft.category, + tags: adminEditorDraft.tags, + sourceText: adminEditorDraft.sourceText, + summary: adminEditorDraft.summary, + implementationNotes: adminEditorDraft.implementationNotes, + } + : undefined, + }), + }, + ); + const nextRequests = Array.isArray(payload.requests) ? payload.requests : requests; + setRequests(nextRequests); + const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || payload.request || adminEditorDraft; + setAdminEditorDraft(cloneLauncherRequest(refreshed)); + if (payload.launched) { + setAdminNotice(mode === "draft" + ? "Edited draft resubmitted to the analyzer." + : "Saved request resubmitted to the analyzer."); + } else { + const reason = String(payload.reason || "no-op"); + if (reason === "request-analysis-already-running") { + setAdminNotice("The queue worker is already running. This request will be picked up on the next pass."); + } else if (reason === "request-not-queued") { + setAdminNotice("That request is not currently eligible for review reruns."); + } else { + setAdminNotice(`Review rerun returned: ${reason}.`); + } + } + await refreshAdminData({ includeLogs: true, silentRequests: true }); + window.setTimeout(() => { + void refreshAdminData({ includeLogs: true, silentRequests: true }); + }, 4200); + } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } + setLogsError(String(nextError || "Failed to requeue this request for review.")); + } finally { + setRequeueingMode(""); + } + } + function handleToggleExpandedRequest(requestId: string): void { setExpandedRequestIds((current) => ( current.includes(requestId) @@ -941,16 +1082,20 @@ function WorldshaperLauncher() { } return true; }); - const boardHint = activeBoardTab === "news" - ? "Latest announcements" - : `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`; + const boardTitle = adminWindowMode ? "Worldshaper Admin" : "Worldshaper Board"; + const boardHint = adminWindowMode + ? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active` + : (activeBoardTab === "news" + ? "Latest announcements" + : `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`); return (
-
+
+ {!adminWindowMode ? (
@@ -987,13 +1132,15 @@ function WorldshaperLauncher() {
+ ) : null}
-
Worldshaper Board
+
{boardTitle}
{boardHint}
+ {!adminWindowMode ? (
+ ) : null} {activeBoardTab === "news" ? (
@@ -1046,8 +1194,8 @@ function WorldshaperLauncher() { ) : (
-
Shared Request Board
-
Requests
+
{adminWindowMode ? "Protected Review Workspace" : "Shared Request Board"}
+
{adminWindowMode ? "Admin Review Console" : "Requests"}
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
@@ -1057,20 +1205,21 @@ function WorldshaperLauncher() { type="button" className="launcher-primary-btn" onClick={() => { - setAdminPanelOpen(false); setRequestDraftOpen((value) => !value); }} disabled={requestSubmitting} > {requestDraftOpen && !adminPanelOpen ? "Hide Request Form" : "Add New Request"} - + {!adminPanelOpen ? ( + + ) : null}