Ctrl K
ComunidadGuías…

web/js/api.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* web/js/api.js
 *
 * Live-mode transport against the real backend, per docs/api-contract-v2.md.
 * As of this writing the backend does NOT implement /v2/* yet (only /query
 * and /health exist — see api/server.py, api/models.py). Every function
 * here is written against the frozen contract so it is ready the moment the
 * backend lands, but callers MUST treat a 404 on /v2/sessions as "backend
 * doesn't support v2 yet", not as a generic error — see main.js.
 *
 * No fabricated results: on any failure we throw or return a tagged error
 * object; we never synthesize a fake Turn to paper over an unreachable API.
 *
 * Authentication (Phase 8, docs/api-contract-v2.md §11): every route here
 * except GET /health requires `Authorization: Bearer <key>`. The key comes
 * from apikey.js (one per analyst, stored in that analyst's own browser —
 * see that module's docstring for why a single baked-in key is wrong for
 * this UI) and is attached in exactly one place, `_fetchV2`, so every v2
 * call site inherits it automatically. `health()` attaches it too when one
 * is already stored (it unlocks the `model` field — see api/server.py's
 * `/health` handler) but never requires it: liveness probes must keep
 * working with no credentials at all.
 */

"use strict";

import { getApiKey } from "./apikey.js";

/* ── Contract types (JSDoc only, no runtime effect) ───────────────────
 * Mirrors docs/api-contract-v2.md §4 and §6 so editors can typecheck
 * render code against the frozen shape without a build step.
 *
 * @typedef {Object} Assumption
 * @property {string} field
 * @property {string} value
 * @property {"question"|"session"|"default"|"policy"|"memory"} source
 * @property {boolean} editable
 *
 * @typedef {Object} Clarification
 * @property {string} field
 * @property {string} prompt
 * @property {string[]} options
 *
 * @typedef {Object} Ambiguity
 * @property {boolean} is_ambiguous
 * @property {Assumption[]} assumptions
 * @property {Clarification[]} clarifications
 *
 * @typedef {Object} Basis
 * @property {"fresh"|"refines"} kind
 * @property {string|null} refines_turn_id
 * @property {"cte"|"none"} composition
 * @property {string[]} inherited
 *
 * @typedef {Object} Guard
 * @property {"allowed"|"rejected"} verdict
 * @property {string|null} rule
 * @property {string|null} [rejected_sql]
 * @property {string|null} [rejected_sql_display]
 * @property {number|null} injected_top
 * @property {string[]} tables_touched
 *
 * @typedef {Object} ResultColumn
 * @property {string} name
 * @property {string} type
 *
 * @typedef {Object} Result
 * @property {ResultColumn[]} columns
 * @property {Object[]} rows
 * @property {number} row_count
 * @property {boolean} truncated
 * @property {boolean} [rows_omitted] -- true when this turn was loaded from
 *   a reopened conversation (GET /v2/sessions/{sid}) rather than just
 *   executed: the transcript endpoint does not persist result rows, so
 *   `rows` is empty here even though `row_count` is the real, accurate
 *   count. See render/table.js's `omitted` shape -- this must never be
 *   confused with an actual 0-row result.
 *
 * @typedef {Object} LlmStatus
 * @property {string} backend
 * @property {string} model
 * @property {string|null} endpoint
 * @property {boolean} trusted
 * @property {number} endpoint_status
 * @property {number} attempts
 * @property {"stop"|"length"|"content_filter"|"tool_calls"|"schema_violation"|"error"|string} finish_reason
 *   -- literal values per docs/api-contract-v2.md §6, or an "other:<raw>"
 *   passthrough for a value none of them anticipated.
 * @property {boolean} structured_output
 * @property {number} prompt_tokens
 * @property {number} completion_tokens
 * @property {number|null} prefill_ms
 * @property {number|null} decode_ms
 * @property {number|null} total_ms
 * @property {number|null} tokens_per_second
 * @property {boolean} prefix_cache_hit
 * @property {number} temperature
 * @property {number|null} seed
 * @property {boolean|null} seed_honored
 * @property {number} corrections
 * @property {string} provider
 * @property {boolean} fallback_used
 * @property {boolean} reasoning_detected
 * @property {number|null} ttft_ms
 * @property {number|null} generation_ms
 * @property {number|null} reasoning_tokens
 * @property {boolean} reasoning_tokens_estimated
 *
 * @typedef {Object} SessionSummary
 * @property {string} session_id
 * @property {string} title
 * @property {string} created_at
 * @property {string} last_active_at
 * @property {number} turn_count
 * @property {string} expires_at
 *
 * @typedef {Object} MemoryEntry
 * @property {string} key
 * @property {string} field
 * @property {string} value
 * @property {string} updated_at
 * @property {boolean} applicable
 *
 * @typedef {Object} RememberableField
 * @property {string} key
 * @property {string} field
 * @property {string[]} options
 * @property {number} max_length
 *
 * @typedef {Object} Turn
 * @property {string} turn_id
 * @property {string} session_id
 * @property {number} index
 * @property {string} question
 * @property {string|null} resolved_question
 * @property {Basis} basis
 * @property {string|null} sql
 * @property {string} [sql_display]
 * @property {Ambiguity} ambiguity
 * @property {Guard|null} guard
 * @property {Result|null} result
 * @property {string|null} interpretation
 * @property {"T0"|"T1"|"T2"|"T3"|null} tier
 * @property {string[]} warnings
 * @property {LlmStatus} llm
 * @property {Object} timings
 * @property {{code: string, message: string}|null} error
 */

