feat: classify gear and weapon mods
Validate and deploy production / validate (push) Successful in 24s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy production / deploy (push) Failing after 29s
Validate and deploy staging / deploy (push) Failing after 6s

This commit is contained in:
jb
2026-07-31 21:54:47 -04:00
parent 1bd9facd3f
commit f48cc878aa
21 changed files with 595 additions and 39 deletions
@@ -14,6 +14,39 @@ type MissingReason = typing.Literal[
"not_applicable",
"redacted",
]
type ScreenshotFamily = typing.Literal[
"weapon",
"gear",
"skill_chip",
"os_protocol",
"specialization",
"weapon_mod",
"gear_mod",
"unknown",
]
type CanonicalRecordType = typing.Literal[
"weapon",
"weapon_talent",
"weapon_augmentation_profile",
"gear",
"brand_set",
"gear_talent",
"gear_augmentation_profile",
"skill_chip_set",
"skill_chip_set_bonus",
"skill_chip_roll_pool",
"skill_chip_talent",
"os_protocol",
"os_protocol_secondary_profile",
"os_protocol_talent",
"specialization",
"specialization_focus",
"specialization_ability",
"specialization_tree_node",
"specialization_tree_rank",
"weapon_mod",
"gear_mod",
]
class SourceEngine(typing.TypedDict):
@@ -76,14 +109,12 @@ class FieldClaim(typing.TypedDict):
class ScreenshotClassification(typing.TypedDict):
family: typing.Literal[
"weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"
]
family: ScreenshotFamily
view: str
gear_slot: (
typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None
)
record_types: list[str]
record_types: list[CanonicalRecordType]
confidence: float
abstention_reason: str | None
@@ -99,7 +130,7 @@ class ObservedItemInstance(typing.TypedDict):
"""Values shown for one configured item; they are not reusable definitions."""
instance_id: str
family: str
family: ScreenshotFamily
source_screen_ids: list[str]
observed_context: ObservedContext
fields: list[FieldClaim]
@@ -109,7 +140,7 @@ class CanonicalDefinitionCandidate(typing.TypedDict):
"""Evidence-backed reusable claims, kept separate from the observed item."""
candidate_id: str
record_type: str
record_type: CanonicalRecordType
source_instance_ids: list[str]
evidence_refs: list[str]
fields: list[FieldClaim]
@@ -12,6 +12,8 @@ class Family(StrEnum):
SKILL_CHIP = "skill_chip"
OS_PROTOCOL = "os_protocol"
SPECIALIZATION = "specialization"
WEAPON_MOD = "weapon_mod"
GEAR_MOD = "gear_mod"
UNKNOWN = "unknown"
@@ -57,6 +59,8 @@ class RecordType(StrEnum):
SPECIALIZATION_ABILITY = "specialization_ability"
SPECIALIZATION_TREE_NODE = "specialization_tree_node"
SPECIALIZATION_TREE_RANK = "specialization_tree_rank"
WEAPON_MOD = "weapon_mod"
GEAR_MOD = "gear_mod"
SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
@@ -80,6 +84,8 @@ SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
"focus_tree",
"rank_details",
),
Family.WEAPON_MOD: ("detail",),
Family.GEAR_MOD: ("detail",),
Family.UNKNOWN: ("unknown",),
}
)
@@ -117,6 +123,8 @@ RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]]
RecordType.SPECIALIZATION_TREE_NODE,
),
(Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,),
(Family.WEAPON_MOD, "detail"): (RecordType.WEAPON_MOD,),
(Family.GEAR_MOD, "detail"): (RecordType.GEAR_MOD,),
(Family.UNKNOWN, "unknown"): (),
}
)
@@ -86,7 +86,13 @@ def validate_classification(classification: Mapping[str, object]) -> None:
raise ContractValidationError("record_types must contain strings")
if len(record_types) != len(set(record_types)):
raise ContractValidationError("record_types must be unique")
allowed = {record_type.value for record_type in taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]}
if len(record_types) > 8:
raise ContractValidationError("record_types cannot contain more than 8 values")
try:
normalized_record_types = {taxonomy.RecordType(record_type) for record_type in record_types}
except ValueError as error:
raise ContractValidationError("Unknown canonical record type") from error
allowed = set(taxonomy.RECORD_TYPES_BY_VIEW[(family, view)])
if family is taxonomy.Family.UNKNOWN:
if record_types:
raise ContractValidationError("Unknown screens cannot claim canonical record types")
@@ -94,7 +100,7 @@ def validate_classification(classification: Mapping[str, object]) -> None:
if reason not in taxonomy.ABSTENTION_REASONS:
raise ContractValidationError("Unknown screens require an abstention reason")
else:
if not record_types or not set(record_types).issubset(allowed):
if not record_types or not normalized_record_types.issubset(allowed):
raise ContractValidationError(
f"record_types are incompatible with {family.value}/{view}"
)
@@ -40,6 +40,8 @@ from game_data_capture.web.security import new_bootstrap_token
_POLICY_VERSION = "capture-v1"
_PROCESSING_VERSION = "capture-v1"
_POLL_SECONDS = 0.1
# This is a bounded latest-history projection, not a corpus total or pagination.
_MAX_CAPTURE_PROJECTIONS = 256
class RuntimeFailure(RuntimeError):
@@ -462,7 +464,7 @@ class CaptureRuntime:
app = create_app(
config=WebConfig(
server=self.config.server,
max_capture_items=min(64, self.config.limits.max_queue_items),
max_capture_items=_MAX_CAPTURE_PROJECTIONS,
max_image_bytes=self.config.limits.max_image_bytes,
),
capture_control=control,
@@ -22,7 +22,7 @@
* { box-sizing: border-box; }
body { margin: 0; min-width: 20rem; background: var(--canvas); color: var(--text); }
body { margin: 0; background: var(--canvas); color: var(--text); }
button, input { font: inherit; }
@@ -67,15 +67,20 @@ h2 { margin-bottom: 0; font-size: 1.15rem; }
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
.capture-panel { border-right: 1px solid var(--line); min-height: 42rem; }
.capture-panel { border-right: 1px solid var(--line); display: flex; flex-direction: column; height: min(42rem, calc(100vh - 10rem)); min-height: 24rem; min-width: 0; }
.capture-panel, .review-panel { padding: 1rem; }
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
.queue-filter { display: flex; gap: .55rem; align-items: center; min-height: 44px; color: var(--muted); font-size: .88rem; }
.queue-filter input { inline-size: 1.1rem; block-size: 1.1rem; flex: 0 0 auto; }
.capture-scroll { min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-right: .25rem; scrollbar-gutter: stable; }
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
.capture-list { margin: 0; padding: 0; list-style: none; }
.capture-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
.capture-list button { width: 100%; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
.capture-list .capture-id { overflow-wrap: anywhere; }
.capture-list .capture-meta { color: var(--muted); font-size: .8rem; }
.capture-list .capture-id { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.capture-list .capture-meta { display: grid; gap: .15rem; color: var(--muted); font-size: .72rem; line-height: 1.2; text-align: right; white-space: nowrap; }
.capture-list .capture-review { color: var(--text); font-size: .75rem; font-weight: 800; letter-spacing: .04em; }
.capture-list .capture-time { overflow: hidden; text-overflow: ellipsis; }
.review-content { padding-top: 1rem; }
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
@@ -103,7 +108,8 @@ dialog p { line-height: 1.5; }
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
.header-state { text-align: left; }
.workspace-grid { grid-template-columns: 1fr; }
.capture-panel { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.capture-panel { height: auto; min-height: 0; border-right: 0; border-bottom: 1px solid var(--line); }
.capture-scroll { overflow: visible; padding-right: 0; scrollbar-gutter: auto; }
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
.capture-list button { margin-top: 0; }
}
@@ -4,6 +4,8 @@ const taxonomy = Object.freeze({
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
weapon_mod: { views: ["detail"], records: { detail: ["weapon_mod"] } },
gear_mod: { views: ["detail"], records: { detail: ["gear_mod"] } },
unknown: { views: ["unknown"], records: { unknown: [] } }
});
const families = Object.keys(taxonomy);
@@ -13,7 +15,7 @@ const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
const $ = (id) => document.getElementById(id);
const els = Object.freeze({
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), filter: $("hide-reviewed"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
});
let csrfToken = null;
@@ -22,6 +24,7 @@ let captures = [];
let armed = null;
let pending = false;
let returnFocus = null;
let hideReviewed = false;
function setText(element, value) { element.textContent = value; }
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
@@ -32,12 +35,30 @@ function selected(name) { return document.querySelector(`input[name="${name}"]:c
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
function currentFamily() { return selected("family"); }
function currentView() { return selected("view"); }
function isReviewedCapture(capture) { return capture?.classification !== null && capture?.classification !== undefined; }
function visibleCaptures() { return hideReviewed ? captures.filter((capture) => !isReviewedCapture(capture)) : captures; }
function abbreviateCaptureId(value) { return value.length <= 22 ? value : `${value.slice(0, 11)}…${value.slice(-8)}`; }
function formatCaptureTime(value) {
if (typeof value !== "string" || value.length > 64) return "TIME UNAVAILABLE";
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return "TIME UNAVAILABLE";
const date = new Date(timestamp);
const parts = [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes()];
if (parts.some((part) => !Number.isInteger(part))) return "TIME UNAVAILABLE";
const [year, month, day, hour, minute] = parts;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} UTC`;
}
function activeCaptureProjection() {
if (!activeCapture) return null;
return captures.find((capture) => capture.capture_id === activeCapture.capture_id) || activeCapture;
}
function syncControls() {
const locked = !csrfToken;
els.start.disabled = pending || locked || armed === true;
els.stop.disabled = pending || locked || armed !== true;
els.refresh.disabled = pending || locked;
els.filter.disabled = pending || locked;
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
els.reject.disabled = pending || locked || !activeCapture;
els.remove.disabled = pending || locked || !activeCapture;
@@ -71,7 +92,7 @@ function lock(message) {
els.listEmpty.hidden = false;
setText(els.listEmpty, message);
clearNode(els.list);
els.count.textContent = "0 items";
els.count.textContent = "0 shown / 0 loaded";
setBusy(false);
}
function validSession(payload) {
@@ -178,24 +199,45 @@ function renderLabelForm(classification) {
validateLabel();
}
function renderCaptures() {
clearNode(els.list); els.count.textContent = `${captures.length} ${captures.length === 1 ? "item" : "items"}`; els.listEmpty.hidden = captures.length > 0;
if (!captures.length) { setText(els.listEmpty, "EMPTY QUEUE: capture evidence will appear after a manual refresh."); return; }
captures.forEach((capture) => {
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(activeCapture?.capture_id === capture.capture_id));
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = capture.capture_id;
const meta = document.createElement("span"); meta.className = "capture-meta"; meta.textContent = `${capture.state} · ${capture.received_at}`;
const visible = visibleCaptures();
clearNode(els.list); els.count.textContent = `${visible.length} shown / ${captures.length} loaded`; els.listEmpty.hidden = visible.length > 0;
if (!visible.length) {
setText(els.listEmpty, captures.length ? "ALL LOADED CAPTURES HIDDEN: clear Hide reviewed captures to inspect loaded captures." : "NO LOADED CAPTURES: refresh to check the latest local queue.");
return;
}
visible.forEach((capture) => {
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button";
if (activeCapture?.capture_id === capture.capture_id) button.setAttribute("aria-current", "true");
const reviewState = isReviewedCapture(capture) ? "REVIEWED" : "UNREVIEWED";
const displayedTime = formatCaptureTime(capture.received_at);
button.setAttribute("aria-label", `Capture ${capture.capture_id}. ${reviewState}. ${displayedTime}.`);
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = abbreviateCaptureId(capture.capture_id);
const meta = document.createElement("span"); meta.className = "capture-meta";
const review = document.createElement("span"); review.className = "capture-review"; review.textContent = reviewState;
const time = document.createElement("span"); time.className = "capture-time"; time.textContent = displayedTime;
meta.append(review, time);
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
});
syncControls();
}
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
function focusVisibleCaptureOrFilter() { focusIfAvailable(els.list.querySelector("button:not(:disabled)") || els.filter); }
function applyReviewedFilter() {
hideReviewed = els.filter.checked;
const activeProjection = activeCaptureProjection();
const activeIsHidden = hideReviewed && isReviewedCapture(activeProjection);
if (activeIsHidden) clearActiveDetail();
renderCaptures();
if (activeIsHidden) focusVisibleCaptureOrFilter();
}
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
if (!csrfToken || pending) return false;
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
const payload = await request("/api/captures");
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
captures = payload.captures;
if (activeCapture && !captures.some((capture) => capture.capture_id === activeCapture.capture_id)) clearActiveDetail();
const activeProjection = activeCaptureProjection();
if (activeCapture && (!activeProjection || (hideReviewed && isReviewedCapture(activeProjection)))) clearActiveDetail();
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
}
async function selectCapture(captureId, { announce = true } = {}) {
@@ -238,6 +280,7 @@ async function confirmed(action, fallbackFocus) {
focusIfAvailable(successFocus);
}
async function initialize() {
els.filter.checked = false; hideReviewed = false;
renderLabelForm(null);
let fragment = window.location.hash; let token = null;
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
@@ -267,10 +310,15 @@ els.labelForm.addEventListener("submit", async (event) => {
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
setBusy(false);
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
if (captures.some((capture) => capture.capture_id === captureId)) await selectCapture(captureId, { announce: false });
const savedCapture = captures.find((capture) => capture.capture_id === captureId);
const savedCaptureIsHidden = Boolean(savedCapture && hideReviewed && isReviewedCapture(savedCapture));
if (savedCapture && !savedCaptureIsHidden) await selectCapture(captureId, { announce: false });
else { clearActiveDetail(); renderCaptures(); }
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
focusIfAvailable(els.save);
if (savedCaptureIsHidden) focusVisibleCaptureOrFilter();
else focusIfAvailable(els.save);
});
els.filter.addEventListener("change", applyReviewedFilter);
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
els.dialog.addEventListener("close", () => {
@@ -41,15 +41,21 @@
<section class="workspace-grid" aria-label="Capture review workspace">
<aside class="capture-panel" aria-labelledby="capture-list-title">
<div class="panel-heading">
<div class="panel-heading">
<div>
<p class="kicker">PRIVATE QUEUE</p>
<h2 id="capture-list-title">Captures</h2>
</div>
<span id="capture-count">0 items</span>
</div>
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
<span id="capture-count">0 shown / 0 loaded</span>
</div>
<label class="queue-filter" for="hide-reviewed">
<input id="hide-reviewed" type="checkbox" />
<span>Hide reviewed captures</span>
</label>
<div class="capture-scroll" id="capture-scroll">
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
</div>
</aside>
<section class="review-panel" aria-labelledby="review-title">