openapi: 3.0.3
info:
  title: Repzo API - AI Object Detection Metric Result
  version: 1.0.0
  description: |
    **Metric results** — one document per (analysis, metric), holding TWO
    deliberately separate value layers:

    - `computed` — what the engine calculated from the AI analysis (always
      includes `answer` + `score` 0..1). Machine-owned: every evaluation
      replaces it and humans can NEVER modify it through the API.
    - `overwrite` + `flag` + `confirmed_edit` — the human layer: sparse VALUE
      overrides of the type's overwritable keys (ratio and score are DERIVED
      from them server-side, never accepted), a boolean attention marker,
      and the admin review gate. Human-owned: evaluations never touch them,
      so an override keeps ruling across recalculations.

    Both layers are STRICT per-type shapes (Mongoose embedded discriminators
    keyed by the metric type — the promotions model pattern); the stored
    subdocuments carry an internal `type` mirror. No loose payloads.

    **Output families** fix what `answer` means (`output` on the document):
    - `compatibility` (adjacent_block) — `answer` is a boolean verdict;
      score = answer ? 1 : 0.
    - `numerical` (facings_count, on_shelf_availability) — `answer` is the
      collected value. BOTH types accept an optional `target_answer`: the
      score is then answer ÷ target (clamped to 1). Without one, OSA returns
      its own factor (availability = answer ÷ total, so mission points =
      weight × availability) and facings_count falls back to the family
      default: a NON-ZERO answer earns full score, a missing or zero answer
      scores 0.
    - `share_of_shelf` — `answer` is the MAIN segment's measured quantity
      (cm, cm² or facings), `total` the CONSIDERED CATEGORY (the same
      measure over facings whose label belongs to ANY of the metric's
      segments — the rest of the shelf never dilutes the share), `ratio`
      their quotient, `target_answer` = target_ratio × total, and score =
      the main segment's min(1, ratio / target_ratio). Every segment row
      (main + context) is reported as a SegmentOutput in
      `computed.segments[]`.

    `score` / `answer` / `ratio` on the document are the DENORMALIZED
    EFFECTIVE values — the CONFIRMED `overwrite.*` when `confirmed_edit` is
    true, else `computed.*` — the numbers mission scoring, widgets and
    analytics read. An unconfirmed override is stored and visible but does
    NOT change them.

    **How results appear.** Automatically: every successful session analysis
    (and every compose-only shelf recompute) evaluates the MISSION-ASSIGNED
    metrics — the stamped mission's list for "SCAN THIS MISSION" sessions,
    the union over the client's currently-assigned missions for generic
    scans. Sessions no mission covers get NO results. Manually:
    `POST { analysis }` recalculates the same assigned set on demand —
    idempotent and safe (an optional `metrics[]` subset can only NARROW the
    assigned set). `source_fingerprints` snapshots the analysis's stage
    fingerprints at evaluation time, so a mismatch marks the result stale.
    Each result also carries a denormalized SCAN CONTEXT (`user`/`user_name`
    = the scanning rep, `client`/`client_name`, `teams`, `visit_id`,
    `route`, `business_day`) in the activity-model shape so shared
    dashboard filters work unchanged.

    **Re-analysis & the human layer.** A NEW analysis of the same session
    CARRIES the previous analysis's human layer forward (flag,
    confirmed_edit, and the entered override VALUES — an override asserts
    the SCENE's truth, not one machine attempt's), with the derived
    ratio/score re-computed against the fresh machine values. Every
    evaluation and every update here also re-syncs the STORED mission
    results (`/ai-object-detection-mission-results`) of the analysis.

    **Multi-tenancy, roles & lifecycle.** Scoped by `company_namespace`
    (inherited from the analysis — there is no namespace-injection hook and
    no create body field for it), soft-deleted via `disabled`. `update`
    accepts ONLY `flag`, `overwrite` and (admin-only) `confirmed_edit`;
    `PATCH` is not supported (400). ADMINS have full access. REPS read
    results of THEIR OWN sessions (a `session` or `analysis` filter is
    required on list reads), may raise `flag` (never clear it) and may
    submit value overrides — a rep override is auto-flagged and stays
    PENDING (`confirmed_edit: false`, not effective) until an admin confirms
    it; recalculation and removal are admin-only. Optional population of
    `metric`, `analysis` and `session` via `populatedKeys[]` (the populated
    document replaces the id in place).