export class V2NotSupportedError extends Error {
  constructor(message) {
    super(message);
    this.name = "V2NotSupportedError";
  }
}

/** `code` is the server's `error.code` (api/errors.py's `_error_response`
 * envelope — `MAINTENANCE_MODE`, `SERVER_OVERLOAD`, `INJECTION_ATTEMPT`,
 * ...) when this came back from a real HTTP response, or `null` when it
 * did not (an unparseable body, or a response this class was constructed
 * for without one). Threading it through here — rather than discarding it
 * the way this class used to — is what lets a catch site downstream (e.g.
 * main.js's `askTurnStream`) recover the REAL failure reason instead of
 * relabelling every non-transport ApiError as a generic transport
 * failure; see render/turn.js's `FAILURE_BY_CODE`, the one table that
 * turns a code into the Persian sentence an analyst actually reads. */
export class ApiError extends Error {
  constructor(message, status, code = null) {
    super(message);
    this.name = "ApiError";
    this.status = status;
    this.code = code;
  }
}

/** A 401: missing or invalid API key. Distinguished from a generic
 * ApiError so the UI can show "your key was rejected, enter a new one"
 * and re-prompt instead of a generic error banner (docs/api-contract-v2.md
 * §11.3 — the server returns the same UNAUTHENTICATED code whether the
 * key was never sent or was sent and rejected, so this UI does not try to
 * tell those two cases apart either). */
export class UnauthorizedError extends ApiError {
  constructor(message, code = "UNAUTHENTICATED") {
    super(message, 401, code);
    this.name = "UnauthorizedError";
  }
}

/** A 429: client-side rate limiting, not a query or model failure — see
 * api/middleware.py's RateLimitMiddleware. Carries the structured fields
 * from the error body (`retry_after_seconds`) so the UI can surface a
 * useful, specific message instead of a generic error. */
export class RateLimitError extends ApiError {
  constructor(message, retryAfterSeconds, code = "RATE_LIMIT_EXCEEDED") {
    super(message, 429, code);
    this.name = "RateLimitError";
    this.retryAfterSeconds = retryAfterSeconds;
  }
}

