rohitsar567's picture
feat!: remove cross-session profile recall (ADR-043) β€” net βˆ’3700 LOC
6d5684e
Raw
History Blame Contribute Delete
26.6 kB
// Typed client for the FastAPI backend.
// Backend URL is configurable via NEXT_PUBLIC_BACKEND_URL so we can point at
// localhost in dev and at Render in production.
// In production (HF Spaces deploy): empty -> same-origin requests, no CORS.
// In dev: set NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 in frontend/.env.local
export const BACKEND_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "";
export type Citation = {
policy_id: string;
policy_name: string;
insurer_slug: string;
page_start: number;
page_end: number;
source_url: string;
score: number;
};
export type ChatResponse = {
reply_text: string;
citations: Citation[];
brain_used: string;
intent: string;
language: string;
latency_ms: number;
session_id: string;
audio_base64?: string | null;
// V3 #4 β€” backend MAY echo the actual mime it produced (e.g. "audio/mp4")
// when the client requested a codec other than the wav default. The
// frontend uses this when constructing the playback Blob URL so Safari
// doesn't refuse to play an mp4 payload labelled as wav.
audio_mime?: string | null;
// Closed-enum TTS failure code. When voice output fails the backend
// classifies it (same contract as the STT path) and ships a user-facing
// message the chat UI renders inline under the bubble. Absent on success.
tts_error_code?:
| "rate_limit"
| "service_unavailable"
| "network"
| "auth"
| "unknown"
| null;
tts_user_message?: string | null;
faithfulness_passed?: boolean;
faithfulness_reasons?: string[];
blocked?: boolean;
// KI-Z7 (2026-05-15) β€” Feature B. True when single_brain.handle_turn's
// turn-1 name heuristic matched a stored named-profile and hydrated the
// live session. Frontend renders a "Welcome back" banner when this is
// true on the assistant turn it arrives with.
returning_user_recalled?: boolean;
};
export type ChatMessage = {
role: "user" | "assistant";
content: string;
};
export type ViewContext = {
// Which top-level panel the user is currently focused on. The chat treats
// this as the "screen" the copilot can see β€” answers can reference what the
// user is looking at without them having to re-state it.
active_view: "chat" | "marketplace" | "profile" | "premium" | "policy_detail";
// Policy ID currently open in a detail modal, if any.
active_policy_id?: string;
// Optional marketplace filters (forwarded for personalization signals).
filters?: Record<string, unknown>;
};
/** HF Space's first request after ~15min idle takes ~50s for cold-start.
* Add retry-with-backoff so a single "Load failed" doesn't surface as an
* error in the chat β€” instead we wait and try again silently. AbortError
* is NOT retried (it's intentional cancellation from Live mode's barge-in).
*/
async function _fetchWithRetry(
url: string,
init: RequestInit,
signal: AbortSignal | undefined,
onRetry?: (attempt: number) => void,
): Promise<Response> {
const retryDelaysMs = [1500, 3500, 7000];
let lastErr: unknown = null;
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
try {
const resp = await fetch(url, { ...init, signal });
if (resp.status >= 500 && attempt < retryDelaysMs.length) {
// 502/503 commonly means HF Space cold-start; retry
await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]));
onRetry?.(attempt + 1);
continue;
}
return resp;
} catch (e) {
const name = (e as { name?: string })?.name;
if (name === "AbortError") throw e;
lastErr = e;
if (attempt < retryDelaysMs.length) {
await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]));
onRetry?.(attempt + 1);
continue;
}
}
}
throw lastErr ?? new Error("network failed after retries");
}
export async function postChat(args: {
user_text: string;
session_id?: string;
chat_history?: ChatMessage[];
profile?: Record<string, unknown>;
policy_filter_ids?: string[];
return_audio?: boolean;
tts_language_code?: string;
view_context?: ViewContext;
// V3 #4 β€” Safari has no webm/opus support. Caller passes its preferred
// codec ("audio/webm; codecs=opus" or "audio/mp4") and the backend SHOULD
// honour it on the TTS payload. Sent as a header (`X-Preferred-Codec`)
// AND included in the body for backends that ignore custom headers.
preferred_codec?: string;
signal?: AbortSignal;
onRetry?: (attempt: number) => void;
}): Promise<ChatResponse> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (args.preferred_codec) headers["X-Preferred-Codec"] = args.preferred_codec;
const resp = await _fetchWithRetry(
`${BACKEND_URL}/api/chat`,
{
method: "POST",
headers,
body: JSON.stringify({
user_text: args.user_text,
session_id: args.session_id,
chat_history: args.chat_history ?? [],
profile: args.profile ?? {},
policy_filter_ids: args.policy_filter_ids,
return_audio: args.return_audio ?? false,
tts_language_code: args.tts_language_code ?? "en-IN",
view_context: args.view_context,
preferred_codec: args.preferred_codec,
}),
},
args.signal,
args.onRetry,
);
if (!resp.ok) {
const t = await resp.text();
throw new Error(`chat failed: ${resp.status} ${t}`);
}
return resp.json();
}
// KI-242 β€” Backend now returns a clean error_code + user_message on STT
// failures (HTTP 200 with empty text). Frontend consumes these directly
// instead of parsing raw httpx text. error_code is a closed enum:
// rate_limit | service_unavailable | network | auth | unknown.
export type TranscribeResponse = {
text: string;
language_code?: string;
latency_ms: number;
error_code?: "rate_limit" | "service_unavailable" | "network" | "auth" | "unknown";
user_message?: string;
};
export async function postTranscribe(
blob: Blob,
language_code?: string,
signal?: AbortSignal,
): Promise<TranscribeResponse> {
const fd = new FormData();
// Use blob's mime to derive extension; default to wav
const mime = blob.type || "audio/wav";
// KI-134 (2026-05-15) β€” iOS Safari MediaRecorder produces audio/mp4 (no
// webm support). Without explicit mapping the file was sent as audio.wav
// with mp4 bytes inside, breaking the backend's mime/ext whitelist.
const ext = mime.includes("webm")
? "webm"
: mime.includes("mp4") || mime.includes("m4a")
? "m4a"
: mime.includes("mp3")
? "mp3"
: mime.includes("ogg")
? "ogg"
: "wav";
fd.append("file", blob, `audio.${ext}`);
if (language_code) fd.append("language_code", language_code);
const resp = await fetch(`${BACKEND_URL}/api/transcribe`, {
method: "POST",
body: fd,
signal,
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`transcribe failed: ${resp.status} ${t}`);
}
return resp.json();
}
export async function getHealth(): Promise<{
status: string;
providers_ok: Record<string, boolean>;
missing_keys: string[];
}> {
const resp = await fetch(`${BACKEND_URL}/api/health`);
if (!resp.ok) throw new Error(`health failed: ${resp.status}`);
return resp.json();
}
export type PolicyEntry = {
name: string;
source_url: string;
};
export type CoverageInsurer = {
slug: string;
name: string;
home_url: string;
policy_count: number;
sample_policies: PolicyEntry[];
};
export type CoverageResponse = {
total_chunks: number;
total_policies: number;
total_insurers: number;
insurers: CoverageInsurer[];
};
export async function getCoverage(): Promise<CoverageResponse> {
const resp = await fetch(`${BACKEND_URL}/api/coverage`);
if (!resp.ok) throw new Error(`coverage failed: ${resp.status}`);
return resp.json();
}
export type UploadResponse = {
policy_id: string;
policy_name: string;
chunks_added: number;
pages_indexed: number;
elapsed_ms: number;
};
export type ScorecardSubScore = {
name: string;
score: number;
summary: string;
signals: string[];
};
// Deterministic, profile-aware {strengths, caveat} β€” the structured
// replacement for the generic grade one-liner. Rendered at the TOP of
// every scorecard surface; surfaces fall back to one_liner when
// strengths is empty / insufficient.
export type ProfileSummary = {
strengths: string[];
caveat: string | null;
};
export type ScorecardResponse = {
policy_id: string;
policy_name: string;
insurer_slug: string;
overall_score: number;
grade: string;
one_liner: string;
sub_scores: ScorecardSubScore[];
data_completeness_pct: number;
methodology_link: string;
profile_summary?: ProfileSummary | null;
};
export async function getScorecard(
policy_id: string,
session_id?: string,
): Promise<ScorecardResponse> {
const qs = session_id
? `?session_id=${encodeURIComponent(session_id)}`
: "";
const resp = await fetch(
`${BACKEND_URL}/api/policies/${encodeURIComponent(policy_id)}/scorecard${qs}`,
);
if (!resp.ok) {
const t = await resp.text();
throw new Error(`scorecard failed: ${resp.status} ${t}`);
}
return resp.json();
}
// ----------------------------------------------------------------------------
// Bulk profile-tuned scorecards (powers PolicyCompareModal scorecard widget).
// One POST returns N scorecards in parallel β€” each weighted by the same
// profile so the widget can render comparable ranks at a glance.
// ----------------------------------------------------------------------------
export type BulkScorecardProfile = {
age?: number;
dependents?: string;
health_conditions?: string[];
primary_goal?: string;
location_tier?: string;
income_band?: string;
budget_band?: string;
budget_inr?: number | null; // #64 β€” exact β‚Ή/yr (lossless; UI prefers this)
existing_cover_inr?: number;
parents_to_insure?: boolean;
parents_age_max?: number;
parents_has_ped?: boolean;
// Task #31 β€” these now drive the deterministic profile_summary
// (copay-preference tag, family-history-aware PED caveat, SI headroom,
// 80D-if-tax-goal). Mirrors the backend SLOT_UNION snapshot.
copay_pct?: number;
desired_sum_insured_inr?: number;
family_medical_history?: string[];
};
export type BulkScorecardEntry = {
policy_id: string;
policy_name: string;
insurer_slug: string;
overall_grade: string; // "A" | "A-" | "B+" | ... | "N/A"
overall_score: number; // 0-100
sub_scores: Record<string, number>; // {coverage_breadth: 82, ...}
profile_rationale: string[];
data_completeness_pct: number; // 0-100
one_liner?: string;
signals?: Record<string, string[]>;
profile_summary?: ProfileSummary | null;
};
export type BulkScorecardResponse = {
per_policy: Record<string, BulkScorecardEntry>;
};
export async function postScorecardBulk(args: {
policy_ids: string[];
profile?: BulkScorecardProfile;
}): Promise<BulkScorecardResponse> {
const resp = await fetch(`${BACKEND_URL}/api/scorecard/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
policy_ids: args.policy_ids,
profile: args.profile ?? null,
}),
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`scorecard/bulk failed: ${resp.status} ${t}`);
}
return resp.json();
}
export type PreExistingCondition =
| "none"
| "diabetes_or_hypertension"
| "heart_disease"
| "multiple";
export type PremiumEstimateRequest = {
age: number;
sum_insured_inr: number;
city_tier?: "metro" | "tier1" | "tier2";
smoker?: boolean;
family_size?: number;
policy_id?: string | null;
pre_existing_conditions?: PreExistingCondition;
copayment_pct?: number;
// B2 widget parity (KI-bugfix, 2026-05-15) β€” optional slider overrides so the
// PolicyPremiumWidget (compare modal) can share the curated-anchored
// estimate() pipeline with PremiumCalculatorPanel. Server snaps tenure to
// {1,2,3} and deductible to {0, 25k, 50k, 100k}.
tenure_years?: 1 | 2 | 3;
deductible_inr?: 0 | 25000 | 50000 | 100000;
};
export type PremiumEstimateResponse = {
policy_id: string;
point_estimate_inr: number;
low_inr: number;
high_inr: number;
methodology: string;
sources: string[];
is_illustrative: boolean;
disclaimer: string;
// Echoed back when caller passed tenure / deductible overrides.
tenure_years?: number | null;
deductible_inr?: number | null;
// BUG #29 β€” whether THIS policy genuinely offers a user-selectable
// voluntary deductible (only ~2 of 148 do). When false the widget hides
// the deductible selector entirely; allowed_deductibles is the exact set
// of pills to render when true.
supports_voluntary_deductible?: boolean;
allowed_deductibles?: number[];
// True when the backend anchored the base to a curated quote sample (i.e.
// the policy is in illustrative_premiums.json). Drives the widget's
// "Estimate" badge β€” replaces bulk_estimate's `assumed` flag.
base_sample_used?: boolean;
// D2 β€” non-null ONLY when the policy publishes no corroborated Sum
// Insured, so this estimate was priced against a fallback cover. Render
// verbatim under the estimate.
sum_insured_disclosure?: string | null;
};
export type ComparePolicyEntry = {
policy_id: string;
policy_name: string;
insurer_slug: string;
fields: Record<string, unknown>;
scorecard?: ScorecardResponse;
};
export type CompareResponse = {
policies: ComparePolicyEntry[];
field_order: string[];
};
export type MarketplacePolicy = {
policy_id: string;
policy_name: string;
aliases?: string[];
insurer_slug: string;
insurer_name: string;
insurer_home_url: string;
source_pdf_url: string;
grade: string;
overall_score: number;
one_liner: string;
data_completeness_pct: number;
profile_summary?: ProfileSummary | null;
min_entry_age?: number | null;
max_entry_age?: number | null;
// SI RATIONALISATION (D1/D3) β€” these are the SOURCE-QUOTE-CORROBORATED
// set only (backend/sum_insured.py): SI values the policy document's own
// quote does not state are dropped. sum_insured_is_band is true ONLY when
// the corroborated set is a genuine continuous band (render "β‚ΉX – β‚ΉY");
// otherwise render the discrete tiers in sum_insured_tiers.
sum_insured_options: number[];
sum_insured_min?: number | null;
sum_insured_max?: number | null;
sum_insured_is_band?: boolean;
sum_insured_tiers?: number[];
pre_existing_disease_waiting_months?: number | null;
initial_waiting_period_days?: number | null;
maternity_waiting_months?: number | null;
copayment_pct?: number | null;
network_hospital_count?: number | null;
no_claim_bonus_pct?: number | null;
ayush_coverage?: boolean | null;
maternity_coverage?: boolean | null;
cashless_treatment_supported?: boolean | null;
room_rent_capping?: string | null;
// #86 β€” sourced insurer-level network: official list URL + official
// stated count (when the insurer publishes one).
network_list_url?: string | null;
network_count_official?: number | null;
network_list_is_pdf?: boolean | null;
};
export type MarketplaceResponse = {
policies: MarketplacePolicy[];
total: number;
insurers_indexed: number;
};
export type InsurerReviews = {
insurer_slug: string;
insurer_name: string;
aggregate_score: { value_0_100?: number; letter_grade?: string; headline?: string };
claim_metrics: {
claim_settlement_ratio_pct?: number;
claim_settlement_ratio_year?: string;
complaints_per_10k_policies?: number;
complaints_year?: string;
source_irdai_url?: string;
};
aggregator_ratings: Record<string, { avg_star?: number; review_count?: number; url?: string }>;
reddit_sentiment: { sentiment_overall?: string; notable_themes?: string[] };
youtube_coverage: { overall_youtube_sentiment?: string; top_creators_who_reviewed?: Array<{ creator?: string; video_url?: string; verdict?: string }> };
in_news?: Array<{ headline?: string; url?: string; publication?: string; date?: string; tone?: string }>;
};
export async function getInsurerReviews(slug: string): Promise<InsurerReviews> {
const resp = await fetch(`${BACKEND_URL}/api/insurers/${slug}/reviews`);
if (!resp.ok) throw new Error(`reviews failed: ${resp.status}`);
return resp.json();
}
export async function getMarketplace(session_id?: string): Promise<MarketplaceResponse> {
// When session_id is passed AND its profile is complete enough, the backend
// re-scores every policy with the user's profile β€” cards reveal personalised
// grades. Without session_id, grades use the generic baseline.
const qs = session_id ? `?session_id=${encodeURIComponent(session_id)}` : "";
const resp = await fetch(`${BACKEND_URL}/api/policies/all${qs}`);
if (!resp.ok) throw new Error(`marketplace failed: ${resp.status}`);
return resp.json();
}
export type UserProfile = {
name?: string | null; // KI-077 β€” captured from chat or entered in profile panel
age?: number | null;
dependents?: string | null;
income_band?: string | null;
existing_cover_inr?: number | null;
primary_goal?: string | null;
location_tier?: string | null;
parents_to_insure?: boolean | null;
parents_age_max?: number | null;
parents_has_ped?: boolean | null;
health_conditions?: string[] | null;
budget_band?: string | null;
budget_inr?: number | null; // #64 β€” exact β‚Ή/yr (lossless; UI prefers this)
// KI-258 (B5, 2026-05-15) β€” Desired sum insured slot. Backend's pricing
// endpoint + retrieve_policies query both read this; frontend pre-fills
// the PremiumCalculatorPanel SI slider from it when present.
desired_sum_insured_inr?: number;
// KI-269 (D2, 2026-05-15) β€” Co-pay % the user is willing to accept.
copay_pct?: number;
// KI-269 (D2, 2026-05-15) β€” Family medical history (free-text tags).
family_medical_history?: string[];
// KI-275 (2026-05-15) β€” Tobacco use; +30-50% premium loading in
// premium_calculator.estimate(). On the Profile dataclass + accepted by
// save_profile_field (chat path). NOTE: the HTTP POST /api/profile
// (ProfileUpdateRequest) does not yet whitelist smoker / copay_pct /
// family_medical_history / desired_sum_insured_inr β€” the profile-builder
// form sends them so they apply the instant that backend gap is closed.
smoker?: boolean | null;
};
export type ProfileCompletenessResponse = {
completeness: number;
completeness_pct: number;
fields_collected: string[];
fields_missing: string[];
is_personalized: boolean;
gate_threshold: number;
next_question_hint?: string | null;
profile?: UserProfile;
session_id?: string | null;
};
export async function getProfileCompleteness(session_id?: string): Promise<ProfileCompletenessResponse> {
const qs = session_id ? `?session_id=${encodeURIComponent(session_id)}` : "";
const resp = await fetch(`${BACKEND_URL}/api/profile/completeness${qs}`);
if (!resp.ok) throw new Error(`profile completeness failed: ${resp.status}`);
return resp.json();
}
export type PredictedPremiumBandResponse = {
min_inr: number;
median_inr: number;
max_inr: number;
sample_size: number;
assumed: boolean;
sum_insured_used?: number; // #63 β€” SI the typical-cohort band priced at
};
export async function getPredictedPremiumBand(
sessionId: string,
): Promise<PredictedPremiumBandResponse> {
const qs = `?session_id=${encodeURIComponent(sessionId)}`;
const resp = await fetch(`${BACKEND_URL}/api/profile/predicted-premium-band${qs}`);
if (!resp.ok) throw new Error(`predicted premium band failed: ${resp.status}`);
return resp.json();
}
// /api/profile/recall-by-name + postProfileRecallByName were REMOVED in
// ADR-043 (2026-05-27). Cross-session profile recall no longer exists β€”
// closing the tab discards the session profile entirely.
export async function postProfileUpdate(req: UserProfile & { session_id: string }): Promise<ProfileCompletenessResponse> {
const resp = await fetch(`${BACKEND_URL}/api/profile`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
if (!resp.ok) throw new Error(`profile update failed: ${resp.status}`);
return resp.json();
}
export async function getCompare(policy_ids: string[]): Promise<CompareResponse> {
// Build query string manually β€” URL constructor requires an absolute URL,
// but in production BACKEND_URL is "" (same-origin) which makes the path
// relative. Constructing `new URL("/api/...")` throws "Invalid URL".
const params = policy_ids.map((id) => `policy_ids=${encodeURIComponent(id)}`).join("&");
const resp = await fetch(`${BACKEND_URL}/api/policies/compare?${params}`);
if (!resp.ok) throw new Error(`compare failed: ${resp.status}`);
return resp.json();
}
export async function postPremiumEstimate(req: PremiumEstimateRequest): Promise<PremiumEstimateResponse> {
const resp = await fetch(`${BACKEND_URL}/api/premium/estimate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...req,
city_tier: req.city_tier ?? "metro",
smoker: req.smoker ?? false,
family_size: req.family_size ?? 1,
}),
});
if (!resp.ok) throw new Error(`premium estimate failed: ${resp.status}`);
return resp.json();
}
// ---------------------------------------------------------------------------
// /api/premium/bulk β€” multi-policy slider-driven calculator. Powers
// PolicyPremiumWidget inside PolicyCompareModal.
// ---------------------------------------------------------------------------
export type PremiumBulkProfile = {
age?: number | null;
dependents?: string | null;
location_tier?: string | null;
family_size?: number | null;
smoker?: boolean | null;
pre_existing_conditions?: PreExistingCondition | null;
// KI-278 (2026-05-16) β€” SI-reconciliation fields. PolicyPremiumWidget
// resolves its initial sum-insured from these with the EXACT precedence
// the header-chip backend uses (resolve_profile_sum_insured in
// backend/premium_calculator.py): desired_sum_insured_inr β†’
// existing_cover_inr β†’ β‚Ή10L default. Carrying them on this shape lets the
// per-policy widget price the SAME SI the header band priced, so the two
// surfaces reconcile instead of contradicting. The backend
// PremiumBulkProfile Pydantic model ignores unknown keys (strict model +
// exclude_none dump), so adding these is forward-safe for the bulk path;
// PolicyPremiumWidget itself only reads them client-side to seed the SI
// slider + estimate request.
desired_sum_insured_inr?: number | null;
existing_cover_inr?: number | null;
};
export type PremiumBulkOverride = {
sum_insured_inr?: number;
tenure_years?: number;
deductible_inr?: number;
};
export type PremiumBulkRequest = {
policy_ids: string[];
profile?: PremiumBulkProfile;
overrides?: Record<string, PremiumBulkOverride>;
};
export type PremiumBulkRow = {
policy_id: string;
premium_inr_annual: number;
breakdown: Record<string, number | string>;
sum_insured_inr: number;
tenure_years: number;
deductible_inr: number;
assumed: boolean;
notes: string[];
};
export type PremiumBulkResponse = {
per_policy: Record<string, PremiumBulkRow>;
profile_used: PremiumBulkProfile;
disclaimer: string;
};
export async function postPremiumBulk(req: PremiumBulkRequest): Promise<PremiumBulkResponse> {
const resp = await fetch(`${BACKEND_URL}/api/premium/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
policy_ids: req.policy_ids,
profile: req.profile ?? {},
overrides: req.overrides ?? {},
}),
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`premium bulk failed: ${resp.status} ${t}`);
}
return resp.json();
}
// KI-020 β€” User-facing chat clear / session restart.
export interface SessionResetResponse {
ok: boolean;
session_id?: string | null; // new session_id returned when drop_profile=true
cleared_state: boolean;
}
export async function postSessionReset(
args: { session_id: string; drop_profile?: boolean }
): Promise<SessionResetResponse> {
const resp = await fetch(`${BACKEND_URL}/api/session/reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: args.session_id,
drop_profile: args.drop_profile ?? false,
}),
});
if (!resp.ok) throw new Error(`session reset failed: ${resp.status}`);
return resp.json();
}
// KI-196 (ADR-041) β€” Clean Clear-chat semantic. Wipes in-memory session
// state for the supplied session_id and ALWAYS returns a fresh UUID the
// caller must adopt going forward. The on-disk profile JSON is preserved.
export interface SessionClearResponse {
cleared: boolean;
new_session_id: string;
}
export async function postSessionClear(
args: { session_id: string }
): Promise<SessionClearResponse> {
const resp = await fetch(`${BACKEND_URL}/api/session/clear`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: args.session_id }),
});
if (!resp.ok) throw new Error(`session clear failed: ${resp.status}`);
return resp.json();
}
export async function uploadPolicy(
file: File,
session_id?: string,
): Promise<UploadResponse> {
const fd = new FormData();
fd.append("file", file);
// Scope the uploaded doc to this chat session so retrieval can answer
// questions about it for this user only (backend tags the quarantine
// chunks with session_id; missing β†’ "anonymous"). Contract: the endpoint
// reads session_id as an optional multipart Form field.
if (session_id) fd.append("session_id", session_id);
const resp = await fetch(`${BACKEND_URL}/api/upload-policy`, {
method: "POST",
body: fd,
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`upload failed: ${resp.status} ${t}`);
}
return resp.json();
}
// Decode a base64 string to a playable audio Blob URL.
export function audioBlobURLFromBase64(b64: string, mime = "audio/wav"): string {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], { type: mime });
return URL.createObjectURL(blob);
}