servers:
  - url: https://sv.api.repzo.me
security:
  - ApiKeyAuth: []
  - JwtAuth: []
paths:
  /ai-object-detection-metric-result:
    get:
      summary: List metric results
      operationId: findAiObjectDetectionMetricResult
      parameters:
        - in: query
          name: _id
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: analysis
          description: Filter by the analysis _id (the common read). Required for rep tokens unless `session` is given.
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: session
          description: Filter by session _id. Rep tokens must own the session.
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: metric
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: user
          description: Filter by the SCANNING rep's id (denormalized scan context).
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: client
          description: Filter by the scanned client's id (denormalized scan context).
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: teams
          description: "Filter by the scanning rep's team id(s) — pass once or as `?teams[]=`."
          schema:
            oneOf:
              - type: string
              - type: array
                items: { type: string }
        - in: query
          name: type
          schema:
            type: string
            enum:
              [
                adjacent_block,
                facings_count,
                on_shelf_availability,
                share_of_shelf,
              ]
        - in: query
          name: flag
          description: Only flagged (or unflagged) results.
          schema: { type: boolean }
        - in: query
          name: status
          schema:
            type: string
            enum: [ok, error]
        - in: query
          name: search
          description: Case-insensitive substring search on the metric-name snapshot `name`.
          schema: { type: string }
        - in: query
          name: disabled
          description: "Include soft-deleted results (`true`) or only live ones (`false`). Omitted = no filter."
          schema: { type: boolean }
        - in: query
          name: from_updatedAt
          schema: { type: number }
        - in: query
          name: to_updatedAt
          schema: { type: number }
        - in: query
          name: from_createdAt
          schema: { type: number }
        - in: query
          name: to_createdAt
          schema: { type: number }
        - in: query
          name: populatedKeys
          description: "Embed refs in place of the id. Supported: `metric`, `analysis`, `session`."
          schema:
            type: array
            items:
              type: string
              enum: [metric, analysis, session]
        - in: query
          name: per_page
          schema: { type: integer, minimum: 1 }
        - in: query
          name: page
          schema: { type: integer, minimum: 1 }
        - in: query
          name: sort
          description: Field to sort by (default `_id`).
          schema: { type: string }
        - in: query
          name: sortPageOrder
          schema:
            type: string
            enum: [asc, dsc]
      responses:
        "200":
          description: Paginated results.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetricResultFindResult"
        "403":
          description: Rep token without a `session`/`analysis` filter, or the session is not the rep's own.
    post:
      summary: Calculate / recalculate metrics for an analysis (admin)
      description: |
        Evaluates every ENABLED, MISSION-ASSIGNED metric (or the given subset
        of them) against the analysis and upserts one result per metric.
        Idempotent: `computed` is replaced, standing `flag`/`overwrite`
        values are preserved and keep ruling the effective `score` while
        confirmed. Also upserts the stored mission results of the session.
        The tenant namespace comes from the caller's token — there is no
        `company_namespace` body field.
      operationId: createAiObjectDetectionMetricResult
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [analysis]
              properties:
                analysis:
                  type: string
                  description: The SUCCESSFUL analysis to evaluate.
                metrics:
                  type: array
                  items: { type: string }
                  description: Optional metric-id subset (default = every mission-assigned enabled metric; can only narrow that set).
      responses:
        "201":
          description: Evaluation summary + the fresh result documents.
          content:
            application/json:
              schema:
                type: object
                properties:
                  analysis: { type: string }
                  evaluated:
                    type: number
                    description: Metrics evaluated.
                  errors:
                    type: number
                    description: Metrics whose evaluation or persistence failed.
                  results:
                    type: array
                    items:
                      $ref: "#/components/schemas/MetricResult"
        "400":
          description: "`analysis` missing, not found, or not in `success` status."
        "403":
          description: Rep tokens may not recalculate.
  /ai-object-detection-metric-result/{id}:
    get:
      summary: Get a metric result
      operationId: getAiObjectDetectionMetricResult
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The result document (refs are never populated on this read).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetricResult"
        "400":
          description: Not found.
        "403":
          description: Rep token — the result's session is not the rep's own.
    put:
      summary: Human override — flag / overwrite / confirmed_edit
      description: |
        The ONLY writable fields are `flag`, `overwrite` and (admin-only)
        `confirmed_edit`. `overwrite` accepts VALUE keys only — `ratio` and
        `score` are rejected and instead DERIVED server-side from the
        effective values by the type's own rule (SOS: ratio = answer ÷ total,
        score = ratio ÷ target_ratio clamped; OSA: ratio = answer ÷ total,
        score = answer ÷ target or the ratio; facings: answer ÷ target or
        existence; compatibility: answer ? 1 : 0). `computed` is immutable
        from the API — the AI-calculated values always remain visible next to
        the override. Enterable keys per type:
        - `adjacent_block`: `answer` (boolean), `member_count`, `cut_count`
        - `facings_count`: `answer` (number)
        - `on_shelf_availability`: `answer` (number)
        - `share_of_shelf`: `answer`, `total`

        REVIEW FLOW: any fresh `overwrite` resets `confirmed_edit` to false
        (pending) unless the same ADMIN request sets it true; the override
        only drives the effective `score` while confirmed. REP rules: only
        results of the rep's own sessions; `flag` may be raised but not
        cleared; sending a value override auto-raises `flag`;
        `confirmed_edit` is rejected. The stored mission results of the
        analysis are re-synced afterwards.
      operationId: updateAiObjectDetectionMetricResult
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                flag: { type: boolean }
                overwrite:
                  $ref: "#/components/schemas/OverwriteInput"
                confirmed_edit:
                  type: boolean
                  description: "ADMIN-ONLY review verdict — true makes the standing overwrite effective."
      responses:
        "200":
          description: The updated result (derived overwrite ratio/score stamped; effective `score`/`answer`/`ratio` recomputed per `confirmed_edit`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetricResult"
        "400":
          description: "Non-overwritable key, `ratio`/`score` sent, wrong value type, or `overwrite` not an object."
        "403":
          description: Rep clearing `flag`, sending `confirmed_edit`, or touching another rep's session.
        "404":
          description: Not found.
    delete:
      summary: Soft-delete a metric result (admin)
      operationId: removeAiObjectDetectionMetricResult
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The disabled result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MetricResult"
        "403":
          description: Rep tokens may not remove results.
        "404":
          description: Not found.
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: api-key
    JwtAuth:
      type: apiKey
      in: header
      name: Authorization
  schemas:
    UserStamp:
      type: object
      description: "Who created / last edited the document (server-stamped from the token)."
      properties:
        _id: { type: string }
        name: { type: string }
        type:
          type: string
          enum: [admin, rep, client, tenant]
        admin: { type: string }
        rep: { type: string }
        client: { type: string }
        tenant: { type: string }
    AdjacentBlockComputed:
      type: object
      description: "Computed shape for `adjacent_block` (compatibility)."
      properties:
        type:
          type: string
          enum: [adjacent_block]
          description: Internal discriminator mirror.
        output:
          type: string
          enum: [compatibility]
          description: Family tag stamped by the engine.
        answer:
          type: boolean
          description: The verdict — contiguous AND count within [from, to].
        score: { type: number, minimum: 0, maximum: 1 }
        member_count: { type: number }
        cut_count: { type: number }
        in_range: { type: boolean }
        blocks:
          type: array
          items:
            type: object
            properties:
              shelf: { type: number }
              from_stack: { type: number }
              to_stack: { type: number }
              count: { type: number }
    FacingsCountComputed:
      type: object
      description: "Computed shape for `facings_count` (numerical)."
      properties:
        type:
          type: string
          enum: [facings_count]
        output:
          type: string
          enum: [numerical]
          description: Family tag stamped by the engine.
        answer:
          type: number
          description: The collected facings count.
        target_answer:
          type: number
          description: "Echo of the metric's optional target — present only when set; score then = answer ÷ target_answer (clamped)."
        score: { type: number, minimum: 0, maximum: 1 }
        shelves:
          type: array
          items:
            type: object
            properties:
              shelf: { type: number }
              count: { type: number }
    OnShelfAvailabilityComputed:
      type: object
      description: "Computed shape for `on_shelf_availability` (numerical)."
      properties:
        type:
          type: string
          enum: [on_shelf_availability]
        output:
          type: string
          enum: [numerical]
          description: Family tag stamped by the engine.
        answer:
          type: number
          description: "How many of the selected labels are AVAILABLE (≥ 1 facing)."
        score:
          type: number
          minimum: 0
          maximum: 1
          description: "answer ÷ target_answer when a target is set, else `ratio` (the availability factor)."
        target_answer:
          type: number
          description: "Echo of the metric's optional target — present only when set."
        total:
          type: number
          description: How many labels were selected (the denominator).
        ratio:
          type: number
          description: answer / total.
        missing:
          type: array
          description: "The out-of-stock labels — the actionable list (names resolved at evaluation time)."
          items:
            type: object
            properties:
              label: { type: string }
              name: { type: string }
        present:
          type: array
          description: The available labels with their facing counts.
          items:
            type: object
            properties:
              label: { type: string }
              name: { type: string }
              facings: { type: number }
    SegmentOutput:
      type: object
      description: |
        One segment row's outcome inside a share-of-shelf evaluation.
        Self-contained on purpose: embedded in `computed.segments[]` today
        but ready to move to its own collection and to be reused by future
        SOS-family metric types. Only the MAIN row carries `target_ratio`,
        `target_answer` and `score`.
      properties:
        segment: { type: string }
        name: { type: string }
        resolved:
          type: boolean
          description: false = the segment was deleted after the metric referenced it.
        main:
          type: boolean
          description: The row the metric's target is defined for — exactly one per metric.
        labels:
          type: array
          items: { type: string }
          description: The effective labels used (override or the segment's own).
        answer:
          type: number
          description: Measured quantity in the metric's measure unit (cm or cm²).
        overwrite_answer:
          type: number
          description: "The CONFIRMED answer override, colocated in the row (effective = overwrite_answer ?? answer) — present only while confirmed_edit is true. Source of truth is the result's `overwrite.answer`; the service keeps this copy in sync across override saves, confirmations and recalculations."
        overwrite_ratio:
          type: number
          description: "The confirmed override's derived ratio, colocated with overwrite_answer (same lifecycle)."
        overwrite_score:
          type: number
          description: "The confirmed override's derived score, colocated with overwrite_answer (same lifecycle)."
        ratio:
          type: number
          description: answer / category total — rows sum to 1 when segments don't overlap.
        target_ratio:
          type: number
          description: Main row only — the target share this segment must reach.
        target_answer:
          type: number
          description: "Main row only — target_ratio × total: the quantity needed to hit the target."
        score:
          type: number
          minimum: 0
          maximum: 1
          description: "Main row only — attainment vs the target (share_of_shelf: min(1, ratio / target_ratio)). Missions multiply it by their weight."
    ShareOfShelfComputed:
      type: object
      description: "Computed shape for `share_of_shelf` — the MAIN segment's numbers at the top level, every row in `segments[]`."
      properties:
        type:
          type: string
          enum: [share_of_shelf]
        output:
          type: string
          enum: [share_of_shelf]
          description: Family tag stamped by the engine.
        answer:
          type: number
          nullable: true
          description: "The MAIN segment's measured quantity (cm or cm²); null when nothing was measurable."
        score:
          type: number
          minimum: 0
          maximum: 1
          description: "= the main segment's score: min(1, ratio / target_ratio)."
        total:
          type: number
          description: "The same measure over the CONSIDERED CATEGORY: facings whose label belongs to any of the metric's segments."
        ratio:
          type: number
          description: answer / total — the main segment's share.
        target_ratio:
          type: number
          description: The target the MAIN segment must meet (from the metric args).
        target_answer:
          type: number
          description: "target_ratio × total — what the main segment needed, in measure units."
        measure:
          type: string
          enum: [width_cm, area_cm2, facings]
        unmeasured:
          type: number
          description: Considered facings without a physical size (excluded from both sides).
        segments:
          type: array
          description: One SegmentOutput per metric row (the main one flagged + scored).
          items:
            $ref: "#/components/schemas/SegmentOutput"
    Overwrite:
      type: object
      description: "Stored human layer — the entered VALUE keys plus the server-DERIVED `ratio`/`score`, strict per type (plus the internal `type` mirror). Which keys exist depends on the type: adjacent_block `answer` (boolean) / `member_count` / `cut_count` / `score`; facings_count `answer` / `score`; on_shelf_availability `answer` / `ratio` / `score`; share_of_shelf `answer` / `total` / `ratio` / `score`."
      properties:
        type:
          type: string
          enum:
            [
              adjacent_block,
              facings_count,
              on_shelf_availability,
              share_of_shelf,
            ]
        answer:
          description: boolean for adjacent_block, number otherwise.
          oneOf:
            - type: boolean
            - type: number
        member_count: { type: number }
        cut_count: { type: number }
        total: { type: number }
        ratio: { type: number }
        score: { type: number, minimum: 0, maximum: 1 }
    OverwriteInput:
      type: object
      description: "Sparse VALUE overrides — only the type's overwritable keys; `ratio`/`score` are rejected (derived). Send `{}` to clear the override."
      properties:
        answer:
          description: "adjacent_block: boolean verdict; other types: number."
          oneOf:
            - type: boolean
            - type: number
        member_count:
          type: number
          description: adjacent_block only.
        cut_count:
          type: number
          description: adjacent_block only.
        total:
          type: number
          description: share_of_shelf only.
    MetricResult:
      type: object
      properties:
        _id: { type: string }
        disabled: { type: boolean }
        metric:
          type: string
          description: "Metric id (or the populated metric document when `populatedKeys` includes `metric`)."
        analysis:
          type: string
          description: "Analysis id (or the populated document when requested)."
        session:
          type: string
          description: "Session id (or the populated document when requested)."
        name:
          type: string
          description: Metric name snapshot at evaluation time.
        type:
          type: string
          enum:
            [
              adjacent_block,
              facings_count,
              on_shelf_availability,
              share_of_shelf,
            ]
        output:
          type: string
          enum: [compatibility, numerical, share_of_shelf]
          description: The type's output family (denormalized for rendering).
        args:
          type: object
          description: Args snapshot at evaluation time.
        computed:
          description: "Machine layer — strict per-type engine output incl. `answer` + `score` 0..1 (plus an internal `type` mirror). Never editable. Absent when the evaluation errored."
          oneOf:
            - $ref: "#/components/schemas/AdjacentBlockComputed"
            - $ref: "#/components/schemas/FacingsCountComputed"
            - $ref: "#/components/schemas/OnShelfAvailabilityComputed"
            - $ref: "#/components/schemas/ShareOfShelfComputed"
        user:
          type: string
          nullable: true
          description: "Denormalized scan context (activity-model shape): the SCANNING rep's id — unset for admin-scanned sessions. Stamped by the evaluator; feeds the dashboard widgets' rep_id filter."
        user_name: { type: string, nullable: true }
        client:
          type: string
          nullable: true
          description: "The scanned client's id (widget client.* filters inflate it in their header stages)."
        client_name: { type: string, nullable: true }
        teams:
          type: array
          items: { type: string }
          description: "The scanning rep's team ids at evaluation time (team-shared dashboards)."
        visit_id:
          type: string
          description: Device visit id of the visit the scan happened in (copied from the session).
        route:
          type: string
          description: "`sv.routes` `_id` of the visit's route."
        business_day:
          type: string
          description: "Business day of the scan, `YYYY-MM-DD`."
        overwrite:
          $ref: "#/components/schemas/Overwrite"
        flag: { type: boolean }
        confirmed_edit:
          type: boolean
          description: "Admin review gate — the overwrite only drives the effective score while true. A fresh overwrite resets it to false; reps can never set it."
        score:
          type: number
          description: "EFFECTIVE score 0..1 (confirmed `overwrite.score`, else `computed.score`)."
        answer:
          description: "EFFECTIVE answer, reconciled like `score` (confirmed override wins) — boolean verdict or number per the output family. What widgets/analytics aggregate."
          nullable: true
          oneOf:
            - type: boolean
            - type: number
        ratio:
          type: number
          nullable: true
          description: "EFFECTIVE ratio, reconciled like `score` (OSA availability / SOS share; null for types without one)."
        status:
          type: string
          enum: [ok, error]
        error: { type: string, nullable: true }
        evaluated_at:
          type: number
          description: Unix ms of the evaluation.
        engine_version: { type: number }
        source_fingerprints:
          type: object
          description: The analysis's stage fingerprints at evaluation time — mismatch vs the analysis's current ones = stale result.
        creator:
          $ref: "#/components/schemas/UserStamp"
        editor:
          $ref: "#/components/schemas/UserStamp"
        company_namespace:
          type: array
          items: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    MetricResultFindResult:
      type: object
      description: Standard paginated result envelope.
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/MetricResult"
        total_result: { type: number }
        current_count: { type: number }
        total_pages: { type: number }
        current_page: { type: number }
        per_page: { type: number }
        first_page_url: { type: string }
        last_page_url: { type: string }
        next_page_url: { type: string, nullable: true }
        prev_page_url: { type: string, nullable: true }
        path: { type: string }