/** Turn a fetch rejection into something a reader can act on.
 *
 * A browser reports every pre-response failure as the same opaque
 * "Failed to fetch": connection refused, DNS failure, and a blocked
 * cross-origin request are indistinguishable to the page, deliberately —
 * telling a page *why* a cross-origin call was refused would itself leak
 * information about the other origin.
 *
 * That indistinguishability cost a real deployment an afternoon. The API
 * was healthy and the CLI was answering questions against it, while this
 * UI showed API, LLM and DB all down: the server's CORS allowlist was
 * empty, so the preflight came back "400 Disallowed CORS origin" and the
 * browser handed the page nothing but "Failed to fetch".
 *
 * So when the UI is talking to a different origin than the page it is
 * served from, say so and name the setting. The guess is stated as a
 * guess — a refused connection produces the identical error, and claiming
 * certainty about which one it is would just be a different lie.
 */
export function describeTransportFailure(baseUrl, path, err) {
  const base = `Network error calling ${path}: ${err.message}`;
  let crossOrigin = false;
  try {
    crossOrigin = new URL(baseUrl, location.href).origin !== location.origin;
  } catch { /* an unparseable baseUrl is its own, more obvious problem */ }
  if (!crossOrigin) return base;
  return (
    `${base}

` +
    `این صفحه از ${location.origin} سرو شده ولی به ${baseUrl} درخواست می‌دهد — ` +
    `یعنی cross-origin. مرورگر دلیل واقعی را به صفحه نمی‌گوید، ولی دو حالت ` +
    `محتمل است: بک‌اند بالا نیست، یا این origin در CORS_ALLOWED_ORIGINS ` +
    `بک‌اند مجاز نشده. برای دومی این را در .env بگذارید و سرور را ری‌استارت کنید:
` +
    `    CORS_ALLOWED_ORIGINS=${location.origin}`
  );
}

export class Api {
  /** @param {string} baseUrl */
  constructor(baseUrl) {
    this.baseUrl = baseUrl.replace(/\/+$/, "");
  }

  /* ── Health ──────────────────────────────────────────────────────── */
  /* Reads the REAL field names from api/models.py::HealthResponse
   * (status, openai, database, model). This drifted once already: an
   * earlier revision of this file read `h.openai` when the backend's
   * field was actually named `ollama`, then the backend was refactored
   * to a single OpenAI-compatible provider (commit 59dbc77) and the
   * field went back to `openai` — but this file had since been "fixed"
   * to read `h.ollama` against that now-superseded name, so it silently
   * went stale again and the LLM pill was permanently wrong in live mode
   * a second time. Reads `h.openai` here, matching the field that
   * actually exists on the response today. */
  async health(timeoutMs = 4000) {
    // Unauthenticated by design (docs/api-contract-v2.md §11.3 — GET
    // /health is the one route that stays open with no key at all, so a
    // liveness probe never depends on auth being configured correctly).
    // A key IS attached when one happens to be stored, purely so an
    // already-authenticated analyst sees the real `model` field instead
    // of it being omitted for "anonymous" callers (api/server.py's
    // `/health` handler) — its absence must never break this call.
    const headers = {};
    const key = getApiKey();
    if (key) headers["Authorization"] = `Bearer ${key}`;
    let res;
    try {
      res = await fetch(`${this.baseUrl}/health`, { signal: AbortSignal.timeout(timeoutMs), headers });
    } catch (err) {
      // No HTTP response at all -- unreachable host, DNS failure, or a
      // refused cross-origin preflight (CORS). Same distinction
      // `_fetchV2` draws for every other route, and the same reason: a
      // 500/404 (caught below) is a live backend answering with a real
      // problem, while THIS is the "three red lights on a healthy
      // backend" symptom `describeTransportFailure` exists for -- see
      // its own docstring for the real deployment incident.
      throw new ApiError(describeTransportFailure(this.baseUrl, "/health", err), 0, "TRANSPORT_ERROR");
    }
    if (!res.ok) throw new ApiError(`HTTP ${res.status}`, res.status);
    // `?? {}`: a body of JSON `null` must reach the shape check below as
    // "not the API's response", not as a TypeError on the next line.
    const h = (await res.json()) ?? {};
    return {
      api: true,
      llm: !!h.openai,
      db: !!h.database,
      status: h.status,
      model: h.model || null,
      // Why each light is what it is. A red indicator with no reason is
      // undiagnosable -- "unreachable host", "wrong key" and "this server
      // does not implement /models" look identical, and the last is not
      // even a fault. Optional: an older backend omits these.
      llmDetail: h.openai_detail || null,
      dbDetail: h.database_detail || null,
      // The parsed body, untouched. Whatever answers at this address can
      // return 200 and JSON that is not the API's health response (a
      // different service on the port); the caller judges that with
      // diagnosis.js's isApiHealthShape rather than this method throwing
      // on a body it cannot know the shape of for every backend version.
      raw: h,
    };
  }

