{
  "started_utc": "2026-11-01T04:47:50.176950+00:00",
  "finished_utc": "2026-11-01T04:47:51.350020+00:00",
  "query_sha256": "772becb48b687568a9a77e28c9bac7c00ef9614285fec6965593fee7656a4772",
  "data": {
    "at": "2026-11-01T13:47:31.6679700+09:00",
    "files": [
      {
        "name": "java\\com\\icerti\\platform\\poc\\dto\\ai\\AiScenarioSetting.java",
        "sha256": "FFCB5B238FB5FED6C63B6FB3ABDBC32341216C1C863D6A95B80E8B7F202D51E2",
        "text": "package com.icerti.platform.poc.dto.ai;\r\n\r\nimport java.util.List;\r\n\r\nimport com.fasterxml.jackson.databind.JsonNode;\r\nimport com.fasterxml.jackson.databind.node.JsonNodeFactory;\r\n\r\n/**\r\n * One scenario in an {@link AiStartCommand}.\r\n *\r\n * <p>\r\n * The codes are the AI Server API v0.1 set and match the {@code SCENARIO_TYPE}\r\n * seed in {@code V2__reference_data.sql}. A start request always carries all\r\n * six codes exactly once, so absence is never how a scenario is turned off —\r\n * {@code enabled} says that. A code with no stored row rides with\r\n * {@code enabled: false} and empty {@code rois}.\r\n *\r\n * <p>\r\n * 신뢰도를 포함한 모든 설정값은 {@code parameters} 안에 있다. 최상위에 {@code confidence}\r\n * 를 따로 두지 않는 이유는 파라미터 정의를 소유하는 쪽이 AI 서버이기 때문이다 - 그중 하나만\r\n * 끌어올려 두면 AI 가 목록을 바꿀 때마다 계약을 두 군데 고쳐야 한다. 고객사 config 전달용\r\n * {@code CameraConfigPayload.ScenarioPayload} 는 아직 최상위 {@code confidence} 를 갖는데,\r\n * 그쪽은 별개 계약이라 함께 바꾸지 않았다.\r\n *\r\n * <p>\r\n * {@code parameters} 에는 {@code ScenarioParameterCatalog} 가 정의한 항목만 담긴다. 카탈로그\r\n * 기본값 위에 저장된 {@code parameters_json} 의 같은 이름 값을 덮으므로, 저장 이후에 늘어난\r\n * 항목은 기본값으로 채워지고 카탈로그에서 빠진 항목은 옛 행에 남아 있어도 나가지 않는다.\r\n * 저장된 행이 없는 코드는 빈 객체를 받는다 - 켜진 적이 없어 보낼 설정이 없다.\r\n */\r\npublic record AiScenarioSetting(String scenarioCode, boolean enabled, List<AiRoi> rois, JsonNode parameters) {\r\n\r\n    /** Declaration order is not significant; the AI matches on the code. */\r\n    public static final List<String> CODES =\r\n            List.of(\"FIRE\", \"FALL\", \"INTRUSION\", \"LOITERING\", \"FIGHT\", \"CROWD\");\r\n\r\n    public AiScenarioSetting {\r\n        if (!CODES.contains(scenarioCode)) {\r\n            throw new IllegalArgumentException(\"unknown scenarioCode: \" + scenarioCode);\r\n        }\r\n        rois = List.copyOf(rois);\r\n        parameters = parameters == null ? JsonNodeFactory.instance.objectNode() : parameters.deepCopy();\r\n    }\r\n}\r\n"
      },
      {
        "name": "java\\com\\icerti\\platform\\poc\\dto\\ai\\AiStartCommand.java",
        "sha256": "BDCDB28CC22A750B531C7E0722A475231DA1AC5DEDD702CD53552023727CE361",
        "text": "package com.icerti.platform.poc.dto.ai;\r\n\r\nimport java.util.List;\r\nimport java.util.Set;\r\nimport java.util.stream.Collectors;\r\n\r\nimport com.icerti.platform.poc.dto.stream.rec.RecRtspSource;\r\n\r\n/**\r\n * The {@code POST /api/v1/cameras/start} request body of AI Server API v0.1.\r\n *\r\n * <p>\r\n * The body is uniformly camelCase, {@code recServerId} and {@code recCameraId}\r\n * included. This also makes the two directions agree: the AI's own push to\r\n * {@code POST /api/v1/events} ({@code AiEventItem}) already spells the same two\r\n * values {@code recServerId} and {@code recCameraId}.\r\n *\r\n * <p>\r\n * Neither {@code recServerId} nor {@code recCameraId} is needed by the AI\r\n * today: it connects using {@code rtspUrl}, which is already complete, and it\r\n * correlates events using {@code cameraId}. The AI may ignore both fields. They\r\n * ride along as forward-looking reference, because {@code recCameraId} alone\r\n * is not globally unique - the schema's {@code UK_CAMERA_REC_ID} is\r\n * {@code UNIQUE (rec_server_pk, rec_camera_id)}, so camera {@code 00000012} can\r\n * exist on two different Streaming Servers. Once the AI eventually pulls media\r\n * from the Streaming Server directly instead of being handed an RTSP URL, the\r\n * pair {@code recServerId} + {@code recCameraId} is the identity it will\r\n * need to resolve one camera.\r\n *\r\n * <p>\r\n * {@code startWallTimeMs} 와 {@code rtpClockRate} 는 REC 가 응답에 실어 준 타임라인\r\n * 앵커다. AI 가 탐지 시각을 자기 시계가 아니라 Streaming Server 기준으로 계산하려면\r\n * 둘이 함께 있어야 한다 —\r\n * {@code absoluteMs = startWallTimeMs + rtpTimestamp * 1000 / rtpClockRate}.\r\n * v0.1 요청 계약에는 없는 필드이므로 AI 쪽과 합의가 필요하다. 또한 REC 가 카메라 소스\r\n * 복구와 32-bit wrap 때 앵커를 다시 잡으므로, 같은 cameraId 로 start 를 다시 불러\r\n * 갱신해야 한다.\r\n */\r\npublic record AiStartCommand(long cameraId,\r\n        String recServerId,\r\n        String recCameraId,\r\n        String rtspUrl,\r\n        long startWallTimeMs,\r\n        int rtpClockRate,\r\n        List<AiScenarioSetting> scenarios) {\r\n\r\n    public AiStartCommand {\r\n        if (cameraId <= 0) {\r\n            throw new IllegalArgumentException(\"cameraId must be positive\");\r\n        }\r\n        if (recServerId == null || recServerId.isBlank()) {\r\n            throw new IllegalArgumentException(\"recServerId must not be blank\");\r\n        }\r\n        if (recCameraId == null || !recCameraId.matches(\"[0-9]{8}\")) {\r\n            throw new IllegalArgumentException(\"recCameraId must contain exactly 8 digits\");\r\n        }\r\n        if (rtspUrl == null || rtspUrl.isBlank()) {\r\n            throw new IllegalArgumentException(\"rtspUrl must not be blank\");\r\n        }\r\n        if (startWallTimeMs <= 0) {\r\n            throw new IllegalArgumentException(\"startWallTimeMs must be positive\");\r\n        }\r\n        if (rtpClockRate <= 0) {\r\n            throw new IllegalArgumentException(\"rtpClockRate must be positive\");\r\n        }\r\n        scenarios = List.copyOf(requireEveryCodeOnce(scenarios));\r\n    }\r\n\r\n    /** Binds one validated REC track to the scenarios resolved for its camera. */\r\n    public static AiStartCommand from(RecRtspSource source, List<AiScenarioSetting> scenarios) {\r\n        return new AiStartCommand(source.localCameraId(), source.recServerId(), source.recCameraId(),\r\n                source.rtspUrl(), source.startWallTimeMs(), source.rtpClockRate(), scenarios);\r\n    }\r\n\r\n    private static List<AiScenarioSetting> requireEveryCodeOnce(List<AiScenarioSetting> scenarios) {\r\n        if (scenarios == null) {\r\n            throw new IllegalArgumentException(\"scenarios must not be null\");\r\n        }\r\n        Set<String> codes = scenarios.stream().map(AiScenarioSetting::scenarioCode)\r\n                .collect(Collectors.toSet());\r\n        if (codes.size() != scenarios.size() || !codes.equals(Set.copyOf(AiScenarioSetting.CODES))) {\r\n            throw new IllegalArgumentException(\r\n                    \"scenarios must carry the six codes exactly once: \" + AiScenarioSetting.CODES);\r\n        }\r\n        return scenarios;\r\n    }\r\n}\r\n"
      },
      {
        "name": "java\\com\\icerti\\platform\\poc\\service\\ai\\CameraAiInferenceService.java",
        "sha256": "CDD307D86B4B197412265D7C430ED4247D3B497E15ECF4036B123C8827753791",
        "text": "package com.icerti.platform.poc.service.ai;\r\n\r\nimport java.util.Comparator;\r\nimport java.util.LinkedHashMap;\r\nimport java.util.List;\r\nimport java.util.Map;\r\nimport java.util.Objects;\r\nimport java.util.concurrent.CompletionException;\r\nimport java.util.regex.Pattern;\r\nimport java.util.stream.Collectors;\r\n\r\nimport org.slf4j.Logger;\r\nimport org.slf4j.LoggerFactory;\r\n\r\nimport com.fasterxml.jackson.databind.JsonNode;\r\nimport com.fasterxml.jackson.databind.node.ObjectNode;\r\nimport com.icerti.platform.poc.domain.scenario.ScenarioCode;\r\nimport com.icerti.platform.poc.dto.ai.AiRoi;\r\nimport com.icerti.platform.poc.dto.ai.AiScenarioSetting;\r\nimport com.icerti.platform.poc.dto.ai.AiStartCommand;\r\nimport com.icerti.platform.poc.dto.ai.AiStartResult;\r\nimport com.icerti.platform.poc.dto.stream.rec.RecRtspSource;\r\nimport com.icerti.platform.poc.repository.camera.CameraMapper;\r\nimport com.icerti.platform.poc.repository.camera.CameraMapper.CameraRow;\r\nimport com.icerti.platform.poc.repository.scenario.ScenarioMapper;\r\nimport com.icerti.platform.poc.repository.scenario.ScenarioMapper.ScenarioRoiRow;\r\nimport com.icerti.platform.poc.repository.scenario.ScenarioMapper.ScenarioRow;\r\nimport com.icerti.platform.poc.service.scenario.ScenarioParameterCatalog;\r\nimport com.icerti.platform.poc.service.stream.rec.RecMediaUrlRejectedException;\r\nimport com.icerti.platform.poc.service.stream.rec.RecStreamQueryService;\r\n\r\n/**\r\n * Joins the REC RTSP lookup to the AI start call for one camera.\r\n *\r\n * <p>\r\n * The order matters: the activation gate runs before REC is queried, so a camera\r\n * that will not run AI costs no control-channel request. Nothing is stored here\r\n * and nothing is retried; the caller decides what to do with the outcome.\r\n *\r\n * <p>\r\n * Not a Spring bean. It needs an open Streaming Server control channel, and\r\n * standing that channel up belongs to the Managed authentication work, so this\r\n * is constructed wherever that wiring lands.\r\n */\r\npublic class CameraAiInferenceService {\r\n    private static final Logger LOGGER = LoggerFactory.getLogger(CameraAiInferenceService.class);\r\n\r\n    /** The AI receives the main track. Sub selection is a separate decision. */\r\n    private static final int MAIN_TRACK = 0;\r\n\r\n    private static final Pattern URL_CREDENTIALS = Pattern.compile(\"(\\\\w+://)[^/\\\\s@]+@\");\r\n    private static final int MAX_LOGGED_MESSAGE = 500;\r\n\r\n    private final CameraMapper cameraMapper;\r\n    private final ScenarioMapper scenarioMapper;\r\n    private final RecStreamQueryService streamQuery;\r\n    private final AiInferencePort aiPort;\r\n\r\n    public CameraAiInferenceService(CameraMapper cameraMapper, ScenarioMapper scenarioMapper,\r\n            RecStreamQueryService streamQuery, AiInferencePort aiPort) {\r\n        this.cameraMapper = Objects.requireNonNull(cameraMapper, \"cameraMapper\");\r\n        this.scenarioMapper = Objects.requireNonNull(scenarioMapper, \"scenarioMapper\");\r\n        this.streamQuery = Objects.requireNonNull(streamQuery, \"streamQuery\");\r\n        this.aiPort = Objects.requireNonNull(aiPort, \"aiPort\");\r\n    }\r\n\r\n    public AiStartOutcome start(long localCameraId) {\r\n        CameraRow camera = cameraMapper.findById(localCameraId)\r\n                .orElseThrow(() -> new IllegalArgumentException(\"unknown camera: \" + localCameraId));\r\n\r\n        if (camera.deletedAtWallMs() != null || \"PENDING_DELETE\".equals(camera.assignmentState())) {\r\n            return stop(localCameraId);\r\n        }\r\n\r\n        AiActivation activation = AiActivation.of(camera);\r\n        if (!activation.active()) {\r\n            // The camera may still be running from an earlier start; releasing it\r\n            // here is why a skip is not simply a no-op.\r\n            AiStartOutcome stopped = stop(localCameraId);\r\n            return stopped.status() == AiStartOutcome.Status.SKIPPED\r\n                    ? AiStartOutcome.skipped(activation.reason()) : stopped;\r\n        }\r\n\r\n        List<AiScenarioSetting> scenarios = resolveScenarios(localCameraId);\r\n\r\n        RecRtspSource source;\r\n        try {\r\n            source = streamQuery\r\n                    .queryRtsp(camera.recServerId(), localCameraId, camera.recCameraId(), MAIN_TRACK)\r\n                    .join();\r\n        } catch (CompletionException exception) {\r\n            return AiStartOutcome.streamNotReady(rejectionReason(exception.getCause()));\r\n        }\r\n\r\n        // 여기서 보내는 타임라인 앵커는 이 순간의 값이다.\r\n        //\r\n        // REC 는 카메라 소스가 복구될 때와 32-bit RTP timestamp 가 wrap 될 때(약 13.25시간)\r\n        // startWallTimeMs 를 다시 잡는다. 그때부터 AI 가 계산하는 탐지 시각은 옛 앵커\r\n        // 기준이라 조용히 어긋난다. 값이 틀렸다는 신호가 어디에도 안 나타나는 것이 이\r\n        // 문제의 성격이다.\r\n        //\r\n        // 갱신은 같은 cameraId 로 start 를 다시 부르면 된다. 명세상 그것은 기존 작업의\r\n        // 갱신이다. Streaming Server 는 재설정을 제어 채널로 통보하지 않고 POST /logs 의\r\n        // TIMELINE_ANCHOR_REBASED 로만 남기므로(Streaming Server API 11장), 그 로그를 받으면\r\n        // TimelineAnchorResync 가 start 를 다시 부른다.\r\n        //\r\n        // 앵커가 갱신되면 브라우저도 같은 값을 다시 받아야 한다. 한쪽만 새 앵커를 쓰면\r\n        // 그 구간 이벤트 시각이 약 13.25시간 어긋난다.\r\n        try {\r\n            AiStartResult result = aiPort.start(AiStartCommand.from(source, scenarios));\r\n            if (!result.succeeded()) {\r\n                LOGGER.warn(\"ai_start_rejected camera={} code={} message={}\", localCameraId, result.code(),\r\n                        loggableMessage(result.message()));\r\n                return AiStartOutcome.rejected(result.code());\r\n            }\r\n            return AiStartOutcome.started(result.streamUrl());\r\n        } catch (AiInferenceUnavailableException exception) {\r\n            LOGGER.warn(\"ai_start_unknown camera={} reason={}\", localCameraId, exception.getMessage());\r\n            return AiStartOutcome.unknown(exception.getMessage());\r\n        }\r\n    }\r\n\r\n    /**\r\n     * Projects the camera's stored rules onto the six codes, confidence\r\n     * thresholds and ROIs the AI understands.\r\n     *\r\n     * <p>\r\n     * All six codes are always emitted, exactly once each — absence must never\r\n     * be read by the AI as \"leave this scenario as it was\", so a code with no\r\n     * enabled row rides with {@code enabled: false} rather than being left out.\r\n     * A code with no stored row at all gets {@code confidence: null} and empty\r\n     * {@code rois}; a code whose only rows are disabled still carries its\r\n     * stored {@code confidence} and {@code rois} alongside {@code enabled:\r\n     * false}, since that data is harmless to send and matches what the\r\n     * settings screen shows for it.\r\n     *\r\n     * <p>\r\n     * {@code IX_SCENARIO_CAMERA_TYPE} is an index, not a unique key, so one\r\n     * camera can hold several rows of the same code — a previous delivery unit\r\n     * made {@code create} an upsert so new duplicates cannot appear, but rows\r\n     * created before that may still exist. When several rows of a code are\r\n     * enabled, the row with the smallest {@code scenarioId} among the\r\n     * <em>enabled</em> ones is used and the rest are logged. This differs from\r\n     * {@link com.icerti.platform.poc.service.scenario.ScenarioService#create\r\n     * ScenarioService.create}'s {@code findByCameraAndType}, which picks the\r\n     * smallest {@code scenarioId} overall regardless of {@code enabled}. With\r\n     * legacy duplicates the AI can therefore run one row's settings while the\r\n     * settings screen edits another; it self-corrects the next time that\r\n     * scenario is saved, since {@code create} updates the row\r\n     * {@code findByCameraAndType} found.\r\n     */\r\n    private List<AiScenarioSetting> resolveScenarios(long localCameraId) {\r\n        Map<Long, List<ScenarioRoiRow>> roisByScenarioId = scenarioMapper.findRoisByCamera(localCameraId).stream()\r\n                .collect(Collectors.groupingBy(ScenarioRoiRow::scenarioId));\r\n\r\n        Map<String, List<ScenarioRow>> rowsByCode = scenarioMapper.findByCameraId(localCameraId).stream()\r\n                .collect(Collectors.groupingBy(ScenarioRow::scenarioCode, LinkedHashMap::new, Collectors.toList()));\r\n\r\n        return AiScenarioSetting.CODES.stream()\r\n                .map(code -> resolveScenario(localCameraId, code, rowsByCode.getOrDefault(code, List.of()),\r\n                        roisByScenarioId))\r\n                .toList();\r\n    }\r\n\r\n    /**\r\n     * AI 가 실패와 함께 보낸 설명. 우리가 보낸 rtspUrl 을 되풀이할 수 있어 URL 의 계정 부분을 가리고,\r\n     * 로그 한 줄을 넘지 않게 줄바꿈을 없애고 길이를 자른다.\r\n     */\r\n    static String loggableMessage(String message) {\r\n        if (message == null) {\r\n            return null;\r\n        }\r\n        String cleaned = URL_CREDENTIALS.matcher(message).replaceAll(\"$1***@\").replaceAll(\"[\\\\r\\\\n]+\", \" \");\r\n        return cleaned.length() > MAX_LOGGED_MESSAGE ? cleaned.substring(0, MAX_LOGGED_MESSAGE) : cleaned;\r\n    }\r\n\r\n    /** A code with no stored row at all: off, with nothing to report. */\r\n    private static AiScenarioSetting resolveScenario(long localCameraId, String code, List<ScenarioRow> rows,\r\n            Map<Long, List<ScenarioRoiRow>> roisByScenarioId) {\r\n        if (rows.isEmpty()) {\r\n            return new AiScenarioSetting(code, false, List.of(), null);\r\n        }\r\n        List<ScenarioRow> enabledRows = rows.stream().filter(ScenarioRow::enabled).toList();\r\n        boolean enabled = !enabledRows.isEmpty();\r\n        ScenarioRow row = enabled\r\n                ? canonicalRow(localCameraId, enabledRows)\r\n                : rows.stream().min(Comparator.comparingLong(ScenarioRow::scenarioId)).orElseThrow();\r\n        return new AiScenarioSetting(code, enabled, resolveRois(row, roisByScenarioId), resolveParameters(row));\r\n    }\r\n\r\n    /** Picks the smallest {@code scenarioId} when a code has more than one enabled row. */\r\n    private static ScenarioRow canonicalRow(long localCameraId, List<ScenarioRow> rows) {\r\n        if (rows.size() > 1) {\r\n            LOGGER.warn(\"scenario_duplicate_code camera={} code={} count={}\", localCameraId,\r\n                    rows.get(0).scenarioCode(), rows.size());\r\n        }\r\n        return rows.stream().min(Comparator.comparingLong(ScenarioRow::scenarioId)).orElseThrow();\r\n    }\r\n\r\n    /**\r\n     * 카탈로그가 정의한 항목만, 카탈로그 기본값 위에 저장된 값을 덮어서 내보낸다. 항목 목록을\r\n     * 카탈로그에서 읽으므로 이름이 여기 적히는 일은 없다 - 파라미터를 더하거나 빼는 곳은\r\n     * {@link ScenarioParameterCatalog} 하나다.\r\n     *\r\n     * <p>\r\n     * 이렇게 하면 두 방향이 같이 해결된다. 저장된 뒤에 카탈로그가 늘어난 항목은 기본값으로\r\n     * 채워지고, 카탈로그에서 빠진 항목이 옛 행에 남아 있어도 나가지 않는다. 뒤쪽이 실제로\r\n     * 일어나고 있었다 - {@code CROWD} 의 알림 수준 4종이 카탈로그에서 빠진 뒤에도 저장된\r\n     * 행에 남아 계속 실려 나갔다. 그대로 두면 계약의 출처가 카탈로그가 아니라 DB 의 과거가\r\n     * 된다.\r\n     */\r\n    private static JsonNode resolveParameters(ScenarioRow row) {\r\n        ScenarioCode code = ScenarioCode.valueOf(row.scenarioCode());\r\n        ObjectNode parameters = ScenarioParameterCatalog.defaultParameters(code);\r\n        JsonNode stored = row.parameters();\r\n        if (stored != null) {\r\n            ScenarioParameterCatalog.fieldsByName(code).keySet().forEach(name -> {\r\n                JsonNode value = stored.get(name);\r\n                if (value != null) {\r\n                    parameters.set(name, value.deepCopy());\r\n                }\r\n            });\r\n        }\r\n        return parameters;\r\n    }\r\n\r\n    /** ROIs are sorted by {@code roiOrder} so the AI's log order matches what a person sees on screen. */\r\n    private static List<AiRoi> resolveRois(ScenarioRow row, Map<Long, List<ScenarioRoiRow>> roisByScenarioId) {\r\n        return roisByScenarioId.getOrDefault(row.scenarioId(), List.of()).stream()\r\n                .sorted(Comparator.comparingInt(ScenarioRoiRow::roiOrder))\r\n                .map(roi -> AiRoi.from(roi.roiLabel(), roi.geometry()))\r\n                .toList();\r\n    }\r\n\r\n    public AiStartOutcome stop(long localCameraId) {\r\n        try {\r\n            aiPort.stop(localCameraId);\r\n            return AiStartOutcome.skipped(\"STOP_ACCEPTED\");\r\n        } catch (HttpAiInferencePort.StopRejectedException exception) {\r\n            return AiStartOutcome.rejected(exception.code());\r\n        } catch (AiInferenceUnavailableException exception) {\r\n            LOGGER.warn(\"ai_stop_unreachable camera={}\", localCameraId);\r\n            return AiStartOutcome.unknown(\"AI_STOP_UNAVAILABLE\");\r\n        }\r\n    }\r\n\r\n    private static String rejectionReason(Throwable cause) {\r\n        return cause instanceof RecMediaUrlRejectedException rejected\r\n                ? rejected.reasonCode()\r\n                : \"REC_STREAM_UNAVAILABLE\";\r\n    }\r\n}\r\n"
      },
      {
        "name": "resources\\mappers\\ScenarioMapper.xml",
        "sha256": "0FE6E86FE99CE73EC0E10A8646A15209BDB59E5A19F927E9EACE37641FD75FD0",
        "text": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\r\n<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"https://mybatis.org/dtd/mybatis-3-mapper.dtd\">\r\n<mapper namespace=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper\">\r\n    <sql id=\"scenarioColumns\">\r\n        SELECT s.scenario_id AS scenarioId, s.camera_id AS cameraId,\r\n               s.scenario_type_id AS scenarioTypeId, t.scenario_code AS scenarioCode,\r\n               s.parameters_json AS parameters, s.scenario_enabled AS enabled\r\n        FROM SCENARIO s\r\n        JOIN SCENARIO_TYPE t ON t.scenario_type_id = s.scenario_type_id\r\n    </sql>\r\n\r\n    <select id=\"findById\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioRow\">\r\n        <include refid=\"scenarioColumns\"/>\r\n        WHERE s.scenario_id = #{scenarioId}\r\n    </select>\r\n    <select id=\"findByCameraId\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioRow\">\r\n        <include refid=\"scenarioColumns\"/>\r\n        WHERE s.camera_id = #{cameraId}\r\n        ORDER BY s.scenario_id\r\n    </select>\r\n    <select id=\"findByCameraAndType\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioRow\">\r\n        <include refid=\"scenarioColumns\"/>\r\n        WHERE s.camera_id = #{cameraId} AND s.scenario_type_id = #{scenarioTypeId}\r\n        ORDER BY s.scenario_id\r\n        LIMIT 1\r\n    </select>\r\n    <select id=\"findScenarioTypeId\" resultType=\"long\">\r\n        SELECT scenario_type_id FROM SCENARIO_TYPE WHERE scenario_code = #{scenarioCode}\r\n    </select>\r\n    <select id=\"findAllTypes\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioTypeRow\">\r\n        SELECT scenario_code AS scenarioCode, scenario_name AS scenarioName FROM SCENARIO_TYPE\r\n    </select>\r\n    <select id=\"findScenarioSummaryByCameraIds\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioSummaryRow\">\r\n        SELECT DISTINCT s.camera_id AS cameraId, t.scenario_code AS scenarioCode, t.scenario_name AS scenarioName\r\n        FROM SCENARIO s\r\n        JOIN SCENARIO_TYPE t ON t.scenario_type_id = s.scenario_type_id\r\n        WHERE s.scenario_enabled = TRUE AND s.camera_id IN\r\n        <foreach item=\"cameraId\" collection=\"cameraIds\" open=\"(\" separator=\",\" close=\")\">#{cameraId}</foreach>\r\n        ORDER BY s.camera_id, t.scenario_code\r\n    </select>\r\n\r\n    <insert id=\"insertScenario\">\r\n        INSERT INTO SCENARIO (camera_id, scenario_type_id, parameters_json, scenario_enabled,\r\n                              created_at_wall_ms, updated_at_wall_ms)\r\n        VALUES (#{cameraId}, #{scenarioTypeId}, #{parameters}, #{enabled}, #{nowWallMs}, #{nowWallMs})\r\n    </insert>\r\n    <update id=\"updateParameters\">\r\n        UPDATE SCENARIO\r\n        SET parameters_json = #{parameters}, updated_at_wall_ms = #{nowWallMs}\r\n        WHERE scenario_id = #{scenarioId}\r\n    </update>\r\n    <update id=\"updateEnabled\">\r\n        UPDATE SCENARIO\r\n        SET scenario_enabled = #{enabled}, updated_at_wall_ms = #{nowWallMs}\r\n        WHERE scenario_id = #{scenarioId}\r\n    </update>\r\n    <update id=\"touch\">\r\n        UPDATE SCENARIO SET updated_at_wall_ms = #{nowWallMs} WHERE scenario_id = #{scenarioId}\r\n    </update>\r\n    <delete id=\"delete\">DELETE FROM SCENARIO WHERE scenario_id = #{scenarioId}</delete>\r\n\r\n    <sql id=\"roiColumns\">\r\n        SELECT roi.roi_id AS roiId, roi.camera_id AS cameraId, roi.roi_order AS roiOrder,\r\n               roi.roi_label AS roiLabel, roi.include_region AS includeRegion,\r\n               roi.geometry_json AS geometry\r\n        FROM ROI roi\r\n    </sql>\r\n    <!--\r\n        영역을 붙은 시나리오와 함께 준다. 영역 하나가 시나리오 둘에 붙으면 두 행으로 나온다.\r\n        화면·배포·AI 조립이 scenarioId 로 묶어 쓰므로 이 모양을 유지한다.\r\n    -->\r\n    <select id=\"findRoisByCamera\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioRoiRow\">\r\n        SELECT roi.roi_id AS roiId, link.scenario_id AS scenarioId, roi.camera_id AS cameraId,\r\n               roi.roi_order AS roiOrder, roi.roi_label AS roiLabel,\r\n               roi.include_region AS includeRegion, roi.geometry_json AS geometry\r\n        FROM ROI roi\r\n        JOIN SCENARIO_ROI link ON link.roi_id = roi.roi_id\r\n        WHERE roi.camera_id = #{cameraId}\r\n        ORDER BY link.scenario_id, roi.roi_order\r\n    </select>\r\n    <select id=\"findRoisForUpdate\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$RoiRow\">\r\n        <include refid=\"roiColumns\"/>\r\n        WHERE roi.camera_id = #{cameraId}\r\n        ORDER BY roi.roi_order\r\n        FOR UPDATE\r\n    </select>\r\n    <select id=\"findRoiByCameraAndLabel\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$RoiRow\">\r\n        <include refid=\"roiColumns\"/>\r\n        WHERE roi.camera_id = #{cameraId} AND roi.roi_label = #{roiLabel}\r\n    </select>\r\n    <select id=\"findScenariosByRoiId\" resultType=\"com.icerti.platform.poc.repository.scenario.ScenarioMapper$ScenarioRow\">\r\n        <include refid=\"scenarioColumns\"/>\r\n        JOIN SCENARIO_ROI link ON link.scenario_id = s.scenario_id\r\n        WHERE link.roi_id = #{roiId}\r\n        ORDER BY s.scenario_id\r\n    </select>\r\n    <select id=\"findBoundRoiIds\" resultType=\"long\">\r\n        SELECT roi_id FROM SCENARIO_ROI WHERE scenario_id = #{scenarioId}\r\n    </select>\r\n    <select id=\"countBindingsByRoi\" resultType=\"int\">\r\n        SELECT COUNT(*) FROM SCENARIO_ROI WHERE roi_id = #{roiId}\r\n    </select>\r\n\r\n    <insert id=\"insertRoi\">\r\n        INSERT INTO ROI (camera_id, roi_order, roi_label, include_region, geometry_json)\r\n        VALUES (#{cameraId}, #{roiOrder}, #{roiLabel}, #{includeRegion}, #{geometryJson})\r\n    </insert>\r\n    <insert id=\"insertBinding\">\r\n        INSERT INTO SCENARIO_ROI (scenario_id, roi_id, camera_id)\r\n        VALUES (#{scenarioId}, #{roiId}, #{cameraId})\r\n    </insert>\r\n    <update id=\"updateRoiById\">\r\n        UPDATE ROI\r\n        SET roi_label = #{roiLabel}, include_region = #{includeRegion}, geometry_json = #{geometryJson}\r\n        WHERE roi_id = #{roiId}\r\n    </update>\r\n    <update id=\"updateRoiOrder\">\r\n        UPDATE ROI SET roi_order = #{roiOrder} WHERE roi_id = #{roiId}\r\n    </update>\r\n    <delete id=\"deleteRoiById\">DELETE FROM ROI WHERE roi_id = #{roiId}</delete>\r\n    <delete id=\"deleteBinding\">\r\n        DELETE FROM SCENARIO_ROI WHERE scenario_id = #{scenarioId} AND roi_id = #{roiId}\r\n    </delete>\r\n    <delete id=\"deleteBindingsByScenario\">\r\n        DELETE FROM SCENARIO_ROI WHERE scenario_id = #{scenarioId}\r\n    </delete>\r\n</mapper>\r\n"
      },
      {
        "name": "java\\com\\icerti\\platform\\poc\\service\\scenario\\ScenarioParameterCatalog.java",
        "sha256": "E09D4F70B95D99B7EF3BFEE0C7EEB2E8C86F0EB1371BB7AA0BAC5B29C905BC85",
        "text": "package com.icerti.platform.poc.service.scenario;\r\n\r\nimport com.icerti.platform.poc.domain.scenario.ScenarioCode;\r\nimport com.icerti.platform.poc.dto.scenario.ScenarioParameterFieldKind;\r\nimport com.icerti.platform.poc.dto.scenario.ScenarioParameterFieldSpec;\r\nimport com.icerti.platform.poc.dto.scenario.ScenarioParameterGroupSpec;\r\n\r\nimport com.fasterxml.jackson.databind.node.JsonNodeFactory;\r\nimport com.fasterxml.jackson.databind.node.ObjectNode;\r\nimport java.math.BigDecimal;\r\nimport java.util.EnumMap;\r\nimport java.util.LinkedHashMap;\r\nimport java.util.List;\r\nimport java.util.Map;\r\n\r\n/**\r\n * AI 서버가 Start Body 의 {@code scenarios[].parameters} 로 받는 시나리오별 파라미터.\r\n *\r\n * <p>\r\n * 근거는 AI 팀이 보낸 PR #13 사전 합의안 하나다\r\n * ({@code docs/reference/ai_contract/contextra_pr13_web_start_schema_draft.html}). 설계서 v0.3 p.42~47 에서 온\r\n * 옛 정의(공통 {@code confidence}, 침입 방향·대상 같은 선택 항목)는 AI 가 해석하지 않아\r\n * 전부 걷어냈다. 키 이름은 합의안의 snake_case 를 글자 그대로 쓴다 - 하나라도 어긋나면 AI 는\r\n * unknown field 로 버리고 자기 기본값으로 돌아가므로, 값을 바꾼 티도 나지 않는다.\r\n * 6종 공통 {@code cooldown_seconds} 와 군중의 기준 인원·status 비율은 그 뒤 군중 Web 연동\r\n * 계약({@code docs/reference/ai_contract/26_09_28_crowd_web_contract.html})으로 더해졌다.\r\n *\r\n * <p>\r\n * <b>기본값 두 개는 합의안 샘플과 다르다.</b> {@code pose_confidence} 는 0.1 이 아니라 0.3,\r\n * {@code smoke_confidence} 는 0.3 이 아니라 0.7 이다. 실측에서 그 값으로는 오탐이 눈에 띄어\r\n * 올렸다. 합의안 자체가 최종 계약이 아니라 \"PR #13 구현 과정에서 기본값·허용범위가 미세\r\n * 조정될 수 있다\"고 적은 문서라 벗어나도 되는 자리다.\r\n *\r\n * <p>\r\n * <b>범위는 아는 것만 건다.</b> confidence 계열 다섯은 0~1, {@code required_bursts} 는\r\n * 합의안이 적어 둔 1~16 이다. 군중 계약이 적은 {@code cooldown_seconds} 0~300,\r\n * {@code reference_count} 1 이상, 군중 status 비율 0~1 도 건다. 나머지는 상·하한이 모두 없다. 음수까지 열리지만, 범위 밖 값은\r\n * AI 가 무시하고 saved → default 로 되돌아가는 계약이라 저장 단계에서 막을 근거가 없다.\r\n *\r\n * <p>\r\n * <b>파라미터 정의가 현재 사는 곳이 여기다.</b> 최종적으로는 AI 서버가 이 정의를 소유해야 하고,\r\n * 중간 단계로는 {@code SCENARIO_PARAMETER_DEF} 테이블이 유력하다. 둘 다 지금은 막혀 있다.\r\n * 그래서 정의를 이 파일에 두되 조회 메서드를 하나만 노출해서, 나중에 옮길 때 이 파일만 바뀌도록 했다.\r\n *\r\n * <p>\r\n * {@link ScenarioParameterValidator}가 허용 필드와 값 범위를 모두 이 카탈로그에서 읽고,\r\n * 이벤트 설정 화면도 같은 정의로 입력칸을 그린다. 파라미터를 추가하거나 고칠 곳은 이 파일 하나다.\r\n *\r\n * <p>\r\n * <b>여기 있는 값은 전부 AI 서버로 나간다.</b> {@code parameters_json}이 Start Body 의\r\n * {@code scenarios[].parameters}에 통째로 실린다({@code AiScenarioSetting} 참고). 저장된 값이\r\n * 비운 항목은 여기 기본값으로 메워 보내므로, AI 는 시나리오 한 종의 파라미터 전체를 항상 받는다.\r\n *\r\n * <p>\r\n * 이미 저장된 {@code parameters_json} 행은 옛 키를 그대로 들고 있다. 따로 옮기지 않는다 -\r\n * {@link ScenarioParameterValidator}가 모르는 키를 떨구고, AI 로 나갈 때는 이 카탈로그의\r\n * 기본값 위에 같은 이름만 덮으므로 옛 키는 실리지 않는다. 화면에서 한 번 저장하면 정리된다.\r\n */\r\npublic final class ScenarioParameterCatalog {\r\n\r\n    private static final JsonNodeFactory NODES = JsonNodeFactory.instance;\r\n\r\n    /** confidence 계열이 공통으로 갖는 경계. */\r\n    private static final double CONFIDENCE_MIN = 0;\r\n    private static final double CONFIDENCE_MAX = 1.0;\r\n\r\n    private static final Map<ScenarioCode, List<ScenarioParameterGroupSpec>> GROUPS = groups();\r\n    private static final Map<ScenarioCode, Map<String, ScenarioParameterFieldSpec>> BY_NAME = byName(GROUPS);\r\n\r\n    private ScenarioParameterCatalog() {\r\n    }\r\n\r\n    /**\r\n     * 시나리오 한 종의 파라미터 폼을 표시 순서대로 돌려준다.\r\n     *\r\n     * @throws IllegalArgumentException 카탈로그에 항목이 없을 때. 시나리오를 추가하면서 폼을\r\n     *                                  같이 만들지 않았다는 뜻이다\r\n     */\r\n    public static List<ScenarioParameterGroupSpec> groupsFor(ScenarioCode code) {\r\n        List<ScenarioParameterGroupSpec> groups = GROUPS.get(code);\r\n        if (groups == null) {\r\n            throw new IllegalArgumentException(\"Scenario has no parameter catalog entry: \" + code);\r\n        }\r\n        return groups;\r\n    }\r\n\r\n    /**\r\n     * 시나리오 한 종이 허용하는 파라미터를 이름으로 찾을 수 있게 돌려준다. 그룹 구분은 화면용이므로\r\n     * 여기서는 펼쳐서 담는다. {@link ScenarioParameterValidator}가 허용 필드 판정과 값 검증에 쓴다.\r\n     *\r\n     * @throws IllegalArgumentException 카탈로그에 항목이 없을 때\r\n     */\r\n    public static Map<String, ScenarioParameterFieldSpec> fieldsByName(ScenarioCode code) {\r\n        Map<String, ScenarioParameterFieldSpec> fields = BY_NAME.get(code);\r\n        if (fields == null) {\r\n            throw new IllegalArgumentException(\"Scenario has no parameter catalog entry: \" + code);\r\n        }\r\n        return fields;\r\n    }\r\n\r\n    /**\r\n     * 아직 저장된 적 없는 시나리오를 켤 때 쓸 기본 파라미터.\r\n     * {@link ScenarioParameterValidator}를 그대로 통과한다.\r\n     *\r\n     * @throws IllegalArgumentException 카탈로그에 항목이 없을 때\r\n     */\r\n    public static ObjectNode defaultParameters(ScenarioCode code) {\r\n        ObjectNode parameters = NODES.objectNode();\r\n        fieldsByName(code).forEach((name, spec) -> parameters.set(name, spec.defaultValue()));\r\n        return parameters;\r\n    }\r\n\r\n    private static Map<ScenarioCode, Map<String, ScenarioParameterFieldSpec>> byName(\r\n            Map<ScenarioCode, List<ScenarioParameterGroupSpec>> groups) {\r\n        Map<ScenarioCode, Map<String, ScenarioParameterFieldSpec>> byName = new EnumMap<>(ScenarioCode.class);\r\n        groups.forEach((code, scenarioGroups) -> {\r\n            Map<String, ScenarioParameterFieldSpec> fields = new LinkedHashMap<>();\r\n            scenarioGroups.stream()\r\n                    .flatMap(group -> group.fields().stream())\r\n                    .forEach(field -> {\r\n                        if (fields.put(field.name(), field) != null) {\r\n                            throw new IllegalStateException(\r\n                                    \"Duplicate parameter in \" + code + \" catalog: \" + field.name());\r\n                        }\r\n                    });\r\n            byName.put(code, Map.copyOf(fields));\r\n        });\r\n        return Map.copyOf(byName);\r\n    }\r\n\r\n    private static Map<ScenarioCode, List<ScenarioParameterGroupSpec>> groups() {\r\n        Map<ScenarioCode, List<ScenarioParameterGroupSpec>> groups = new EnumMap<>(ScenarioCode.class);\r\n        groups.put(ScenarioCode.INTRUSION, intrusion());\r\n        groups.put(ScenarioCode.LOITERING, loitering());\r\n        groups.put(ScenarioCode.FALL, fall());\r\n        groups.put(ScenarioCode.FIGHT, fight());\r\n        groups.put(ScenarioCode.FIRE, fire());\r\n        groups.put(ScenarioCode.CROWD, crowd());\r\n        return Map.copyOf(groups);\r\n    }\r\n\r\n    /** 침입 설정 — 합의안 1. */\r\n    private static List<ScenarioParameterGroupSpec> intrusion() {\r\n        return List.of(ungrouped(\r\n                confidence(\"person_confidence\", \"사람 신뢰도\", \"0.5\",\r\n                        \"사람 객체를 유효하게 볼 최소 confidence\"),\r\n                seconds(\"dwell_seconds\", \"체류 시간\", \"0.5\",\r\n                        \"ROI 안에 이 시간 이상 있어야 침입으로 판정\"),\r\n                seconds(\"rearm_seconds\", \"이탈 시간\", \"1.0\",\r\n                        \"한 번 이벤트 후 ROI 밖에서 이 시간 이상 있어야 새 침입으로 재인식\"),\r\n                seconds(\"grace_seconds\", \"끊김 허용 시간\", \"0.5\",\r\n                        \"탐지가 잠깐 끊겨도 같은 상태로 이어볼 허용 시간\"),\r\n                cooldown()));\r\n    }\r\n\r\n    /** 배회 설정 — 합의안 2. */\r\n    private static List<ScenarioParameterGroupSpec> loitering() {\r\n        return List.of(ungrouped(\r\n                confidence(\"person_confidence\", \"사람 신뢰도\", \"0.5\",\r\n                        \"사람 객체를 유효하게 볼 최소 confidence\"),\r\n                seconds(\"dwell_seconds\", \"체류 시간\", \"5.0\",\r\n                        \"ROI 안에 이 시간 이상 머물러야 배회로 판정\"),\r\n                seconds(\"grace_seconds\", \"끊김 허용 시간\", \"1.0\",\r\n                        \"사람 탐지가 잠깐 끊겨도 기존 배회 상태를 이어볼 허용 시간\"),\r\n                seconds(\"rearm_seconds\", \"이탈 시간\", \"1.0\",\r\n                        \"이벤트 후 ROI 밖에서 이 시간 이상 있어야 새 배회로 재인식\"),\r\n                cooldown()));\r\n    }\r\n\r\n    /**\r\n     * 쓰러짐 설정 — 합의안 3.\r\n     *\r\n     * <p>\r\n     * {@code pose_confidence} 는 합의안 샘플이 0.1 인데 실측 오탐 때문에 0.3 으로 올렸다.\r\n     */\r\n    private static List<ScenarioParameterGroupSpec> fall() {\r\n        return List.of(ungrouped(\r\n                confidence(\"person_confidence\", \"사람 신뢰도\", \"0.15\",\r\n                        \"Pose 모델에서 사람 자체를 유효하게 볼 최소 confidence\"),\r\n                confidence(\"pose_confidence\", \"관절 신뢰도\", \"0.3\",\r\n                        \"어깨·엉덩이·무릎 등 각 관절을 사용할 최소 score\"),\r\n                degrees(\"down_angle_deg\", \"기울기 기준\", \"50.0\",\r\n                        \"상체가 이 각도 이상 기울면 DOWN 자세로 보기 시작\"),\r\n                seconds(\"recovery_hold_seconds\", \"회복 유지 시간\", \"1.0\",\r\n                        \"다시 선 자세를 이 시간 이상 유지하면 FALL 상태를 해제\"),\r\n                cooldown()));\r\n    }\r\n\r\n    /**\r\n     * 싸움 설정 — 합의안 4.\r\n     *\r\n     * <p>\r\n     * {@code pose_confidence} 는 합의안 샘플이 0.1 인데 실측 오탐 때문에 0.3 으로 올렸다.\r\n     * {@code required_bursts} 만 합의안이 허용 범위(1~16)를 적어 두었다.\r\n     */\r\n    private static List<ScenarioParameterGroupSpec> fight() {\r\n        return List.of(ungrouped(\r\n                confidence(\"person_confidence\", \"사람 신뢰도\", \"0.15\",\r\n                        \"Pose 모델에서 사람 자체를 유효하게 볼 최소 confidence\"),\r\n                confidence(\"pose_confidence\", \"관절 신뢰도\", \"0.3\",\r\n                        \"손목·어깨·엉덩이 등 각 관절을 사용할 최소 score\"),\r\n                ratio(\"pair_distance_ratio\", \"근접 거리 비율\", \"1.8\",\r\n                        \"두 사람을 싸움 후보로 볼 최대 상대 거리\"),\r\n                seconds(\"pair_close_hold_seconds\", \"근접 유지 시간\", \"0.1\",\r\n                        \"가까운 상태를 최소 얼마 동안 유지해야 하는지\"),\r\n                ratio(\"arm_motion_ratio\", \"동작 크기 비율\", \"0.1\",\r\n                        \"손목 움직임을 공격성 motion으로 볼 최소 크기\"),\r\n                bursts(\"required_bursts\", \"공격 동작 횟수\", \"1\",\r\n                        \"공격성 motion이 몇 번 발생해야 FIGHT로 볼지.\"),\r\n                cooldown()));\r\n    }\r\n\r\n    /**\r\n     * 화재 설정 — 합의안 5.\r\n     *\r\n     * <p>\r\n     * Flame 조건과 Smoke 조건은 OR 다. 둘 다 만족해도 이벤트는 하나다. 그래서 두 벌의\r\n     * confidence·window·ratio 를 각각 따로 받는다.\r\n     *\r\n     * <p>\r\n     * {@code smoke_confidence} 는 합의안 샘플이 0.3 인데 실측 오탐 때문에 0.7 로 올렸다.\r\n     */\r\n    private static List<ScenarioParameterGroupSpec> fire() {\r\n        return List.of(ungrouped(\r\n                confidence(\"flame_confidence\", \"불꽃 신뢰도\", \"0.5\",\r\n                        \"불꽃 객체를 유효하게 볼 최소 confidence\"),\r\n                seconds(\"flame_window_seconds\", \"불꽃 관찰 시간\", \"2.0\",\r\n                        \"불꽃 여부를 판단할 최근 시간 구간\"),\r\n                ratio(\"flame_positive_ratio\", \"불꽃 감지 비율\", \"0.6\",\r\n                        \"관찰 시간 중 불꽃이 보여야 하는 최소 비율\"),\r\n                confidence(\"smoke_confidence\", \"연기 신뢰도\", \"0.7\",\r\n                        \"연기 객체를 유효하게 볼 최소 confidence\"),\r\n                seconds(\"smoke_window_seconds\", \"연기 관찰 시간\", \"3.0\",\r\n                        \"연기 여부를 판단할 최근 시간 구간\"),\r\n                ratio(\"smoke_positive_ratio\", \"연기 감지 비율\", \"0.5\",\r\n                        \"관찰 시간 중 연기가 보여야 하는 최소 비율\"),\r\n                seconds(\"clear_seconds\", \"해제 대기 시간\", \"5.0\",\r\n                        \"불꽃·연기가 사라진 상태가 이 시간 지속되면 FIRE 상태 해제\"),\r\n                cooldown()));\r\n    }\r\n\r\n    /**\r\n     * 군중 설정 — 합의안 6, 기준 인원과 status 비율은 군중 Web 연동 계약.\r\n     *\r\n     * <p>\r\n     * {@code reference_count} 기본값 50 은 계약 예시값(20)과 다르다. 기본값이 늘 실려 나가므로\r\n     * 따로 설정하지 않은 군중 카메라도 이 기준으로 DANGER Event 를 낸다.\r\n     *\r\n     * <p>\r\n     * status 비율 기본값은 계약 예시값이다. {@code safe_ratio} 는 AI 가 받아만 두고 판정에 쓰지\r\n     * 않는다. 비율 순서(caution &lt; warning &lt; danger)는 검사하지 않는다 - 어긋나면 AI 가\r\n     * 무효로 보고 자기 기본값으로 돌아가는 계약이다.\r\n     */\r\n    private static List<ScenarioParameterGroupSpec> crowd() {\r\n        return List.of(ungrouped(\r\n                confidence(\"person_confidence\", \"사람 신뢰도\", \"0.45\",\r\n                        \"군중 전용 person 객체를 유효하게 볼 최소 confidence\"),\r\n                confidence(\"head_confidence\", \"머리 신뢰도\", \"0.45\",\r\n                        \"head 객체를 유효하게 볼 최소 confidence\"),\r\n                seconds(\"window_seconds\", \"집계 관찰 시간\", \"3.0\",\r\n                        \"군중 수를 계산할 최근 시간 구간\"),\r\n                seconds(\"report_seconds\", \"갱신 주기\", \"3.0\",\r\n                        \"군중 집계 결과를 갱신·보고할 주기\"),\r\n                cooldown(),\r\n                number(\"reference_count\", \"기준 인원\", \"50\", 1.0, null, \"1\", \"명\",\r\n                        \"비율 계산 분모. 감지 인원 / 기준 인원 = 비율\"),\r\n                crowdRatio(\"safe_ratio\", \"안전\", \"0.25\", \"\"),\r\n                crowdRatio(\"caution_ratio\", \"주의\", \"0.5\", \"CAUTION 시작 비율\"),\r\n                crowdRatio(\"warning_ratio\", \"경고\", \"0.75\", \"WARNING 시작 비율\"),\r\n                crowdRatio(\"danger_ratio\", \"위험\", \"0.9\", \"DANGER 시작 비율이자 군중 Event 진입 기준\")));\r\n    }\r\n\r\n    private static ScenarioParameterGroupSpec ungrouped(ScenarioParameterFieldSpec... fields) {\r\n        return new ScenarioParameterGroupSpec(\"\", List.of(fields));\r\n    }\r\n\r\n    private static ScenarioParameterFieldSpec confidence(String name, String label, String defaultValue, String hint) {\r\n        return number(name, label, defaultValue, CONFIDENCE_MIN, CONFIDENCE_MAX, \"0.01\", \"\", hint);\r\n    }\r\n\r\n    private static ScenarioParameterFieldSpec seconds(String name, String label, String defaultValue, String hint) {\r\n        return number(name, label, defaultValue, null, null, \"0.1\", \"초\", hint);\r\n    }\r\n\r\n    private static ScenarioParameterFieldSpec ratio(String name, String label, String defaultValue, String hint) {\r\n        return number(name, label, defaultValue, null, null, \"0.01\", \"\", hint);\r\n    }\r\n\r\n    private static ScenarioParameterFieldSpec degrees(String name, String label, String defaultValue, String hint) {\r\n        return number(name, label, defaultValue, null, null, \"1\", \"°\", hint);\r\n    }\r\n\r\n    /** 6종 공통. 계약이 범위(0~300)와 서버 기본값(10)을 적어 두었다. 0 은 간격 제한 해제다. */\r\n    private static ScenarioParameterFieldSpec cooldown() {\r\n        return number(\"cooldown_seconds\", \"이벤트 간격\", \"10\", 0.0, 300.0, \"1\", \"초\",\r\n                \"카메라·시나리오별 새 Event 최소 간격. 0이면 간격 제한 없음\");\r\n    }\r\n\r\n    /** 군중 status 비율. 계약이 0~1 을 적어 두었다. */\r\n    private static ScenarioParameterFieldSpec crowdRatio(String name, String label, String defaultValue, String hint) {\r\n        return number(name, label, defaultValue, 0.0, 1.0, \"0.01\", \"\", hint);\r\n    }\r\n\r\n    /** 합의안이 범위를 적어 둔 항목이라 경계를 여기 박는다. */\r\n    private static ScenarioParameterFieldSpec bursts(String name, String label, String defaultValue, String hint) {\r\n        return number(name, label, defaultValue, 1.0, 16.0, \"1\", \"회\", hint);\r\n    }\r\n\r\n    /**\r\n     * {@code hint} 는 화면이 라벨의 {@code title} 로 그린다. 입력칸 아래 캡션 줄로 그리면\r\n     * 항목마다 줄이 하나씩 늘어 패널 길이가 두 배가 된다 - 고객사 원본도 캡션은 몇 항목에만\r\n     * 달았다. 라벨만으로 뜻이 통하지 않는 값이 여럿이라 설명 자체를 버리지는 않는다.\r\n     */\r\n    private static ScenarioParameterFieldSpec number(String name, String label, String defaultValue, Double minValue,\r\n            Double maxValue, String step, String unit, String hint) {\r\n        return new ScenarioParameterFieldSpec(name, label, ScenarioParameterFieldKind.NUMBER,\r\n                NODES.numberNode(new BigDecimal(defaultValue)), minValue, maxValue, step, unit, hint, List.of());\r\n    }\r\n}\r\n"
      }
    ]
  }
}
