Question definition editor¶
The workspace at /admin/question-definitions maintains effective
QuestionDefinition rows in PostgreSQL. Authoring is league-scoped (MLB,
NHL, NFL); sample Field Browser content is richest for MLB today.
For field semantics and runtime behavior, also read Question definitions and League event / store_state contracts. To inspect how a definition inherits league / match / group settings, use Question Inheritance. To see why a published question issued, use Issuance Explorer.
Persistence model¶
- Save writes only to PostgreSQL.
- Edits are held as in-memory drafts until Save to DB.
- The listener consumes database rows at runtime. Bulk create/update is also available via Export to CSV / Import from CSV (preview then confirm). There is no JSON seed import path for question definitions.
id,league_id,created_at, andupdated_atare display-only in the editor and cannot be overwritten through Save to DB or CSV import.- Definitions are unique by name within the selected league at the API layer.
Operator workflow¶
- Open
/admin/question-definitions. A deep link (?league=mlb&id=<definition uuid>or?folder=Pre-game/Winner) opens that definition’s editor or folder after the list loads.leaguemay be the slug or the Python enum form (LeagueTypes.MLB). - Use the filter bar above the list (League, Search, Pivotal, Date filter, Show archived). With no definition selected, the expanded overview table shows name, folder, status (archived / active / inactive — active means included in league defaults), group, weight, probability, id, and timestamps (click column headers to sort). Click a row to open the editor; the folder tree then sits in the sidebar. The expand-list icon (next to Rename folder) returns to the table. Click a folder or breadcrumb to drill in; New folder / Rename folder rewrite descendant paths. Drag a definition or folder to move it (cannot drop a folder into itself). Tree order is name-sorted and not persisted. The issuance Group slug stays a badge, not a folder. New definition inherits the folder you are browsing (or the selected group’s name at root). Changing league reloads that league’s definitions (unsaved drafts for other leagues are kept until Save to DB). Folder moves are drafts until Save to DB.
- Select an existing definition or choose New definition.
- Work through the guided tabs. Switching definitions keeps in-memory drafts; each dirty rule shows an unsaved indicator in the sidebar.
- On Issuance, write player-facing copy and run preview with the same event type used for issuance.
- Review Raw JSON when advanced control is needed.
- Choose Save to DB. A confirmation modal summarizes field-level changes for every dirty definition before POST/PUT runs.
Unsaved drafts are protected when leaving the page (admin navigation, refresh, or close). Switching between rules does not prompt to discard.
Copy all JSON fetches each listed definition’s full row (preferring local drafts when present) and copies a JSON array of writable editor documents to the clipboard (useful for backup or external review).
Export to CSV downloads every in-game definition across all leagues
(including archived), not just the league in the filter bar. Columns are the
live question_definitions table fields plus a league slug; JSON columns
are written as JSON strings. The same file is GET /api/question-definitions/export.
Import from CSV uploads that same export-shaped file. The API validates
every row and shows creates, field-level updates, and errors before anything
is written (POST /api/question-definitions/import?dry_run=true). Confirm
applies all creates and updates in one transaction (dry_run=false); any row
error refuses the whole import. Blank id creates a definition; an id that
is not in the database is an error. Rows missing from the file are not
deleted. Changing a definition does not rewrite already-issued questions.
Keep JSON cells intact. Desktop spreadsheet apps often break JSON and UUIDs.
Overview¶
- Name: operations label, unique within MLB; not player-facing.
- Include in league defaults: when on, matches that inherit the league allowlist may issue this definition. New and cloned rows start off. Turning on requires at least one issuance trigger. Does not archive.
- Folder: slash-separated admin path (empty = unfiled). Organization only — changing folder does not change issuance group. Unlimited nesting.
- Pivotal moment: skips match max questions and the match normal
period cap (does not consume them) and wins selection over routine props
on the same event. Still uses match-wide max pivotal moments (league
default 4) and the per-period pivotal cap (
[normal, pivotal]or even-fill default 1). Group / definition period maps and Max issuances per match still apply. Shows a sidebar//playbadge. - Override question points: optional question-effective points. Leave empty
to inherit match override → league default. Absolute (e.g.
10) or multiplier of the effective match/league value (e.g.*2).
Issuance¶
Issuance includes:
- Question text — player-facing copy at the top of the Issuance tab,
with
store_stateinsert chips. Right/wrong phrases are league defaults, not on this definition. - Issuance group / min interval / weight — pick an existing
question group from the dropdown (no free-text slug).
Manage groups opens the groups page for this league.
Post-resolve wait is
max(match floor, live group interval, this definition snapshot). Relative selection weight (plus optional dynamic weighting / weight decay). - Probability & match caps — after weighted selection picks one
definition, that winner’s percent roll (default
100) decides whether to issue. Other eligible definitions are not rolled; a skip means no question on that event. Max false streak (default0= off) forces a true after consecutive skips of this definition. - Period distribution (this definition) — optional int-only period/half
caps for this question (
{}= none).unlisted: blockmeans only listed keys may issue. Pivotal skip of the match map does not skip this map. - Max issuances per match (this definition) — optional hard cap on
how many times this type may publish in one match (empty = unlimited,
0= never). Retracted rows do not count. MLB standard types typically use2; leave empty on pivotal. Pivotal skip of match max does not skip this cap. - Subject extract — optional JSONPath for the player this question is
about (evaluated after
store_state). Empty = team / situation (no uniqueness). At most one non-pivotal in-game question per subject per period, match-wide (not limited to this definition’s question group). Pivotal skip this gate and do not occupy the slot. - Issuance correction window — wait for a same-event-id feed correction
before publishing (default
0= create immediately). While deferred, the question is not in the DB; a corrective may update or drop the candidate. - Start issuance on corrective — allow a correction to begin issuing when triggers first match on the corrected payload (pair with a positive issuance correction window when first delivery is incomplete).
- Retract on corrective — after publish, close the open question if a same-event-id correction no longer matches issuance triggers (0 points; does not count toward period/max). Prefer the issuance correction window when you can wait before publish.
- Trigger conditions decide whether an incoming event can select the definition. Conditions in the list are OR'ed.
- State extract copies values from the issuing event into persisted
store_state. - Sample-event preview — question text only, at the bottom of the tab. Unresolved placeholders usually mean the selected sample lacks that path.
Example MLB trigger:
Typical MLB at-bat state:
State-extract rows execute top to bottom. Later rows may read values written by
earlier rows through $.store_state.<key>.
Extract helpers (source column may be more than a plain path):
| Pattern | Meaning |
|---|---|
$.payload.event.hitter.full_name |
Plain JSONPath (copy from Field browser) |
$.path ~ fallback |
Default when the path is missing (or trailing after a helper: to_sec(…) ~ 0) |
rand({{$.a}}, {{$.b}}, …) |
Pick one argument at random |
to_sec({{$.payload.event.clock}}) |
Convert mm:ss clock to total seconds |
if_eq(actual, expected, then, else) |
If actual == expected return then, else else |
Example MLB batting team on half_start (top = away, bottom = home):
if_eq({{$.payload.event.inning_half}}, 'T', {{$.payload.game.away.name}}, {{$.payload.game.home.name}}) ~ batting team
Open the ? help on Issuance state extract or Field browser for the
same catalog (qd_extract_helpers in field_help.js). The Field browser also
lists read-only $.match_state.* keys the listener publishes for the selected
league (not configured per definition). Open ? on the match_state heading
or on a listed key for meaning, when it updates, and when it resets.
Answer Choices¶
Each answer choice includes:
- Identifier: stable stored answer value, commonly
yesorno. - Display text: player-facing label; may be static, a direct JSONPath, or a template.
- Outcome text: optional on-field line when this choice wins. Same
placeholders as display text. Right/wrong phrases live on
League Defaults (
default_resolve_*_copies), stamped asresolve_copyat score. Play REST,question.closed, and history send the winningoutcome_textafter resolve. - Correct condition: expression evaluated against later resolution events
plus
store_state.
Choice order in the editor is player-facing order. Avoid changing identifiers after users have submitted answers.
Each choice may also set Override points (override_points) with the same
absolute / *N syntax as Overview’s question points override. Blank inherits
the question-effective value. Use this for asymmetric awards (for example more
points for Yes than No). Open/in-play clients see the resolved points on each
choice.
Resolution¶
On a later feed event the listener decides (a) whether the open question may
close, and (b) which answers are correct. The event that triggers
closing is the same event used to evaluate answer correct_conditions.
Answer conditions on the Answers tab always own (b). By default they also
own (a). Explicit resolution triggers are an optional override for (a) only.
- Empty (default): the question may close when any answer
correct_conditionmatches; that same closing event marks the winner. Leave empty for most questions. - If set: the question may close only when a trigger matches. That closing event is then used to evaluate answer conditions (triggers do not mark Yes/No). If no answer matches, the question still closes with no winner (players get 0).
- Example: trigger waits for MLB
is_ab_over; Yes/No conditions on Answers score that same terminal pitch. Putevent_overon No’scorrect_condition(not only in triggers) if walk-offs should resolve No. - Correction window
0resolves immediately unless the play is under review. A positive value waits for a same-event-ID corrective update before finalizing. The window does not expire while the play is under review; after the feed marks it final, or after a short hold cap, the configured seconds run as call-on-the-field. - Start resolution on corrective (default on for new definitions): a
matching feed correction of the same event may begin waiting/resolving, not
only update a wait that already started. The first delivery can still start
resolve if it already matches. Off = a correction may only update a resolve
already waiting. Existing rows keep their saved value until you save them.
See the
?help. Added after TOR @ WSH (2026-07-29), where a half-inning “Will a run score?” stayed pending while runs appeared only on feed corrections, then resolved No at half end. The inverse: a late rewrite of an earlier play can start resolve if close / No is “any event from a different unit” (MLB TOR @ TB, 2026-08-20 — prior-batter strikeout locked the new batter’s hit question; MLB CLE / Jays 2026-09-01 — MLB At Bat Number of Balls locked with no winning choice because the same clause was an explicit resolution trigger). Use the next unit’s start event, not any later event whose unit id differs. Do not turn this flag off to paper over that. New issuances pin the setting; already-open questions keep their old snapshot.
Field Browser¶
The Field Browser provides searchable paths, types, and representative values from a real MLB sample stream. Select:
- An event type
- A path
Use Copy on a path to place the path on the clipboard, then paste it into
the appropriate editor field (often an issuance state-extract source). Paths
are plain JSONPaths; wrap them in extract helpers (rand, to_sec, if_eq)
when needed — see the ? help on this tab and the helper table under
Issuance above. The Field Browser does not modify the definition.
Success and error feedback uses the shared admin toast (AdminToast in
base.js); keep field-level notes (for example Raw JSON parse errors) inline
beside the field.
Raw JSON¶
Raw JSON exposes the complete row shape:
- Format: prettifies valid JSON.
- Reset from form: discards raw-editor changes and regenerates JSON from guided controls.
- Apply JSON → form: parses valid JSON into guided controls.
- Save to DB: validates and persists editable fields for all dirty drafts.
Invalid JSON remains in the editor with an error and is not applied.
Sample catalog¶
The browser calls:
src/lib/question_definitions/sample_catalog.py currently resolves this file:
The server:
- Loads the JSON stream.
- Keeps the first representative item for each
payload.event.type. - Recursively extracts JSONPath-like paths, value types, and examples.
- Returns a compact catalog instead of the full stream.
- Caches the generated catalog in process with
lru_cache.
The sample path is hard-coded for MLB. Changing the file on disk does not invalidate an already populated process cache; restart the API or call the cache-clear helper in tests.
REST API¶
Question definition CRUD lives outside /admin, as a normal REST resource:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/question-definitions?league={league} |
List definitions |
POST |
/api/question-definitions?league={league} |
Create |
GET |
/api/question-definitions/{definition_id} |
Read one |
PUT |
/api/question-definitions/{definition_id} |
Replace editable fields |
POST |
/api/question-definitions/{definition_id}/clone |
Clone |
POST |
/api/question-definitions/{definition_id}/archive |
Set is_archived=true (hidden from default tree) |
POST |
/api/question-definitions/{definition_id}/activate |
Set is_archived=false (show in tree again; does not enable league defaults) |
DELETE |
/api/question-definitions/{definition_id} |
Hard-delete when no issuances/sponsorships |
GET |
/api/question-definitions/export |
CSV of every league (including archived) |
POST |
/api/question-definitions/import?dry_run= |
Preview (true) or apply (false) that CSV |
POST |
/api/question-definitions/preview |
Render copy against a sample |
GET |
/api/question-definitions/sample-catalog?league={league} |
Field catalog |
definition_id is the internal question-definition UUID. It is not a
Sportradar match or event ID. List/create/sample-catalog are league-scoped via
the league query parameter; get/update/clone/archive/activate are ID-scoped.
Code map¶
| Concern | Location |
|---|---|
| REST routes | src/api/routes/question_definitions.py |
| Admin HTML page | src/api/admin/routes/question_definitions.py |
| Workspace template | src/api/admin/templates/question_definitions/workspace.jinja2 |
| Editor state | src/api/admin/static/js/question_definitions_app.js |
| Serialization helpers | src/api/admin/static/js/question_definitions_model.js |
| Feature CSS | src/api/admin/static/css/question_definitions.css |
| CRUD helpers | src/lib/question_definitions/ops.py |
| CSV import | src/lib/csv_import.py |
| Request/response schemas | src/lib/question_definitions/schemas.py |
| Sample catalog | src/lib/question_definitions/sample_catalog.py |
| API tests | src/api/tests/test_question_definitions.py |
| Admin HTML tests | src/api/tests/admin/test_question_definitions.py |
Common failures¶
- MLB league not found: seed the
leaguesrow before using the editor. - Duplicate name: choose a unique name within MLB.
- Sample catalog missing: confirm the configured sample JSON exists in the repository at the documented path.
- Preview leaves placeholders: verify event type, extraction path, and
store_statekey spelling. - Save returns validation errors: inspect nested trigger and answer-choice fields; Raw JSON must match the Pydantic schema. Active definitions need at least one evaluable issuance trigger; blank/broken condition strings and blank extract values are rejected. Inactive drafts may keep an empty trigger list, but Activate will fail until a trigger is added.