  /* ── Sessions (v2, not yet implemented server-side) ────────────────── */
  async createSession() {
    const res = await this._fetchV2(`/v2/sessions`, { method: "POST" });
    return res.json();
  }

  /** The conversation list for the sidebar (contract §... "session
   * index"): `{sessions: SessionSummary[], total}`. Ordering is NOT
   * trusted from the server -- render/sessions.js re-sorts by
   * `last_active_at` itself, so this just returns the raw body. */
  async listSessions() {
    const res = await this._fetchV2(`/v2/sessions`, { method: "GET" });
    return res.json();
  }

  async getSession(sessionId) {
    const res = await this._fetchV2(`/v2/sessions/${encodeURIComponent(sessionId)}`, { method: "GET" });
    return res.json();
  }

  /** Inline rename from the sidebar. Returns the updated index entry
   * (SessionSummary), per the frozen PATCH /v2/sessions/{sid} contract. */
  async renameSession(sessionId, title) {
    const res = await this._fetchV2(`/v2/sessions/${encodeURIComponent(sessionId)}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    });
    return res.json();
  }

  async deleteSession(sessionId) {
    await this._fetchV2(`/v2/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
  }

  /**
   * Ask a non-streaming turn. Returns a Turn (contract §4).
   */
  async askTurn(sessionId, question, { interpret = false } = {}) {
    const res = await this._fetchV2(`/v2/sessions/${encodeURIComponent(sessionId)}/turns`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ question, interpret }),
    });
    return res.json();
  }

  /**
   * Ask a streaming turn (contract §7). Calls `onEvent(eventName, payload)`
   * for every SSE frame in order: stage, resolved, assumptions, sql_delta,
   * sql, rows, interpretation_delta, llm, done, error.
   *
   * fetch()+ReadableStream is used instead of EventSource because this is a
   * POST with a body, which EventSource cannot express.
   */
  async askTurnStreaming(sessionId, question, onEvent, { signal, interpret = false } = {}) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/turns?stream=1`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
        // `interpret` decides whether the server sends result rows to the
        // language model for a summary. Always sent explicitly rather than
        // omitted, so the request says what it wants instead of relying on
        // whatever the server's default happens to be.
        body: JSON.stringify({ question, interpret }),
        signal,
      },
    );
    if (!res.body) throw new ApiError("Streaming response has no body (browser or server issue).", res.status);

    const reader = res.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      let sepIndex;
      // SSE frames are separated by a blank line ("\n\n").
      while ((sepIndex = buffer.indexOf("\n\n")) !== -1) {
        const frame = buffer.slice(0, sepIndex);
        buffer = buffer.slice(sepIndex + 2);
        const parsed = parseSseFrame(frame);
        if (parsed) onEvent(parsed.event, parsed.data);
      }
    }
  }

  /** "این عدد درست نیست" (admin panel phase 4, spec §2) — flags one turn's
   * answer as wrong. `category` must be one of the closed set
   * appdb/feedback.py::FEEDBACK_CATEGORIES defines; `note` is optional
   * free text. Never sends the question or the SQL — the server joins
   * those from its own audit log by session/turn id, precisely so this
   * request never has to carry them. */
  async submitFeedback(sessionId, turnId, category, note) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/feedback`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ category, note }),
      },
    );
    return res.json();
  }

  /** "پاسخ درست است" — confirms one turn's answer as correct. No request
   * body: the principal is stamped server-side and the request id is
   * resolved from the audit record. Idempotent. The server answers 409 for
   * a turn that was not a successful answer. The response carries
   * `removed_flags` (an open flag of the caller's on this turn is removed:
   * the later action wins). */
  async confirmAnswer(sessionId, turnId) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/confirmation`,
      { method: "POST" },
    );
    return res.json();
  }

  /** Withdraws the caller's own confirmation of a turn. Safe to repeat. */
  async withdrawConfirmation(sessionId, turnId) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/confirmation`,
      { method: "DELETE" },
    );
    return res.json();
  }

  /** The turns of one session the caller has confirmed:
   * `{ confirmations: [...], turn_ids: [...] }`. One request per session,
   * so a reopened transcript shows each turn's confirmed state. */
  async listConfirmations(sessionId) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/confirmations`,
      { method: "GET" },
    );
    return res.json();
  }

  /** Questions from the deployment's examples.yaml to start a conversation
   * with: `{ examples: [{ question }] }`, question text only, already
   * filtered by the server for this key's denied columns. */
  async listExamples(limit = 6) {
    const res = await this._fetchV2(`/v2/examples?limit=${encodeURIComponent(limit)}`, { method: "GET" });
    return res.json();
  }

  /** "درخواست دسترسی" (ADR-004 part 1) -- requests access to the denied
   * column that caused a guard rejection on this turn. No request body:
   * the requester is stamped server-side from the authenticated
   * principal, and the column is resolved server-side from the joined
   * audit record -- this call has nothing to send but the turn's own
   * identifiers. Returns the stored (or, on dedup, the already-open)
   * request row plus `already_pending`. */
  async submitAccessRequest(sessionId, turnId) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/access-request`,
      { method: "POST" },
    );
    return res.json();
  }

  /** GET /v2/access-requests -- the caller's own access requests, across
   * every session, with current status and (once resolved) a denial
   * reason -- the minimal status read the account/menu UI shows. */
  async listAccessRequests() {
    const res = await this._fetchV2(`/v2/access-requests`, { method: "GET" });
    return res.json();
  }

  async patchAssumptions(sessionId, turnId, assumptions) {
    const res = await this._fetchV2(
      `/v2/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/assumptions`,
      {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ assumptions }),
      },
    );
    return res.json();
  }

  /* ── Memory (v2, standing preferences) ──────────────────────────────
   * Memory is created EXPLICITLY (the "pin this" affordance on an
   * editable assumption chip -- see render/assumptions.js), never
   * inferred here. */

  /** `{entries: MemoryEntry[], rememberable: RememberableField[]}` --
   * what IS currently remembered, and the closed set of fields that CAN
   * be. See render/memory.js's docstring for why both lists matter. */
  async getMemory() {
    const res = await this._fetchV2(`/v2/memory`, { method: "GET" });
    return res.json();
  }

  /** Set (or overwrite) one standing preference. A 422 (value fails
   * server-side validation -- length/options/control characters) comes
   * back as a plain ApiError via `_fetchV2`'s generic non-2xx branch;
   * its `message` is the server's real validation message, rendered
   * as-is by the caller -- this module never invents its own wording to
   * replace it. */
  async putMemory(key, value) {
    const res = await this._fetchV2(`/v2/memory/${encodeURIComponent(key)}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ value }),
    });
    return res.json();
  }

  async deleteMemoryEntry(key) {
    await this._fetchV2(`/v2/memory/${encodeURIComponent(key)}`, { method: "DELETE" });
  }

  /** Clears every standing preference at once (the memory panel's
   * "پاک کردن همهٔ حافظه" control -- always behind a confirm in the UI). */
  async clearMemory() {
    await this._fetchV2(`/v2/memory`, { method: "DELETE" });
  }

  /** Wraps fetch for every /v2/* endpoint. All of these routes require
   * auth (docs/api-contract-v2.md §11.3), so this is the single place
   * that attaches `Authorization: Bearer <key>` — every v2 call site
   * (createSession, getSession, deleteSession, askTurn, askTurnStreaming,
   * patchAssumptions) goes through here and inherits it automatically.
   *
   * A 404 is promoted to V2NotSupportedError so callers can degrade to
   * simulated mode instead of showing a generic error. A 401 and a 429
   * are promoted to their own typed errors (see above) so the UI can
   * treat "your key was rejected" and "you're being rate-limited" as the
   * distinct, actionable situations they are instead of a generic error
   * banner. Every v2 call site routes through here, including the
   * session-index and memory endpoints added later (listSessions,
   * renameSession, getMemory, putMemory, deleteMemoryEntry,
   * clearMemory) -- same chokepoint, same auth guarantee. */
  async _fetchV2(path, init) {
    let res;
    const headers = { ...(init && init.headers) };
    const key = getApiKey();
    if (key) headers["Authorization"] = `Bearer ${key}`;
    try {
      res = await fetch(`${this.baseUrl}${path}`, {
        signal: AbortSignal.timeout(60000),
        ...init,
        headers,
      });
    } catch (err) {
      // No HTTP response was ever received (host unreachable, DNS
      // failure, a refused cross-origin preflight, ...) — the one
      // genuinely transport-level failure this method can throw, so it
      // is the one case that gets the "TRANSPORT_ERROR" code explicitly
      // rather than reading one off a body that does not exist.
      throw new ApiError(describeTransportFailure(this.baseUrl, path, err), 0, "TRANSPORT_ERROR");
    }
    if (res.status === 404) {
      throw new V2NotSupportedError(
        `${this.baseUrl} does not implement ${path} — the v2 conversational API is not deployed on this backend yet.`,
      );
    }
    if (res.status === 401) {
      const body = await _safeJson(res);
      throw new UnauthorizedError(
        body?.error?.message || body?.detail || "Missing or invalid API key.",
        body?.error?.code || "UNAUTHENTICATED",
      );
    }
    if (res.status === 429) {
      const body = await _safeJson(res);
      const message = body?.error?.message || body?.detail || `HTTP 429 calling ${path}`;
      const retryAfterSeconds = body?.error?.retry_after_seconds ?? null;
      throw new RateLimitError(message, retryAfterSeconds, body?.error?.code || "RATE_LIMIT_EXCEEDED");
    }
    if (!res.ok) {
      const body = await _safeJson(res);
      // The real error envelope (api/errors.py::_error_response) nests
      // the message under `error.message`, not a top-level `detail` —
      // `j.detail` alone (the previous shape of this fallback) never
      // actually matched it, so every non-2xx response rendered as a
      // bare "HTTP <status>" regardless of what the server said.
      const detail = body?.error?.message || body?.detail || `HTTP ${res.status}`;
      // `body?.error?.code` (e.g. "MAINTENANCE_MODE", "SERVER_OVERLOAD",
      // "INJECTION_ATTEMPT") is carried on the thrown error so a catch
      // site downstream can render the real reason instead of a generic
      // one — see the `code` field's own docstring on `ApiError` above.
      throw new ApiError(detail, res.status, body?.error?.code || null);
    }
    return res;
  }
}

/** Best-effort JSON body read that never throws — an error response body
 * is not guaranteed to be valid JSON (or present at all). */
async function _safeJson(res) {
  try {
    return await res.json();
  } catch {
    return null;
  }
}

function parseSseFrame(frame) {
  let event = "message";
  const dataLines = [];
  for (const line of frame.split("\n")) {
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
  }
  if (dataLines.length === 0) return null;
  const raw = dataLines.join("\n");
  try {
    return { event, data: JSON.parse(raw) };
  } catch {
    return { event, data: raw };
  }
}