Vaibhav Kathait commited on
Commit
6754826
·
0 Parent(s):

Initial deploy

Browse files
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ .git/
3
+ .claude/
4
+ heygen/
5
+ tavus/
6
+ silmi/
7
+ **/__pycache__/
8
+ **/*.pyc
9
+ **/.env
10
+ anam/.chromadb/
11
+ anam/rag_state.json
12
+ anam/telemetry_data.json
13
+ agent/data/report_state.json
14
+ agent/data/supplements_embeddings.json
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ **/.env
3
+ __pycache__/
4
+ *.pyc
5
+ .DS_Store
6
+ anam/.chromadb/
7
+ anam/telemetry_data.json
8
+ anam/rag_state.json
9
+ agent/data/report_state.json
10
+ agent/data/supplements_embeddings.json
11
+ agent/data/blood_rag_store.json
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY anam/requirements.txt anam/requirements.txt
6
+ RUN pip install --no-cache-dir -r anam/requirements.txt
7
+
8
+ COPY anam anam
9
+ COPY agent agent
10
+
11
+ COPY start.sh start.sh
12
+ RUN chmod +x start.sh
13
+
14
+ # Hugging Face Spaces (Docker SDK) routes traffic to port 7860.
15
+ EXPOSE 7860
16
+
17
+ CMD ["./start.sh"]
README.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Anam Code
3
+ emoji: 🧑‍⚕️
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Anam avatar demo — Hugging Face Spaces deploy package
12
+
13
+ Self-contained copy of the `anam` (FastAPI backend + frontend) and `agent` (LLM +
14
+ local RAG server) apps, stripped of local caches/secrets, ready to push to a
15
+ Hugging Face Space.
16
+
17
+ ## What's in here
18
+
19
+ - `Dockerfile`, `start.sh` — build the image and run both servers (`agent` internally
20
+ on port 8005, `anam` publicly on port 7860 — the port Hugging Face Spaces expects).
21
+ - `anam/` — the avatar app (frontend + backend in one FastAPI process).
22
+ - `agent/` — the LLM proxy used by `anam`'s "Agent LLM" mode.
23
+ - `scripts/create_vector_store.py` — one-off script to create the persistent OpenAI
24
+ vector store used by anam's local RAG (upload documents → search).
25
+
26
+ ## Deploy steps
27
+
28
+ ### 1. Create the vector store (once)
29
+
30
+ ```bash
31
+ pip install openai
32
+ OPENAI_API_KEY=sk-... python scripts/create_vector_store.py
33
+ ```
34
+
35
+ Copy the printed `vs_...` id — you'll need it in step 4.
36
+
37
+ ### 2. Create the Space
38
+
39
+ On huggingface.co → New Space → SDK: **Docker** → Hardware: **CPU basic (free)**.
40
+
41
+ ### 3. Push this folder to it
42
+
43
+ ```bash
44
+ git init
45
+ git add .
46
+ git commit -m "Initial deploy"
47
+ git remote add hf https://huggingface.co/spaces/<your-username>/<space-name>
48
+ git push hf main
49
+ ```
50
+
51
+ ### 4. Add repository secrets
52
+
53
+ Space → Settings → Variables and secrets → New secret:
54
+
55
+ | Name | Value |
56
+ |---|---|
57
+ | `ANAM_API_KEY` | your Anam API key |
58
+ | `OPENAI_API_KEY` | your OpenAI API key |
59
+ | `OPENAI_VECTOR_STORE_ID` | the `vs_...` id from step 1 |
60
+
61
+ ### 5. Done
62
+
63
+ The Space rebuilds automatically once secrets are set. Open the Space URL to verify.
64
+
65
+ ## Known limitation
66
+
67
+ `anam/rag_state.json` (native Anam-hosted RAG ids) and `anam/telemetry_data.json`
68
+ reset on every Space rebuild/restart — the free tier has no persistent disk. The
69
+ OpenAI-vector-store-backed local RAG (documents you upload via the UI) is not
70
+ affected by this — it survives restarts as long as `OPENAI_VECTOR_STORE_ID` stays
71
+ the same.
agent/.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Required ──────────────────────────────────────────────────────────────────
2
+ OPENAI_API_KEY=your_openai_key_here
3
+
4
+ # ── LLM ───────────────────────────────────────────────────────────────────────
5
+ # LLM_BASE_URL + LLM_MODEL control which model powers the agent's tool-calling.
6
+ # Any OpenAI-compatible endpoint works — swap both lines to change provider.
7
+
8
+ # Default: OpenAI
9
+ LLM_BASE_URL=https://api.openai.com/v1
10
+ LLM_MODEL=gpt-4o-mini
11
+
12
+ # ── Provider examples (uncomment the pair you want) ───────────────────────────
13
+
14
+ # Groq (free tier, very fast):
15
+ # LLM_BASE_URL=https://api.groq.com/openai/v1
16
+ # LLM_MODEL=llama3-8b-8192
17
+ # OPENAI_API_KEY=your_groq_key_here
18
+
19
+ # Anthropic (via OpenAI-compatible proxy):
20
+ # LLM_BASE_URL=https://api.anthropic.com/v1
21
+ # LLM_MODEL=claude-3-haiku-20240307
22
+ # OPENAI_API_KEY=your_anthropic_key_here
23
+
24
+ # Ollama (local, free):
25
+ # LLM_BASE_URL=http://localhost:11434/v1
26
+ # LLM_MODEL=llama3
27
+ # OPENAI_API_KEY=ollama ← Ollama ignores the key but the field must not be empty
28
+
29
+ # OpenAI GPT-4o (more capable, slower):
30
+ # LLM_BASE_URL=https://api.openai.com/v1
31
+ # LLM_MODEL=gpt-4o
32
+
33
+ # ── Server ────────────────────────────────────────────────────────────────────
34
+ PORT=8005
agent/agent.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import AsyncGenerator
3
+
4
+ from openai import AsyncOpenAI
5
+
6
+ from config import LLM_BASE_URL, LLM_MODEL, OPENAI_API_KEY
7
+ from tools import TOOLS, run_tool
8
+
9
+ _SYSTEM_INJECT = (
10
+ "You have access to a live notepad. "
11
+ "Use write_to_notepad whenever the user asks you to note, write down, remember, save, "
12
+ "or record anything — always confirm what you wrote after calling the tool. "
13
+ "Use read_notepad to recall saved notes. "
14
+ "Use clear_notepad only when the user explicitly asks to erase all notes. "
15
+ "Use get_datetime for the current date or time.\n\n"
16
+ "You also act as a clinical assistant for blood test results. "
17
+ "Whenever the user asks about their blood test, lab results, deficiencies, or what "
18
+ "supplements they should take, first call get_blood_report_summary to see which "
19
+ "markers are low, high, or normal. For every abnormal (low or high) marker, call "
20
+ "search_supplements with a short query describing that marker (e.g. 'low vitamin D') "
21
+ "to get supplement recommendations, then explain the deficiency and recommendation in "
22
+ "plain spoken language — no markdown, bullet points, asterisks, or headers, since this "
23
+ "is read aloud by a speaking avatar. If the search_supplements result includes "
24
+ "interaction cautions (e.g. a medication, pregnancy, or condition to be careful with), "
25
+ "briefly mention that caution too, in plain language — this matters most for markers "
26
+ "like kidney function, cholesterol/lipids, and blood sugar where the wrong supplement "
27
+ "can be risky. Always end any discussion of blood results or supplements with a "
28
+ "reminder that this is not a medical diagnosis and the user should consult their "
29
+ "doctor before starting any supplement or changing their dose."
30
+ )
31
+
32
+ _client: AsyncOpenAI | None = None
33
+
34
+
35
+ def _get_client() -> AsyncOpenAI:
36
+ global _client
37
+ if _client is None:
38
+ _client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=LLM_BASE_URL)
39
+ return _client
40
+
41
+
42
+ def _build_history(messages: list) -> list:
43
+ """Inject tool guidance into the system message."""
44
+ msgs = list(messages)
45
+ if msgs and msgs[0].get("role") == "system":
46
+ msgs[0] = {**msgs[0], "content": _SYSTEM_INJECT + "\n\n" + msgs[0]["content"]}
47
+ else:
48
+ msgs.insert(0, {"role": "system", "content": _SYSTEM_INJECT})
49
+ return msgs
50
+
51
+
52
+ async def run_agent(messages: list, model: str | None = None) -> str:
53
+ """Run the full tool-calling loop and return the final text response."""
54
+ history = _build_history(messages)
55
+ m = model or LLM_MODEL
56
+ print(f"[AGENT → {LLM_BASE_URL}] model={m} msgs={len(history)}", flush=True)
57
+
58
+ while True:
59
+ resp = await _get_client().chat.completions.create(
60
+ model=m,
61
+ messages=history,
62
+ tools=TOOLS,
63
+ tool_choice="auto",
64
+ store=True,
65
+ )
66
+ msg = resp.choices[0].message
67
+
68
+ if not msg.tool_calls:
69
+ print(f"[AGENT] ✓ final reply: {(msg.content or '')!r:.100}", flush=True)
70
+ return msg.content or ""
71
+
72
+ history.append(msg.model_dump(exclude_none=True))
73
+ for tc in msg.tool_calls:
74
+ args = json.loads(tc.function.arguments or "{}")
75
+ print(f"[AGENT] tool call: {tc.function.name}({args})", flush=True)
76
+ result = await run_tool(tc.function.name, args)
77
+ history.append({"role": "tool", "tool_call_id": tc.id, "content": result})
78
+
79
+
80
+ async def stream_agent(messages: list, model: str | None = None) -> AsyncGenerator[str, None]:
81
+ """Resolve all tool calls, then stream the final response token by token."""
82
+ history = _build_history(messages)
83
+ m = model or LLM_MODEL
84
+
85
+ # Tool-resolution phase — non-streaming until no more tool calls
86
+ while True:
87
+ resp = await _get_client().chat.completions.create(
88
+ model=m,
89
+ messages=history,
90
+ tools=TOOLS,
91
+ tool_choice="auto",
92
+ store=True,
93
+ )
94
+ msg = resp.choices[0].message
95
+
96
+ if not msg.tool_calls:
97
+ break # hand off to streaming pass
98
+
99
+ history.append(msg.model_dump(exclude_none=True))
100
+ for tc in msg.tool_calls:
101
+ args = json.loads(tc.function.arguments or "{}")
102
+ result = await run_tool(tc.function.name, args)
103
+ history.append({"role": "tool", "tool_call_id": tc.id, "content": result})
104
+
105
+ # Streaming final-answer phase
106
+ stream = await _get_client().chat.completions.create(
107
+ model=m,
108
+ messages=history,
109
+ tools=TOOLS,
110
+ tool_choice="none",
111
+ stream=True,
112
+ store=True,
113
+ )
114
+ async for chunk in stream:
115
+ token = chunk.choices[0].delta.content
116
+ if token:
117
+ yield token
agent/blood_rag.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local blood-test RAG — no external vector store needed.
3
+ Chunks any text document, embeds with text-embedding-3-small,
4
+ persists to data/blood_rag_store.json, searches by cosine similarity.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import math
10
+ import re
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+ from openai import AsyncOpenAI
16
+
17
+ from config import OPENAI_API_KEY
18
+
19
+ DATA_DIR = Path(__file__).parent / "data"
20
+ STORE_FILE = DATA_DIR / "blood_rag_store.json"
21
+ EMBED_MODEL = "text-embedding-3-small"
22
+ CHUNK_SIZE = 80 # words per chunk — keeps each section separate
23
+ CHUNK_OVERLAP = 15 # words overlap between chunks
24
+
25
+ _embed_client: AsyncOpenAI | None = None
26
+ _store: dict = {} # doc_id → {name, chunks: [{id, text, embedding}]}
27
+
28
+
29
+ def _get_client() -> AsyncOpenAI:
30
+ global _embed_client
31
+ if _embed_client is None:
32
+ _embed_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url="https://api.openai.com/v1")
33
+ return _embed_client
34
+
35
+
36
+ def _load() -> None:
37
+ global _store
38
+ if STORE_FILE.exists():
39
+ try:
40
+ _store = json.loads(STORE_FILE.read_text())
41
+ except Exception:
42
+ _store = {}
43
+
44
+
45
+ def _save() -> None:
46
+ STORE_FILE.write_text(json.dumps(_store))
47
+
48
+
49
+ def _chunk_text(text: str) -> List[str]:
50
+ """Split text into overlapping word-window chunks."""
51
+ words = text.split()
52
+ chunks = []
53
+ start = 0
54
+ while start < len(words):
55
+ end = min(start + CHUNK_SIZE, len(words))
56
+ chunks.append(" ".join(words[start:end]))
57
+ if end == len(words):
58
+ break
59
+ start += CHUNK_SIZE - CHUNK_OVERLAP
60
+ return [c for c in chunks if c.strip()]
61
+
62
+
63
+ def _cosine(a: list, b: list) -> float:
64
+ dot = sum(x * y for x, y in zip(a, b))
65
+ mag_a = math.sqrt(sum(x * x for x in a))
66
+ mag_b = math.sqrt(sum(y * y for y in b))
67
+ return dot / (mag_a * mag_b) if mag_a and mag_b else 0.0
68
+
69
+
70
+ async def add_document(name: str, text: str) -> dict:
71
+ """Chunk, embed, and store a document. Returns metadata."""
72
+ chunks = _chunk_text(text)
73
+ client = _get_client()
74
+ doc_id = uuid.uuid4().hex[:8]
75
+ stored = []
76
+
77
+ print(f"[blood_rag] embedding {len(chunks)} chunks for '{name}'…", flush=True)
78
+ # Batch embed all chunks in one API call
79
+ resp = await client.embeddings.create(model=EMBED_MODEL, input=chunks)
80
+ for i, emb_obj in enumerate(resp.data):
81
+ stored.append({
82
+ "id": f"{doc_id}_{i}",
83
+ "text": chunks[i],
84
+ "embedding": emb_obj.embedding,
85
+ })
86
+
87
+ _store[doc_id] = {"name": name, "chunks": stored}
88
+ _save()
89
+ print(f"[blood_rag] stored doc '{name}' id={doc_id} chunks={len(stored)}", flush=True)
90
+ return {"doc_id": doc_id, "name": name, "chunks": len(stored)}
91
+
92
+
93
+ async def search(query: str, top_k: int = 4) -> str:
94
+ """Embed query, return top-k matching passages as a formatted context string."""
95
+ if not _store:
96
+ return "No documents indexed in the blood test RAG yet."
97
+
98
+ client = _get_client()
99
+ resp = await client.embeddings.create(model=EMBED_MODEL, input=query)
100
+ q_vec = resp.data[0].embedding
101
+
102
+ # Flatten all chunks across all docs and score them
103
+ scored = []
104
+ for doc_id, doc in _store.items():
105
+ for chunk in doc["chunks"]:
106
+ score = _cosine(q_vec, chunk["embedding"])
107
+ scored.append((score, doc["name"], chunk["text"]))
108
+
109
+ scored.sort(key=lambda x: x[0], reverse=True)
110
+ top = scored[:top_k]
111
+
112
+ if not top:
113
+ return "No relevant passages found."
114
+
115
+ parts = [f"[{name}]\n{text}" for _, name, text in top]
116
+ return (
117
+ "BLOOD TEST CONTEXT — relevant passages retrieved from the patient's records:\n\n"
118
+ + "\n\n---\n\n".join(parts)
119
+ )
120
+
121
+
122
+ def list_documents() -> list:
123
+ return [
124
+ {"doc_id": did, "name": doc["name"], "chunks": len(doc["chunks"])}
125
+ for did, doc in _store.items()
126
+ ]
127
+
128
+
129
+ def delete_document(doc_id: str) -> bool:
130
+ if doc_id not in _store:
131
+ return False
132
+ del _store[doc_id]
133
+ _save()
134
+ return True
135
+
136
+
137
+ _load()
agent/config.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv()
5
+
6
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
7
+ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini")
8
+ LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") # swap for Groq/Ollama/etc.
9
+ PORT = int(os.getenv("PORT", 8005))
agent/data/blood_test_sample.txt ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PATIENT BLOOD TEST REPORT
2
+ Patient: Demo Patient
3
+ Age: 38 Sex: Male
4
+ Test Date: June 28, 2026
5
+ Lab: OLMA Health Diagnostics
6
+
7
+ === COMPLETE BLOOD COUNT (CBC) ===
8
+ Hemoglobin: 11.2 g/dL Reference: 13.5–17.5 FLAG: LOW
9
+ Hematocrit: 34 % Reference: 41–53 FLAG: LOW
10
+ RBC: 3.8 M/µL Reference: 4.5–5.9 FLAG: LOW
11
+ WBC: 7.2 K/µL Reference: 4.5–11.0 NORMAL
12
+ Platelets: 245 K/µL Reference: 150–400 NORMAL
13
+ MCV: 72 fL Reference: 80–100 FLAG: LOW
14
+ MCH: 24 pg Reference: 27–33 FLAG: LOW
15
+
16
+ === IRON STUDIES ===
17
+ Serum Iron: 45 µg/dL Reference: 60–170 FLAG: LOW
18
+ Ferritin: 8 ng/mL Reference: 20–250 FLAG: LOW
19
+ TIBC: 420 µg/dL Reference: 250–370 FLAG: HIGH
20
+ Transferrin Saturation: 10 % Reference: 20–50 FLAG: LOW
21
+
22
+ === VITAMINS & MINERALS ===
23
+ Vitamin D (25-OH): 18 ng/mL Reference: 30–100 FLAG: LOW
24
+ Vitamin B12: 180 pg/mL Reference: 200–900 FLAG: LOW
25
+ Folate (Serum): 3.2 ng/mL Reference: 3.0–20.0 BORDERLINE LOW
26
+ Magnesium: 1.6 mg/dL Reference: 1.7–2.2 FLAG: LOW
27
+ Zinc: 68 µg/dL Reference: 70–120 FLAG: LOW
28
+
29
+ === THYROID PANEL ===
30
+ TSH: 3.8 mIU/L Reference: 0.4–4.0 NORMAL
31
+ Free T4: 1.1 ng/dL Reference: 0.8–1.8 NORMAL
32
+ Free T3: 2.9 pg/mL Reference: 2.3–4.2 NORMAL
33
+
34
+ === METABOLIC PANEL ===
35
+ Glucose (Fasting): 95 mg/dL Reference: 70–100 NORMAL
36
+ HbA1c: 5.4 % Reference: <5.7 NORMAL
37
+ Creatinine: 0.9 mg/dL Reference: 0.7–1.3 NORMAL
38
+ eGFR: >60 mL/min Reference: >60 NORMAL
39
+ Sodium: 139 mEq/L Reference: 136–145 NORMAL
40
+ Potassium: 4.1 mEq/L Reference: 3.5–5.1 NORMAL
41
+ ALT (SGPT): 28 U/L Reference: 7–56 NORMAL
42
+ AST (SGOT): 24 U/L Reference: 10–40 NORMAL
43
+
44
+ === LIPID PANEL ===
45
+ Total Cholesterol: 218 mg/dL Reference: <200 FLAG: HIGH
46
+ LDL Cholesterol: 148 mg/dL Reference: <100 FLAG: HIGH
47
+ HDL Cholesterol: 41 mg/dL Reference: >40 BORDERLINE LOW
48
+ Triglycerides: 192 mg/dL Reference: <150 FLAG: HIGH
49
+ Non-HDL Cholesterol: 177 mg/dL Reference: <130 FLAG: HIGH
50
+
51
+ === INFLAMMATION MARKERS ===
52
+ hsCRP: 3.8 mg/L Reference: <1.0 FLAG: HIGH
53
+ ESR: 22 mm/hr Reference: 0–15 FLAG: HIGH
54
+
55
+ === HORMONE PANEL ===
56
+ Testosterone (Total): 320 ng/dL Reference: 300–1000 LOW-NORMAL
57
+ DHEA-S: 95 µg/dL Reference: 80–560 NORMAL
58
+ Cortisol (AM): 18 µg/dL Reference: 6–23 NORMAL
59
+
60
+ === SUMMARY OF ABNORMAL FINDINGS ===
61
+ CRITICAL LOW: Ferritin (8 ng/mL) — severe iron deficiency
62
+ LOW: Hemoglobin, Hematocrit, RBC, MCV, MCH — microcytic anemia consistent with iron deficiency
63
+ LOW: Serum Iron (45 µg/dL), Transferrin Saturation (10%) — confirms iron deficiency anemia
64
+ LOW: Vitamin D (18 ng/mL) — deficiency; optimal is 50–80 ng/mL
65
+ LOW: Vitamin B12 (180 pg/mL) — mild deficiency; risk of neurological symptoms
66
+ BORDERLINE LOW: Folate, Magnesium, Zinc
67
+ HIGH: Total Cholesterol, LDL, Triglycerides, Non-HDL — dyslipidemia; cardiovascular risk
68
+ HIGH: hsCRP (3.8 mg/L) — elevated systemic inflammation
69
+ HIGH: ESR — confirms inflammation
70
+
71
+ === CLINICAL NOTES ===
72
+ - Iron deficiency anemia is the most significant finding. Dietary iron and/or supplementation
73
+ with iron bisglycinate recommended. Investigate cause (dietary, absorption, or blood loss).
74
+ - Vitamin D deficiency is common and may contribute to fatigue, muscle weakness, and low immunity.
75
+ Recommend 2000–4000 IU Vitamin D3 + K2 daily with fat-containing meal.
76
+ - Vitamin B12 of 180 pg/mL is below optimal. Consider methylcobalamin supplementation
77
+ 1000 mcg/day sublingually, or dietary increase (meat, eggs, dairy).
78
+ - Elevated LDL and triglycerides with borderline HDL indicates metabolic risk.
79
+ Dietary changes (reduce refined carbs, increase omega-3s) are the first step.
80
+ - Elevated hsCRP suggests systemic inflammation. Investigate dietary triggers; omega-3
81
+ supplementation (EPA+DHA 2g/day) may help reduce inflammation.
82
+ - All findings should be discussed with a physician before starting any supplementation.
agent/data/sample_reports/generate_samples.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Generates sample text-based blood test report PDFs for the Clinical RAG demo.
3
+
4
+ Each report is a fictional patient with a few markers flagged low/high that map onto
5
+ entries in agent/data/supplements_kb.json, so the agent's extraction + supplement
6
+ RAG flow has something realistic to work with.
7
+
8
+ Run:
9
+ source venv/bin/activate
10
+ python agent/data/sample_reports/generate_samples.py
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ from fpdf import FPDF
16
+
17
+ OUT_DIR = Path(__file__).parent
18
+
19
+ # Column widths (mm) for the results table
20
+ COL_WIDTHS = (70, 22, 20, 38, 20)
21
+ HEADERS = ("Test Name", "Result", "Units", "Reference Range", "Flag")
22
+
23
+
24
+ def make_report(filename, title, patient, rows, comments=""):
25
+ pdf = FPDF()
26
+ pdf.set_auto_page_break(auto=True, margin=15)
27
+ pdf.add_page()
28
+
29
+ pdf.set_font("Helvetica", "B", 14)
30
+ pdf.cell(0, 10, title, new_x="LMARGIN", new_y="NEXT")
31
+
32
+ pdf.set_font("Helvetica", "", 10)
33
+ for line in patient:
34
+ pdf.cell(0, 6, line, new_x="LMARGIN", new_y="NEXT")
35
+ pdf.ln(4)
36
+
37
+ # Table header
38
+ pdf.set_font("Helvetica", "B", 9)
39
+ for w, h in zip(COL_WIDTHS, HEADERS):
40
+ pdf.cell(w, 7, h, border=1)
41
+ pdf.ln()
42
+
43
+ # Table rows
44
+ pdf.set_font("Helvetica", "", 9)
45
+ for test_name, value, unit, ref_range, flag in rows:
46
+ pdf.cell(COL_WIDTHS[0], 7, test_name, border=1)
47
+ pdf.cell(COL_WIDTHS[1], 7, value, border=1, align="R")
48
+ pdf.cell(COL_WIDTHS[2], 7, unit, border=1)
49
+ pdf.cell(COL_WIDTHS[3], 7, ref_range, border=1)
50
+ pdf.cell(COL_WIDTHS[4], 7, flag, border=1, align="C")
51
+ pdf.ln()
52
+
53
+ if comments:
54
+ pdf.ln(6)
55
+ pdf.set_font("Helvetica", "B", 10)
56
+ pdf.cell(0, 6, "Comments:", new_x="LMARGIN", new_y="NEXT")
57
+ pdf.set_font("Helvetica", "", 10)
58
+ pdf.multi_cell(0, 6, comments)
59
+
60
+ out_path = OUT_DIR / filename
61
+ pdf.output(str(out_path))
62
+ print(f"Wrote {out_path}")
63
+
64
+
65
+ # ── Sample 1: Vitamin D + B12 deficiency ───────────────────────────────────────
66
+ make_report(
67
+ "sample_report_1_vitamin_d_b12.pdf",
68
+ "Comprehensive Vitamin & Metabolic Panel",
69
+ [
70
+ "Patient: John Doe DOB: 1985-03-14 Sex: Male",
71
+ "Collected: 2026-06-10 Reported: 2026-06-12",
72
+ "Ordering Physician: Dr. A. Sharma",
73
+ ],
74
+ [
75
+ ("Vitamin D, 25-Hydroxy", "14", "ng/mL", "30-100", "LOW"),
76
+ ("Vitamin B12", "180", "pg/mL", "200-900", "LOW"),
77
+ ("Hemoglobin", "14.2", "g/dL", "13.5-17.5", "NORMAL"),
78
+ ("Calcium, Total", "9.2", "mg/dL", "8.5-10.5", "NORMAL"),
79
+ ("TSH", "2.1", "mIU/L", "0.4-4.0", "NORMAL"),
80
+ ("Folate, Serum", "6.5", "ng/mL", "2.7-17.0", "NORMAL"),
81
+ ("Potassium", "4.3", "mEq/L", "3.5-5.0", "NORMAL"),
82
+ ],
83
+ comments="Patient reports ongoing fatigue, low mood, and occasional muscle aches over the past two months.",
84
+ )
85
+
86
+ # ── Sample 2: Iron-deficiency anemia + low Folate ──────────────────────────────
87
+ make_report(
88
+ "sample_report_2_iron_folate.pdf",
89
+ "Complete Blood Count & Nutrient Panel",
90
+ [
91
+ "Patient: Jane Smith DOB: 1990-11-02 Sex: Female",
92
+ "Collected: 2026-06-08 Reported: 2026-06-10",
93
+ "Ordering Physician: Dr. R. Mehta",
94
+ ],
95
+ [
96
+ ("Hemoglobin", "10.8", "g/dL", "12.0-15.5", "LOW"),
97
+ ("Ferritin", "8", "ng/mL", "10-120", "LOW"),
98
+ ("Folate, Serum", "2.1", "ng/mL", "2.7-17.0", "LOW"),
99
+ ("Vitamin D, 25-Hydroxy","32", "ng/mL", "30-100", "NORMAL"),
100
+ ("Potassium", "4.1", "mEq/L", "3.5-5.0", "NORMAL"),
101
+ ("Vitamin B12", "410", "pg/mL", "200-900", "NORMAL"),
102
+ ],
103
+ comments="Patient reports persistent tiredness, occasional dizziness on standing, and brittle nails.",
104
+ )
105
+
106
+ # ── Sample 3: Magnesium, Zinc, lipid panel, B6, thyroid ────────────────────────
107
+ make_report(
108
+ "sample_report_3_magnesium_lipids_thyroid.pdf",
109
+ "Wellness Panel - Minerals, Lipids & Thyroid",
110
+ [
111
+ "Patient: Raj Patel DOB: 1978-07-22 Sex: Male",
112
+ "Collected: 2026-06-05 Reported: 2026-06-07",
113
+ "Ordering Physician: Dr. S. Iyer",
114
+ ],
115
+ [
116
+ ("Magnesium", "1.4", "mg/dL", "1.7-2.2", "LOW"),
117
+ ("Zinc", "55", "mcg/dL","60-120", "LOW"),
118
+ ("Triglycerides", "210", "mg/dL", "below 150", "HIGH"),
119
+ ("HDL Cholesterol", "38", "mg/dL", "above 40", "LOW"),
120
+ ("Vitamin B6", "3.2", "ng/mL", "5-50", "LOW"),
121
+ ("TSH", "5.8", "mIU/L", "0.4-4.0", "HIGH"),
122
+ ("Calcium, Total", "9.4", "mg/dL", "8.5-10.5", "NORMAL"),
123
+ ],
124
+ comments="Patient reports muscle cramps, poor sleep, and recent weight gain.",
125
+ )
agent/data/sample_reports/sample_report_1_vitamin_d_b12.pdf ADDED
Binary file (1.94 kB). View file
 
agent/data/sample_reports/sample_report_2_iron_folate.pdf ADDED
Binary file (1.87 kB). View file
 
agent/data/sample_reports/sample_report_3_magnesium_lipids_thyroid.pdf ADDED
Binary file (1.93 kB). View file
 
agent/data/supplements_kb.json ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "vitamin_d",
4
+ "marker_names": ["Vitamin D", "25-OH Vitamin D", "Vitamin D3", "25-hydroxyvitamin D", "Vitamin D, 25-Hydroxy"],
5
+ "reference_range": "Sufficient: 30-100 ng/mL; Insufficient: 20-29 ng/mL; Deficient: below 20 ng/mL",
6
+ "deficiency_symptoms": "Fatigue, bone or joint pain, muscle weakness or aches, frequent infections, low mood, hair loss, and slow wound healing.",
7
+ "recommendations": "Vitamin D3 (cholecalciferol) supplement — commonly 1000-2000 IU per day for general maintenance, or higher doses (around 5000 IU per day) for several weeks if significantly deficient, taken with a meal containing some fat for better absorption. Often paired with Vitamin K2 and Magnesium, which support how the body uses Vitamin D. Sensible sun exposure and foods like fatty fish, egg yolks, and fortified milk also help.",
8
+ "interaction_cautions": "Caution with thiazide diuretics — vitamin D increases calcium absorption and the combination can push blood calcium too high. Avoid high doses if you have kidney disease, sarcoidosis, or a history of hyperparathyroidism without medical supervision.",
9
+ "consult_note": "Always consult your doctor before starting supplementation, especially at higher doses, and re-test levels after a few months."
10
+ },
11
+ {
12
+ "id": "vitamin_b12",
13
+ "marker_names": ["Vitamin B12", "B12", "Cobalamin", "Vitamin B-12", "Serum B12"],
14
+ "reference_range": "Normal: 200-900 pg/mL (varies by lab); levels below 200 pg/mL are generally considered deficient",
15
+ "deficiency_symptoms": "Fatigue, weakness, pale skin, tingling or numbness in hands and feet, memory problems, mood changes, and shortness of breath.",
16
+ "recommendations": "Vitamin B12 supplement (methylcobalamin or cyanocobalamin) — typically 500-1000 mcg per day orally for mild deficiency, or as prescribed sublingual/injectable forms for more severe deficiency. A B-complex supplement can also help if multiple B vitamins are low. Dietary sources include eggs, dairy, fish, and fortified cereals — important for those on plant-based diets.",
17
+ "interaction_cautions": "Generally very safe with few interactions. Long-term use of metformin (for diabetes) and acid-reducing medications (PPIs or H2 blockers) can lower B12 absorption over time, so people on these medications may need higher intake or periodic re-testing.",
18
+ "consult_note": "Always consult your doctor before starting supplementation; B12 deficiency can sometimes indicate an absorption issue that needs further evaluation."
19
+ },
20
+ {
21
+ "id": "iron_ferritin",
22
+ "marker_names": ["Iron", "Ferritin", "Hemoglobin", "Hb", "Serum Iron", "Total Iron Binding Capacity", "TIBC", "Transferrin Saturation"],
23
+ "reference_range": "Ferritin: 20-250 ng/mL (men), 10-120 ng/mL (women); Hemoglobin: 13.5-17.5 g/dL (men), 12.0-15.5 g/dL (women)",
24
+ "deficiency_symptoms": "Fatigue, weakness, pale skin, shortness of breath, dizziness, brittle nails, and unusual cravings for non-food items (pica) in severe cases.",
25
+ "recommendations": "Iron supplement (ferrous sulfate, ferrous gluconate, or ferrous bisglycinate which is gentler on the stomach) — commonly 25-65 mg of elemental iron per day, taken on an empty stomach or with Vitamin C to improve absorption, and away from coffee, tea, or calcium supplements which reduce absorption. Iron-rich foods include red meat, lentils, spinach, and fortified cereals.",
26
+ "interaction_cautions": "Iron can reduce absorption of thyroid medication (levothyroxine) and certain antibiotics (tetracyclines, fluoroquinolones) — separate doses by at least 2 hours. Avoid iron supplementation without testing if you have hemochromatosis or another iron-overload condition, since excess iron can be harmful.",
27
+ "consult_note": "Always consult your doctor before starting iron supplementation — too much iron can be harmful, and persistent low iron should be investigated for an underlying cause (such as blood loss)."
28
+ },
29
+ {
30
+ "id": "folate",
31
+ "marker_names": ["Folate", "Folic Acid", "Vitamin B9", "Serum Folate", "RBC Folate"],
32
+ "reference_range": "Normal: 2.7-17.0 ng/mL (varies by lab); levels below this range suggest deficiency",
33
+ "deficiency_symptoms": "Fatigue, weakness, irritability, headaches, pale skin, mouth sores, and in some cases shortness of breath.",
34
+ "recommendations": "Folic acid (folate) supplement — commonly 400-800 mcg per day. Often included in a standard B-complex or multivitamin. Folate-rich foods include leafy greens, beans, citrus fruits, and fortified grains.",
35
+ "interaction_cautions": "High-dose folic acid can mask an underlying Vitamin B12 deficiency on lab tests, so check B12 alongside folate when both could be low. Folic acid can also interact with methotrexate and some anti-seizure medications — check with your doctor if you take either.",
36
+ "consult_note": "Always consult your doctor before starting supplementation, particularly if pregnant or planning pregnancy, as folate needs differ in those cases."
37
+ },
38
+ {
39
+ "id": "calcium",
40
+ "marker_names": ["Calcium", "Serum Calcium", "Total Calcium", "Ionized Calcium", "Corrected Calcium"],
41
+ "reference_range": "Normal: 8.5-10.5 mg/dL (total serum calcium)",
42
+ "deficiency_symptoms": "Muscle cramps or spasms, numbness or tingling in fingers, fatigue, brittle nails, and over the long term, weaker bones.",
43
+ "recommendations": "Calcium supplement (calcium citrate is well-absorbed and easier on digestion; calcium carbonate is taken with food) — commonly 500-1000 mg per day in divided doses, paired with Vitamin D to support absorption. Dietary sources include dairy, fortified plant milks, leafy greens, and almonds.",
44
+ "interaction_cautions": "Take calcium at least 2-4 hours apart from thyroid medication, bisphosphonates, and certain antibiotics (tetracyclines, fluoroquinolones), as it can block their absorption. Avoid combining high-dose calcium with high-dose vitamin D without monitoring, as both raise blood calcium.",
45
+ "consult_note": "Always consult your doctor before starting supplementation — low calcium can also relate to Vitamin D, magnesium, or parathyroid issues that should be checked."
46
+ },
47
+ {
48
+ "id": "magnesium",
49
+ "marker_names": ["Magnesium", "Serum Magnesium", "Mg"],
50
+ "reference_range": "Normal: 1.7-2.2 mg/dL",
51
+ "deficiency_symptoms": "Muscle cramps or twitches, fatigue, irregular heartbeat, headaches, poor sleep, and irritability.",
52
+ "recommendations": "Magnesium supplement (magnesium glycinate or citrate are well-absorbed and gentle on digestion; magnesium oxide is less well absorbed) — commonly 200-400 mg per day, ideally in the evening as it can support relaxation and sleep. Dietary sources include nuts, seeds, whole grains, and leafy greens.",
53
+ "interaction_cautions": "Can interact with certain antibiotics (tetracyclines, fluoroquinolones — separate doses by a few hours) and some blood pressure medications. Use caution and consult your doctor if you have reduced kidney function, since magnesium is cleared by the kidneys and can build up to unsafe levels.",
54
+ "consult_note": "Always consult your doctor before starting supplementation, especially if you have kidney problems, as magnesium is cleared by the kidneys."
55
+ },
56
+ {
57
+ "id": "zinc",
58
+ "marker_names": ["Zinc", "Serum Zinc", "Plasma Zinc"],
59
+ "reference_range": "Normal: 60-120 mcg/dL",
60
+ "deficiency_symptoms": "Frequent infections, slow wound healing, hair loss, loss of appetite, reduced sense of taste or smell, and skin issues.",
61
+ "recommendations": "Zinc supplement (zinc picolinate, citrate, or gluconate) — commonly 15-30 mg per day, ideally taken with food to avoid stomach upset and not at the same time as iron or calcium supplements, which can compete for absorption. Dietary sources include meat, shellfish, legumes, seeds, and nuts.",
62
+ "interaction_cautions": "Taking zinc long-term at high doses can lower copper levels — a balanced multivitamin or small copper supplement is often recommended alongside long-term zinc use. Avoid taking zinc at the same time as iron, calcium, or certain antibiotics (tetracyclines, fluoroquinolones), which compete for absorption.",
63
+ "consult_note": "Always consult your doctor before starting supplementation — taking too much zinc for too long can interfere with copper absorption."
64
+ },
65
+ {
66
+ "id": "omega_3",
67
+ "marker_names": ["Omega-3 Index", "EPA", "DHA", "Omega-3 Fatty Acids"],
68
+ "reference_range": "Omega-3 Index: 8% or higher is considered desirable; below 4% is considered low",
69
+ "deficiency_symptoms": "A low Omega-3 Index has been associated with dry skin, joint stiffness, fatigue, low mood, and (over the long term) an unfavorable cardiovascular risk profile.",
70
+ "recommendations": "Omega-3 fish oil supplement (EPA/DHA combined) — commonly 1000-2000 mg per day, taken with meals to reduce any fishy aftertaste and improve absorption. Algae-based omega-3 is a good alternative for vegetarians/vegans. Dietary sources include fatty fish (salmon, sardines, mackerel), walnuts, chia seeds, and flaxseed.",
71
+ "interaction_cautions": "High-dose fish oil can mildly increase bleeding risk — use caution and talk to your doctor if you take blood thinners (e.g. warfarin, aspirin, clopidogrel) or have surgery scheduled.",
72
+ "consult_note": "Always consult your doctor before starting supplementation, especially if you take blood thinners, as high-dose fish oil can affect bleeding risk."
73
+ },
74
+ {
75
+ "id": "vitamin_b6_complex",
76
+ "marker_names": ["Vitamin B6", "Pyridoxine", "Vitamin B1", "Thiamine", "Vitamin B2", "Riboflavin"],
77
+ "reference_range": "Vitamin B6: 5-50 ng/mL (varies by lab); deficiency generally below this range",
78
+ "deficiency_symptoms": "Fatigue, irritability, depression-like mood changes, cracked lips or mouth sores, tingling in hands and feet, and skin rashes.",
79
+ "recommendations": "A B-complex supplement covering B1, B2, B6, and related B vitamins — commonly one capsule per day with a meal, providing roughly 100% of the daily value for each B vitamin unless a higher dose is specifically needed. Dietary sources include whole grains, poultry, fish, bananas, and potatoes.",
80
+ "interaction_cautions": "Very high doses of B6 (well above 100 mg/day) over long periods have been linked to nerve damage (peripheral neuropathy) — stick to moderate B-complex doses unless a higher amount is specifically prescribed. B6 may also interact with levodopa (a Parkinson's medication).",
81
+ "consult_note": "Always consult your doctor before starting supplementation — very high, long-term doses of B6 in particular can cause nerve problems."
82
+ },
83
+ {
84
+ "id": "vitamin_c",
85
+ "marker_names": ["Vitamin C", "Ascorbic Acid", "Serum Vitamin C"],
86
+ "reference_range": "Normal: 0.4-2.0 mg/dL",
87
+ "deficiency_symptoms": "Fatigue, easy bruising, bleeding or swollen gums, slow wound healing, dry skin, and joint pain.",
88
+ "recommendations": "Vitamin C supplement — commonly 250-1000 mg per day, taken with or without food. Dietary sources include citrus fruits, berries, bell peppers, and broccoli, which can also help boost levels naturally.",
89
+ "interaction_cautions": "Generally very safe, but very high doses (several grams per day) can increase iron absorption — use caution if you also have high iron or ferritin. Very high doses can also interfere with some lab tests and certain chemotherapy regimens, so mention any vitamin C supplementation to your care team.",
90
+ "consult_note": "Always consult your doctor before starting supplementation, especially at higher doses, as very high amounts can cause digestive upset in some people."
91
+ },
92
+ {
93
+ "id": "potassium",
94
+ "marker_names": ["Potassium", "Serum Potassium", "K", "K+"],
95
+ "reference_range": "Normal: 3.5-5.0 mEq/L",
96
+ "deficiency_symptoms": "Muscle weakness or cramps, fatigue, irregular heartbeat, constipation, and tingling sensations.",
97
+ "recommendations": "Mild low potassium is often best addressed through potassium-rich foods such as bananas, oranges, potatoes, spinach, beans, and yogurt rather than high-dose supplements, since over-the-counter potassium supplements are typically low-dose for safety reasons.",
98
+ "interaction_cautions": "Potassium supplements can be dangerous if combined with ACE inhibitors, ARBs, or potassium-sparing diuretics (common blood pressure medications) — this combination can raise potassium to unsafe levels (hyperkalemia) and affect heart rhythm. Always check with your doctor first, especially with any kidney issues.",
99
+ "consult_note": "Always consult your doctor before taking potassium supplements — both low and high potassium levels can affect heart rhythm and often need medical evaluation, especially if you take blood pressure medication or have kidney issues."
100
+ },
101
+ {
102
+ "id": "thyroid_tsh",
103
+ "marker_names": ["TSH", "Thyroid Stimulating Hormone", "T3", "T4", "Free T4", "Free T3"],
104
+ "reference_range": "TSH: 0.4-4.0 mIU/L (varies by lab); Free T4: 0.8-1.8 ng/dL",
105
+ "deficiency_symptoms": "An elevated TSH with low T4 (underactive thyroid) can cause fatigue, weight gain, cold intolerance, dry skin, and hair thinning. A low TSH with high T4 (overactive thyroid) can cause weight loss, rapid heartbeat, and anxiety.",
106
+ "recommendations": "Thyroid markers outside the normal range are not addressed with over-the-counter supplements — they typically require evaluation and, if needed, prescription medication from a doctor. Adequate iodine and selenium intake through diet (iodized salt, seafood, eggs, Brazil nuts) supports normal thyroid function, but should not replace medical care for an abnormal result.",
107
+ "interaction_cautions": "If thyroid medication (e.g. levothyroxine) is or becomes part of your treatment, take calcium, iron, and magnesium supplements at least 4 hours apart, since they can reduce absorption of thyroid medication.",
108
+ "consult_note": "Please see your doctor for any abnormal thyroid result — this is an area where supplements alone are not an appropriate substitute for medical evaluation."
109
+ },
110
+ {
111
+ "id": "cholesterol_lipids",
112
+ "marker_names": ["Triglycerides", "LDL", "LDL Cholesterol", "HDL", "HDL Cholesterol", "Total Cholesterol", "Non-HDL Cholesterol", "Cholesterol"],
113
+ "reference_range": "Total Cholesterol: below 200 mg/dL desirable; LDL: below 100 mg/dL optimal; HDL: above 40 mg/dL (men) / 50 mg/dL (women); Triglycerides: below 150 mg/dL",
114
+ "deficiency_symptoms": "High triglycerides or LDL and low HDL are often linked to a diet high in refined carbohydrates and saturated fat, low physical activity, and excess body weight, and over time are associated with increased cardiovascular risk. Often there are no symptoms, which is why regular testing matters.",
115
+ "recommendations": "Omega-3 fish oil (1000-2000 mg EPA/DHA per day) can help lower triglycerides. Soluble fiber (psyllium, oats, 5-10 g per day) and plant sterols/stanols (around 2 g per day) can help lower LDL. Niacin and red yeast rice are sometimes used for cholesterol but have notable interaction risks (see cautions). Lifestyle changes — reducing refined carbs and saturated fat, regular aerobic exercise, and weight management — are the foundation for improving these markers.",
116
+ "interaction_cautions": "If you are taking statin medication, talk to your doctor before adding niacin or red yeast rice — both can increase the risk of muscle-related side effects (myopathy) when combined with statins, and red yeast rice itself contains a natural statin-like compound. High-dose fish oil can mildly increase bleeding risk if you take blood thinners.",
117
+ "consult_note": "Always consult your doctor before starting supplements for cholesterol or triglycerides, especially if you are already on cholesterol-lowering medication."
118
+ },
119
+ {
120
+ "id": "blood_glucose_hba1c",
121
+ "marker_names": ["Glucose", "Fasting Glucose", "Blood Sugar", "HbA1c", "Hemoglobin A1c", "Glycated Hemoglobin"],
122
+ "reference_range": "Fasting Glucose: 70-99 mg/dL normal, 100-125 mg/dL prediabetic range, 126 mg/dL or higher diabetic range; HbA1c: below 5.7% normal, 5.7-6.4% prediabetic range, 6.5% or higher diabetic range",
123
+ "deficiency_symptoms": "Elevated fasting glucose or HbA1c (often called 'blood sugar') can cause increased thirst, frequent urination, fatigue, and blurred vision, though early-stage elevations are often symptom-free.",
124
+ "recommendations": "Berberine (around 500 mg, 2-3 times daily before meals), chromium picolinate (200-1000 mcg per day), alpha-lipoic acid (300-600 mg per day), and magnesium (which is often low alongside elevated blood sugar) are commonly used to support healthy blood sugar. Lifestyle measures — reducing refined carbs and sugary drinks, regular exercise, and a higher-fiber diet — have the strongest evidence and should be the foundation.",
125
+ "interaction_cautions": "Berberine and chromium can lower blood sugar further — if you take metformin, insulin, sulfonylureas, or other diabetes medications, talk to your doctor before adding these, to avoid hypoglycemia (blood sugar dropping too low). Berberine can also interact with several other medications metabolized by the liver.",
126
+ "consult_note": "Always consult your doctor before starting any supplement for blood sugar, especially if you are on diabetes medication — an elevated glucose or HbA1c result should be followed up medically."
127
+ },
128
+ {
129
+ "id": "liver_enzymes",
130
+ "marker_names": ["ALT", "AST", "Alanine Aminotransferase", "Aspartate Aminotransferase", "GGT", "Alkaline Phosphatase", "ALP", "Liver Enzymes"],
131
+ "reference_range": "ALT: 7-56 U/L; AST: 10-40 U/L; GGT: 9-48 U/L (ranges vary by lab)",
132
+ "deficiency_symptoms": "Elevated liver enzymes can indicate liver stress or inflammation. This may cause fatigue or mild abdominal discomfort, but is often symptom-free in early stages, which is why it usually shows up first on a routine blood test.",
133
+ "recommendations": "Milk thistle (silymarin, commonly 150-300 mg, 2-3 times daily) and NAC (N-acetylcysteine, commonly 600 mg, once or twice daily) are commonly used to support liver health. Vitamin E has also been studied for fatty liver. Reducing alcohol intake, processed foods, and unnecessary over-the-counter medications that stress the liver (such as frequent high-dose acetaminophen) is an important part of supporting these markers.",
134
+ "interaction_cautions": "Milk thistle can affect how the liver processes certain medications, since it influences liver enzymes (CYP450) involved in drug metabolism — check with your doctor or pharmacist if you take prescription medications, especially for cholesterol, seizures, or blood thinning.",
135
+ "consult_note": "Always consult your doctor about elevated liver enzymes — this finding should be followed up to identify and address the underlying cause, not just managed with supplements."
136
+ },
137
+ {
138
+ "id": "kidney_function",
139
+ "marker_names": ["Creatinine", "eGFR", "Estimated Glomerular Filtration Rate", "BUN", "Blood Urea Nitrogen", "Kidney Function"],
140
+ "reference_range": "Creatinine: 0.6-1.3 mg/dL; eGFR: above 90 mL/min/1.73m2 normal, 60-89 mildly reduced, below 60 reduced; BUN: 7-20 mg/dL",
141
+ "deficiency_symptoms": "Reduced kidney function (lower eGFR, or higher creatinine/BUN) can cause fatigue, swelling in the legs or ankles, changes in urination, and higher blood pressure, though early stages are often symptom-free.",
142
+ "recommendations": "This marker is different from the others on this list — when kidney function is reduced, the focus is generally on AVOIDING or carefully dose-adjusting certain supplements rather than adding new ones, because the kidneys clear many minerals from the body and impaired kidneys can let levels build up to unsafe amounts. Staying well hydrated (unless told otherwise by a doctor), moderating protein and sodium intake, and managing blood pressure and blood sugar are the main supportive steps, and should be guided by a doctor.",
143
+ "interaction_cautions": "If kidney function is reduced, avoid high-dose magnesium, potassium, and vitamin D/calcium supplements without medical supervision, since impaired kidneys can't clear excess minerals and levels can build up to dangerous amounts. Many common over-the-counter pain relievers (NSAIDs like ibuprofen) can also further reduce kidney function and should be used cautiously.",
144
+ "consult_note": "Always have a doctor review any supplement plan if kidney function markers are abnormal — this is an area where the wrong supplement can cause real harm."
145
+ },
146
+ {
147
+ "id": "vitamin_a",
148
+ "marker_names": ["Vitamin A", "Retinol", "Serum Vitamin A"],
149
+ "reference_range": "Normal: 20-60 mcg/dL (varies by lab); below 20 mcg/dL suggests deficiency",
150
+ "deficiency_symptoms": "Night vision problems (difficulty seeing in low light), dry eyes or skin, frequent infections, and slow wound healing.",
151
+ "recommendations": "Vitamin A supplement (as retinyl palmitate, or beta-carotene which the body converts to vitamin A as needed) — commonly 2500-5000 IU per day from beta-carotene sources is considered a gentler, safer option for ongoing use. Dietary sources include carrots, sweet potatoes, leafy greens, liver, and eggs.",
152
+ "interaction_cautions": "Avoid high-dose preformed vitamin A (retinol) supplements if pregnant or planning pregnancy, as excess vitamin A can cause birth defects. Also avoid combining high-dose vitamin A supplements with prescription retinoid medications (e.g. isotretinoin/Accutane), due to the risk of vitamin A toxicity.",
153
+ "consult_note": "Always consult your doctor before starting supplementation, especially if pregnant, planning pregnancy, or already taking a retinoid medication."
154
+ },
155
+ {
156
+ "id": "selenium",
157
+ "marker_names": ["Selenium", "Serum Selenium"],
158
+ "reference_range": "Normal: 70-150 mcg/L (varies by lab)",
159
+ "deficiency_symptoms": "Fatigue, muscle weakness, hair loss, and a weakened immune system. Very low levels are also linked to thyroid problems, since selenium supports thyroid hormone metabolism.",
160
+ "recommendations": "Selenium supplement — commonly 55-100 mcg per day (selenomethionine is a well-absorbed form). Brazil nuts are an exceptionally rich dietary source — just one or two nuts can provide a full day's worth, so supplementation is often unnecessary if these are eaten regularly.",
161
+ "interaction_cautions": "Selenium has a narrow safety margin — avoid exceeding 400 mcg per day total from food and supplements combined, as excess can cause hair loss, nausea, brittle nails, and nerve problems (selenosis). Use caution if taking blood-thinning medication, as high doses may increase bleeding risk.",
162
+ "consult_note": "Always consult your doctor before starting supplementation, and avoid stacking multiple selenium-containing supplements at once given its narrow safety margin."
163
+ }
164
+ ]
agent/main.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+
4
+ import uvicorn
5
+ from fastapi import FastAPI, File, UploadFile
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import HTMLResponse, StreamingResponse
8
+ from pydantic import BaseModel
9
+ from typing import Any, List, Optional
10
+
11
+ import blood_rag
12
+ import reports
13
+ import tools as notepad
14
+ from agent import run_agent, stream_agent
15
+ from config import PORT
16
+
17
+ app = FastAPI(title="Agentic Notepad Server")
18
+
19
+ app.add_middleware(
20
+ CORSMiddleware,
21
+ allow_origins=["*"],
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+
27
+ # ── OpenAI-compatible request types ───────────────────────────────────────────
28
+ class Message(BaseModel):
29
+ role: str
30
+ content: Any
31
+
32
+ class ChatRequest(BaseModel):
33
+ model: Optional[str] = None # None → fall back to LLM_MODEL in agent config
34
+ messages: List[Message]
35
+ stream: bool = False
36
+ temperature: Optional[float] = None
37
+
38
+
39
+ # ── /v1/chat/completions ───────────────────────────────────────────────────────
40
+ @app.post("/v1/chat/completions")
41
+ async def chat_completions(req: ChatRequest):
42
+ msgs = [m.model_dump() for m in req.messages]
43
+ model = req.model or None # pass None → agent uses LLM_MODEL from config
44
+ print(f"[agent] ▶ using model: {model or 'default → ' + __import__('config').LLM_MODEL}", flush=True)
45
+
46
+ if req.stream:
47
+ async def event_stream():
48
+ async for token in stream_agent(msgs, model=model):
49
+ chunk = {
50
+ "id": "chatcmpl-agent",
51
+ "object": "chat.completion.chunk",
52
+ "choices": [{"delta": {"content": token}, "index": 0, "finish_reason": None}],
53
+ }
54
+ yield f"data: {json.dumps(chunk)}\n\n"
55
+ done = {
56
+ "id": "chatcmpl-agent",
57
+ "object": "chat.completion.chunk",
58
+ "choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}],
59
+ }
60
+ yield f"data: {json.dumps(done)}\n\n"
61
+ yield "data: [DONE]\n\n"
62
+
63
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
64
+
65
+ text = await run_agent(msgs, model=model)
66
+ return {
67
+ "id": "chatcmpl-agent",
68
+ "object": "chat.completion",
69
+ "model": model or "gpt-4o-mini",
70
+ "choices": [{"message": {"role": "assistant", "content": text}, "finish_reason": "stop", "index": 0}],
71
+ }
72
+
73
+
74
+ # ── Blood RAG routes ──────────────────────────────────────────────────────────
75
+ @app.post("/api/blood-rag/upload")
76
+ async def blood_rag_upload(file: UploadFile = File(...)):
77
+ raw = await file.read()
78
+ name = file.filename or "document"
79
+ try:
80
+ text = raw.decode("utf-8", errors="replace")
81
+ except Exception:
82
+ return {"error": "Could not decode file"}
83
+ meta = await blood_rag.add_document(name, text)
84
+ return meta
85
+
86
+
87
+ @app.get("/api/blood-rag/documents")
88
+ async def blood_rag_list():
89
+ return blood_rag.list_documents()
90
+
91
+
92
+ @app.delete("/api/blood-rag/documents/{doc_id}")
93
+ async def blood_rag_delete(doc_id: str):
94
+ deleted = blood_rag.delete_document(doc_id)
95
+ return {"deleted": deleted, "doc_id": doc_id}
96
+
97
+
98
+ @app.get("/api/blood-rag/search")
99
+ async def blood_rag_search(q: str, top_k: int = 4):
100
+ result = await blood_rag.search(q, top_k=top_k)
101
+ return {"query": q, "result": result}
102
+
103
+
104
+ # ── Blood report routes ────────────────────────────────────────────────────────
105
+ @app.post("/api/reports/upload")
106
+ async def upload_report(file: UploadFile = File(...)):
107
+ raw = await file.read()
108
+ return await reports.process_report_upload(raw, file.filename or "report.pdf")
109
+
110
+
111
+ @app.get("/api/reports/current")
112
+ async def current_report():
113
+ return reports.get_current_report()
114
+
115
+
116
+ @app.delete("/api/reports/current")
117
+ async def delete_report():
118
+ reports.clear_report()
119
+ return {"status": "cleared"}
120
+
121
+
122
+ # ── Notepad routes ─────────────────────────────────────────────────────────────
123
+ @app.get("/notepad", response_class=HTMLResponse)
124
+ async def notepad_page():
125
+ return _NOTEPAD_HTML
126
+
127
+
128
+ @app.get("/notepad/events")
129
+ async def notepad_events():
130
+ async def stream():
131
+ q = asyncio.Queue(maxsize=30)
132
+ notepad.add_listener(q)
133
+ try:
134
+ yield f"data: {json.dumps(notepad.get_notes_snapshot())}\n\n"
135
+ while True:
136
+ try:
137
+ payload = await asyncio.wait_for(q.get(), timeout=25)
138
+ yield f"data: {payload}\n\n"
139
+ except asyncio.TimeoutError:
140
+ yield ": keepalive\n\n"
141
+ finally:
142
+ notepad.remove_listener(q)
143
+
144
+ return StreamingResponse(stream(), media_type="text/event-stream")
145
+
146
+
147
+ @app.post("/notepad/clear")
148
+ async def clear_notepad_route():
149
+ notepad.clear_notepad()
150
+ return {"status": "cleared"}
151
+
152
+
153
+ @app.get("/notepad/content")
154
+ async def notepad_content():
155
+ return notepad.get_notes_snapshot()
156
+
157
+
158
+ # ── Notepad HTML ───────────────────────────────────────────────────────────────
159
+ _NOTEPAD_HTML = """<!DOCTYPE html>
160
+ <html lang="en">
161
+ <head>
162
+ <meta charset="UTF-8"/>
163
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
164
+ <title>Live Notepad</title>
165
+ <style>
166
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
167
+ body {
168
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
169
+ background: #0d0d0d; color: #e0e0e0;
170
+ min-height: 100vh; display: flex; flex-direction: column;
171
+ align-items: center; padding: 40px 16px; gap: 20px;
172
+ }
173
+ header { text-align: center; }
174
+ header h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
175
+ header p { font-size: 13px; color: #666; margin-top: 4px; }
176
+
177
+ #status-bar {
178
+ display: flex; align-items: center; gap: 8px;
179
+ font-size: 13px; color: #888;
180
+ }
181
+ #status-dot {
182
+ width: 8px; height: 8px; border-radius: 50%;
183
+ background: #444; transition: background 0.3s;
184
+ }
185
+ #status-dot.live { background: #4CAF50; }
186
+ #status-dot.error { background: #f44336; }
187
+
188
+ #notepad {
189
+ width: 660px; max-width: 100%;
190
+ background: #141414; border: 1px solid #222;
191
+ border-radius: 14px; min-height: 320px;
192
+ padding: 20px; display: flex; flex-direction: column; gap: 10px;
193
+ }
194
+
195
+ #empty-hint {
196
+ color: #3a3a3a; font-size: 14px;
197
+ text-align: center; margin: auto;
198
+ line-height: 1.6;
199
+ }
200
+
201
+ .note-item {
202
+ display: flex; flex-direction: column; gap: 3px;
203
+ padding: 12px 14px; background: #1a1a1a;
204
+ border-radius: 8px; border-left: 3px solid #4CAF50;
205
+ animation: fadeIn 0.25s ease;
206
+ }
207
+ @keyframes fadeIn {
208
+ from { opacity: 0; transform: translateY(5px); }
209
+ to { opacity: 1; transform: none; }
210
+ }
211
+ .note-time { font-size: 11px; color: #555; font-family: monospace; }
212
+ .note-text { font-size: 14px; color: #e0e0e0; line-height: 1.5; }
213
+
214
+ #clear-btn {
215
+ padding: 10px 24px; border-radius: 8px; border: none;
216
+ font-size: 13px; font-weight: 600; cursor: pointer;
217
+ background: #2a2a2a; color: #e0e0e0;
218
+ transition: background 0.2s;
219
+ }
220
+ #clear-btn:hover { background: #f44336; color: #fff; }
221
+
222
+ #note-count {
223
+ font-size: 12px; color: #555;
224
+ }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <header>
229
+ <h1>Live Notepad</h1>
230
+ <p>Notes appear here in real time as you speak to the avatar</p>
231
+ </header>
232
+
233
+ <div id="status-bar">
234
+ <div id="status-dot"></div>
235
+ <span id="status-text">Connecting…</span>
236
+ <span style="margin-left:8px" id="note-count"></span>
237
+ </div>
238
+
239
+ <div id="notepad">
240
+ <div id="notes-list"></div>
241
+ <div id="empty-hint">
242
+ No notes yet.<br/>
243
+ Tell the avatar: <em>"Note that…"</em> or <em>"Write down that…"</em>
244
+ </div>
245
+ </div>
246
+
247
+ <button id="clear-btn">Clear All Notes</button>
248
+
249
+ <script>
250
+ const notesList = document.getElementById('notes-list');
251
+ const emptyHint = document.getElementById('empty-hint');
252
+ const statusDot = document.getElementById('status-dot');
253
+ const statusText = document.getElementById('status-text');
254
+ const noteCount = document.getElementById('note-count');
255
+
256
+ function esc(s) {
257
+ return String(s)
258
+ .replace(/&/g, '&amp;')
259
+ .replace(/</g, '&lt;')
260
+ .replace(/>/g, '&gt;');
261
+ }
262
+
263
+ function renderNotes(notes) {
264
+ noteCount.textContent = notes.length ? `${notes.length} note${notes.length > 1 ? 's' : ''}` : '';
265
+ if (!notes.length) {
266
+ notesList.innerHTML = '';
267
+ emptyHint.style.display = 'block';
268
+ return;
269
+ }
270
+ emptyHint.style.display = 'none';
271
+ notesList.innerHTML = notes.map(n =>
272
+ `<div class="note-item">
273
+ <span class="note-time">${esc(n.time)}</span>
274
+ <span class="note-text">${esc(n.text)}</span>
275
+ </div>`
276
+ ).join('');
277
+ notesList.lastElementChild?.scrollIntoView({ behavior: 'smooth' });
278
+ }
279
+
280
+ const es = new EventSource('/notepad/events');
281
+ es.onopen = () => { statusDot.className = 'live'; statusText.textContent = 'Live'; };
282
+ es.onmessage = (e) => { const d = JSON.parse(e.data); if (d.notes !== undefined) renderNotes(d.notes); };
283
+ es.onerror = () => { statusDot.className = 'error'; statusText.textContent = 'Reconnecting…'; };
284
+
285
+ document.getElementById('clear-btn').addEventListener('click', () => {
286
+ fetch('/notepad/clear', { method: 'POST' });
287
+ });
288
+ </script>
289
+ </body>
290
+ </html>"""
291
+
292
+
293
+ if __name__ == "__main__":
294
+ uvicorn.run("main:app", host="0.0.0.0", port=PORT, reload=True)
agent/rag.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import math
3
+ from pathlib import Path
4
+
5
+ from openai import AsyncOpenAI
6
+
7
+ from config import OPENAI_API_KEY
8
+
9
+ DATA_DIR = Path(__file__).parent / "data"
10
+ KB_FILE = DATA_DIR / "supplements_kb.json"
11
+ EMBEDDINGS_FILE = DATA_DIR / "supplements_embeddings.json"
12
+ EMBEDDING_MODEL = "text-embedding-3-small"
13
+
14
+ # Embeddings always go to OpenAI directly — LLM_BASE_URL may point at a
15
+ # non-OpenAI provider (Groq/Ollama/etc.) that doesn't serve this model.
16
+ _embed_client: AsyncOpenAI | None = None
17
+
18
+
19
+ def _get_embed_client() -> AsyncOpenAI:
20
+ global _embed_client
21
+ if _embed_client is None:
22
+ _embed_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url="https://api.openai.com/v1")
23
+ return _embed_client
24
+
25
+
26
+ def _load_kb() -> list[dict]:
27
+ return json.loads(KB_FILE.read_text())
28
+
29
+
30
+ def _load_embeddings() -> dict:
31
+ if EMBEDDINGS_FILE.exists():
32
+ try:
33
+ return json.loads(EMBEDDINGS_FILE.read_text())
34
+ except Exception:
35
+ pass
36
+ return {}
37
+
38
+
39
+ def _save_embeddings(embeddings: dict) -> None:
40
+ EMBEDDINGS_FILE.write_text(json.dumps(embeddings))
41
+
42
+
43
+ def _chunk_text(entry: dict) -> str:
44
+ return (
45
+ f"Marker: {', '.join(entry['marker_names'])}\n"
46
+ f"Reference range: {entry['reference_range']}\n"
47
+ f"Deficiency symptoms: {entry['deficiency_symptoms']}\n"
48
+ f"Recommendations: {entry['recommendations']}\n"
49
+ f"Interaction cautions: {entry.get('interaction_cautions', '')}\n"
50
+ f"Note: {entry['consult_note']}"
51
+ )
52
+
53
+
54
+ def _cosine_similarity(a: list[float], b: list[float]) -> float:
55
+ dot = sum(x * y for x, y in zip(a, b))
56
+ mag_a = math.sqrt(sum(x * x for x in a))
57
+ mag_b = math.sqrt(sum(y * y for y in b))
58
+ if mag_a == 0 or mag_b == 0:
59
+ return 0.0
60
+ return dot / (mag_a * mag_b)
61
+
62
+
63
+ async def _ensure_embeddings() -> dict:
64
+ """Return {id: {chunk, embedding}}, embedding any KB entries missing from the cache."""
65
+ kb = _load_kb()
66
+ embeddings = _load_embeddings()
67
+
68
+ missing = [e for e in kb if e["id"] not in embeddings]
69
+ if missing:
70
+ client = _get_embed_client()
71
+ for entry in missing:
72
+ chunk = _chunk_text(entry)
73
+ resp = await client.embeddings.create(model=EMBEDDING_MODEL, input=chunk)
74
+ embeddings[entry["id"]] = {"chunk": chunk, "embedding": resp.data[0].embedding}
75
+ _save_embeddings(embeddings)
76
+
77
+ return embeddings
78
+
79
+
80
+ async def search_supplements(query: str, top_k: int = 3) -> str:
81
+ """Embed `query` and return the top-k most relevant supplement KB chunks."""
82
+ embeddings = await _ensure_embeddings()
83
+ if not embeddings:
84
+ return "The supplement knowledge base is empty."
85
+
86
+ client = _get_embed_client()
87
+ resp = await client.embeddings.create(model=EMBEDDING_MODEL, input=query)
88
+ q_vec = resp.data[0].embedding
89
+
90
+ scored = [
91
+ (_cosine_similarity(q_vec, data["embedding"]), kb_id, data["chunk"])
92
+ for kb_id, data in embeddings.items()
93
+ ]
94
+ scored.sort(key=lambda x: x[0], reverse=True)
95
+
96
+ blocks = [f"--- {kb_id} ---\n{chunk}" for _, kb_id, chunk in scored[:top_k]]
97
+ return "\n\n".join(blocks)
agent/reports.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from openai import AsyncOpenAI
7
+
8
+ from config import LLM_BASE_URL, LLM_MODEL, OPENAI_API_KEY
9
+
10
+ DATA_DIR = Path(__file__).parent / "data"
11
+ STATE_FILE = DATA_DIR / "report_state.json"
12
+
13
+ EXTRACTION_PROMPT = (
14
+ "You are a medical lab report parser. Extract every test result from the blood test "
15
+ "report text below. Return ONLY a JSON object of the form "
16
+ '{"results": [{"test_name": str, "value": str, "unit": str, "reference_range": str, '
17
+ '"flag": "low"|"normal"|"high"|"unknown", "value_numeric": number|null, '
18
+ '"ref_low": number|null, "ref_high": number|null}]}. '
19
+ "If the report does not explicitly mark a result as low/high, infer the flag by "
20
+ "comparing the value to the reference range. If a field isn't present in the report, "
21
+ "use an empty string for string fields. "
22
+ "value_numeric is the numeric form of `value` (e.g. \"14.2\" -> 14.2), or null if the "
23
+ "value is not a plain number. ref_low/ref_high are the numeric lower/upper bounds "
24
+ "parsed from `reference_range` when it describes a simple numeric range, e.g. "
25
+ "\"30-100\" -> ref_low=30, ref_high=100; \"below 150\" -> ref_low=null, ref_high=150; "
26
+ "\"above 40\" -> ref_low=40, ref_high=null. If `reference_range` is not a simple "
27
+ "numeric range (qualitative, multiple sub-ranges, etc.), set both ref_low and ref_high "
28
+ "to null. Do not include any text outside the JSON object."
29
+ )
30
+
31
+ _client: AsyncOpenAI | None = None
32
+
33
+
34
+ def _get_client() -> AsyncOpenAI:
35
+ global _client
36
+ if _client is None:
37
+ _client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=LLM_BASE_URL)
38
+ return _client
39
+
40
+
41
+ def _load_state() -> dict:
42
+ if STATE_FILE.exists():
43
+ try:
44
+ return json.loads(STATE_FILE.read_text())
45
+ except Exception:
46
+ pass
47
+ return {"report_id": None, "filename": None, "results": [], "summary": None}
48
+
49
+
50
+ def _save_state() -> None:
51
+ STATE_FILE.write_text(json.dumps(report_state, indent=2))
52
+
53
+
54
+ report_state: dict = _load_state()
55
+
56
+
57
+ def parse_pdf_text(raw: bytes) -> str:
58
+ from pypdf import PdfReader
59
+ reader = PdfReader(io.BytesIO(raw))
60
+ pages = [p.extract_text() or "" for p in reader.pages]
61
+ return "\n\n".join(p for p in pages if p.strip())
62
+
63
+
64
+ async def extract_lab_values(report_text: str, model: str | None = None) -> list[dict]:
65
+ resp = await _get_client().chat.completions.create(
66
+ model=model or LLM_MODEL,
67
+ messages=[
68
+ {"role": "system", "content": EXTRACTION_PROMPT},
69
+ {"role": "user", "content": report_text[:12000]},
70
+ ],
71
+ response_format={"type": "json_object"},
72
+ )
73
+ try:
74
+ data = json.loads(resp.choices[0].message.content or "{}")
75
+ except Exception:
76
+ data = {}
77
+ return data.get("results", [])
78
+
79
+
80
+ def format_report_summary(results: list[dict]) -> str:
81
+ abnormal = [r for r in results if r.get("flag") in ("low", "high")]
82
+ normal = [r for r in results if r.get("flag") == "normal"]
83
+
84
+ lines = []
85
+ if abnormal:
86
+ lines.append("Here are the markers that are outside the normal range:")
87
+ for r in abnormal:
88
+ name = r.get("test_name") or "Unknown marker"
89
+ val_str = f"{r.get('value', '')} {r.get('unit', '')}".strip()
90
+ line = f"{name} is {r.get('flag')} at {val_str}".strip()
91
+ ref = r.get("reference_range")
92
+ if ref:
93
+ line += f" (reference range: {ref})"
94
+ lines.append(line + ".")
95
+ else:
96
+ lines.append("All markers in this report are within the normal range.")
97
+
98
+ if normal:
99
+ names = ", ".join(r.get("test_name") or "Unknown marker" for r in normal)
100
+ lines.append(f"The following markers were normal: {names}.")
101
+
102
+ lines.append(
103
+ "This is not a medical diagnosis — please consult your doctor about these results "
104
+ "before making any changes to your diet or supplements."
105
+ )
106
+ return "\n".join(lines)
107
+
108
+
109
+ async def process_report_upload(raw: bytes, filename: str, model: str | None = None) -> dict:
110
+ if filename.lower().endswith(".pdf"):
111
+ text = parse_pdf_text(raw)
112
+ else:
113
+ text = raw.decode("utf-8", errors="replace")
114
+
115
+ if not text.strip():
116
+ raise ValueError("Could not extract any text from the uploaded report.")
117
+
118
+ results = await extract_lab_values(text, model=model)
119
+ summary = format_report_summary(results)
120
+
121
+ report_state["report_id"] = uuid.uuid4().hex[:8]
122
+ report_state["filename"] = filename
123
+ report_state["results"] = results
124
+ report_state["summary"] = summary
125
+ _save_state()
126
+
127
+ return dict(report_state)
128
+
129
+
130
+ def get_current_report() -> dict:
131
+ if not report_state.get("results"):
132
+ return {"has_report": False}
133
+ return {"has_report": True, **report_state}
134
+
135
+
136
+ def clear_report() -> None:
137
+ report_state["report_id"] = None
138
+ report_state["filename"] = None
139
+ report_state["results"] = []
140
+ report_state["summary"] = None
141
+ _save_state()
agent/tools.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from datetime import datetime
4
+
5
+ import blood_rag
6
+ import reports
7
+ from rag import search_supplements as _search_supplements
8
+
9
+ # ── Notepad state ──────────────────────────────────────────────────────────────
10
+ _notes: list = [] # [{"id": int, "text": str, "time": str}]
11
+ _listeners: list = [] # one asyncio.Queue per connected SSE client
12
+ _counter: int = 0
13
+
14
+
15
+ def _broadcast(payload: dict):
16
+ data = json.dumps(payload)
17
+ for q in list(_listeners):
18
+ try:
19
+ q.put_nowait(data)
20
+ except asyncio.QueueFull:
21
+ pass
22
+
23
+
24
+ def add_listener(q: asyncio.Queue):
25
+ _listeners.append(q)
26
+
27
+
28
+ def remove_listener(q: asyncio.Queue):
29
+ if q in _listeners:
30
+ _listeners.remove(q)
31
+
32
+
33
+ def get_notes_snapshot() -> dict:
34
+ return {"notes": list(_notes)}
35
+
36
+
37
+ # ── Tool implementations ───────────────────────────────────────────────────────
38
+ def write_to_notepad(text: str) -> str:
39
+ global _counter
40
+ _counter += 1
41
+ entry = {
42
+ "id": _counter,
43
+ "text": text.strip(),
44
+ "time": datetime.now().strftime("%I:%M:%S %p"),
45
+ }
46
+ _notes.append(entry)
47
+ _broadcast(get_notes_snapshot())
48
+ return f'Written to notepad: "{text.strip()}"'
49
+
50
+
51
+ def read_notepad() -> str:
52
+ if not _notes:
53
+ return "The notepad is empty."
54
+ lines = [f"{n['time']} — {n['text']}" for n in _notes]
55
+ return "Current notes:\n" + "\n".join(lines)
56
+
57
+
58
+ def clear_notepad() -> str:
59
+ _notes.clear()
60
+ _broadcast(get_notes_snapshot())
61
+ return "Notepad cleared."
62
+
63
+
64
+ def get_datetime() -> str:
65
+ return datetime.now().strftime("%A, %B %d %Y — %I:%M %p")
66
+
67
+
68
+ # ── Clinical tools ────────────────────────────────────────────────────────────
69
+ async def get_blood_report_summary() -> str:
70
+ report = reports.get_current_report()
71
+ if not report.get("has_report"):
72
+ return (
73
+ "No blood test report has been uploaded yet. Ask the user to upload their "
74
+ "blood test report (PDF) using the Blood Test Report uploader."
75
+ )
76
+ return report["summary"]
77
+
78
+
79
+ async def search_supplements(query: str) -> str:
80
+ return await _search_supplements(query)
81
+
82
+
83
+ async def search_blood_knowledge(query: str) -> str:
84
+ """Search the locally indexed blood test documents."""
85
+ return await blood_rag.search(query)
86
+
87
+
88
+ async def run_tool(name: str, args: dict) -> str:
89
+ if name == "write_to_notepad":
90
+ return write_to_notepad(args.get("text", ""))
91
+ if name == "read_notepad":
92
+ return read_notepad()
93
+ if name == "clear_notepad":
94
+ return clear_notepad()
95
+ if name == "get_datetime":
96
+ return get_datetime()
97
+ if name == "get_blood_report_summary":
98
+ return await get_blood_report_summary()
99
+ if name == "search_supplements":
100
+ return await search_supplements(args.get("query", ""))
101
+ if name == "search_blood_knowledge":
102
+ return await search_blood_knowledge(args.get("query", ""))
103
+ return f"Unknown tool: {name}"
104
+
105
+
106
+ # ── OpenAI tool schemas ────────────────────────────────────────────────────────
107
+ TOOLS = [
108
+ {
109
+ "type": "function",
110
+ "function": {
111
+ "name": "write_to_notepad",
112
+ "description": (
113
+ "Write a note to the live notepad. Use this whenever the user says "
114
+ "'note that', 'write down', 'remember', 'save this', 'keep track of', "
115
+ "or asks you to record anything."
116
+ ),
117
+ "parameters": {
118
+ "type": "object",
119
+ "properties": {
120
+ "text": {
121
+ "type": "string",
122
+ "description": (
123
+ "The note text. Be concise — capture the key information "
124
+ "in plain language, no bullet points or markdown."
125
+ ),
126
+ }
127
+ },
128
+ "required": ["text"],
129
+ },
130
+ },
131
+ },
132
+ {
133
+ "type": "function",
134
+ "function": {
135
+ "name": "read_notepad",
136
+ "description": (
137
+ "Read all notes currently on the notepad. Use when the user asks "
138
+ "'what did I note', 'read my notes', or 'what have you written down'."
139
+ ),
140
+ "parameters": {"type": "object", "properties": {}, "required": []},
141
+ },
142
+ },
143
+ {
144
+ "type": "function",
145
+ "function": {
146
+ "name": "clear_notepad",
147
+ "description": "Erase all notes from the notepad. Use only when the user explicitly asks to clear or delete all notes.",
148
+ "parameters": {"type": "object", "properties": {}, "required": []},
149
+ },
150
+ },
151
+ {
152
+ "type": "function",
153
+ "function": {
154
+ "name": "get_datetime",
155
+ "description": "Get the current date and time.",
156
+ "parameters": {"type": "object", "properties": {}, "required": []},
157
+ },
158
+ },
159
+ {
160
+ "type": "function",
161
+ "function": {
162
+ "name": "get_blood_report_summary",
163
+ "description": (
164
+ "Get a summary of the user's most recently uploaded blood test report, "
165
+ "including which markers are low, high, or normal. Call this first "
166
+ "whenever the user asks about their blood test, lab results, deficiencies, "
167
+ "or what supplements they should take."
168
+ ),
169
+ "parameters": {"type": "object", "properties": {}, "required": []},
170
+ },
171
+ },
172
+ {
173
+ "type": "function",
174
+ "function": {
175
+ "name": "search_blood_knowledge",
176
+ "description": (
177
+ "Search the locally indexed blood test RAG for relevant passages about "
178
+ "a patient's blood test results, markers, deficiencies, or lab values. "
179
+ "Use this when the user asks about their blood work, specific markers, "
180
+ "or what their results mean."
181
+ ),
182
+ "parameters": {
183
+ "type": "object",
184
+ "properties": {
185
+ "query": {
186
+ "type": "string",
187
+ "description": "The question or marker to search for, e.g. 'iron deficiency anemia' or 'vitamin D levels'.",
188
+ }
189
+ },
190
+ "required": ["query"],
191
+ },
192
+ },
193
+ },
194
+ {
195
+ "type": "function",
196
+ "function": {
197
+ "name": "search_supplements",
198
+ "description": (
199
+ "Search the supplement knowledge base for recommendations related to a "
200
+ "specific marker or deficiency (e.g. 'low vitamin D', 'low iron / "
201
+ "ferritin'). Call this once per abnormal marker found in the user's blood "
202
+ "report before recommending supplements."
203
+ ),
204
+ "parameters": {
205
+ "type": "object",
206
+ "properties": {
207
+ "query": {
208
+ "type": "string",
209
+ "description": "The marker or deficiency to look up, e.g. 'low vitamin D' or 'high triglycerides'.",
210
+ }
211
+ },
212
+ "required": ["query"],
213
+ },
214
+ },
215
+ },
216
+ ]
anam/.env.example ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Required ──────────────────────────────────────────────────────────────────
2
+ ANAM_API_KEY=your_anam_key_here
3
+
4
+ # ── Avatar ─────────────────────────────────────────────────────────────────────
5
+ # ANAM_AVATAR_ID=NA → use Anam's default public avatar (Cara)
6
+ # ANAM_AVATAR_ID=<id> → use a specific avatar from your Anam Lab dashboard
7
+ ANAM_AVATAR_ID=NA
8
+
9
+ # ── Voice ──────────────────────────────────────────────────────────────────────
10
+ # ANAM_VOICE_ID=NA → use Anam's default voice
11
+ # ANAM_VOICE_ID=<id> → use a specific voice or an ElevenLabs voice imported into Anam Lab
12
+ # To import an ElevenLabs voice: share it as "Professional Voice Clone" in ElevenLabs,
13
+ # then add it in Anam Lab → Voices → Import. Copy the resulting voice ID here.
14
+ ANAM_VOICE_ID=NA
15
+
16
+ # ── LLM ────────────────────────────────────────────────────────────────────────
17
+ # LLM_PROVIDER=NA → use Anam's default built-in LLM (GPT OSS 120B)
18
+ # LLM_PROVIDER=CustomLLM → use a custom LLM registered in your Anam Lab
19
+ # (requires ANAM_LLM_ID set to the ID from Anam Lab)
20
+ # LLM_PROVIDER=ClientSide → disable Anam's brain; use .talk() commands from frontend only
21
+ #
22
+ # Available built-in LLM IDs (set LLM_PROVIDER=CustomLLM + paste one below as ANAM_LLM_ID):
23
+ # a7cf662c-2ace-4de1-a21e-ef0fbf144bb7 → GPT OSS 120B (recommended for new projects)
24
+ # ANAM_LLAMA_v3_3_70B_V1 → Llama 3.3 70B (fast, great conversationalist)
25
+ # b4f89001-9638-4879-a9c3-02cc9f9f2004 → GPT 4.1 (powerful, slower)
26
+ #
27
+ # To register a custom external LLM (Groq, Azure, Gemini, your own server):
28
+ # 1. Go to https://lab.anam.ai/llms
29
+ # 2. Add your LLM endpoint, API key, and model spec
30
+ # 3. Anam will validate it and give you a custom LLM ID
31
+ # 4. Set LLM_PROVIDER=CustomLLM and ANAM_LLM_ID=<that-id>
32
+ LLM_PROVIDER=NA
33
+ # ANAM_LLM_ID=your-custom-llm-id-from-anam-lab
34
+
35
+ # ── TTS ────────────────────────────────────────────────────────────────────────
36
+ # TTS_PROVIDER=NA → use Anam's built-in TTS (recommended, lowest latency)
37
+ # TTS_PROVIDER=ElevenLabs → enable audio passthrough mode; the frontend JS SDK
38
+ # connects to ElevenLabs Conversational AI and pipes
39
+ # PCM audio (16-bit, 16kHz, mono) to the Anam avatar
40
+ # TTS_PROVIDER=Cartesia → same audio passthrough pattern with Cartesia TTS
41
+ # TTS_PROVIDER=CustomVoice → use an ElevenLabs voice imported into your Anam Lab
42
+ # (just set ANAM_VOICE_ID to the imported voice ID and keep
43
+ # TTS_PROVIDER=NA — no passthrough needed)
44
+ #
45
+ # ⚠️ When TTS_PROVIDER != NA:
46
+ # - The backend issues session tokens with enableAudioPassthrough=true
47
+ # - The frontend must implement anamClient.createAgentAudioInputStream()
48
+ # - STT is also then handled by the external TTS provider (e.g. ElevenLabs ConvAI)
49
+ # - See: https://docs.anam.ai/javascript-sdk/examples/custom-tts
50
+ TTS_PROVIDER=NA
51
+
52
+ # ── RAG system prompt ──────────────────────────────────────────────────────────
53
+ # ANAM_RAG_SYSTEM_PROMPT=NA → use the built-in default prompt
54
+ # ANAM_RAG_SYSTEM_PROMPT=<your prompt> → override with a custom prompt
55
+ ANAM_RAG_SYSTEM_PROMPT=NA
56
+
57
+ # ── Local RAG (OpenAI Vector Store) ────────────────────────────────────────────
58
+ # Required for the /api/local-rag/* upload + search feature.
59
+ OPENAI_API_KEY=your_openai_key_here
60
+ #
61
+ # Create once with `python scripts/create_vector_store.py` and paste the id here.
62
+ # Without it, a new empty vector store is created on every restart (uploaded docs
63
+ # won't survive a redeploy).
64
+ OPENAI_VECTOR_STORE_ID=your_vector_store_id_here
anam/README.md ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Anam AI Demo with RAG
2
+
3
+ A FastAPI backend + single-page frontend for real-time conversational AI avatars using the [Anam AI](https://anam.ai) platform, with optional native RAG (Retrieval-Augmented Generation) via Anam's knowledge-base API.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.11+
8
+ - An [Anam AI](https://anam.ai) account and API key
9
+
10
+ ## Project structure
11
+
12
+ ```
13
+ anam/
14
+ ├── config.py # Env vars and constants (API key, base URL, default IDs)
15
+ ├── helpers.py # Shared HTTP utilities (auth_headers, check)
16
+ ├── rag.py # RAG state management + /api/rag/* routes
17
+ ├── main.py # FastAPI app, middleware, startup, session/persona routes
18
+ ├── index.html # Single-page frontend (Anam JS SDK v3.4.0)
19
+ ├── requirements.txt # Python dependencies
20
+ ├── rag_state.json # Auto-created — persists RAG group/tool/persona IDs
21
+ └── .env # Not committed — holds your API key
22
+ ```
23
+
24
+ ## Setup
25
+
26
+ ### 1. Create the virtual environment
27
+
28
+ ```bash
29
+ python3 -m venv .venv
30
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
31
+ ```
32
+
33
+ ### 2. Install dependencies
34
+
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ ```
38
+
39
+ ### 3. Configure the environment
40
+
41
+ Create `anam/.env`:
42
+
43
+ ```env
44
+ # Required
45
+ ANAM_API_KEY=your_anam_api_key_here
46
+
47
+ # Optional — override the default public avatar / voice / LLM
48
+ # ANAM_AVATAR_ID=30fa96d0-26c4-4e55-94a0-517025942e18
49
+ # ANAM_VOICE_ID=6bfbe25a-979d-40f3-a92b-5394170af54b
50
+ # ANAM_LLM_ID=a7cf662c-2ace-4de1-a21e-ef0fbf144bb7
51
+
52
+ # Optional — override the RAG persona system prompt
53
+ # ANAM_RAG_SYSTEM_PROMPT="Your custom prompt here"
54
+ ```
55
+
56
+ If none of the `ANAM_AVATAR_ID / VOICE_ID / LLM_ID` overrides are set, the server falls back to the public Anam demo persona (Cara) from [docs.anam.ai](https://anam.ai/docs).
57
+
58
+ ### 4. Run the server
59
+
60
+ ```bash
61
+ uvicorn main:app --reload --port 8000
62
+ ```
63
+
64
+ Open [http://localhost:8000](http://localhost:8000) in your browser.
65
+
66
+ ## Dependencies
67
+
68
+ | Package | Purpose |
69
+ |---|---|
70
+ | `fastapi` | Web framework |
71
+ | `uvicorn[standard]` | ASGI server |
72
+ | `httpx` | Async HTTP client for Anam API calls |
73
+ | `python-dotenv` | Loads `.env` into the environment |
74
+ | `python-multipart` | Required for file upload (`/api/rag/upload`) |
75
+
76
+ ## API reference
77
+
78
+ ### Config
79
+
80
+ | Method | Path | Description |
81
+ |---|---|---|
82
+ | `GET` | `/api/check` | Returns whether the API key is set |
83
+
84
+ ### Personas
85
+
86
+ | Method | Path | Description |
87
+ |---|---|---|
88
+ | `GET` | `/api/personas` | List all personas in your Anam account |
89
+
90
+ ### Session token
91
+
92
+ | Method | Path | Description |
93
+ |---|---|---|
94
+ | `POST` | `/api/session/token` | Create a client-safe session token |
95
+
96
+ Request body (all fields optional):
97
+
98
+ ```json
99
+ {
100
+ "persona_id": "existing-persona-uuid",
101
+ "name": "AI Assistant",
102
+ "system_prompt": "You are ...",
103
+ "avatar_id": null,
104
+ "voice_id": null,
105
+ "llm_id": null,
106
+ "use_rag": false
107
+ }
108
+ ```
109
+
110
+ Resolution order:
111
+ 1. `use_rag: true` + RAG persona exists → uses the RAG persona
112
+ 2. `persona_id` set → uses that existing persona
113
+ 3. Otherwise → creates an ephemeral persona with the provided (or default) avatar/voice/LLM
114
+
115
+ ### RAG — knowledge base
116
+
117
+ | Method | Path | Description |
118
+ |---|---|---|
119
+ | `GET` | `/api/rag/status` | RAG setup state (group/tool/persona IDs, `ready` flag) |
120
+ | `POST` | `/api/rag/upload` | Upload a document (PDF, TXT, MD, DOCX, CSV — max 50 MB). Auto-creates the knowledge group, RAG tool, and persona on first call |
121
+ | `GET` | `/api/rag/documents` | List all documents in the knowledge group |
122
+ | `DELETE` | `/api/rag/documents/{doc_id}` | Delete a single document |
123
+ | `GET` | `/api/rag/persona` | Get the RAG persona's current system prompt |
124
+ | `PUT` | `/api/rag/persona` | Update the RAG persona's system prompt live (no reconnect needed) |
125
+ | `POST` | `/api/rag/reset` | Clear local RAG state (IDs only — does not delete resources from your Anam account) |
126
+
127
+ RAG state (`group_id`, `tool_id`, `persona_id`) is persisted to `rag_state.json` and survives server restarts. Delete the file or call `POST /api/rag/reset` to start fresh.
128
+
129
+ ## How it works
130
+
131
+ ```
132
+ Browser (Anam JS SDK v3.4.0)
133
+ │
134
+ │ POST /api/session/token
135
+ ▼
136
+ FastAPI (main.py)
137
+ │
138
+ │ Bearer token → Anam REST API (api.anam.ai/v1)
139
+ ▼
140
+ Anam platform — WebRTC stream back to browser
141
+ ```
142
+
143
+ For RAG sessions the avatar automatically invokes the `search_knowledge_base` tool (a native Anam `SERVER_RAG` tool) when the user asks about uploaded documents.
144
+
145
+ ## Free plan (2-minute session limit)
146
+
147
+ The "Free / Pro" switch above the controls mirrors the Tavus demo's free-plan UX:
148
+
149
+ - **Free** (default): the call auto-disconnects after **2 minutes**. A live countdown
150
+ appears next to the status text. When **Agent LLM** mode is active, the avatar also
151
+ speaks an opening "you have two minutes" message and a spoken 30-second warning.
152
+ - **Pro**: no time limit, no banner/countdown.
153
+
154
+ The visual countdown and hard disconnect work regardless of which LLM is selected; the
155
+ spoken opener/warning only fire in **Agent LLM** mode (`createTalkMessageStream`).
156
+
157
+ ## Clinical RAG Assistant (blood test → supplement advice)
158
+
159
+ A demo of a clinical use case built entirely on **our own LLM** — Anam only provides the
160
+ avatar's speech-to-text and text-to-speech.
161
+
162
+ 1. Click **🩺 Clinical Assistant preset** — this sets the system prompt, switches the LLM
163
+ dropdown to **Agent LLM (localhost:8005)**, and opens the advanced panel.
164
+ 2. Upload a **text-based PDF** blood test report in the **🩺 Blood Test Report** card. The
165
+ `agent` server (port 8005) extracts each lab value, flags it `low` / `normal` / `high`,
166
+ and shows the results.
167
+ 3. Click **Connect** and ask the avatar about your results (e.g. *"What does my blood test
168
+ show and what should I take?"*). The Agent LLM calls `get_blood_report_summary` and then
169
+ `search_supplements` (a small pure-Python RAG over `agent/data/supplements_kb.json`) for
170
+ each abnormal marker, and replies in plain spoken language with a reminder to consult
171
+ your doctor.
172
+
173
+ This requires the shared `agent` server running on port 8005 (`uvicorn main:app --reload
174
+ --port 8005 --app-dir agent`), reachable via `AGENT_URL` in `anam/.env` (defaults to
175
+ `http://localhost:8005`).
176
+
177
+ ### Clinical API endpoints (proxied to `agent`)
178
+
179
+ | Method | Path | Description |
180
+ |---|---|---|
181
+ | `POST` | `/api/clinical/upload` | Upload a blood test report (PDF) → `agent`'s `/api/reports/upload` |
182
+ | `GET` | `/api/clinical/current` | Get the current report's extracted markers + summary |
183
+ | `DELETE` | `/api/clinical/current` | Clear the current report |
anam/config.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv(Path(__file__).parent / ".env")
6
+
7
+ ANAM_API_KEY = os.getenv("ANAM_API_KEY", "")
8
+ BASE = "https://api.anam.ai/v1"
9
+ AGENT_URL = os.getenv("AGENT_URL", "http://localhost:8005")
10
+ CATALOG_PDF = os.getenv("CATALOG_PDF", "NA")
11
+
12
+ # ── Local RAG (OpenAI Vector Store) ────────────────────────────────────────────
13
+ # OPENAI_API_KEY is required to use the local RAG upload/search feature.
14
+ # OPENAI_VECTOR_STORE_ID should point at a vector store created once (see
15
+ # scripts/create_vector_store.py) so uploaded documents survive server restarts —
16
+ # without it, a new (empty) vector store is created on every process start.
17
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
18
+ OPENAI_VECTOR_STORE_ID = os.getenv("OPENAI_VECTOR_STORE_ID", "")
19
+
20
+ # ── Telemetry (cost estimation) ────────────────────────────────────────────────
21
+ # Anam doesn't expose cost via API — this rate is used to estimate session cost
22
+ # from duration. Override via ANAM_RATE_PER_MINUTE in .env to match your plan.
23
+ ANAM_RATE_PER_MINUTE: float = float(os.getenv("ANAM_RATE_PER_MINUTE", "0.05"))
24
+
25
+ if not ANAM_API_KEY:
26
+ print("WARNING: ANAM_API_KEY not set — check anam/.env")
27
+
28
+ # ── Avatar / Voice ─────────────────────────────────────────────────────────────
29
+ # Set to NA to use Anam's default public demo persona (Cara).
30
+ # Set to a valid ID from your Anam Lab dashboard to use a custom avatar/voice.
31
+ _avatar_env = os.getenv("ANAM_AVATAR_ID", "NA")
32
+ _voice_env = os.getenv("ANAM_VOICE_ID", "NA")
33
+
34
+ DEFAULT_AVATAR_ID = None if _avatar_env.upper() == "NA" else _avatar_env
35
+ DEFAULT_VOICE_ID = None if _voice_env.upper() == "NA" else _voice_env
36
+
37
+ # Fallback IDs used when the above are None (Anam's public demo persona - Cara)
38
+ _FALLBACK_AVATAR_ID = "30fa96d0-26c4-4e55-94a0-517025942e18"
39
+ _FALLBACK_VOICE_ID = "6bfbe25a-979d-40f3-a92b-5394170af54b"
40
+ _FALLBACK_LLM_ID = "a7cf662c-2ace-4de1-a21e-ef0fbf144bb7" # GPT OSS 120B (recommended)
41
+
42
+ DEFAULT_AVATAR_ID = DEFAULT_AVATAR_ID or _FALLBACK_AVATAR_ID
43
+ DEFAULT_VOICE_ID = DEFAULT_VOICE_ID or _FALLBACK_VOICE_ID
44
+
45
+ # ── Language ───────────────────────────────────────────────────────────────────
46
+ # ISO 639-1 code override for Anam's STT pipeline (what the avatar understands
47
+ # when listening). Set ANAM_LANGUAGE_CODE=NA to use Anam's default multilingual
48
+ # auto-detection instead.
49
+ _language_env = os.getenv("ANAM_LANGUAGE_CODE", "it")
50
+ DEFAULT_LANGUAGE_CODE = None if _language_env.upper() == "NA" else _language_env
51
+
52
+ # ── LLM ────────────────────────────────────────────────────────────────────────
53
+ # LLM_PROVIDER=NA → use Anam's default built-in LLM
54
+ # LLM_PROVIDER=CustomLLM → use a custom LLM registered in Anam Lab
55
+ # (set ANAM_LLM_ID to the ID returned by Anam Lab)
56
+ # LLM_PROVIDER=ClientSide → disable Anam's brain entirely; use .talk() commands only
57
+ #
58
+ # Available built-in Anam LLM IDs:
59
+ # a7cf662c-2ace-4de1-a21e-ef0fbf144bb7 → GPT OSS 120B (recommended)
60
+ # ANAM_LLAMA_v3_3_70B_V1 → Llama 3.3 70B (fast, no tools/RAG)
61
+ # b4f89001-9638-4879-a9c3-02cc9f9f2004 → GPT 4.1 (capable, slower)
62
+ # CUSTOMER_CLIENT_V1 → Client-side LLM (use .talk() only)
63
+ LLM_PROVIDER = os.getenv("LLM_PROVIDER", "NA")
64
+ _llm_id_env = os.getenv("ANAM_LLM_ID", "NA")
65
+
66
+ if LLM_PROVIDER.upper() == "CLIENTSIDE":
67
+ DEFAULT_LLM_ID = "CUSTOMER_CLIENT_V1"
68
+ elif LLM_PROVIDER.upper() == "CUSTOMLLM" and _llm_id_env.upper() != "NA":
69
+ DEFAULT_LLM_ID = _llm_id_env # ID of your LLM registered in Anam Lab
70
+ else:
71
+ DEFAULT_LLM_ID = _FALLBACK_LLM_ID # Anam's default GPT OSS 120B
72
+
73
+ # ── TTS ────────────────────────────────────────────────────────────────────────
74
+ # TTS_PROVIDER=NA → use Anam's built-in TTS (default, lowest latency)
75
+ # TTS_PROVIDER=ElevenLabs → enable audio passthrough; frontend must pipe
76
+ # ElevenLabs audio chunks to the Anam JS SDK
77
+ # TTS_PROVIDER=Cartesia → same audio passthrough pattern, Cartesia audio
78
+ # TTS_PROVIDER=CustomVoice → use a voice imported from ElevenLabs into Anam Lab
79
+ # (set ANAM_VOICE_ID to the imported voice ID)
80
+ #
81
+ # NOTE: When TTS_PROVIDER != NA, the session token is issued with
82
+ # enableAudioPassthrough=true. The frontend JS SDK then handles
83
+ # connecting to the TTS provider and forwarding PCM audio to the avatar.
84
+ TTS_PROVIDER = os.getenv("TTS_PROVIDER", "NA")
85
+
86
+ # Derived flag consumed by the session token endpoint
87
+ ENABLE_AUDIO_PASSTHROUGH: bool = TTS_PROVIDER.upper() != "NA"
88
+
89
+ # ── RAG system prompt ──────────────────────────────────────────────────────────
90
+ _rag_prompt_env = os.getenv("ANAM_RAG_SYSTEM_PROMPT", "NA")
91
+ RAG_SYSTEM_PROMPT = (
92
+ "You are a helpful, friendly AI assistant with access to a knowledge base of uploaded documents. "
93
+ "When the user asks about something that may be in the documents, use the search_knowledge_base tool to look it up and answer based on what you find. "
94
+ "For questions not covered in the documents, answer freely from your general knowledge. Never refuse to answer. "
95
+ "VERY IMPORTANT: You are a speaking avatar. Always respond in plain natural spoken language only. "
96
+ "Never use bullet points, numbered lists, asterisks, pipe characters, hyphens as list markers, markdown headers, or any other formatting symbols. "
97
+ "Never write symbols that would sound strange when spoken aloud. Use only clear conversational sentences. "
98
+ "IMPORTANT: Always respond in Italian (italiano), regardless of the language the question was asked in."
99
+ ) if _rag_prompt_env.upper() == "NA" else _rag_prompt_env
anam/helpers.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from fastapi import HTTPException
3
+
4
+ from config import ANAM_API_KEY
5
+
6
+
7
+ def auth_headers() -> dict:
8
+ return {"Authorization": f"Bearer {ANAM_API_KEY}", "Content-Type": "application/json"}
9
+
10
+
11
+ def check(r: httpx.Response) -> None:
12
+ if r.is_error:
13
+ try:
14
+ detail = r.json()
15
+ except Exception:
16
+ detail = r.text
17
+ raise HTTPException(status_code=r.status_code, detail=detail)
anam/index.html ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Anam AI Demo</title>
7
+ <link rel="stylesheet" href="/static/css/style.css" />
8
+ <script src="/static/js/anam-sdk.js"></script>
9
+ </head>
10
+ <body>
11
+
12
+ <header>
13
+ <h1>Anam AI Demo</h1>
14
+ <p>Real-time conversation with an Anam AI persona — with document Q&amp;A</p>
15
+ </header>
16
+
17
+ <!-- Video -->
18
+ <div id="video-container">
19
+ <div id="placeholder">
20
+ <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
21
+ <circle cx="12" cy="8" r="4"/>
22
+ <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
23
+ </svg>
24
+ <p>Avatar will appear here</p>
25
+ <p id="placeholder-hint" style="font-size:11px;margin-top:2px;">Click <strong>Connect</strong> to start</p>
26
+ <div id="placeholder-spinner"></div>
27
+ <p id="placeholder-status"></p>
28
+ </div>
29
+ <video id="avatar-video" autoplay playsinline></video>
30
+ <div id="rag-active-badge">📚 RAG Active</div>
31
+ <div id="mic-indicator">
32
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
33
+ <rect x="9" y="2" width="6" height="12" rx="3"/>
34
+ <path d="M5 10a7 7 0 0 0 14 0M12 19v3M9 22h6"/>
35
+ </svg>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Status -->
40
+ <div id="status-bar">
41
+ <div id="status-dot"></div>
42
+ <span id="status-text">Not connected</span>
43
+ <span id="call-timer"></span>
44
+ </div>
45
+
46
+ <!-- Plan toggle (Free = 2 min cap) -->
47
+ <div id="plan-section">
48
+ <div id="plan-row">
49
+ <span class="plan-label" id="plan-free-label">Free · 2 min limit</span>
50
+ <label class="switch">
51
+ <input type="checkbox" id="plan-switch" checked />
52
+ <span class="knob"></span>
53
+ </label>
54
+ <span class="plan-label active" id="plan-pro-label">Pro · unlimited</span>
55
+ </div>
56
+ <div id="plan-banner">
57
+ ⏱ <strong>Free plan:</strong> the avatar will tell you up front that you have
58
+ <strong>2 minutes</strong> together, show a live countdown, and the session will
59
+ disconnect automatically when time runs out.
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Controls -->
64
+ <div class="controls">
65
+ <select id="persona-select"><option value="">— Default ephemeral —</option></select>
66
+ <button id="connect-btn">Connect</button>
67
+ <button id="disconnect-btn" disabled>Disconnect</button>
68
+ </div>
69
+
70
+ <div id="preset-row">
71
+ <button id="advanced-toggle">⚙ Advanced (system prompt)</button>
72
+ <button id="clinical-preset-btn">🩺 Clinical Assistant preset</button>
73
+ </div>
74
+
75
+ <div id="advanced-panel">
76
+ <label for="system-prompt-input">System prompt (used when no persona selected and RAG is off)</label>
77
+ <textarea id="system-prompt-input" rows="3">You are a helpful, friendly AI assistant. Keep responses concise and conversational. Always respond in plain spoken language — never use bullet points, asterisks, pipe characters, markdown headers, or any formatting symbols. Always respond in Italian (italiano), regardless of the language the question was asked in.</textarea>
78
+
79
+ <!-- ── LLM selector ───────────────────────────────────────────────────── -->
80
+ <div style="margin-top:10px;display:flex;flex-direction:column;gap:4px">
81
+ <label for="llm-select" style="font-size:11px;color:#666;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">LLM</label>
82
+ <select id="llm-select" style="padding:8px 10px;border-radius:6px;border:1px solid #2a2a2a;background:#1a1a1a;color:#e0e0e0;font-size:13px">
83
+ <optgroup label="Anam built-in">
84
+ <option value="a7cf662c-2ace-4de1-a21e-ef0fbf144bb7">GPT OSS 120B — recommended default</option>
85
+ <option value="b4f89001-9638-4879-a9c3-02cc9f9f2004">GPT 4.1 — most capable</option>
86
+ <option value="ANAM_LLAMA_v3_3_70B_V1">Llama 3.3 70B — fastest (no tools/RAG)</option>
87
+ </optgroup>
88
+ <optgroup label="Custom">
89
+ <option value="__agent__">Agent LLM (localhost:8005) — tools &amp; notepad</option>
90
+ <option value="__custom__">Custom Anam LLM ID…</option>
91
+ </optgroup>
92
+ </select>
93
+ </div>
94
+
95
+ <!-- shown only when Agent LLM is selected -->
96
+ <div id="agent-model-row" style="display:none;margin-top:6px;display:flex;flex-direction:column;gap:4px">
97
+ <label for="agent-model-input" style="font-size:11px;color:#666;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">OpenAI Model</label>
98
+ <input id="agent-model-input" list="agent-model-list" type="text" placeholder="gpt-4o-mini (default)"
99
+ style="padding:8px 10px;border-radius:6px;border:1px solid #2a2a2a;background:#1a1a1a;color:#e0e0e0;font-size:12px" />
100
+ <datalist id="agent-model-list">
101
+ <option value="gpt-4o-mini">
102
+ <option value="gpt-4o">
103
+ <option value="gpt-4-turbo">
104
+ <option value="gpt-4">
105
+ <option value="gpt-3.5-turbo">
106
+ <option value="o1-mini">
107
+ <option value="o1-preview">
108
+ </datalist>
109
+ </div>
110
+
111
+ <!-- shown only when Custom LLM ID is selected -->
112
+ <div id="custom-id-row" style="display:none;margin-top:6px;display:flex;flex-direction:column;gap:4px">
113
+ <label for="custom-llm-id-input" style="font-size:11px;color:#666;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Custom LLM ID</label>
114
+ <input id="custom-llm-id-input" type="text" placeholder="Paste your Anam LLM ID here"
115
+ style="padding:8px 10px;border-radius:6px;border:1px solid #2a2a2a;background:#1a1a1a;color:#e0e0e0;font-size:12px;font-family:monospace" />
116
+ </div>
117
+
118
+ <!-- Voice ID -->
119
+ <div style="margin-top:6px;display:flex;flex-direction:column;gap:4px">
120
+ <label for="voice-id-input" style="font-size:11px;color:#666;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Voice ID</label>
121
+ <input id="voice-id-input" type="text" placeholder="Leave blank for default"
122
+ style="padding:8px 10px;border-radius:6px;border:1px solid #2a2a2a;background:#1a1a1a;color:#e0e0e0;font-size:12px" />
123
+ </div>
124
+
125
+ <!-- hidden — driven by llm-select, kept for legacy JS compatibility -->
126
+ <input type="checkbox" id="custom-llm-checkbox" style="display:none" />
127
+ </div>
128
+
129
+ <!-- Mode tabs (shown after connect) -->
130
+ <div class="mode-tabs" id="mode-tabs" style="display:none">
131
+ <button class="mode-tab active" data-mode="voice">🎙 Voice</button>
132
+ <button class="mode-tab" data-mode="text" >⌨️ Text</button>
133
+ </div>
134
+
135
+ <!-- Input area -->
136
+ <div class="input-area">
137
+ <div id="voice-mode-row">
138
+ <div class="voice-btn-row">
139
+ <button id="mic-btn" disabled title="Toggle microphone">
140
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
141
+ <rect x="9" y="2" width="6" height="12" rx="3"/>
142
+ <path d="M5 10a7 7 0 0 0 14 0M12 19v3M9 22h6"/>
143
+ </svg>
144
+ </button>
145
+ <button id="interrupt-btn" disabled>⏹ Interrupt</button>
146
+ </div>
147
+ <span id="voice-hint">Microphone is active — speak to the avatar</span>
148
+ </div>
149
+ <div id="text-mode-row">
150
+ <input id="message-input" type="text" placeholder="Type a message to the avatar…" disabled />
151
+ <button id="send-btn" disabled>Send</button>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Knowledge Base -->
156
+ <div id="kb-section">
157
+ <div class="kb-header">
158
+ <h3>📚 Knowledge Base</h3>
159
+ <div class="kb-header-right">
160
+ <!-- RAG mode tabs -->
161
+ <div class="rag-mode-tabs" id="rag-mode-tabs">
162
+ <button class="rag-mode-tab active" data-mode="anam">☁ Anam Cloud</button>
163
+ <button class="rag-mode-tab" data-mode="local">💻 Local</button>
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- ── Anam Cloud RAG panel ─────────────────────────────────────────────── -->
169
+ <div id="anam-rag-panel" style="display:flex;flex-direction:column;gap:14px">
170
+ <div class="rag-mode-note">Documents indexed in Anam's cloud — avatar searches per turn</div>
171
+
172
+ <div class="kb-header" style="margin-top:4px">
173
+ <div></div>
174
+ <div class="kb-header-right">
175
+ <span id="kb-setup-badge"></span>
176
+ <button class="kb-reset-btn" id="kb-reset-btn" title="Clear RAG state (does not delete from Anam)">Reset</button>
177
+ </div>
178
+ </div>
179
+
180
+ <div id="drop-zone">
181
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
182
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
183
+ <polyline points="17 8 12 3 7 8"/>
184
+ <line x1="12" y1="3" x2="12" y2="15"/>
185
+ </svg>
186
+ <p>Drop documents here</p>
187
+ <span>PDF · TXT · MD · DOCX · CSV — max 50 MB</span>
188
+ <button id="browse-btn">Browse files</button>
189
+ </div>
190
+ <input type="file" id="file-input" multiple accept=".pdf,.txt,.md,.docx,.csv" hidden />
191
+
192
+ <div id="upload-status">
193
+ <div class="spinner"></div>
194
+ <span id="upload-status-text">Uploading…</span>
195
+ </div>
196
+
197
+ <div id="doc-list"></div>
198
+
199
+ <label id="use-rag-row" for="use-rag-checkbox">
200
+ <input type="checkbox" id="use-rag-checkbox" checked />
201
+ <span id="use-rag-label">Use knowledge base — avatar will <strong>search your docs</strong> when answering</span>
202
+ <span id="rag-doc-count"></span>
203
+ </label>
204
+
205
+ <div id="rag-prompt-section">
206
+ <button id="rag-prompt-toggle">✏️ Edit RAG assistant behaviour (system prompt)</button>
207
+ <div id="rag-prompt-body" style="display:none;flex-direction:column;gap:8px">
208
+ <label for="rag-system-prompt">
209
+ System prompt — controls how the avatar uses the knowledge base and answers general questions
210
+ </label>
211
+ <textarea id="rag-system-prompt" rows="6"></textarea>
212
+ <div class="rag-prompt-actions">
213
+ <button id="apply-rag-prompt-btn">Apply to persona</button>
214
+ <span id="rag-prompt-hint">Changes apply to the next session you start.</span>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <!-- ── Local RAG panel ──────────────────────────────────────────────────── -->
221
+ <div id="local-rag-panel" style="display:none;flex-direction:column;gap:14px">
222
+ <div class="rag-mode-note">Documents chunked &amp; embedded locally — only the most relevant passages are retrieved per question.
223
+ <br><span style="color:#555">Ideal for clinical guidelines, protocols &amp; reference PDFs. Embeddings persist across restarts.</span>
224
+ </div>
225
+
226
+ <div id="local-drop-zone">
227
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
228
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
229
+ <polyline points="17 8 12 3 7 8"/>
230
+ <line x1="12" y1="3" x2="12" y2="15"/>
231
+ </svg>
232
+ <p>Drop reference documents here</p>
233
+ <span>PDF · DOCX · TXT · MD</span>
234
+ <button id="local-browse-btn">Browse files</button>
235
+ </div>
236
+ <input type="file" id="local-file-input" multiple accept=".pdf,.docx,.txt,.md" hidden />
237
+
238
+ <div id="local-upload-status" style="display:none">
239
+ <div class="spinner"></div>
240
+ <span id="local-upload-status-text">Parsing…</span>
241
+ </div>
242
+
243
+ <div id="local-doc-list"></div>
244
+
245
+ <label id="use-local-rag-row" for="use-local-rag-checkbox" style="display:none">
246
+ <input type="checkbox" id="use-local-rag-checkbox" checked />
247
+ <span>Inject documents into avatar's context when connecting</span>
248
+ <span id="local-doc-count"></span>
249
+ </label>
250
+ </div>
251
+ </div>
252
+
253
+ <!-- Blood Test Report -->
254
+ <div id="clinical-section">
255
+ <div class="kb-header">
256
+ <h3>🩺 Blood Test Report</h3>
257
+ <div class="kb-header-right">
258
+ <span id="report-status-badge"></span>
259
+ <button class="kb-reset-btn" id="report-reset-btn" title="Clear uploaded report">Reset</button>
260
+ </div>
261
+ </div>
262
+
263
+ <div id="report-drop-zone">
264
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
265
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
266
+ <polyline points="17 8 12 3 7 8"/>
267
+ <line x1="12" y1="3" x2="12" y2="15"/>
268
+ </svg>
269
+ <p>Drop your blood test report here</p>
270
+ <span>Text-based PDF — max 50 MB</span>
271
+ <button id="report-browse-btn">Browse files</button>
272
+ </div>
273
+ <input type="file" id="report-file-input" accept=".pdf" hidden />
274
+
275
+ <div id="report-upload-status">
276
+ <div class="spinner"></div>
277
+ <span id="report-upload-status-text">Analysing report…</span>
278
+ </div>
279
+
280
+ <div id="report-summary"></div>
281
+ </div>
282
+
283
+ <!-- Usage & cost telemetry -->
284
+ <div id="telemetry-panel">
285
+ <div class="tm-header">
286
+ <h3>📊 Session Cost</h3>
287
+ <button class="kb-reset-btn" id="telemetry-reset-btn">Reset</button>
288
+ </div>
289
+
290
+ <div id="telemetry-stats">
291
+ <div class="tm-stat">
292
+ <span class="tm-stat-value" id="tm-total-cost">$0.00</span>
293
+ <span class="tm-stat-label">Estimated cost</span>
294
+ </div>
295
+ <div class="tm-stat">
296
+ <span class="tm-stat-value" id="tm-total-minutes">0</span>
297
+ <span class="tm-stat-label">Minutes billed</span>
298
+ </div>
299
+ <div class="tm-stat">
300
+ <span class="tm-stat-value" id="tm-session-count">0</span>
301
+ <span class="tm-stat-label">Sessions</span>
302
+ </div>
303
+ <div class="tm-stat live" id="tm-live-stat" style="display:none">
304
+ <span class="tm-stat-value" id="tm-live-cost">$0.00</span>
305
+ <span class="tm-stat-label">Live session</span>
306
+ </div>
307
+ </div>
308
+
309
+ <p id="telemetry-rate-note"></p>
310
+ <div id="telemetry-sessions"></div>
311
+ </div>
312
+
313
+ <div id="log"></div>
314
+
315
+ <script type="module" src="/static/js/app.js"></script>
316
+ </body>
317
+ </html>
anam/local_store.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local RAG store, backed by OpenAI's hosted Vector Store:
3
+ upload → OpenAI Files + Vector Store attach (OpenAI does the chunking/embedding)
4
+ query → OpenAI Vector Store search → return top-k chunks
5
+
6
+ Documents live in OpenAI's vector store (OPENAI_VECTOR_STORE_ID), not on local disk,
7
+ so they survive server restarts/redeploys without needing a persistent volume.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from openai import OpenAI
12
+
13
+ from config import OPENAI_API_KEY, OPENAI_VECTOR_STORE_ID
14
+
15
+ TOP_K = 4
16
+ MIN_SCORE = 0.20
17
+
18
+ _client: OpenAI | None = None
19
+ _vector_store_id: str | None = OPENAI_VECTOR_STORE_ID or None
20
+
21
+ # OpenAI's vector_stores.files.delete reports success immediately but the file can
22
+ # stay listed/searchable for a while afterwards (observed, not documented). Track
23
+ # deletions in-process so a delete is reflected right away regardless of backend lag.
24
+ # Intentionally in-memory only — doesn't reintroduce a disk-persistence requirement,
25
+ # just resets (harmlessly) on restart once OpenAI's side has caught up.
26
+ _deleted_ids: set[str] = set()
27
+
28
+
29
+ def _get_client() -> OpenAI:
30
+ global _client
31
+ if _client is None:
32
+ _client = OpenAI(api_key=OPENAI_API_KEY)
33
+ return _client
34
+
35
+
36
+ def _get_vector_store_id() -> str:
37
+ """Return the configured vector store id, or lazily create one (dev fallback —
38
+ documents won't survive a restart unless OPENAI_VECTOR_STORE_ID is set)."""
39
+ global _vector_store_id
40
+ if _vector_store_id is None:
41
+ store = _get_client().vector_stores.create(name="anam-local-rag")
42
+ _vector_store_id = store.id
43
+ print(
44
+ f"[local_store] No OPENAI_VECTOR_STORE_ID set — created {store.id}. "
45
+ "Set OPENAI_VECTOR_STORE_ID to this value so documents persist across restarts."
46
+ )
47
+ return _vector_store_id
48
+
49
+
50
+ def warmup() -> None:
51
+ """Keeps the API stable with the previous module (no model to preload anymore)."""
52
+ pass
53
+
54
+
55
+ # ── Public API ────────────────────────────────────────────────────────────────
56
+ def add_doc(name: str, text: str) -> dict:
57
+ """
58
+ Upload the document to OpenAI, attach it to the vector store, and wait for
59
+ indexing to complete. Returns metadata dict: {id, name, chars, words, chunk_count}.
60
+ """
61
+ client = _get_client()
62
+ vector_store_id = _get_vector_store_id()
63
+ chars = len(text)
64
+ words = len(text.split())
65
+
66
+ file = client.vector_stores.files.upload_and_poll(
67
+ vector_store_id=vector_store_id,
68
+ file=(name, text.encode("utf-8")),
69
+ attributes={"filename": name},
70
+ )
71
+
72
+ if file.status != "completed":
73
+ raise RuntimeError(f"Indexing failed for {name!r}: {file.last_error}")
74
+
75
+ return {
76
+ "id": file.id,
77
+ "name": name,
78
+ "chars": chars,
79
+ "words": words,
80
+ "chunk_count": None, # OpenAI doesn't expose a chunk count
81
+ }
82
+
83
+
84
+ def delete_doc(doc_id: str) -> bool:
85
+ """Detach and delete a document from the vector store."""
86
+ client = _get_client()
87
+ vector_store_id = _get_vector_store_id()
88
+ try:
89
+ client.vector_stores.files.delete(doc_id, vector_store_id=vector_store_id)
90
+ except Exception:
91
+ return False
92
+ try:
93
+ client.files.delete(doc_id)
94
+ except Exception:
95
+ pass
96
+ _deleted_ids.add(doc_id)
97
+ return True
98
+
99
+
100
+ def list_docs() -> list[dict]:
101
+ """List all documents currently indexed in the vector store."""
102
+ client = _get_client()
103
+ vector_store_id = _get_vector_store_id()
104
+ docs: list[dict] = []
105
+ for f in client.vector_stores.files.list(vector_store_id):
106
+ if f.id in _deleted_ids:
107
+ continue
108
+ name = (f.attributes or {}).get("filename", "unknown")
109
+ docs.append({
110
+ "id": f.id,
111
+ "name": name,
112
+ "chars": None,
113
+ "words": None,
114
+ "chunk_count": None,
115
+ })
116
+ return docs
117
+
118
+
119
+ def search(query: str, doc_ids: list[str] | None = None, top_k: int = TOP_K) -> str:
120
+ """
121
+ Search the vector store for chunks relevant to `query` and return a formatted
122
+ context block for the LLM.
123
+
124
+ - doc_ids: restrict results to these document (file) IDs (None = search all docs)
125
+ - Returns "" when nothing relevant is found (score < MIN_SCORE).
126
+ """
127
+ if not query.strip():
128
+ return ""
129
+
130
+ client = _get_client()
131
+ vector_store_id = _get_vector_store_id()
132
+
133
+ results = client.vector_stores.search(
134
+ vector_store_id,
135
+ query=query,
136
+ max_num_results=top_k * 2 if doc_ids else top_k,
137
+ )
138
+
139
+ parts: list[str] = []
140
+ for r in results.data:
141
+ if r.file_id in _deleted_ids:
142
+ continue
143
+ if doc_ids and r.file_id not in doc_ids:
144
+ continue
145
+ if r.score < MIN_SCORE:
146
+ continue
147
+ text = "\n".join(c.text for c in r.content if c.type == "text")
148
+ if text.strip():
149
+ parts.append(f"[Source: {r.filename}]\n{text}")
150
+ if len(parts) >= top_k:
151
+ break
152
+
153
+ if not parts:
154
+ return ""
155
+
156
+ return (
157
+ "RETRIEVED CONTEXT — The following passages were retrieved from the uploaded "
158
+ "reference documents because they are semantically relevant to the user's "
159
+ "question. Answer using this information:\n\n"
160
+ + "\n\n---\n\n".join(parts)
161
+ )
anam/main.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import httpx
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from config import BASE, RAG_SYSTEM_PROMPT
9
+ from helpers import auth_headers
10
+ from rag import rag_state, router as rag_router
11
+ from routers import clinical, frontend, llm, local_rag as local_rag_router, personas, sessions, telemetry as telemetry_router
12
+
13
+ app = FastAPI(title="Anam AI Demo with RAG")
14
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
15
+ app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
16
+
17
+ for router in (frontend.router, personas.router, sessions.router, llm.router, clinical.router, telemetry_router.router):
18
+ app.include_router(router)
19
+ app.include_router(rag_router)
20
+ app.include_router(local_rag_router.router)
21
+
22
+
23
+
24
+ @app.on_event("startup")
25
+ async def sync_persona_prompt():
26
+ """Push the current RAG_SYSTEM_PROMPT to the existing persona on every server start."""
27
+ if not rag_state.get("persona_id"):
28
+ return
29
+ try:
30
+ async with httpx.AsyncClient(timeout=15) as c:
31
+ await c.put(
32
+ f"{BASE}/personas/{rag_state['persona_id']}",
33
+ headers=auth_headers(),
34
+ json={"systemPrompt": RAG_SYSTEM_PROMPT},
35
+ )
36
+ print("RAG persona system prompt synced on startup.")
37
+ except Exception as e:
38
+ print(f"Could not sync persona prompt on startup: {e}")
anam/rag.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+ from fastapi import APIRouter, HTTPException, UploadFile, File
6
+ from fastapi.responses import JSONResponse
7
+ from pydantic import BaseModel
8
+
9
+ from config import BASE, ANAM_API_KEY, DEFAULT_AVATAR_ID, DEFAULT_VOICE_ID, DEFAULT_LLM_ID, DEFAULT_LANGUAGE_CODE, RAG_SYSTEM_PROMPT
10
+ from helpers import auth_headers, check
11
+
12
+ # ── Persistent state ──────────────────────────────────────────────────────────
13
+ # Stored in rag_state.json so IDs survive server restarts.
14
+ # Resets require deleting that file or calling POST /api/rag/reset.
15
+
16
+ RAG_STATE_FILE = Path(__file__).parent / "rag_state.json"
17
+
18
+
19
+ def _load_state() -> dict:
20
+ if RAG_STATE_FILE.exists():
21
+ try:
22
+ return json.loads(RAG_STATE_FILE.read_text())
23
+ except Exception:
24
+ pass
25
+ return {"group_id": None, "tool_id": None, "persona_id": None}
26
+
27
+
28
+ def _save_state() -> None:
29
+ RAG_STATE_FILE.write_text(json.dumps(rag_state, indent=2))
30
+
31
+
32
+ rag_state: dict = _load_state()
33
+
34
+
35
+ async def ensure_rag_setup() -> None:
36
+ """
37
+ Lazily create, in order:
38
+ 1. A knowledge group to hold uploaded documents.
39
+ 2. A SERVER_RAG tool that searches that group.
40
+ 3. A persona with the tool attached (so sessions can use personaId).
41
+ IDs are persisted to rag_state.json after each step.
42
+ """
43
+ async with httpx.AsyncClient(timeout=30) as c:
44
+ if not rag_state["group_id"]:
45
+ r = await c.post(
46
+ f"{BASE}/knowledge/groups",
47
+ headers=auth_headers(),
48
+ json={"name": "Demo Knowledge Base", "description": "Uploaded documents for RAG demo"},
49
+ )
50
+ check(r)
51
+ rag_state["group_id"] = r.json()["id"]
52
+ _save_state()
53
+
54
+ if not rag_state["tool_id"]:
55
+ r = await c.post(
56
+ f"{BASE}/tools",
57
+ headers=auth_headers(),
58
+ json={
59
+ "name": "search_knowledge_base",
60
+ "description": (
61
+ "Search the knowledge base for relevant information to answer user questions. "
62
+ "Use this whenever the user asks about content from their documents."
63
+ ),
64
+ "type": "SERVER_RAG",
65
+ "config": {"documentFolderIds": [rag_state["group_id"]]},
66
+ },
67
+ )
68
+ check(r)
69
+ rag_state["tool_id"] = r.json()["id"]
70
+ _save_state()
71
+
72
+ if not rag_state["persona_id"]:
73
+ persona_payload = {
74
+ "name": "RAG Demo Assistant",
75
+ "avatarId": DEFAULT_AVATAR_ID,
76
+ "voiceId": DEFAULT_VOICE_ID,
77
+ "llmId": DEFAULT_LLM_ID,
78
+ "systemPrompt": RAG_SYSTEM_PROMPT,
79
+ "toolIds": [rag_state["tool_id"]],
80
+ }
81
+ if DEFAULT_LANGUAGE_CODE:
82
+ persona_payload["languageCode"] = DEFAULT_LANGUAGE_CODE
83
+ r = await c.post(
84
+ f"{BASE}/personas",
85
+ headers=auth_headers(),
86
+ json=persona_payload,
87
+ )
88
+ check(r)
89
+ rag_state["persona_id"] = r.json()["id"]
90
+ _save_state()
91
+
92
+
93
+ # ── RAG router ────────────────────────────────────────────────────────────────
94
+
95
+ router = APIRouter(prefix="/api/rag")
96
+
97
+
98
+ @router.get("/status")
99
+ async def rag_status():
100
+ return JSONResponse({
101
+ "group_id": rag_state.get("group_id"),
102
+ "tool_id": rag_state.get("tool_id"),
103
+ "persona_id": rag_state.get("persona_id"),
104
+ "ready": bool(rag_state.get("persona_id")),
105
+ })
106
+
107
+
108
+ @router.post("/upload")
109
+ async def rag_upload(file: UploadFile = File(...)):
110
+ """Upload a document to the knowledge group. Auto-creates group/tool/persona on first call."""
111
+ await ensure_rag_setup()
112
+ content = await file.read()
113
+ async with httpx.AsyncClient(timeout=120) as c:
114
+ r = await c.post(
115
+ f"{BASE}/knowledge/groups/{rag_state['group_id']}/documents",
116
+ headers={"Authorization": f"Bearer {ANAM_API_KEY}"},
117
+ files={"file": (file.filename, content, file.content_type or "application/octet-stream")},
118
+ data={"chunkSize": "1000", "chunkOverlap": "200"},
119
+ )
120
+ check(r)
121
+ return JSONResponse(r.json())
122
+
123
+
124
+ @router.get("/documents")
125
+ async def rag_documents():
126
+ """List all documents in the knowledge group."""
127
+ if not rag_state.get("group_id"):
128
+ return JSONResponse({"items": []})
129
+ async with httpx.AsyncClient(timeout=30) as c:
130
+ r = await c.get(
131
+ f"{BASE}/knowledge/groups/{rag_state['group_id']}/documents",
132
+ headers=auth_headers(),
133
+ )
134
+ check(r)
135
+ return JSONResponse(r.json())
136
+
137
+
138
+ @router.delete("/documents/{doc_id}")
139
+ async def rag_delete_document(doc_id: str):
140
+ """Delete a single document from the knowledge group."""
141
+ async with httpx.AsyncClient(timeout=30) as c:
142
+ r = await c.delete(
143
+ f"{BASE}/knowledge/documents/{doc_id}",
144
+ headers=auth_headers(),
145
+ )
146
+ check(r)
147
+ return JSONResponse({"ok": True})
148
+
149
+
150
+ class UpdatePersonaRequest(BaseModel):
151
+ system_prompt: str
152
+
153
+
154
+ @router.put("/persona")
155
+ async def update_rag_persona(req: UpdatePersonaRequest):
156
+ """Update the RAG persona's system prompt on the fly (no need to reconnect)."""
157
+ if not rag_state.get("persona_id"):
158
+ raise HTTPException(status_code=404, detail="RAG persona not set up yet — upload a document first.")
159
+ async with httpx.AsyncClient(timeout=30) as c:
160
+ r = await c.put(
161
+ f"{BASE}/personas/{rag_state['persona_id']}",
162
+ headers=auth_headers(),
163
+ json={"systemPrompt": req.system_prompt},
164
+ )
165
+ check(r)
166
+ return JSONResponse({"ok": True})
167
+
168
+
169
+ @router.get("/persona")
170
+ async def get_rag_persona():
171
+ """Return the current RAG persona's system prompt."""
172
+ if not rag_state.get("persona_id"):
173
+ return JSONResponse({"system_prompt": RAG_SYSTEM_PROMPT})
174
+ async with httpx.AsyncClient(timeout=30) as c:
175
+ r = await c.get(f"{BASE}/personas/{rag_state['persona_id']}", headers=auth_headers())
176
+ check(r)
177
+ data = r.json()
178
+ return JSONResponse({"system_prompt": data.get("systemPrompt", RAG_SYSTEM_PROMPT)})
179
+
180
+
181
+ @router.post("/reset")
182
+ async def rag_reset():
183
+ """
184
+ Clear local RAG state (group/tool/persona IDs).
185
+ Does NOT delete resources from your Anam account — do that in the Anam dashboard if needed.
186
+ """
187
+ rag_state["group_id"] = None
188
+ rag_state["tool_id"] = None
189
+ rag_state["persona_id"] = None
190
+ _save_state()
191
+ return JSONResponse({"ok": True})
anam/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ httpx
4
+ python-dotenv
5
+ python-multipart
6
+ pypdf
7
+ python-docx
8
+ openai
anam/routers/__init__.py ADDED
File without changes
anam/routers/clinical.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from fastapi import APIRouter, File, UploadFile
3
+ from fastapi.responses import JSONResponse
4
+
5
+ from config import AGENT_URL
6
+ from helpers import check
7
+
8
+ router = APIRouter(prefix="/api/clinical", tags=["clinical"])
9
+
10
+
11
+ @router.post("/upload")
12
+ async def upload_report(file: UploadFile = File(...)):
13
+ """Proxy a blood test report to the agent server for parsing + extraction."""
14
+ content = await file.read()
15
+ async with httpx.AsyncClient(timeout=120) as client:
16
+ r = await client.post(
17
+ f"{AGENT_URL}/api/reports/upload",
18
+ files={"file": (file.filename, content, file.content_type or "application/octet-stream")},
19
+ )
20
+ check(r)
21
+ return JSONResponse(r.json())
22
+
23
+
24
+ @router.get("/current")
25
+ async def current_report():
26
+ async with httpx.AsyncClient(timeout=30) as client:
27
+ r = await client.get(f"{AGENT_URL}/api/reports/current")
28
+ check(r)
29
+ return JSONResponse(r.json())
30
+
31
+
32
+ @router.delete("/current")
33
+ async def delete_report():
34
+ async with httpx.AsyncClient(timeout=30) as client:
35
+ r = await client.delete(f"{AGENT_URL}/api/reports/current")
36
+ check(r)
37
+ return JSONResponse(r.json())
anam/routers/frontend.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from fastapi import APIRouter
4
+ from fastapi.responses import HTMLResponse
5
+
6
+ from config import ANAM_API_KEY, DEFAULT_LLM_ID, LLM_PROVIDER
7
+
8
+ router = APIRouter(tags=["frontend"])
9
+
10
+ _INDEX = Path(__file__).parent.parent / "index.html"
11
+
12
+
13
+ @router.get("/", response_class=HTMLResponse)
14
+ async def serve_frontend():
15
+ return _INDEX.read_text()
16
+
17
+
18
+ @router.get("/api/check")
19
+ async def check_config():
20
+ return {
21
+ "api_key_set": bool(ANAM_API_KEY),
22
+ "prefix": ANAM_API_KEY[:8] + "…" if ANAM_API_KEY else "",
23
+ "llm_provider": LLM_PROVIDER,
24
+ "default_llm_id": DEFAULT_LLM_ID, # pre-selects the correct dropdown option
25
+ }
anam/routers/llm.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import time
4
+ from functools import partial
5
+ from typing import List
6
+
7
+ import httpx
8
+ from fastapi import APIRouter
9
+ from fastapi.responses import StreamingResponse
10
+ from pydantic import BaseModel
11
+
12
+ from config import AGENT_URL, BASE
13
+ from helpers import auth_headers
14
+ from local_store import search as search_local
15
+
16
+ router = APIRouter(prefix="/api", tags=["llm"])
17
+
18
+
19
+ class ChatMessage(BaseModel):
20
+ role: str
21
+ content: str
22
+
23
+ class ChatStreamRequest(BaseModel):
24
+ messages: List[ChatMessage]
25
+ model: str | None = None # OpenAI model ID
26
+ system_prompt: str | None = None # from the Advanced panel — ignored by Anam's brain in ClientSide LLM mode, so we inject it here instead
27
+ local_rag_doc_ids: List[str] = [] # inject local docs into system prompt
28
+ cloud_rag_group_id: str | None = None # search Anam knowledge group per turn
29
+
30
+
31
+ async def _search_anam_knowledge(group_id: str, query: str) -> str:
32
+ """Search Anam's knowledge group and return a formatted context block."""
33
+ try:
34
+ async with httpx.AsyncClient(timeout=10) as c:
35
+ r = await c.post(
36
+ f"{BASE}/knowledge/groups/{group_id}/search",
37
+ headers=auth_headers(),
38
+ json={"query": query, "topK": 4},
39
+ )
40
+ if r.is_error:
41
+ return ""
42
+ results = r.json().get("results") or r.json().get("items") or []
43
+ if not results:
44
+ return ""
45
+ chunks = "\n\n".join(
46
+ r.get("text") or r.get("content") or r.get("chunk", "")
47
+ for r in results
48
+ )
49
+ return (
50
+ "KNOWLEDGE BASE — Retrieved context relevant to the user's question:\n\n"
51
+ + chunks
52
+ )
53
+ except Exception:
54
+ return ""
55
+
56
+
57
+ def _build_messages(req: ChatStreamRequest, rag_context: str) -> list:
58
+ """
59
+ Normalise Anam roles and inject RAG context as the first system message.
60
+ If Anam already provided a system message, prepend the RAG block to it.
61
+ """
62
+ msgs = [
63
+ {"role": "assistant" if m.role == "persona" else m.role, "content": m.content}
64
+ for m in req.messages
65
+ ]
66
+
67
+ system_parts = [p for p in (req.system_prompt, rag_context) if p]
68
+ if not system_parts:
69
+ return msgs
70
+
71
+ system_block = "\n\n".join(system_parts)
72
+ if msgs and msgs[0]["role"] == "system":
73
+ msgs[0]["content"] = system_block + "\n\n" + msgs[0]["content"]
74
+ else:
75
+ msgs.insert(0, {"role": "system", "content": system_block})
76
+
77
+ return msgs
78
+
79
+
80
+ @router.post("/chat-stream")
81
+ async def chat_stream(req: ChatStreamRequest):
82
+ """
83
+ Proxy the conversation to the agent server and re-stream in Anam's
84
+ newline-delimited JSON format: {"content": "chunk"}\\n
85
+
86
+ RAG injection order (highest priority first):
87
+ 1. local_rag_doc_ids → full doc text from in-memory store
88
+ 2. cloud_rag_group_id → per-turn semantic search against Anam knowledge group
89
+ """
90
+ t0 = time.perf_counter()
91
+ user_query = next(
92
+ (m.content for m in reversed(req.messages) if m.role == "user"), ""
93
+ )
94
+ print(f"[chat-stream] query: {user_query!r}")
95
+
96
+ # ── RAG retrieval ──────────────────────────────────────────────────────────
97
+ rag_context = ""
98
+ if req.local_rag_doc_ids:
99
+ loop = asyncio.get_event_loop()
100
+ rag_context = await loop.run_in_executor(
101
+ None, partial(search_local, user_query, req.local_rag_doc_ids)
102
+ )
103
+ print(f"[chat-stream] RAG search done in {(time.perf_counter()-t0)*1000:.0f}ms chunks={rag_context[:60]!r}...")
104
+ elif req.cloud_rag_group_id:
105
+ if user_query:
106
+ rag_context = await _search_anam_knowledge(req.cloud_rag_group_id, user_query)
107
+ print(f"[chat-stream] cloud RAG done in {(time.perf_counter()-t0)*1000:.0f}ms")
108
+
109
+ payload = {
110
+ "messages": _build_messages(req, rag_context),
111
+ "stream": True,
112
+ }
113
+ if req.model:
114
+ payload["model"] = req.model
115
+
116
+ llm_url = f"{AGENT_URL}/v1/chat/completions"
117
+ print(f"[chat-stream] --> calling LLM server: POST {llm_url} msgs={len(payload['messages'])} t={( time.perf_counter()-t0)*1000:.0f}ms")
118
+
119
+ async def generate():
120
+ first = True
121
+ total_chars = 0
122
+ async with httpx.AsyncClient(timeout=60) as client:
123
+ async with client.stream(
124
+ "POST",
125
+ llm_url,
126
+ json=payload,
127
+ headers={"Content-Type": "application/json"},
128
+ ) as resp:
129
+ print(f"[chat-stream] <-- LLM server responded: HTTP {resp.status_code} t={( time.perf_counter()-t0)*1000:.0f}ms")
130
+ async for line in resp.aiter_lines():
131
+ if not line.startswith("data: "):
132
+ continue
133
+ data = line[6:]
134
+ if data.strip() == "[DONE]":
135
+ break
136
+ try:
137
+ chunk = json.loads(data)
138
+ content = chunk["choices"][0]["delta"].get("content", "")
139
+ if content:
140
+ if first:
141
+ print(f"[chat-stream] first token at {(time.perf_counter()-t0)*1000:.0f}ms")
142
+ first = False
143
+ total_chars += len(content)
144
+ yield json.dumps({"content": content}) + "\n"
145
+ except (json.JSONDecodeError, KeyError):
146
+ pass
147
+ print(f"[chat-stream] done — {total_chars} chars streamed total={( time.perf_counter()-t0)*1000:.0f}ms")
148
+
149
+ return StreamingResponse(generate(), media_type="text/event-stream")
anam/routers/local_rag.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import io
3
+ import sys
4
+ import traceback
5
+ from functools import partial
6
+
7
+ from fastapi import APIRouter, File, HTTPException, UploadFile
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from local_store import add_doc, delete_doc, list_docs
11
+
12
+ router = APIRouter(prefix="/api/local-rag", tags=["local-rag"])
13
+
14
+ MAX_FILE_BYTES = 20 * 1024 * 1024 # 20 MB hard limit
15
+
16
+
17
+ async def _parse(file: UploadFile) -> str:
18
+ raw = await file.read()
19
+
20
+ if len(raw) > MAX_FILE_BYTES:
21
+ raise HTTPException(
22
+ 413,
23
+ f"File too large ({len(raw) // 1024 // 1024} MB). "
24
+ f"Maximum allowed size is {MAX_FILE_BYTES // 1024 // 1024} MB."
25
+ )
26
+
27
+ name = (file.filename or "").lower()
28
+
29
+ if name.endswith(".pdf"):
30
+ try:
31
+ from pypdf import PdfReader
32
+ reader = PdfReader(io.BytesIO(raw))
33
+ pages = [p.extract_text() or "" for p in reader.pages]
34
+ return "\n\n".join(p for p in pages if p.strip())
35
+ except Exception as e:
36
+ raise HTTPException(400, f"PDF parse error: {e}")
37
+
38
+ if name.endswith(".docx"):
39
+ try:
40
+ from docx import Document
41
+ doc = Document(io.BytesIO(raw))
42
+ return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
43
+ except Exception as e:
44
+ raise HTTPException(400, f"DOCX parse error: {e}")
45
+
46
+ try:
47
+ return raw.decode("utf-8", errors="replace")
48
+ except Exception:
49
+ raise HTTPException(400, "Could not decode file as UTF-8 text")
50
+
51
+
52
+ @router.post("/upload")
53
+ async def upload_local_doc(file: UploadFile = File(...)):
54
+ text = await _parse(file)
55
+ if not text.strip():
56
+ raise HTTPException(400, "No text could be extracted from this file.")
57
+
58
+ filename = file.filename or "document"
59
+ loop = asyncio.get_event_loop()
60
+ try:
61
+ result = await loop.run_in_executor(None, partial(add_doc, filename, text))
62
+ except MemoryError:
63
+ print(f"[local-rag] OOM while indexing {filename}", file=sys.stderr)
64
+ raise HTTPException(507, "Server ran out of memory while indexing. Try a smaller file.")
65
+ except Exception as e:
66
+ print(f"[local-rag] indexing failed for {filename}: {e}", file=sys.stderr)
67
+ traceback.print_exc(file=sys.stderr)
68
+ raise HTTPException(500, f"Indexing failed: {e}")
69
+
70
+ print(f"[local-rag] indexed: {result['name']} chunks={result['chunk_count']} words={result['words']}")
71
+ return JSONResponse(result)
72
+
73
+
74
+ @router.get("/documents")
75
+ async def list_local_docs():
76
+ return JSONResponse({"items": list_docs()})
77
+
78
+
79
+ @router.delete("/documents/{doc_id}")
80
+ async def delete_local_doc(doc_id: str):
81
+ if not delete_doc(doc_id):
82
+ raise HTTPException(404, "Document not found")
83
+ return JSONResponse({"ok": True})
anam/routers/personas.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ from fastapi import APIRouter
3
+ from fastapi.responses import JSONResponse
4
+
5
+ from config import BASE
6
+ from helpers import auth_headers, check
7
+
8
+ router = APIRouter(prefix="/api", tags=["personas"])
9
+
10
+
11
+ @router.get("/personas")
12
+ async def list_personas():
13
+ async with httpx.AsyncClient(timeout=30) as c:
14
+ r = await c.get(f"{BASE}/personas", headers=auth_headers())
15
+ check(r)
16
+ return JSONResponse(r.json())
anam/routers/sessions.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+ import httpx
4
+ from fastapi import APIRouter
5
+ from fastapi.responses import JSONResponse
6
+ from pydantic import BaseModel
7
+
8
+ import telemetry
9
+ from config import (
10
+ BASE,
11
+ DEFAULT_AVATAR_ID,
12
+ DEFAULT_LANGUAGE_CODE,
13
+ DEFAULT_LLM_ID,
14
+ DEFAULT_VOICE_ID,
15
+ ENABLE_AUDIO_PASSTHROUGH,
16
+ TTS_PROVIDER,
17
+ )
18
+ from helpers import auth_headers, check
19
+ from rag import rag_state
20
+ from schemas import SessionTokenRequest
21
+
22
+ router = APIRouter(prefix="/api", tags=["sessions"])
23
+
24
+
25
+ class EndSessionRequest(BaseModel):
26
+ session_id: str
27
+
28
+
29
+ @router.post("/session/token")
30
+ async def create_session_token(req: SessionTokenRequest):
31
+ system_prompt = req.system_prompt
32
+
33
+ language_code = req.language_code or DEFAULT_LANGUAGE_CODE
34
+
35
+ if req.use_custom_llm:
36
+ persona_config = {
37
+ "name": req.name,
38
+ "avatarId": req.avatar_id or DEFAULT_AVATAR_ID,
39
+ "voiceId": req.voice_id or DEFAULT_VOICE_ID,
40
+ "llmId": "CUSTOMER_CLIENT_V1",
41
+ "systemPrompt": system_prompt,
42
+ }
43
+ if language_code:
44
+ persona_config["languageCode"] = language_code
45
+ elif req.use_rag and rag_state.get("persona_id") and not req.local_rag_doc_ids:
46
+ # Persistent personas only accept personaId here — the persona itself
47
+ # already has languageCode/systemPrompt baked in (see rag.py).
48
+ persona_config: dict = {"personaId": rag_state["persona_id"]}
49
+ elif req.persona_id:
50
+ persona_config = {"personaId": req.persona_id}
51
+ else:
52
+ persona_config = {
53
+ "name": req.name,
54
+ "avatarId": req.avatar_id or DEFAULT_AVATAR_ID,
55
+ "voiceId": req.voice_id or DEFAULT_VOICE_ID,
56
+ "llmId": req.llm_id or DEFAULT_LLM_ID,
57
+ "systemPrompt": system_prompt,
58
+ }
59
+ if language_code:
60
+ persona_config["languageCode"] = language_code
61
+
62
+ # ── TTS passthrough ────────────────────────────────────────────────────────
63
+ # When TTS_PROVIDER != NA, Anam disables its own TTS pipeline so the
64
+ # frontend JS SDK can pipe audio from ElevenLabs / Cartesia / etc.
65
+ # The frontend must call anamClient.createAgentAudioInputStream() and
66
+ # forward PCM audio chunks (16-bit, 16 kHz, mono) to the avatar.
67
+ if ENABLE_AUDIO_PASSTHROUGH:
68
+ persona_config["enableAudioPassthrough"] = True
69
+
70
+ async with httpx.AsyncClient(timeout=30) as c:
71
+ r = await c.post(
72
+ f"{BASE}/auth/session-token",
73
+ headers=auth_headers(),
74
+ json={"personaConfig": persona_config},
75
+ )
76
+ check(r)
77
+ data = r.json()
78
+ # Surface the active TTS provider to the frontend so it knows what to connect to
79
+ data["ttsProvider"] = TTS_PROVIDER
80
+
81
+ # ── Telemetry ──────────────────────────────────────────────────────────
82
+ session_id = uuid.uuid4().hex
83
+ telemetry.start_session(session_id, req.name, req.plan)
84
+ data["sessionId"] = session_id
85
+
86
+ # When Cloud RAG + Custom LLM, the SERVER_RAG tool is bypassed because
87
+ # CUSTOMER_CLIENT_V1 has no Anam LLM to call it. Pass the knowledge
88
+ # group ID to the frontend so it can do per-turn search via /api/chat-stream.
89
+ if req.use_rag and req.use_custom_llm and rag_state.get("group_id"):
90
+ data["ragGroupId"] = rag_state["group_id"]
91
+
92
+ return JSONResponse(data)
93
+
94
+
95
+ @router.post("/session/end")
96
+ async def end_session(req: EndSessionRequest):
97
+ telemetry.end_session(req.session_id)
98
+ return {"status": "ok"}
anam/routers/telemetry.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ import telemetry
4
+
5
+ router = APIRouter(prefix="/api/telemetry", tags=["telemetry"])
6
+
7
+
8
+ @router.get("/summary")
9
+ async def get_summary():
10
+ return telemetry.summary()
11
+
12
+
13
+ @router.get("/sessions")
14
+ async def get_sessions():
15
+ return telemetry.list_sessions()
16
+
17
+
18
+ @router.post("/reset")
19
+ async def reset_telemetry():
20
+ telemetry.reset()
21
+ return {"status": "reset"}
anam/schemas.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class SessionTokenRequest(BaseModel):
7
+ persona_id: Optional[str] = None
8
+ name: str = "AI Assistant"
9
+ system_prompt: str = (
10
+ "You are a helpful, friendly AI assistant. Keep responses concise and conversational. "
11
+ "Always respond in plain spoken language. Never use bullet points, asterisks, pipe characters, "
12
+ "markdown headers, or any formatting symbols — speak in natural sentences only. "
13
+ "Always respond in Italian (italiano), regardless of the language the question was asked in."
14
+ )
15
+ avatar_id: Optional[str] = None
16
+ voice_id: Optional[str] = None
17
+ llm_id: Optional[str] = None
18
+ language_code: Optional[str] = None # ISO 639-1 STT override, e.g. "it" — falls back to ANAM_LANGUAGE_CODE
19
+ use_rag: bool = False
20
+ local_rag_doc_ids: list[str] = [] # inject these local docs into system prompt
21
+ use_custom_llm: bool = False # set llmId=CUSTOMER_CLIENT_V1 to bypass Anam's brain
22
+ plan: str = "pro" # "free" or "pro" — recorded in telemetry only
anam/static/css/style.css ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ body {
4
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5
+ background: #0d0d0d; color: #e0e0e0;
6
+ min-height: 100vh; display: flex; flex-direction: column;
7
+ align-items: center; padding: 32px 16px; gap: 20px;
8
+ }
9
+
10
+ header { text-align: center; }
11
+ header h1 { font-size: 26px; font-weight: 700; }
12
+ header p { font-size: 13px; color: #666; margin-top: 4px; }
13
+
14
+ /* ── Video ── */
15
+ #video-container {
16
+ width: 640px; max-width: 100%; aspect-ratio: 16/9;
17
+ background: #1a1a1a; border-radius: 14px; overflow: hidden;
18
+ position: relative; border: 1px solid #2a2a2a;
19
+ }
20
+ #avatar-video { width: 100%; height: 100%; object-fit: cover; display: none; }
21
+ #placeholder {
22
+ position: absolute; inset: 0; display: flex; flex-direction: column;
23
+ align-items: center; justify-content: center; gap: 8px; color: #444;
24
+ }
25
+ #placeholder svg { opacity: 0.3; transition: opacity 0.3s; }
26
+ #placeholder p { font-size: 14px; }
27
+
28
+ #placeholder.connecting svg { display: none; }
29
+ #placeholder.connecting #placeholder-hint { display: none; }
30
+ #placeholder-spinner {
31
+ display: none; width: 48px; height: 48px;
32
+ border: 3px solid #2a2a2a; border-top-color: #FFC107;
33
+ border-radius: 50%; animation: spin 0.9s linear infinite;
34
+ }
35
+ #placeholder.connecting #placeholder-spinner { display: block; }
36
+ #placeholder-status {
37
+ font-size: 13px; color: #888; display: none;
38
+ }
39
+ #placeholder.connecting #placeholder-status { display: block; }
40
+
41
+ #rag-active-badge {
42
+ position: absolute; top: 12px; left: 12px; display: none;
43
+ background: rgba(156,39,176,0.85); color: #fff;
44
+ font-size: 11px; font-weight: 600; padding: 4px 10px;
45
+ border-radius: 20px; letter-spacing: 0.3px;
46
+ }
47
+
48
+ #mic-indicator {
49
+ position: absolute; bottom: 14px; right: 14px;
50
+ width: 36px; height: 36px; border-radius: 50%;
51
+ background: rgba(0,0,0,0.6); display: none;
52
+ align-items: center; justify-content: center;
53
+ border: 2px solid #4CAF50; transition: border-color 0.2s;
54
+ }
55
+ #mic-indicator.muted { border-color: #f44336; }
56
+ #mic-indicator svg { width: 18px; height: 18px; }
57
+
58
+ /* ── Status ── */
59
+ #status-bar { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #888; }
60
+ #status-dot {
61
+ width: 8px; height: 8px; border-radius: 50%; background: #444; transition: background 0.3s;
62
+ }
63
+ #status-dot.connected { background: #4CAF50; }
64
+ #status-dot.connecting { background: #FFC107; animation: pulse 1s infinite; }
65
+ #status-dot.error { background: #f44336; }
66
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
67
+ @keyframes spin { to { transform: rotate(360deg); } }
68
+
69
+ #call-timer {
70
+ display: none; margin-left: auto; font-size: 13px; font-weight: 600;
71
+ color: #FFC107; font-variant-numeric: tabular-nums;
72
+ }
73
+ #call-timer.visible { display: inline-block; }
74
+ #call-timer.ending { color: #f44336; animation: pulse 1s infinite; }
75
+
76
+ /* ── Plan toggle (Free / Pro) ── */
77
+ #plan-section { width: 640px; max-width: 100%; }
78
+ #plan-row { display: flex; align-items: center; gap: 10px; }
79
+ #plan-row .plan-label { font-size: 12px; color: #666; transition: color 0.2s; }
80
+ #plan-row .plan-label.active { color: #e0e0e0; font-weight: 600; }
81
+
82
+ .switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
83
+ .switch input { opacity: 0; width: 0; height: 0; }
84
+ .switch .knob {
85
+ position: absolute; inset: 0; cursor: pointer; border-radius: 24px;
86
+ background: #ff9800; transition: background 0.25s; /* Free = orange */
87
+ }
88
+ .switch .knob::before {
89
+ content: ""; position: absolute; left: 3px; top: 3px;
90
+ width: 18px; height: 18px; border-radius: 50%;
91
+ background: #fff; transition: transform 0.25s;
92
+ }
93
+ .switch input:checked + .knob { background: #4CAF50; } /* Pro = green */
94
+ .switch input:checked + .knob::before { transform: translateX(20px); }
95
+
96
+ #plan-banner {
97
+ display: none; margin-top: 8px; padding: 10px 12px;
98
+ background: #2a2112; border: 1px solid #4a3a1b; border-radius: 8px;
99
+ font-size: 12px; color: #FFC107; line-height: 1.5;
100
+ }
101
+ #plan-banner.visible { display: block; }
102
+
103
+ /* ── Controls ── */
104
+ .controls {
105
+ display: flex; gap: 10px; flex-wrap: wrap;
106
+ justify-content: center; width: 640px; max-width: 100%;
107
+ }
108
+ select {
109
+ flex: 1; min-width: 200px; padding: 10px 14px;
110
+ border-radius: 8px; border: 1px solid #2a2a2a;
111
+ background: #1a1a1a; color: #e0e0e0; font-size: 13px; cursor: pointer;
112
+ }
113
+ button {
114
+ padding: 10px 20px; border-radius: 8px; border: none;
115
+ font-size: 13px; font-weight: 600; cursor: pointer;
116
+ transition: opacity 0.2s, transform 0.1s; white-space: nowrap;
117
+ }
118
+ button:active:not(:disabled) { transform: scale(0.97); }
119
+ button:disabled { opacity: 0.35; cursor: not-allowed; }
120
+ #connect-btn { background: #4CAF50; color: #fff; }
121
+ #disconnect-btn { background: #f44336; color: #fff; }
122
+
123
+ /* ── Advanced panel ── */
124
+ #preset-row { display: flex; gap: 16px; align-items: center; }
125
+ #advanced-toggle {
126
+ font-size: 12px; color: #555; cursor: pointer; text-decoration: underline;
127
+ background: none; border: none; padding: 0;
128
+ }
129
+ #advanced-toggle:hover { color: #888; }
130
+ #clinical-preset-btn {
131
+ font-size: 12px; color: #4a7a4a; cursor: pointer; text-decoration: underline;
132
+ background: none; border: none; padding: 0;
133
+ }
134
+ #clinical-preset-btn:hover { color: #4CAF50; }
135
+ #advanced-panel {
136
+ display: none; width: 640px; max-width: 100%;
137
+ background: #111; border-radius: 10px; padding: 14px;
138
+ border: 1px solid #1e1e1e; flex-direction: column; gap: 8px;
139
+ }
140
+ #advanced-panel label { font-size: 12px; color: #888; }
141
+ #system-prompt-input {
142
+ width: 100%; padding: 10px 12px; border-radius: 8px;
143
+ border: 1px solid #2a2a2a; background: #1a1a1a;
144
+ color: #e0e0e0; font-size: 13px; font-family: inherit;
145
+ resize: vertical; outline: none;
146
+ }
147
+ #system-prompt-input:focus { border-color: #2196F3; }
148
+
149
+ /* ── Mode tabs ── */
150
+ .mode-tabs {
151
+ display: flex; gap: 0; border-radius: 8px; overflow: hidden;
152
+ border: 1px solid #2a2a2a; width: fit-content;
153
+ }
154
+ .mode-tab {
155
+ padding: 8px 18px; font-size: 13px; font-weight: 500;
156
+ background: #1a1a1a; color: #888; cursor: pointer; border: none;
157
+ transition: background 0.2s, color 0.2s;
158
+ }
159
+ .mode-tab.active { background: #2196F3; color: #fff; }
160
+
161
+ /* ── Input area ── */
162
+ .input-area { width: 640px; max-width: 100%; }
163
+
164
+ #voice-mode-row {
165
+ display: none; flex-direction: column;
166
+ align-items: center; gap: 14px; padding: 8px 0;
167
+ }
168
+ .voice-btn-row { display: flex; gap: 14px; align-items: center; }
169
+
170
+ #mic-btn {
171
+ width: 72px; height: 72px; border-radius: 50%; border: none; cursor: pointer;
172
+ display: flex; align-items: center; justify-content: center;
173
+ background: #1a1a1a; border: 2px solid #4CAF50;
174
+ transition: background 0.2s, border-color 0.2s, transform 0.1s;
175
+ }
176
+ #mic-btn:disabled { opacity: 0.35; cursor: not-allowed; }
177
+ #mic-btn.muted { border-color: #f44336; background: #2a1a1a; }
178
+ #mic-btn:active:not(:disabled) { transform: scale(0.94); }
179
+
180
+ #interrupt-btn {
181
+ padding: 10px 18px; background: #FF9800; color: #fff;
182
+ border-radius: 8px; border: none; font-size: 13px; font-weight: 600;
183
+ cursor: pointer; transition: opacity 0.2s;
184
+ }
185
+ #interrupt-btn:disabled { opacity: 0.35; cursor: not-allowed; }
186
+ #voice-hint { font-size: 12px; color: #666; }
187
+
188
+ #text-mode-row { display: none; gap: 10px; }
189
+ #message-input {
190
+ flex: 1; padding: 10px 14px; border-radius: 8px;
191
+ border: 1px solid #2a2a2a; background: #1a1a1a;
192
+ color: #e0e0e0; font-size: 14px; outline: none; transition: border-color 0.2s;
193
+ }
194
+ #message-input:focus { border-color: #2196F3; }
195
+ #message-input:disabled { opacity: 0.35; }
196
+ #send-btn { background: #2196F3; color: #fff; }
197
+
198
+ /* ── Knowledge Base ── */
199
+ #kb-section {
200
+ width: 640px; max-width: 100%;
201
+ background: #111; border: 1px solid #1e2a1e;
202
+ border-radius: 12px; padding: 18px;
203
+ display: flex; flex-direction: column; gap: 14px;
204
+ }
205
+ .kb-header { display: flex; align-items: center; justify-content: space-between; }
206
+ .kb-header h3 { font-size: 15px; font-weight: 600; color: #c8e6c9; }
207
+ .kb-header-right { display: flex; align-items: center; gap: 10px; }
208
+ #kb-setup-badge {
209
+ font-size: 11px; font-weight: 600; padding: 3px 9px;
210
+ border-radius: 20px; display: none;
211
+ }
212
+ #kb-setup-badge.ready { background: #1b3a1b; color: #4CAF50; border: 1px solid #2a5a2a; }
213
+ #kb-setup-badge.setting-up { background: #2a2a1b; color: #FFC107; border: 1px solid #4a4a1b; }
214
+
215
+ /* Drop zone */
216
+ #drop-zone {
217
+ border: 2px dashed #2a2a2a; border-radius: 10px;
218
+ padding: 24px 16px; text-align: center; cursor: pointer;
219
+ transition: border-color 0.2s, background 0.2s;
220
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
221
+ }
222
+ #drop-zone:hover, #drop-zone.drag-over { border-color: #4CAF50; background: #0d1a0d; }
223
+ #drop-zone svg { opacity: 0.4; }
224
+ #drop-zone p { font-size: 13px; color: #666; }
225
+ #drop-zone span { font-size: 12px; color: #444; }
226
+ #browse-btn {
227
+ padding: 6px 14px; border-radius: 6px; border: 1px solid #2a2a2a;
228
+ background: #1a1a1a; color: #aaa; font-size: 12px; font-weight: 600;
229
+ cursor: pointer; margin-top: 4px;
230
+ }
231
+ #browse-btn:hover { border-color: #4CAF50; color: #4CAF50; }
232
+
233
+ /* Upload status */
234
+ #upload-status {
235
+ display: none; align-items: center; gap: 10px;
236
+ background: #0d1a0d; border: 1px solid #2a5a2a;
237
+ border-radius: 8px; padding: 10px 14px; font-size: 13px; color: #4CAF50;
238
+ }
239
+ .spinner {
240
+ width: 16px; height: 16px; border: 2px solid #2a5a2a;
241
+ border-top-color: #4CAF50; border-radius: 50%;
242
+ animation: spin 0.8s linear infinite; flex-shrink: 0;
243
+ }
244
+
245
+ /* Document list */
246
+ #doc-list { display: flex; flex-direction: column; gap: 8px; }
247
+ .doc-row {
248
+ display: flex; align-items: center; gap: 10px;
249
+ background: #161616; border: 1px solid #222; border-radius: 8px; padding: 10px 12px;
250
+ }
251
+ .doc-icon { font-size: 18px; flex-shrink: 0; }
252
+ .doc-name { flex: 1; font-size: 13px; color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
253
+ .doc-size { font-size: 11px; color: #555; flex-shrink: 0; }
254
+ .doc-badge {
255
+ font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 20px;
256
+ flex-shrink: 0; display: flex; align-items: center; gap: 5px;
257
+ }
258
+ .doc-badge.ready { background: #1b3a1b; color: #4CAF50; border: 1px solid #2a5a2a; }
259
+ .doc-badge.processing { background: #2a1f0d; color: #FF9800; border: 1px solid #4a3a1b; }
260
+ .doc-badge.uploading { background: #1a1a2a; color: #FFC107; border: 1px solid #3a3a2a; }
261
+ .doc-badge.failed { background: #2a1010; color: #f44336; border: 1px solid #4a2020; }
262
+ .doc-badge-spinner {
263
+ width: 10px; height: 10px; border: 1.5px solid currentColor;
264
+ border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite;
265
+ }
266
+ .doc-delete {
267
+ background: none; border: none; color: #555; cursor: pointer;
268
+ font-size: 18px; line-height: 1; padding: 0 2px;
269
+ transition: color 0.2s; flex-shrink: 0;
270
+ }
271
+ .doc-delete:hover:not(:disabled) { color: #f44336; }
272
+ .doc-delete:disabled { opacity: 0.3; cursor: not-allowed; }
273
+
274
+ /* RAG toggle */
275
+ #use-rag-row {
276
+ display: none; align-items: center; gap: 10px;
277
+ background: #0d0d1a; border: 1px solid #2a2a4a;
278
+ border-radius: 8px; padding: 12px 14px; cursor: pointer;
279
+ }
280
+ #use-rag-row input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; accent-color: #9C27B0; }
281
+ #use-rag-label { font-size: 13px; color: #aaa; cursor: pointer; user-select: none; }
282
+ #use-rag-label strong { color: #CE93D8; }
283
+ #rag-doc-count { font-size: 12px; color: #666; margin-left: auto; }
284
+
285
+ .kb-reset-btn {
286
+ background: none; border: none; color: #555; font-size: 12px;
287
+ cursor: pointer; text-decoration: underline;
288
+ }
289
+ .kb-reset-btn:hover { color: #f44336; }
290
+
291
+ /* RAG system prompt editor */
292
+ #rag-prompt-section {
293
+ display: none; flex-direction: column; gap: 8px;
294
+ border-top: 1px solid #1e2a1e; padding-top: 14px;
295
+ }
296
+ #rag-prompt-section label { font-size: 12px; color: #6a9e6a; }
297
+ #rag-system-prompt {
298
+ width: 100%; padding: 10px 12px; border-radius: 8px;
299
+ border: 1px solid #2a3a2a; background: #0d160d;
300
+ color: #c8e6c9; font-size: 12px; font-family: inherit;
301
+ resize: vertical; outline: none; line-height: 1.6;
302
+ }
303
+ #rag-system-prompt:focus { border-color: #4CAF50; }
304
+ .rag-prompt-actions { display: flex; gap: 8px; align-items: center; }
305
+ #apply-rag-prompt-btn {
306
+ padding: 7px 16px; border-radius: 6px; border: none;
307
+ background: #2e7d32; color: #fff; font-size: 12px; font-weight: 600;
308
+ cursor: pointer; transition: background 0.2s;
309
+ }
310
+ #apply-rag-prompt-btn:hover { background: #388e3c; }
311
+ #apply-rag-prompt-btn:disabled { opacity: 0.4; cursor: not-allowed; }
312
+ #rag-prompt-hint { font-size: 11px; color: #555; }
313
+ #rag-prompt-toggle {
314
+ font-size: 12px; color: #4a7a4a; cursor: pointer;
315
+ background: none; border: none; text-decoration: underline; padding: 0;
316
+ }
317
+ #rag-prompt-toggle:hover { color: #4CAF50; }
318
+
319
+ /* ── RAG mode tabs ── */
320
+ .rag-mode-tabs {
321
+ display: flex; gap: 0; border-radius: 6px; overflow: hidden;
322
+ border: 1px solid #2a2a2a;
323
+ }
324
+ .rag-mode-tab {
325
+ padding: 5px 14px; font-size: 12px; font-weight: 500;
326
+ background: #1a1a1a; color: #666; cursor: pointer; border: none;
327
+ transition: background 0.2s, color 0.2s;
328
+ }
329
+ .rag-mode-tab.active { background: #1a2a3a; color: #64B5F6; }
330
+ .rag-mode-tab:hover:not(.active) { color: #aaa; }
331
+
332
+ .rag-mode-note {
333
+ font-size: 12px; color: #555; line-height: 1.5;
334
+ }
335
+
336
+ /* Local RAG drop zone */
337
+ #local-drop-zone {
338
+ border: 2px dashed #2a2a3a; border-radius: 10px;
339
+ padding: 24px 16px; text-align: center; cursor: pointer;
340
+ transition: border-color 0.2s, background 0.2s;
341
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
342
+ }
343
+ #local-drop-zone:hover, #local-drop-zone.drag-over { border-color: #64B5F6; background: #0d0d1a; }
344
+ #local-drop-zone svg { opacity: 0.4; }
345
+ #local-drop-zone p { font-size: 13px; color: #666; }
346
+ #local-drop-zone span { font-size: 12px; color: #444; }
347
+ #local-browse-btn {
348
+ padding: 6px 14px; border-radius: 6px; border: 1px solid #2a2a3a;
349
+ background: #1a1a1a; color: #aaa; font-size: 12px; font-weight: 600;
350
+ cursor: pointer; margin-top: 4px;
351
+ }
352
+ #local-browse-btn:hover { border-color: #64B5F6; color: #64B5F6; }
353
+
354
+ #local-upload-status {
355
+ display: none; align-items: center; gap: 10px;
356
+ background: #0d0d1a; border: 1px solid #2a2a4a;
357
+ border-radius: 8px; padding: 10px 14px; font-size: 13px; color: #64B5F6;
358
+ }
359
+
360
+ #use-local-rag-row {
361
+ display: none; align-items: center; gap: 10px;
362
+ background: #0d0d1a; border: 1px solid #2a2a4a;
363
+ border-radius: 8px; padding: 12px 14px; cursor: pointer;
364
+ font-size: 13px; color: #aaa;
365
+ }
366
+ #use-local-rag-row input[type=checkbox] { width: 16px; height: 16px; cursor: pointer; accent-color: #2196F3; }
367
+ #local-doc-count { font-size: 12px; color: #666; margin-left: auto; }
368
+
369
+ /* ── Log ── */
370
+ #log {
371
+ width: 640px; max-width: 100%; max-height: 160px; overflow-y: auto;
372
+ background: #111; border-radius: 10px; padding: 12px 14px;
373
+ font-size: 12px; font-family: 'Menlo', 'Courier New', monospace;
374
+ border: 1px solid #1e1e1e;
375
+ }
376
+ .log-line { line-height: 1.7; }
377
+ .log-info { color: #888; }
378
+ .log-success { color: #4CAF50; }
379
+ .log-error { color: #f44336; }
380
+ .log-user { color: #64B5F6; }
381
+ .log-warn { color: #FFC107; }
382
+ .log-rag { color: #CE93D8; }
383
+
384
+ /* ── Blood Test Report (Clinical RAG) ── */
385
+ #clinical-section {
386
+ width: 640px; max-width: 100%;
387
+ background: #111; border: 1px solid #1e2a1e;
388
+ border-radius: 12px; padding: 18px;
389
+ display: flex; flex-direction: column; gap: 14px;
390
+ }
391
+
392
+ #report-status-badge {
393
+ font-size: 11px; font-weight: 600; padding: 3px 9px;
394
+ border-radius: 20px; display: none;
395
+ }
396
+ #report-status-badge.ready { background: #1b3a1b; color: #4CAF50; border: 1px solid #2a5a2a; }
397
+
398
+ #report-drop-zone {
399
+ border: 2px dashed #2a2a2a; border-radius: 10px;
400
+ padding: 24px 16px; text-align: center; cursor: pointer;
401
+ transition: border-color 0.2s, background 0.2s;
402
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
403
+ }
404
+ #report-drop-zone:hover, #report-drop-zone.drag-over { border-color: #4CAF50; background: #0d1a0d; }
405
+ #report-drop-zone svg { opacity: 0.4; }
406
+ #report-drop-zone p { font-size: 13px; color: #666; }
407
+ #report-drop-zone span { font-size: 12px; color: #444; }
408
+ #report-browse-btn {
409
+ padding: 6px 14px; border-radius: 6px; border: 1px solid #2a2a2a;
410
+ background: #1a1a1a; color: #aaa; font-size: 12px; font-weight: 600;
411
+ cursor: pointer; margin-top: 4px;
412
+ }
413
+ #report-browse-btn:hover { border-color: #4CAF50; color: #4CAF50; }
414
+
415
+ #report-upload-status {
416
+ display: none; align-items: center; gap: 10px;
417
+ background: #0d1a0d; border: 1px solid #2a5a2a;
418
+ border-radius: 8px; padding: 10px 14px; font-size: 13px; color: #4CAF50;
419
+ }
420
+
421
+ #report-summary { display: flex; flex-direction: column; gap: 8px; }
422
+ .report-line {
423
+ display: flex; flex-direction: column; gap: 8px;
424
+ background: #161616; border: 1px solid #222; border-radius: 8px; padding: 10px 12px;
425
+ font-size: 13px;
426
+ }
427
+ .report-line-top { display: flex; align-items: center; gap: 10px; }
428
+ .report-marker { flex: 1; color: #ccc; }
429
+ .report-value { color: #888; font-size: 12px; }
430
+ .report-flag {
431
+ font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 20px;
432
+ text-transform: uppercase; flex-shrink: 0;
433
+ }
434
+ .report-line.flag-low .report-flag { background: #2a1010; color: #f44336; border: 1px solid #4a2020; }
435
+ .report-line.flag-high .report-flag { background: #2a2a1b; color: #FFC107; border: 1px solid #4a4a1b; }
436
+ .report-line.flag-normal .report-flag { background: #1b3a1b; color: #4CAF50; border: 1px solid #2a5a2a; }
437
+ .report-line.flag-unknown .report-flag { background: #1a1a2a; color: #888; border: 1px solid #2a2a3a; }
438
+
439
+ /* ── Report value bar — shows value vs. reference range ── */
440
+ .report-bar-track {
441
+ position: relative; height: 6px; border-radius: 3px;
442
+ background: #0d0d0d; border: 1px solid #222;
443
+ }
444
+ .report-bar-range {
445
+ position: absolute; top: -1px; bottom: -1px;
446
+ background: #1b3a1b; border: 1px solid #2a5a2a; border-radius: 3px;
447
+ }
448
+ .report-bar-marker {
449
+ position: absolute; top: 50%; width: 11px; height: 11px; border-radius: 50%;
450
+ transform: translate(-50%, -50%);
451
+ background: #888; border: 2px solid #0d0d0d;
452
+ }
453
+ .report-line.flag-low .report-bar-marker { background: #f44336; }
454
+ .report-line.flag-high .report-bar-marker { background: #FFC107; }
455
+ .report-line.flag-normal .report-bar-marker { background: #4CAF50; }
456
+ .report-line.flag-unknown .report-bar-marker { background: #888; }
457
+
458
+ /* ── Telemetry / Session Cost ── */
459
+ #telemetry-panel {
460
+ width: 640px; max-width: 100%;
461
+ background: #111; border: 1px solid #1e2a1e;
462
+ border-radius: 12px; padding: 18px;
463
+ display: flex; flex-direction: column; gap: 12px;
464
+ }
465
+ .tm-header { display: flex; align-items: center; justify-content: space-between; }
466
+ .tm-header h3 { font-size: 15px; font-weight: 600; color: #c8e6c9; }
467
+
468
+ #telemetry-stats { display: flex; gap: 12px; flex-wrap: wrap; }
469
+ .tm-stat {
470
+ flex: 1; min-width: 90px;
471
+ background: #161616; border: 1px solid #222;
472
+ border-radius: 8px; padding: 10px 14px;
473
+ display: flex; flex-direction: column; gap: 2px;
474
+ }
475
+ .tm-stat-value { font-size: 1.3rem; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums; }
476
+ .tm-stat-label { font-size: 0.7rem; color: #888; text-transform: uppercase; letter-spacing: 0.06em; }
477
+ .tm-stat.live { border-color: #2a5a2a; background: #0d1a0d; }
478
+ .tm-stat.live .tm-stat-value { color: #4CAF50; }
479
+
480
+ #telemetry-rate-note { font-size: 0.74rem; color: #555; line-height: 1.5; }
481
+
482
+ #telemetry-sessions { display: flex; flex-direction: column; gap: 4px; max-height: 220px; overflow-y: auto; }
483
+ #telemetry-empty { font-size: 0.82rem; color: #555; font-style: italic; padding: 8px 0; }
484
+
485
+ .telemetry-row {
486
+ display: grid;
487
+ grid-template-columns: 1fr 1.2fr 3rem 3.5rem 3.5rem 3rem;
488
+ gap: 8px; align-items: center;
489
+ background: #161616; border: 1px solid #222;
490
+ border-radius: 6px; padding: 7px 10px; font-size: 0.78rem;
491
+ }
492
+ .telemetry-row.active { border-color: #2a5a2a; background: #0d1a0d; }
493
+ .telemetry-row .tm-time { color: #888; }
494
+ .telemetry-row .tm-persona { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
495
+ .telemetry-row .tm-plan { text-transform: uppercase; font-size: 0.68rem; letter-spacing: 0.05em; }
496
+ .telemetry-row .tm-plan-free { color: #FFC107; }
497
+ .telemetry-row .tm-plan-pro { color: #4CAF50; }
498
+ .telemetry-row .tm-duration { font-variant-numeric: tabular-nums; }
499
+ .telemetry-row .tm-cost { font-weight: 600; color: #fff; text-align: right; }
500
+ .telemetry-row .tm-status { text-align: right; font-size: 0.72rem; }
501
+ .telemetry-row.active .tm-status { color: #4CAF50; }
anam/static/js/anam-sdk.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ /*! For license information please see anam.js.LICENSE.txt */
2
+ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.anam=t():e.anam=t()}(self,(()=>{return e={526:(e,t)=>{"use strict";t.byteLength=function(e){var t=a(e),n=t[0],i=t[1];return 3*(n+i)/4-i},t.toByteArray=function(e){var t,n,o=a(e),s=o[0],c=o[1],l=new r(function(e,t,n){return 3*(t+n)/4-n}(0,s,c)),u=0,d=c>0?s-4:s;for(n=0;n<d;n+=4)t=i[e.charCodeAt(n)]<<18|i[e.charCodeAt(n+1)]<<12|i[e.charCodeAt(n+2)]<<6|i[e.charCodeAt(n+3)],l[u++]=t>>16&255,l[u++]=t>>8&255,l[u++]=255&t;return 2===c&&(t=i[e.charCodeAt(n)]<<2|i[e.charCodeAt(n+1)]>>4,l[u++]=255&t),1===c&&(t=i[e.charCodeAt(n)]<<10|i[e.charCodeAt(n+1)]<<4|i[e.charCodeAt(n+2)]>>2,l[u++]=t>>8&255,l[u++]=255&t),l},t.fromByteArray=function(e){for(var t,i=e.length,r=i%3,o=[],s=16383,a=0,l=i-r;a<l;a+=s)o.push(c(e,a,a+s>l?l:a+s));return 1===r?(t=e[i-1],o.push(n[t>>2]+n[t<<4&63]+"==")):2===r&&(t=(e[i-2]<<8)+e[i-1],o.push(n[t>>10]+n[t>>4&63]+n[t<<2&63]+"=")),o.join("")};for(var n=[],i=[],r="undefined"!=typeof Uint8Array?Uint8Array:Array,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",s=0;s<64;++s)n[s]=o[s],i[o.charCodeAt(s)]=s;function a(e){var t=e.length;if(t%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var n=e.indexOf("=");return-1===n&&(n=t),[n,n===t?0:4-n%4]}function c(e,t,i){for(var r,o,s=[],a=t;a<i;a+=3)r=(e[a]<<16&16711680)+(e[a+1]<<8&65280)+(255&e[a+2]),s.push(n[(o=r)>>18&63]+n[o>>12&63]+n[o>>6&63]+n[63&o]);return s.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},287:(e,t,n)=>{"use strict";const i=n(526),r=n(251),o="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;t.Buffer=c,t.SlowBuffer=function(e){return+e!=e&&(e=0),c.alloc(+e)},t.INSPECT_MAX_BYTES=50;const s=2147483647;function a(e){if(e>s)throw new RangeError('The value "'+e+'" is invalid for option "size"');const t=new Uint8Array(e);return Object.setPrototypeOf(t,c.prototype),t}function c(e,t,n){if("number"==typeof e){if("string"==typeof t)throw new TypeError('The "string" argument must be of type string. Received type number');return d(e)}return l(e,t,n)}function l(e,t,n){if("string"==typeof e)return function(e,t){if("string"==typeof t&&""!==t||(t="utf8"),!c.isEncoding(t))throw new TypeError("Unknown encoding: "+t);const n=0|E(e,t);let i=a(n);const r=i.write(e,t);return r!==n&&(i=i.slice(0,r)),i}(e,t);if(ArrayBuffer.isView(e))return function(e){if(J(e,Uint8Array)){const t=new Uint8Array(e);return f(t.buffer,t.byteOffset,t.byteLength)}return h(e)}(e);if(null==e)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof e);if(J(e,ArrayBuffer)||e&&J(e.buffer,ArrayBuffer))return f(e,t,n);if("undefined"!=typeof SharedArrayBuffer&&(J(e,SharedArrayBuffer)||e&&J(e.buffer,SharedArrayBuffer)))return f(e,t,n);if("number"==typeof e)throw new TypeError('The "value" argument must not be of type number. Received type number');const i=e.valueOf&&e.valueOf();if(null!=i&&i!==e)return c.from(i,t,n);const r=function(e){if(c.isBuffer(e)){const t=0|p(e.length),n=a(t);return 0===n.length||e.copy(n,0,0,t),n}return void 0!==e.length?"number"!=typeof e.length||q(e.length)?a(0):h(e):"Buffer"===e.type&&Array.isArray(e.data)?h(e.data):void 0}(e);if(r)return r;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof e[Symbol.toPrimitive])return c.from(e[Symbol.toPrimitive]("string"),t,n);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof e)}function u(e){if("number"!=typeof e)throw new TypeError('"size" argument must be of type number');if(e<0)throw new RangeError('The value "'+e+'" is invalid for option "size"')}function d(e){return u(e),a(e<0?0:0|p(e))}function h(e){const t=e.length<0?0:0|p(e.length),n=a(t);for(let i=0;i<t;i+=1)n[i]=255&e[i];return n}function f(e,t,n){if(t<0||e.byteLength<t)throw new RangeError('"offset" is outside of buffer bounds');if(e.byteLength<t+(n||0))throw new RangeError('"length" is outside of buffer bounds');let i;return i=void 0===t&&void 0===n?new Uint8Array(e):void 0===n?new Uint8Array(e,t):new Uint8Array(e,t,n),Object.setPrototypeOf(i,c.prototype),i}function p(e){if(e>=s)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+s.toString(16)+" bytes");return 0|e}function E(e,t){if(c.isBuffer(e))return e.length;if(ArrayBuffer.isView(e)||J(e,ArrayBuffer))return e.byteLength;if("string"!=typeof e)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof e);const n=e.length,i=arguments.length>2&&!0===arguments[2];if(!i&&0===n)return 0;let r=!1;for(;;)switch(t){case"ascii":case"latin1":case"binary":return n;case"utf8":case"utf-8":return K(e).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*n;case"hex":return n>>>1;case"base64":return z(e).length;default:if(r)return i?-1:K(e).length;t=(""+t).toLowerCase(),r=!0}}function g(e,t,n){let i=!1;if((void 0===t||t<0)&&(t=0),t>this.length)return"";if((void 0===n||n>this.length)&&(n=this.length),n<=0)return"";if((n>>>=0)<=(t>>>=0))return"";for(e||(e="utf8");;)switch(e){case"hex":return N(this,t,n);case"utf8":case"utf-8":return T(this,t,n);case"ascii":return b(this,t,n);case"latin1":case"binary":return w(this,t,n);case"base64":return R(this,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return M(this,t,n);default:if(i)throw new TypeError("Unknown encoding: "+e);e=(e+"").toLowerCase(),i=!0}}function m(e,t,n){const i=e[t];e[t]=e[n],e[n]=i}function v(e,t,n,i,r){if(0===e.length)return-1;if("string"==typeof n?(i=n,n=0):n>2147483647?n=2147483647:n<-2147483648&&(n=-2147483648),q(n=+n)&&(n=r?0:e.length-1),n<0&&(n=e.length+n),n>=e.length){if(r)return-1;n=e.length-1}else if(n<0){if(!r)return-1;n=0}if("string"==typeof t&&(t=c.from(t,i)),c.isBuffer(t))return 0===t.length?-1:C(e,t,n,i,r);if("number"==typeof t)return t&=255,"function"==typeof Uint8Array.prototype.indexOf?r?Uint8Array.prototype.indexOf.call(e,t,n):Uint8Array.prototype.lastIndexOf.call(e,t,n):C(e,[t],n,i,r);throw new TypeError("val must be string, number or Buffer")}function C(e,t,n,i,r){let o,s=1,a=e.length,c=t.length;if(void 0!==i&&("ucs2"===(i=String(i).toLowerCase())||"ucs-2"===i||"utf16le"===i||"utf-16le"===i)){if(e.length<2||t.length<2)return-1;s=2,a/=2,c/=2,n/=2}function l(e,t){return 1===s?e[t]:e.readUInt16BE(t*s)}if(r){let i=-1;for(o=n;o<a;o++)if(l(e,o)===l(t,-1===i?0:o-i)){if(-1===i&&(i=o),o-i+1===c)return i*s}else-1!==i&&(o-=o-i),i=-1}else for(n+c>a&&(n=a-c),o=n;o>=0;o--){let n=!0;for(let i=0;i<c;i++)if(l(e,o+i)!==l(t,i)){n=!1;break}if(n)return o}return-1}function S(e,t,n,i){n=Number(n)||0;const r=e.length-n;i?(i=Number(i))>r&&(i=r):i=r;const o=t.length;let s;for(i>o/2&&(i=o/2),s=0;s<i;++s){const i=parseInt(t.substr(2*s,2),16);if(q(i))return s;e[n+s]=i}return s}function y(e,t,n,i){return Y(K(t,e.length-n),e,n,i)}function A(e,t,n,i){return Y(function(e){const t=[];for(let n=0;n<e.length;++n)t.push(255&e.charCodeAt(n));return t}(t),e,n,i)}function I(e,t,n,i){return Y(z(t),e,n,i)}function _(e,t,n,i){return Y(function(e,t){let n,i,r;const o=[];for(let s=0;s<e.length&&!((t-=2)<0);++s)n=e.charCodeAt(s),i=n>>8,r=n%256,o.push(r),o.push(i);return o}(t,e.length-n),e,n,i)}function R(e,t,n){return 0===t&&n===e.length?i.fromByteArray(e):i.fromByteArray(e.slice(t,n))}function T(e,t,n){n=Math.min(e.length,n);const i=[];let r=t;for(;r<n;){const t=e[r];let o=null,s=t>239?4:t>223?3:t>191?2:1;if(r+s<=n){let n,i,a,c;switch(s){case 1:t<128&&(o=t);break;case 2:n=e[r+1],128==(192&n)&&(c=(31&t)<<6|63&n,c>127&&(o=c));break;case 3:n=e[r+1],i=e[r+2],128==(192&n)&&128==(192&i)&&(c=(15&t)<<12|(63&n)<<6|63&i,c>2047&&(c<55296||c>57343)&&(o=c));break;case 4:n=e[r+1],i=e[r+2],a=e[r+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(c=(15&t)<<18|(63&n)<<12|(63&i)<<6|63&a,c>65535&&c<1114112&&(o=c))}}null===o?(o=65533,s=1):o>65535&&(o-=65536,i.push(o>>>10&1023|55296),o=56320|1023&o),i.push(o),r+=s}return function(e){const t=e.length;if(t<=O)return String.fromCharCode.apply(String,e);let n="",i=0;for(;i<t;)n+=String.fromCharCode.apply(String,e.slice(i,i+=O));return n}(i)}t.kMaxLength=s,c.TYPED_ARRAY_SUPPORT=function(){try{const e=new Uint8Array(1),t={foo:function(){return 42}};return Object.setPrototypeOf(t,Uint8Array.prototype),Object.setPrototypeOf(e,t),42===e.foo()}catch(e){return!1}}(),c.TYPED_ARRAY_SUPPORT||"undefined"==typeof console||"function"!=typeof console.error||console.error("This browser lacks typed array (Uint8Array) support which is required by `buffer` v5.x. Use `buffer` v4.x if you require old browser support."),Object.defineProperty(c.prototype,"parent",{enumerable:!0,get:function(){if(c.isBuffer(this))return this.buffer}}),Object.defineProperty(c.prototype,"offset",{enumerable:!0,get:function(){if(c.isBuffer(this))return this.byteOffset}}),c.poolSize=8192,c.from=function(e,t,n){return l(e,t,n)},Object.setPrototypeOf(c.prototype,Uint8Array.prototype),Object.setPrototypeOf(c,Uint8Array),c.alloc=function(e,t,n){return function(e,t,n){return u(e),e<=0?a(e):void 0!==t?"string"==typeof n?a(e).fill(t,n):a(e).fill(t):a(e)}(e,t,n)},c.allocUnsafe=function(e){return d(e)},c.allocUnsafeSlow=function(e){return d(e)},c.isBuffer=function(e){return null!=e&&!0===e._isBuffer&&e!==c.prototype},c.compare=function(e,t){if(J(e,Uint8Array)&&(e=c.from(e,e.offset,e.byteLength)),J(t,Uint8Array)&&(t=c.from(t,t.offset,t.byteLength)),!c.isBuffer(e)||!c.isBuffer(t))throw new TypeError('The "buf1", "buf2" arguments must be one of type Buffer or Uint8Array');if(e===t)return 0;let n=e.length,i=t.length;for(let r=0,o=Math.min(n,i);r<o;++r)if(e[r]!==t[r]){n=e[r],i=t[r];break}return n<i?-1:i<n?1:0},c.isEncoding=function(e){switch(String(e).toLowerCase()){case"hex":case"utf8":case"utf-8":case"ascii":case"latin1":case"binary":case"base64":case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return!0;default:return!1}},c.concat=function(e,t){if(!Array.isArray(e))throw new TypeError('"list" argument must be an Array of Buffers');if(0===e.length)return c.alloc(0);let n;if(void 0===t)for(t=0,n=0;n<e.length;++n)t+=e[n].length;const i=c.allocUnsafe(t);let r=0;for(n=0;n<e.length;++n){let t=e[n];if(J(t,Uint8Array))r+t.length>i.length?(c.isBuffer(t)||(t=c.from(t)),t.copy(i,r)):Uint8Array.prototype.set.call(i,t,r);else{if(!c.isBuffer(t))throw new TypeError('"list" argument must be an Array of Buffers');t.copy(i,r)}r+=t.length}return i},c.byteLength=E,c.prototype._isBuffer=!0,c.prototype.swap16=function(){const e=this.length;if(e%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let t=0;t<e;t+=2)m(this,t,t+1);return this},c.prototype.swap32=function(){const e=this.length;if(e%4!=0)throw new RangeError("Buffer size must be a multiple of 32-bits");for(let t=0;t<e;t+=4)m(this,t,t+3),m(this,t+1,t+2);return this},c.prototype.swap64=function(){const e=this.length;if(e%8!=0)throw new RangeError("Buffer size must be a multiple of 64-bits");for(let t=0;t<e;t+=8)m(this,t,t+7),m(this,t+1,t+6),m(this,t+2,t+5),m(this,t+3,t+4);return this},c.prototype.toString=function(){const e=this.length;return 0===e?"":0===arguments.length?T(this,0,e):g.apply(this,arguments)},c.prototype.toLocaleString=c.prototype.toString,c.prototype.equals=function(e){if(!c.isBuffer(e))throw new TypeError("Argument must be a Buffer");return this===e||0===c.compare(this,e)},c.prototype.inspect=function(){let e="";const n=t.INSPECT_MAX_BYTES;return e=this.toString("hex",0,n).replace(/(.{2})/g,"$1 ").trim(),this.length>n&&(e+=" ... "),"<Buffer "+e+">"},o&&(c.prototype[o]=c.prototype.inspect),c.prototype.compare=function(e,t,n,i,r){if(J(e,Uint8Array)&&(e=c.from(e,e.offset,e.byteLength)),!c.isBuffer(e))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof e);if(void 0===t&&(t=0),void 0===n&&(n=e?e.length:0),void 0===i&&(i=0),void 0===r&&(r=this.length),t<0||n>e.length||i<0||r>this.length)throw new RangeError("out of range index");if(i>=r&&t>=n)return 0;if(i>=r)return-1;if(t>=n)return 1;if(this===e)return 0;let o=(r>>>=0)-(i>>>=0),s=(n>>>=0)-(t>>>=0);const a=Math.min(o,s),l=this.slice(i,r),u=e.slice(t,n);for(let e=0;e<a;++e)if(l[e]!==u[e]){o=l[e],s=u[e];break}return o<s?-1:s<o?1:0},c.prototype.includes=function(e,t,n){return-1!==this.indexOf(e,t,n)},c.prototype.indexOf=function(e,t,n){return v(this,e,t,n,!0)},c.prototype.lastIndexOf=function(e,t,n){return v(this,e,t,n,!1)},c.prototype.write=function(e,t,n,i){if(void 0===t)i="utf8",n=this.length,t=0;else if(void 0===n&&"string"==typeof t)i=t,n=this.length,t=0;else{if(!isFinite(t))throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported");t>>>=0,isFinite(n)?(n>>>=0,void 0===i&&(i="utf8")):(i=n,n=void 0)}const r=this.length-t;if((void 0===n||n>r)&&(n=r),e.length>0&&(n<0||t<0)||t>this.length)throw new RangeError("Attempt to write outside buffer bounds");i||(i="utf8");let o=!1;for(;;)switch(i){case"hex":return S(this,e,t,n);case"utf8":case"utf-8":return y(this,e,t,n);case"ascii":case"latin1":case"binary":return A(this,e,t,n);case"base64":return I(this,e,t,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return _(this,e,t,n);default:if(o)throw new TypeError("Unknown encoding: "+i);i=(""+i).toLowerCase(),o=!0}},c.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};const O=4096;function b(e,t,n){let i="";n=Math.min(e.length,n);for(let r=t;r<n;++r)i+=String.fromCharCode(127&e[r]);return i}function w(e,t,n){let i="";n=Math.min(e.length,n);for(let r=t;r<n;++r)i+=String.fromCharCode(e[r]);return i}function N(e,t,n){const i=e.length;(!t||t<0)&&(t=0),(!n||n<0||n>i)&&(n=i);let r="";for(let i=t;i<n;++i)r+=X[e[i]];return r}function M(e,t,n){const i=e.slice(t,n);let r="";for(let e=0;e<i.length-1;e+=2)r+=String.fromCharCode(i[e]+256*i[e+1]);return r}function D(e,t,n){if(e%1!=0||e<0)throw new RangeError("offset is not uint");if(e+t>n)throw new RangeError("Trying to access beyond buffer length")}function L(e,t,n,i,r,o){if(!c.isBuffer(e))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>r||t<o)throw new RangeError('"value" argument is out of bounds');if(n+i>e.length)throw new RangeError("Index out of range")}function P(e,t,n,i,r){V(t,i,r,e,n,7);let o=Number(t&BigInt(4294967295));e[n++]=o,o>>=8,e[n++]=o,o>>=8,e[n++]=o,o>>=8,e[n++]=o;let s=Number(t>>BigInt(32)&BigInt(4294967295));return e[n++]=s,s>>=8,e[n++]=s,s>>=8,e[n++]=s,s>>=8,e[n++]=s,n}function U(e,t,n,i,r){V(t,i,r,e,n,7);let o=Number(t&BigInt(4294967295));e[n+7]=o,o>>=8,e[n+6]=o,o>>=8,e[n+5]=o,o>>=8,e[n+4]=o;let s=Number(t>>BigInt(32)&BigInt(4294967295));return e[n+3]=s,s>>=8,e[n+2]=s,s>>=8,e[n+1]=s,s>>=8,e[n]=s,n+8}function k(e,t,n,i,r,o){if(n+i>e.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("Index out of range")}function B(e,t,n,i,o){return t=+t,n>>>=0,o||k(e,0,n,4),r.write(e,t,n,i,23,4),n+4}function j(e,t,n,i,o){return t=+t,n>>>=0,o||k(e,0,n,8),r.write(e,t,n,i,52,8),n+8}c.prototype.slice=function(e,t){const n=this.length;(e=~~e)<0?(e+=n)<0&&(e=0):e>n&&(e=n),(t=void 0===t?n:~~t)<0?(t+=n)<0&&(t=0):t>n&&(t=n),t<e&&(t=e);const i=this.subarray(e,t);return Object.setPrototypeOf(i,c.prototype),i},c.prototype.readUintLE=c.prototype.readUIntLE=function(e,t,n){e>>>=0,t>>>=0,n||D(e,t,this.length);let i=this[e],r=1,o=0;for(;++o<t&&(r*=256);)i+=this[e+o]*r;return i},c.prototype.readUintBE=c.prototype.readUIntBE=function(e,t,n){e>>>=0,t>>>=0,n||D(e,t,this.length);let i=this[e+--t],r=1;for(;t>0&&(r*=256);)i+=this[e+--t]*r;return i},c.prototype.readUint8=c.prototype.readUInt8=function(e,t){return e>>>=0,t||D(e,1,this.length),this[e]},c.prototype.readUint16LE=c.prototype.readUInt16LE=function(e,t){return e>>>=0,t||D(e,2,this.length),this[e]|this[e+1]<<8},c.prototype.readUint16BE=c.prototype.readUInt16BE=function(e,t){return e>>>=0,t||D(e,2,this.length),this[e]<<8|this[e+1]},c.prototype.readUint32LE=c.prototype.readUInt32LE=function(e,t){return e>>>=0,t||D(e,4,this.length),(this[e]|this[e+1]<<8|this[e+2]<<16)+16777216*this[e+3]},c.prototype.readUint32BE=c.prototype.readUInt32BE=function(e,t){return e>>>=0,t||D(e,4,this.length),16777216*this[e]+(this[e+1]<<16|this[e+2]<<8|this[e+3])},c.prototype.readBigUInt64LE=Z((function(e){G(e>>>=0,"offset");const t=this[e],n=this[e+7];void 0!==t&&void 0!==n||H(e,this.length-8);const i=t+256*this[++e]+65536*this[++e]+this[++e]*2**24,r=this[++e]+256*this[++e]+65536*this[++e]+n*2**24;return BigInt(i)+(BigInt(r)<<BigInt(32))})),c.prototype.readBigUInt64BE=Z((function(e){G(e>>>=0,"offset");const t=this[e],n=this[e+7];void 0!==t&&void 0!==n||H(e,this.length-8);const i=t*2**24+65536*this[++e]+256*this[++e]+this[++e],r=this[++e]*2**24+65536*this[++e]+256*this[++e]+n;return(BigInt(i)<<BigInt(32))+BigInt(r)})),c.prototype.readIntLE=function(e,t,n){e>>>=0,t>>>=0,n||D(e,t,this.length);let i=this[e],r=1,o=0;for(;++o<t&&(r*=256);)i+=this[e+o]*r;return r*=128,i>=r&&(i-=Math.pow(2,8*t)),i},c.prototype.readIntBE=function(e,t,n){e>>>=0,t>>>=0,n||D(e,t,this.length);let i=t,r=1,o=this[e+--i];for(;i>0&&(r*=256);)o+=this[e+--i]*r;return r*=128,o>=r&&(o-=Math.pow(2,8*t)),o},c.prototype.readInt8=function(e,t){return e>>>=0,t||D(e,1,this.length),128&this[e]?-1*(255-this[e]+1):this[e]},c.prototype.readInt16LE=function(e,t){e>>>=0,t||D(e,2,this.length);const n=this[e]|this[e+1]<<8;return 32768&n?4294901760|n:n},c.prototype.readInt16BE=function(e,t){e>>>=0,t||D(e,2,this.length);const n=this[e+1]|this[e]<<8;return 32768&n?4294901760|n:n},c.prototype.readInt32LE=function(e,t){return e>>>=0,t||D(e,4,this.length),this[e]|this[e+1]<<8|this[e+2]<<16|this[e+3]<<24},c.prototype.readInt32BE=function(e,t){return e>>>=0,t||D(e,4,this.length),this[e]<<24|this[e+1]<<16|this[e+2]<<8|this[e+3]},c.prototype.readBigInt64LE=Z((function(e){G(e>>>=0,"offset");const t=this[e],n=this[e+7];void 0!==t&&void 0!==n||H(e,this.length-8);const i=this[e+4]+256*this[e+5]+65536*this[e+6]+(n<<24);return(BigInt(i)<<BigInt(32))+BigInt(t+256*this[++e]+65536*this[++e]+this[++e]*2**24)})),c.prototype.readBigInt64BE=Z((function(e){G(e>>>=0,"offset");const t=this[e],n=this[e+7];void 0!==t&&void 0!==n||H(e,this.length-8);const i=(t<<24)+65536*this[++e]+256*this[++e]+this[++e];return(BigInt(i)<<BigInt(32))+BigInt(this[++e]*2**24+65536*this[++e]+256*this[++e]+n)})),c.prototype.readFloatLE=function(e,t){return e>>>=0,t||D(e,4,this.length),r.read(this,e,!0,23,4)},c.prototype.readFloatBE=function(e,t){return e>>>=0,t||D(e,4,this.length),r.read(this,e,!1,23,4)},c.prototype.readDoubleLE=function(e,t){return e>>>=0,t||D(e,8,this.length),r.read(this,e,!0,52,8)},c.prototype.readDoubleBE=function(e,t){return e>>>=0,t||D(e,8,this.length),r.read(this,e,!1,52,8)},c.prototype.writeUintLE=c.prototype.writeUIntLE=function(e,t,n,i){e=+e,t>>>=0,n>>>=0,i||L(this,e,t,n,Math.pow(2,8*n)-1,0);let r=1,o=0;for(this[t]=255&e;++o<n&&(r*=256);)this[t+o]=e/r&255;return t+n},c.prototype.writeUintBE=c.prototype.writeUIntBE=function(e,t,n,i){e=+e,t>>>=0,n>>>=0,i||L(this,e,t,n,Math.pow(2,8*n)-1,0);let r=n-1,o=1;for(this[t+r]=255&e;--r>=0&&(o*=256);)this[t+r]=e/o&255;return t+n},c.prototype.writeUint8=c.prototype.writeUInt8=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,1,255,0),this[t]=255&e,t+1},c.prototype.writeUint16LE=c.prototype.writeUInt16LE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,2,65535,0),this[t]=255&e,this[t+1]=e>>>8,t+2},c.prototype.writeUint16BE=c.prototype.writeUInt16BE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,2,65535,0),this[t]=e>>>8,this[t+1]=255&e,t+2},c.prototype.writeUint32LE=c.prototype.writeUInt32LE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,4,4294967295,0),this[t+3]=e>>>24,this[t+2]=e>>>16,this[t+1]=e>>>8,this[t]=255&e,t+4},c.prototype.writeUint32BE=c.prototype.writeUInt32BE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,4,4294967295,0),this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e,t+4},c.prototype.writeBigUInt64LE=Z((function(e,t=0){return P(this,e,t,BigInt(0),BigInt("0xffffffffffffffff"))})),c.prototype.writeBigUInt64BE=Z((function(e,t=0){return U(this,e,t,BigInt(0),BigInt("0xffffffffffffffff"))})),c.prototype.writeIntLE=function(e,t,n,i){if(e=+e,t>>>=0,!i){const i=Math.pow(2,8*n-1);L(this,e,t,n,i-1,-i)}let r=0,o=1,s=0;for(this[t]=255&e;++r<n&&(o*=256);)e<0&&0===s&&0!==this[t+r-1]&&(s=1),this[t+r]=(e/o|0)-s&255;return t+n},c.prototype.writeIntBE=function(e,t,n,i){if(e=+e,t>>>=0,!i){const i=Math.pow(2,8*n-1);L(this,e,t,n,i-1,-i)}let r=n-1,o=1,s=0;for(this[t+r]=255&e;--r>=0&&(o*=256);)e<0&&0===s&&0!==this[t+r+1]&&(s=1),this[t+r]=(e/o|0)-s&255;return t+n},c.prototype.writeInt8=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,1,127,-128),e<0&&(e=255+e+1),this[t]=255&e,t+1},c.prototype.writeInt16LE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,2,32767,-32768),this[t]=255&e,this[t+1]=e>>>8,t+2},c.prototype.writeInt16BE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,2,32767,-32768),this[t]=e>>>8,this[t+1]=255&e,t+2},c.prototype.writeInt32LE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,4,2147483647,-2147483648),this[t]=255&e,this[t+1]=e>>>8,this[t+2]=e>>>16,this[t+3]=e>>>24,t+4},c.prototype.writeInt32BE=function(e,t,n){return e=+e,t>>>=0,n||L(this,e,t,4,2147483647,-2147483648),e<0&&(e=4294967295+e+1),this[t]=e>>>24,this[t+1]=e>>>16,this[t+2]=e>>>8,this[t+3]=255&e,t+4},c.prototype.writeBigInt64LE=Z((function(e,t=0){return P(this,e,t,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),c.prototype.writeBigInt64BE=Z((function(e,t=0){return U(this,e,t,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),c.prototype.writeFloatLE=function(e,t,n){return B(this,e,t,!0,n)},c.prototype.writeFloatBE=function(e,t,n){return B(this,e,t,!1,n)},c.prototype.writeDoubleLE=function(e,t,n){return j(this,e,t,!0,n)},c.prototype.writeDoubleBE=function(e,t,n){return j(this,e,t,!1,n)},c.prototype.copy=function(e,t,n,i){if(!c.isBuffer(e))throw new TypeError("argument should be a Buffer");if(n||(n=0),i||0===i||(i=this.length),t>=e.length&&(t=e.length),t||(t=0),i>0&&i<n&&(i=n),i===n)return 0;if(0===e.length||0===this.length)return 0;if(t<0)throw new RangeError("targetStart out of bounds");if(n<0||n>=this.length)throw new RangeError("Index out of range");if(i<0)throw new RangeError("sourceEnd out of bounds");i>this.length&&(i=this.length),e.length-t<i-n&&(i=e.length-t+n);const r=i-n;return this===e&&"function"==typeof Uint8Array.prototype.copyWithin?this.copyWithin(t,n,i):Uint8Array.prototype.set.call(e,this.subarray(n,i),t),r},c.prototype.fill=function(e,t,n,i){if("string"==typeof e){if("string"==typeof t?(i=t,t=0,n=this.length):"string"==typeof n&&(i=n,n=this.length),void 0!==i&&"string"!=typeof i)throw new TypeError("encoding must be a string");if("string"==typeof i&&!c.isEncoding(i))throw new TypeError("Unknown encoding: "+i);if(1===e.length){const t=e.charCodeAt(0);("utf8"===i&&t<128||"latin1"===i)&&(e=t)}}else"number"==typeof e?e&=255:"boolean"==typeof e&&(e=Number(e));if(t<0||this.length<t||this.length<n)throw new RangeError("Out of range index");if(n<=t)return this;let r;if(t>>>=0,n=void 0===n?this.length:n>>>0,e||(e=0),"number"==typeof e)for(r=t;r<n;++r)this[r]=e;else{const o=c.isBuffer(e)?e:c.from(e,i),s=o.length;if(0===s)throw new TypeError('The value "'+e+'" is invalid for argument "value"');for(r=0;r<n-t;++r)this[r+t]=o[r%s]}return this};const F={};function x(e,t,n){F[e]=class extends n{constructor(){super(),Object.defineProperty(this,"message",{value:t.apply(this,arguments),writable:!0,configurable:!0}),this.name=`${this.name} [${e}]`,this.stack,delete this.name}get code(){return e}set code(e){Object.defineProperty(this,"code",{configurable:!0,enumerable:!0,value:e,writable:!0})}toString(){return`${this.name} [${e}]: ${this.message}`}}}function $(e){let t="",n=e.length;const i="-"===e[0]?1:0;for(;n>=i+4;n-=3)t=`_${e.slice(n-3,n)}${t}`;return`${e.slice(0,n)}${t}`}function V(e,t,n,i,r,o){if(e>n||e<t){const i="bigint"==typeof t?"n":"";let r;throw r=o>3?0===t||t===BigInt(0)?`>= 0${i} and < 2${i} ** ${8*(o+1)}${i}`:`>= -(2${i} ** ${8*(o+1)-1}${i}) and < 2 ** ${8*(o+1)-1}${i}`:`>= ${t}${i} and <= ${n}${i}`,new F.ERR_OUT_OF_RANGE("value",r,e)}!function(e,t,n){G(t,"offset"),void 0!==e[t]&&void 0!==e[t+n]||H(t,e.length-(n+1))}(i,r,o)}function G(e,t){if("number"!=typeof e)throw new F.ERR_INVALID_ARG_TYPE(t,"number",e)}function H(e,t,n){if(Math.floor(e)!==e)throw G(e,n),new F.ERR_OUT_OF_RANGE(n||"offset","an integer",e);if(t<0)throw new F.ERR_BUFFER_OUT_OF_BOUNDS;throw new F.ERR_OUT_OF_RANGE(n||"offset",`>= ${n?1:0} and <= ${t}`,e)}x("ERR_BUFFER_OUT_OF_BOUNDS",(function(e){return e?`${e} is outside of buffer bounds`:"Attempt to access memory outside buffer bounds"}),RangeError),x("ERR_INVALID_ARG_TYPE",(function(e,t){return`The "${e}" argument must be of type number. Received type ${typeof t}`}),TypeError),x("ERR_OUT_OF_RANGE",(function(e,t,n){let i=`The value of "${e}" is out of range.`,r=n;return Number.isInteger(n)&&Math.abs(n)>2**32?r=$(String(n)):"bigint"==typeof n&&(r=String(n),(n>BigInt(2)**BigInt(32)||n<-(BigInt(2)**BigInt(32)))&&(r=$(r)),r+="n"),i+=` It must be ${t}. Received ${r}`,i}),RangeError);const W=/[^+/0-9A-Za-z-_]/g;function K(e,t){let n;t=t||1/0;const i=e.length;let r=null;const o=[];for(let s=0;s<i;++s){if(n=e.charCodeAt(s),n>55295&&n<57344){if(!r){if(n>56319){(t-=3)>-1&&o.push(239,191,189);continue}if(s+1===i){(t-=3)>-1&&o.push(239,191,189);continue}r=n;continue}if(n<56320){(t-=3)>-1&&o.push(239,191,189),r=n;continue}n=65536+(r-55296<<10|n-56320)}else r&&(t-=3)>-1&&o.push(239,191,189);if(r=null,n<128){if((t-=1)<0)break;o.push(n)}else if(n<2048){if((t-=2)<0)break;o.push(n>>6|192,63&n|128)}else if(n<65536){if((t-=3)<0)break;o.push(n>>12|224,n>>6&63|128,63&n|128)}else{if(!(n<1114112))throw new Error("Invalid code point");if((t-=4)<0)break;o.push(n>>18|240,n>>12&63|128,n>>6&63|128,63&n|128)}}return o}function z(e){return i.toByteArray(function(e){if((e=(e=e.split("=")[0]).trim().replace(W,"")).length<2)return"";for(;e.length%4!=0;)e+="=";return e}(e))}function Y(e,t,n,i){let r;for(r=0;r<i&&!(r+n>=t.length||r>=e.length);++r)t[r+n]=e[r];return r}function J(e,t){return e instanceof t||null!=e&&null!=e.constructor&&null!=e.constructor.name&&e.constructor.name===t.name}function q(e){return e!=e}const X=function(){const e="0123456789abcdef",t=new Array(256);for(let n=0;n<16;++n){const i=16*n;for(let r=0;r<16;++r)t[i+r]=e[n]+e[r]}return t}();function Z(e){return"undefined"==typeof BigInt?Q:e}function Q(){throw new Error("BigInt not supported")}},251:(e,t)=>{t.read=function(e,t,n,i,r){var o,s,a=8*r-i-1,c=(1<<a)-1,l=c>>1,u=-7,d=n?r-1:0,h=n?-1:1,f=e[t+d];for(d+=h,o=f&(1<<-u)-1,f>>=-u,u+=a;u>0;o=256*o+e[t+d],d+=h,u-=8);for(s=o&(1<<-u)-1,o>>=-u,u+=i;u>0;s=256*s+e[t+d],d+=h,u-=8);if(0===o)o=1-l;else{if(o===c)return s?NaN:1/0*(f?-1:1);s+=Math.pow(2,i),o-=l}return(f?-1:1)*s*Math.pow(2,o-i)},t.write=function(e,t,n,i,r,o){var s,a,c,l=8*o-r-1,u=(1<<l)-1,d=u>>1,h=23===r?Math.pow(2,-24)-Math.pow(2,-77):0,f=i?0:o-1,p=i?1:-1,E=t<0||0===t&&1/t<0?1:0;for(t=Math.abs(t),isNaN(t)||t===1/0?(a=isNaN(t)?1:0,s=u):(s=Math.floor(Math.log(t)/Math.LN2),t*(c=Math.pow(2,-s))<1&&(s--,c*=2),(t+=s+d>=1?h/c:h*Math.pow(2,1-d))*c>=2&&(s++,c/=2),s+d>=u?(a=0,s=u):s+d>=1?(a=(t*c-1)*Math.pow(2,r),s+=d):(a=t*Math.pow(2,d-1)*Math.pow(2,r),s=0));r>=8;e[n+f]=255&a,f+=p,a/=256,r-=8);for(s=s<<r|a,l+=r;l>0;e[n+f]=255&s,f+=p,s/=256,l-=8);e[n+f-p]|=128*E}},816:function(e,t,n){"use strict";var i=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0});const r=n(287),o=n(71),s=n(948),a=n(353),c=n(958),l=n(170);t.default=class{constructor(e,t,n){var i,r;this.inputAudioState={isMuted:!1},this.sessionId=null,this.organizationId=null,this.streamingClient=null,this._isStreaming=!1;const a=this.validateClientConfig(e,t,n);if(a)throw new o.ClientError(a,o.ErrorCode.CLIENT_ERROR_CODE_CONFIGURATION_ERROR,400);this.personaConfig=t,this.clientOptions=n,((null===(i=null==n?void 0:n.api)||void 0===i?void 0:i.baseUrl)||(null===(r=null==n?void 0:n.api)||void 0===r?void 0:r.apiVersion))&&(0,s.setClientMetricsBaseUrl)(n.api.baseUrl||s.DEFAULT_ANAM_METRICS_BASE_URL,n.api.apiVersion||s.DEFAULT_ANAM_API_VERSION),this.publicEventEmitter=new c.PublicEventEmitter,this.internalEventEmitter=new c.InternalEventEmitter,this.apiClient=new c.CoreApiRestClient(e,null==n?void 0:n.apiKey,null==n?void 0:n.api),this.messageHistoryClient=new c.MessageHistoryClient(this.publicEventEmitter,this.internalEventEmitter)}decodeJwt(e){try{const t=e.split(".")[1],n=r.Buffer.from(t,"base64").toString("utf8");return JSON.parse(n)}catch(e){throw new Error("Invalid session token format")}}validateClientConfig(e,t,n){var i;if(!e&&!(null==n?void 0:n.apiKey))return"Either sessionToken or apiKey must be provided";if((null==n?void 0:n.apiKey)&&e)return"Only one of sessionToken or apiKey should be used";if(e){const n=this.decodeJwt(e);this.organizationId=n.accountId,(0,s.setMetricsContext)({organizationId:this.organizationId});const r=null===(i=n.type)||void 0===i?void 0:i.toLowerCase();if("legacy"===r)return"Legacy session tokens are no longer supported. Please define your persona when creating your session token. See https://docs.anam.ai/resources/migrating-legacy for more information.";if(("ephemeral"===r||"stateful"===r)&&t)return"This session token already contains a persona configuration. Please remove the personaConfig parameter."}else if(!t)return"Missing persona config. Persona configuration must be provided when using apiKey";if(null==n?void 0:n.voiceDetection){if(n.disableInputAudio)return"Voice detection is disabled because input audio is disabled. Please set disableInputAudio to false to enable voice detection.";if(void 0!==n.voiceDetection.endOfSpeechSensitivity){if("number"!=typeof n.voiceDetection.endOfSpeechSensitivity)return"End of speech sensitivity must be a number";if(n.voiceDetection.endOfSpeechSensitivity<0||n.voiceDetection.endOfSpeechSensitivity>1)return"End of speech sensitivity must be between 0 and 1"}}}buildStartSessionOptionsForClient(){var e;const t={};if((null===(e=this.clientOptions)||void 0===e?void 0:e.voiceDetection)&&(t.voiceDetection=this.clientOptions.voiceDetection),0!==Object.keys(t).length)return t}startSession(e){return i(this,void 0,void 0,(function*(){var t,n,i,r,a,l,u,d,h;const f=this.personaConfig,p=this.buildStartSessionOptionsForClient(),E=yield this.apiClient.startSession(f,p),{sessionId:g,clientConfig:m,engineHost:v,engineProtocol:C,signallingEndpoint:S}=E,{heartbeatIntervalSeconds:y,maxWsReconnectionAttempts:A,iceServers:I}=m;this.sessionId=g,(0,s.setMetricsContext)({sessionId:this.sessionId});try{this.streamingClient=new c.StreamingClient(g,{engine:{baseUrl:`${C}://${v}`},signalling:{heartbeatIntervalSeconds:y,maxWsReconnectionAttempts:A,url:{baseUrl:v,protocol:C,signallingPath:S}},iceServers:I,inputAudio:{inputAudioState:this.inputAudioState,userProvidedMediaStream:(null===(t=this.clientOptions)||void 0===t?void 0:t.disableInputAudio)?void 0:e,audioDeviceId:null===(n=this.clientOptions)||void 0===n?void 0:n.audioDeviceId,disableInputAudio:null===(i=this.clientOptions)||void 0===i?void 0:i.disableInputAudio},metrics:{showPeerConnectionStatsReport:null!==(l=null===(a=null===(r=this.clientOptions)||void 0===r?void 0:r.metrics)||void 0===a?void 0:a.showPeerConnectionStatsReport)&&void 0!==l&&l,peerConnectionStatsReportOutputFormat:null!==(h=null===(d=null===(u=this.clientOptions)||void 0===u?void 0:u.metrics)||void 0===d?void 0:d.peerConnectionStatsReportOutputFormat)&&void 0!==h?h:"console"}},this.publicEventEmitter,this.internalEventEmitter)}catch(e){throw(0,s.setMetricsContext)({sessionId:null}),new o.ClientError("Failed to initialize streaming client",o.ErrorCode.CLIENT_ERROR_CODE_SERVER_ERROR,500,{cause:e instanceof Error?e.message:String(e),sessionId:g})}return g}))}startSessionIfNeeded(e){return i(this,void 0,void 0,(function*(){if(!(this.sessionId&&this.streamingClient||(yield this.startSession(e),this.sessionId&&this.streamingClient)))throw new o.ClientError("Session ID or streaming client is not available after starting session",o.ErrorCode.CLIENT_ERROR_CODE_SERVER_ERROR,500,{cause:"Failed to initialize session properly"})}))}stream(e){return i(this,void 0,void 0,(function*(){var t;if(this._isStreaming)throw new Error("Already streaming");const n=(0,a.generateCorrelationId)();return(0,s.setMetricsContext)({attemptCorrelationId:n,sessionId:null}),(0,s.sendClientMetric)(s.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_SESSION_ATTEMPT,"1"),(null===(t=this.clientOptions)||void 0===t?void 0:t.disableInputAudio)&&e&&console.warn("AnamClient:Input audio is disabled. User provided audio stream will be ignored."),yield this.startSessionIfNeeded(e),this._isStreaming=!0,new Promise((e=>{var t;const n=[];let i=!1,r=!1;this.publicEventEmitter.addListener(l.AnamEvent.VIDEO_STREAM_STARTED,(t=>{n.push(t),i=!0,r&&e(n)})),this.publicEventEmitter.addListener(l.AnamEvent.AUDIO_STREAM_STARTED,(t=>{n.push(t),r=!0,i&&e(n)})),null===(t=this.streamingClient)||void 0===t||t.startConnection()}))}))}streamToVideoAndAudioElements(e,t,n){return i(this,void 0,void 0,(function*(){console.warn("AnamClient: streamToVideoAndAudioElements is deprecated. To avoid possible audio issues, please use streamToVideoElement instead."),yield this.streamToVideoElement(e,n)}))}streamToVideoElement(e,t){return i(this,void 0,void 0,(function*(){var n;const i=(0,a.generateCorrelationId)();(0,s.setMetricsContext)({attemptCorrelationId:i,sessionId:null}),(0,s.sendClientMetric)(s.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_SESSION_ATTEMPT,"1"),(null===(n=this.clientOptions)||void 0===n?void 0:n.disableInputAudio)&&t&&console.warn("AnamClient:Input audio is disabled. User provided audio stream will be ignored.");try{yield this.startSessionIfNeeded(t)}catch(e){if(e instanceof o.ClientError)throw e;throw new o.ClientError("Failed to start session",o.ErrorCode.CLIENT_ERROR_CODE_SERVER_ERROR,500,{cause:e instanceof Error?e.message:String(e),sessionId:this.sessionId})}if(this._isStreaming)throw new Error("Already streaming");if(this._isStreaming=!0,!this.streamingClient)throw new Error("Failed to stream: streaming client is not available");this.streamingClient.setMediaStreamTargetById(e),this.streamingClient.startConnection()}))}talk(e){return i(this,void 0,void 0,(function*(){if(!this.streamingClient)throw new Error("Failed to send talk command: session is not started. Have you called startSession?");if(!this._isStreaming)throw new Error("Failed to send talk command: not currently streaming. Have you called stream?");yield this.streamingClient.sendTalkCommand(e)}))}sendDataMessage(e){if(!this.streamingClient)throw new Error("Failed to send message: session is not started.");this.streamingClient.sendDataMessage(e)}sendUserMessage(e){if(!this._isStreaming)throw console.warn("AnamClient: Not currently streaming. User message will not be sent."),new Error("Failed to send user message: not currently streaming");const t=this.getActiveSessionId();if(!t)throw new Error("Failed to send user message: no active session");const n=(new Date).toISOString().replace("Z",""),i=JSON.stringify({content:e,timestamp:n,session_id:t,message_type:"speech"});this.sendDataMessage(i)}interruptPersona(){if(!this._isStreaming)throw new Error("Failed to send interrupt command: not currently streaming");const e=this.getActiveSessionId();if(!e)throw new Error("Failed to send interrupt command: no active session");const t=JSON.stringify({message_type:"interrupt",session_id:e,timestamp:(new Date).toISOString()});this.sendDataMessage(t)}stopStreaming(){return i(this,void 0,void 0,(function*(){this.streamingClient&&(this.publicEventEmitter.emit(l.AnamEvent.CONNECTION_CLOSED,l.ConnectionClosedCode.NORMAL),yield this.streamingClient.stopConnection(),this.streamingClient=null,this.sessionId=null,(0,s.setMetricsContext)({attemptCorrelationId:null,sessionId:null,organizationId:this.organizationId}),this._isStreaming=!1)}))}isStreaming(){return this._isStreaming}setPersonaConfig(e){this.personaConfig=e}getPersonaConfig(){return this.personaConfig}getInputAudioState(){var e;return(null===(e=this.clientOptions)||void 0===e?void 0:e.disableInputAudio)&&console.warn("AnamClient: Audio state will not be used because input audio is disabled."),this.streamingClient&&(this.inputAudioState=this.streamingClient.getInputAudioState()),this.inputAudioState}muteInputAudio(){var e,t;return(null===(e=this.clientOptions)||void 0===e?void 0:e.disableInputAudio)&&console.warn("AnamClient: Input audio is disabled. Muting input audio will have no effect."),this.streamingClient&&!(null===(t=this.clientOptions)||void 0===t?void 0:t.disableInputAudio)?this.inputAudioState=this.streamingClient.muteInputAudio():this.inputAudioState=Object.assign(Object.assign({},this.inputAudioState),{isMuted:!0}),this.inputAudioState}unmuteInputAudio(){var e,t;return(null===(e=this.clientOptions)||void 0===e?void 0:e.disableInputAudio)&&console.warn("AnamClient: Input audio is disabled. Unmuting input audio will have no effect."),this.streamingClient&&!(null===(t=this.clientOptions)||void 0===t?void 0:t.disableInputAudio)?this.inputAudioState=this.streamingClient.unmuteInputAudio():this.inputAudioState=Object.assign(Object.assign({},this.inputAudioState),{isMuted:!1}),this.inputAudioState}createTalkMessageStream(e){if(!this.streamingClient)throw new Error("Failed to start talk message stream: session is not started.");if(e&&""===e.trim())throw new Error("Failed to start talk message stream: correlationId is empty");return this.streamingClient.startTalkMessageStream(e)}addListener(e,t){this.publicEventEmitter.addListener(e,t)}removeListener(e,t){this.publicEventEmitter.removeListener(e,t)}getActiveSessionId(){return this.sessionId}}},440:function(e,t,n){"use strict";var i=this&&this.__createBinding||(Object.create?function(e,t,n,i){void 0===i&&(i=n);var r=Object.getOwnPropertyDescriptor(t,n);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[n]}}),Object.defineProperty(e,i,r)}:function(e,t,n,i){void 0===i&&(i=n),e[i]=t[n]}),r=this&&this.__exportStar||function(e,t){for(var n in e)"default"===n||Object.prototype.hasOwnProperty.call(t,n)||i(t,e,n)},o=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.unsafe_createClientWithApiKey=t.createClient=void 0;const s=o(n(816));t.createClient=(e,t)=>new s.default(e,void 0,t),t.unsafe_createClientWithApiKey=(e,t,n)=>new s.default(void 0,t,Object.assign(Object.assign({},n),{apiKey:e})),r(n(170),t)},71:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.ClientError=t.ErrorCode=void 0;const i=n(948);var r;!function(e){e.CLIENT_ERROR_CODE_USAGE_LIMIT_REACHED="CLIENT_ERROR_CODE_USAGE_LIMIT_REACHED",e.CLIENT_ERROR_CODE_VALIDATION_ERROR="CLIENT_ERROR_CODE_VALIDATION_ERROR",e.CLIENT_ERROR_CODE_AUTHENTICATION_ERROR="CLIENT_ERROR_CODE_AUTHENTICATION_ERROR",e.CLIENT_ERROR_CODE_SERVER_ERROR="CLIENT_ERROR_CODE_SERVER_ERROR",e.CLIENT_ERROR_CODE_MAX_CONCURRENT_SESSIONS_REACHED="CLIENT_ERROR_CODE_MAX_CONCURRENT_SESSIONS_REACHED",e.CLIENT_ERROR_CODE_SERVICE_BUSY="CLIENT_ERROR_CODE_SERVICE_BUSY",e.CLIENT_ERROR_CODE_NO_PLAN_FOUND="CLIENT_ERROR_CODE_NO_PLAN_FOUND",e.CLIENT_ERROR_CODE_UNKNOWN_ERROR="CLIENT_ERROR_CODE_UNKNOWN_ERROR",e.CLIENT_ERROR_CODE_CONFIGURATION_ERROR="CLIENT_ERROR_CODE_CONFIGURATION_ERROR"}(r||(t.ErrorCode=r={}));class o extends Error{constructor(e,t,n=500,r){super(e),this.name="ClientError",this.code=t,this.statusCode=n,this.details=r,Object.setPrototypeOf(this,o.prototype),(0,i.sendClientMetric)(i.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_ERROR,t,{details:r,statusCode:n})}}t.ClientError=o},948:function(e,t,n){"use strict";var i=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.createRTCStatsReport=t.sendClientMetric=t.setMetricsContext=t.setClientMetricsBaseUrl=t.ClientMetricMeasurement=t.DEFAULT_ANAM_API_VERSION=t.DEFAULT_ANAM_METRICS_BASE_URL=void 0;const r=n(985);var o;t.DEFAULT_ANAM_METRICS_BASE_URL="https://api.anam.ai",t.DEFAULT_ANAM_API_VERSION="/v1",function(e){e.CLIENT_METRIC_MEASUREMENT_ERROR="client_error",e.CLIENT_METRIC_MEASUREMENT_CONNECTION_CLOSED="client_connection_closed",e.CLIENT_METRIC_MEASUREMENT_CONNECTION_ESTABLISHED="client_connection_established",e.CLIENT_METRIC_MEASUREMENT_SESSION_ATTEMPT="client_session_attempt",e.CLIENT_METRIC_MEASUREMENT_SESSION_SUCCESS="client_session_success"}(o||(t.ClientMetricMeasurement=o={}));let s=t.DEFAULT_ANAM_METRICS_BASE_URL,a=t.DEFAULT_ANAM_API_VERSION;t.setClientMetricsBaseUrl=(e,n=t.DEFAULT_ANAM_API_VERSION)=>{s=e,a=n};let c={sessionId:null,organizationId:null,attemptCorrelationId:null};t.setMetricsContext=e=>{c=Object.assign(Object.assign({},c),e)},t.sendClientMetric=(e,t,n)=>i(void 0,void 0,void 0,(function*(){try{const i=Object.assign(Object.assign({},r.CLIENT_METADATA),n);c.sessionId&&(i.sessionId=c.sessionId),c.organizationId&&(i.organizationId=c.organizationId),c.attemptCorrelationId&&(i.attemptCorrelationId=c.attemptCorrelationId),yield fetch(`${s}${a}/metrics/client`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e,value:t,tags:i})})}catch(e){console.error("Failed to send error metric:",e)}})),t.createRTCStatsReport=(e,t="console")=>{var n,i,r;const o={};e.forEach((e=>{o[e.type]||(o[e.type]=[]),o[e.type].push(e)}));const s={issues:[]},a=(null===(n=o["inbound-rtp"])||void 0===n?void 0:n.filter((e=>"video"===e.kind)))||[];a.length>0&&(s.personaVideoStream=[],a.forEach((e=>{var t,n,i,r,o;const a={framesReceived:null!==(t=e.framesReceived)&&void 0!==t?t:"unknown",framesDropped:null!==(n=e.framesDropped)&&void 0!==n?n:"unknown",framesPerSecond:null!==(i=e.framesPerSecond)&&void 0!==i?i:"unknown",packetsReceived:null!==(r=e.packetsReceived)&&void 0!==r?r:"unknown",packetsLost:null!==(o=e.packetsLost)&&void 0!==o?o:"unknown",resolution:e.frameWidth&&e.frameHeight?`${e.frameWidth}x${e.frameHeight}`:void 0,jitter:void 0!==e.jitter?e.jitter:void 0};s.personaVideoStream.push(a)})));const c=(null===(i=o["inbound-rtp"])||void 0===i?void 0:i.filter((e=>"audio"===e.kind)))||[];c.length>0&&(s.personaAudioStream=[],c.forEach((e=>{var t,n,i;const r={packetsReceived:null!==(t=e.packetsReceived)&&void 0!==t?t:"unknown",packetsLost:null!==(n=e.packetsLost)&&void 0!==n?n:"unknown",audioLevel:null!==(i=e.audioLevel)&&void 0!==i?i:"unknown",jitter:void 0!==e.jitter?e.jitter:void 0,totalAudioEnergy:void 0!==e.totalAudioEnergy?e.totalAudioEnergy:void 0};s.personaAudioStream.push(r)})));const l=(null===(r=o["outbound-rtp"])||void 0===r?void 0:r.filter((e=>"audio"===e.kind)))||[];l.length>0&&(s.userAudioInput=[],l.forEach((e=>{var t,n;const i={packetsSent:null!==(t=e.packetsSent)&&void 0!==t?t:"unknown",retransmittedPackets:null!==(n=e.retransmittedPacketsSent)&&void 0!==n?n:void 0,avgPacketSendDelay:void 0!==e.totalPacketSendDelay?e.totalPacketSendDelay/(e.packetsSent||1)*1e3:void 0};s.userAudioInput.push(i)}))),o.codec&&(s.codecs=[],o.codec.forEach((e=>{const t={status:e.payloadType?"Active":"Available",mimeType:e.mimeType||"Unknown",payloadType:e.payloadType||"N/A",clockRate:e.clockRate||void 0,channels:e.channels||void 0};s.codecs.push(t)}))),o.transport&&(s.transportLayer=[],o.transport.forEach((e=>{const t={dtlsState:e.dtlsState||"unknown",iceState:e.iceState||"unknown",bytesSent:e.bytesSent||void 0,bytesReceived:e.bytesReceived||void 0};s.transportLayer.push(t)})));const u=[];if(a.forEach((e=>{"number"==typeof e.framesDropped&&e.framesDropped>0&&u.push(`Video: ${e.framesDropped} frames dropped`),"number"==typeof e.packetsLost&&e.packetsLost>0&&u.push(`Video: ${e.packetsLost} packets lost`),"number"==typeof e.framesPerSecond&&e.framesPerSecond<23&&u.push(`Video: Low frame rate (${e.framesPerSecond} fps)`)})),c.forEach((e=>{"number"==typeof e.packetsLost&&e.packetsLost>0&&u.push(`Audio: ${e.packetsLost} packets lost`),"number"==typeof e.jitter&&e.jitter>.1&&u.push(`Audio: High jitter (${(1e3*e.jitter).toFixed(1)}ms)`)})),s.issues=u,"json"===t)return s;console.group("📊 WebRTC Session Statistics Report"),s.personaVideoStream&&s.personaVideoStream.length>0&&(console.group("📹 Persona Video Stream (Inbound)"),s.personaVideoStream.forEach((e=>{console.log(`Frames Received: ${e.framesReceived}`),console.log(`Frames Dropped: ${e.framesDropped}`),console.log(`Frames Per Second: ${e.framesPerSecond}`),console.log(`Packets Received: ${"number"==typeof e.packetsReceived?e.packetsReceived.toLocaleString():e.packetsReceived}`),console.log(`Packets Lost: ${e.packetsLost}`),e.resolution&&console.log(`Resolution: ${e.resolution}`),void 0!==e.jitter&&console.log(`Jitter: ${e.jitter.toFixed(5)}ms`)})),console.groupEnd()),s.personaAudioStream&&s.personaAudioStream.length>0&&(console.group("🔊 Persona Audio Stream (Inbound)"),s.personaAudioStream.forEach((e=>{console.log(`Packets Received: ${"number"==typeof e.packetsReceived?e.packetsReceived.toLocaleString():e.packetsReceived}`),console.log(`Packets Lost: ${e.packetsLost}`),console.log(`Audio Level: ${e.audioLevel}`),void 0!==e.jitter&&console.log(`Jitter: ${e.jitter.toFixed(5)}ms`),void 0!==e.totalAudioEnergy&&console.log(`Total Audio Energy: ${e.totalAudioEnergy.toFixed(6)}`)})),console.groupEnd()),s.userAudioInput&&s.userAudioInput.length>0&&(console.group("🎤 User Audio Input (Outbound)"),s.userAudioInput.forEach((e=>{console.log(`Packets Sent: ${"number"==typeof e.packetsSent?e.packetsSent.toLocaleString():e.packetsSent}`),e.retransmittedPackets&&console.log(`Retransmitted Packets: ${e.retransmittedPackets}`),void 0!==e.avgPacketSendDelay&&console.log(`Avg Packet Send Delay: ${e.avgPacketSendDelay.toFixed(5)}ms`)})),console.groupEnd()),s.codecs&&s.codecs.length>0&&(console.group("🔧 Codecs Used"),s.codecs.forEach((e=>{console.log(`${e.status} ${e.mimeType} - Payload Type: ${e.payloadType}`),e.clockRate&&console.log(` Clock Rate: ${e.clockRate}Hz`),e.channels&&console.log(` Channels: ${e.channels}`)})),console.groupEnd()),s.transportLayer&&s.transportLayer.length>0&&(console.group("🚚 Transport Layer"),s.transportLayer.forEach((e=>{console.log(`DTLS State: ${e.dtlsState}`),console.log(`ICE State: ${e.iceState}`),(e.bytesReceived||e.bytesSent)&&console.log(`Data Transfer (bytes) - Sent: ${(e.bytesSent||0).toLocaleString()}, Received: ${(e.bytesReceived||0).toLocaleString()}`)})),console.groupEnd()),s.issues.length>0?(console.group("⚠️ Potential Issues Detected"),s.issues.forEach((e=>console.warn(e))),console.groupEnd()):console.log("✅ No significant issues detected"),console.groupEnd()}},985:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.CLIENT_METADATA=t.DEFAULT_API_VERSION=t.DEFAULT_API_BASE_URL=t.DEFAULT_HEADERS=void 0,t.DEFAULT_HEADERS={"Content-Type":"application/json"},t.DEFAULT_API_BASE_URL="https://api.anam.ai",t.DEFAULT_API_VERSION="/v1",t.CLIENT_METADATA={client:"js-sdk",version:"3.4.0"}},353:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.generateCorrelationId=void 0,t.generateCorrelationId=function(){return"undefined"!=typeof crypto&&crypto.randomUUID?crypto.randomUUID():Date.now().toString(36)+Math.random().toString(36).substr(2,9)}},18:function(e,t,n){"use strict";var i=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreApiRestClient=void 0;const r=n(71),o=n(985),s=n(672);t.CoreApiRestClient=class{constructor(e,t,n){if(!e&&!t)throw new Error("Either sessionToken or apiKey must be provided");this.sessionToken=e||null,this.apiKey=t||null,this.baseUrl=(null==n?void 0:n.baseUrl)||o.DEFAULT_API_BASE_URL,this.apiVersion=(null==n?void 0:n.apiVersion)||o.DEFAULT_API_VERSION}startSession(e,t){return i(this,void 0,void 0,(function*(){if(!this.sessionToken){if(!e)throw new r.ClientError("Persona configuration must be provided when using apiKey",r.ErrorCode.CLIENT_ERROR_CODE_VALIDATION_ERROR,400);this.sessionToken=yield this.unsafe_getSessionToken(e)}e&&"brainType"in e&&console.warn("Warning: brainType is deprecated and will be removed in a future version. Please use llmId instead.");try{const n=yield fetch(`${this.getApiUrl()}/engine/session`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.sessionToken}`},body:JSON.stringify({personaConfig:e,sessionOptions:t,clientMetadata:o.CLIENT_METADATA})}),i=yield n.json();switch(n.status){case 200:case 201:return i;case 400:throw new r.ClientError("Invalid request to start session",r.ErrorCode.CLIENT_ERROR_CODE_VALIDATION_ERROR,400,{cause:i.message});case 401:throw new r.ClientError("Authentication failed when starting session",r.ErrorCode.CLIENT_ERROR_CODE_AUTHENTICATION_ERROR,401,{cause:i.message});case 402:throw new r.ClientError("Please sign up for a plan to start a session",r.ErrorCode.CLIENT_ERROR_CODE_NO_PLAN_FOUND,402,{cause:i.message});case 403:throw new r.ClientError("Authentication failed when starting session",r.ErrorCode.CLIENT_ERROR_CODE_AUTHENTICATION_ERROR,403,{cause:i.message});case 429:throw new r.ClientError("Usage limit reached, please upgrade your plan",r.ErrorCode.CLIENT_ERROR_CODE_USAGE_LIMIT_REACHED,429,{cause:i.message});case 503:throw new r.ClientError("There are no available personas, please try again later",r.ErrorCode.CLIENT_ERROR_CODE_SERVICE_BUSY,503,{cause:i.message});default:throw new r.ClientError("Unknown error when starting session",r.ErrorCode.CLIENT_ERROR_CODE_SERVER_ERROR,500,{cause:i.message})}}catch(e){if(e instanceof r.ClientError)throw e;throw new r.ClientError("Failed to start session",r.ErrorCode.CLIENT_ERROR_CODE_SERVER_ERROR,500,{cause:e instanceof Error?e.message:String(e)})}}))}unsafe_getSessionToken(e){return i(this,void 0,void 0,(function*(){if(console.warn("Using unsecure method. This method should not be used in production."),!this.apiKey)throw new Error("No apiKey provided");e&&"brainType"in e&&console.warn("Warning: brainType is deprecated and will be removed in a future version. Please use llmId instead.");let t={clientLabel:"js-sdk-api-key"};(0,s.isCustomPersonaConfig)(e)&&(t=Object.assign(Object.assign({},t),{personaConfig:e}));try{const e=yield fetch(`${this.getApiUrl()}/auth/session-token`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${this.apiKey}`},body:JSON.stringify(t)});return(yield e.json()).sessionToken}catch(e){throw new Error("Failed to get session token")}}))}getApiUrl(){return`${this.baseUrl}${this.apiVersion}`}}},529:function(e,t){"use strict";var n=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.EngineApiRestClient=void 0,t.EngineApiRestClient=class{constructor(e,t){this.baseUrl=e,this.sessionId=t}sendTalkCommand(e){return n(this,void 0,void 0,(function*(){try{const t=yield fetch(`${this.baseUrl}/talk?session_id=${this.sessionId}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:e})});if(!t.ok)throw new Error(`Failed to send talk command: ${t.status} ${t.statusText}`)}catch(e){throw console.error(e),new Error("EngineApiRestClient - sendTalkCommand: Failed to send talk command")}}))}}},259:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.InternalEventEmitter=void 0,t.InternalEventEmitter=class{constructor(){this.listeners={}}addListener(e,t){this.listeners[e]||(this.listeners[e]=new Set),this.listeners[e].add(t)}removeListener(e,t){this.listeners[e]&&this.listeners[e].delete(t)}emit(e,...t){this.listeners[e]&&this.listeners[e].forEach((e=>{e(...t)}))}}},462:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.MessageHistoryClient=void 0;const i=n(170);t.MessageHistoryClient=class{constructor(e,t){this.messages=[],this.publicEventEmitter=e,this.internalEventEmitter=t,this.internalEventEmitter.addListener(i.InternalEvent.WEBRTC_CHAT_MESSAGE_RECEIVED,this.processWebRtcTextMessageEvent.bind(this))}webRtcTextMessageEventToMessageStreamEvent(e){return{id:`${e.role}::${e.message_id}`,content:e.content,role:e.role,endOfSpeech:e.end_of_speech,interrupted:e.interrupted}}processUserMessage(e){const t={id:e.id,content:e.content,role:e.role};this.messages.push(t)}processPersonaMessage(e){const t={id:e.id,content:e.content,role:e.role,interrupted:e.interrupted},n=this.messages.findIndex((e=>e.id===t.id));if(-1!==n){const e=this.messages[n];this.messages[n]=Object.assign(Object.assign({},e),{content:e.content+t.content,interrupted:e.interrupted||t.interrupted})}else this.messages.push(t)}processWebRtcTextMessageEvent(e){const t=this.webRtcTextMessageEventToMessageStreamEvent(e);switch(this.publicEventEmitter.emit(i.AnamEvent.MESSAGE_STREAM_EVENT_RECEIVED,t),t.role){case i.MessageRole.USER:this.processUserMessage(t);break;case i.MessageRole.PERSONA:this.processPersonaMessage(t)}t.endOfSpeech&&this.publicEventEmitter.emit(i.AnamEvent.MESSAGE_HISTORY_UPDATED,this.messages)}}},995:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.PublicEventEmitter=void 0;const i=n(948),r=n(170);t.PublicEventEmitter=class{constructor(){this.listeners={}}addListener(e,t){this.listeners[e]||(this.listeners[e]=new Set),this.listeners[e].add(t)}removeListener(e,t){this.listeners[e]&&this.listeners[e].delete(t)}emit(e,...t){if(e===r.AnamEvent.CONNECTION_ESTABLISHED&&(0,i.sendClientMetric)(i.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_CONNECTION_ESTABLISHED,"1"),e===r.AnamEvent.CONNECTION_CLOSED){const[e,n]=t;(0,i.sendClientMetric)(i.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_CONNECTION_CLOSED,e,n?{details:n}:void 0)}this.listeners[e]&&this.listeners[e].forEach((e=>{e(...t)}))}}},279:function(e,t,n){"use strict";var i=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.SignallingClient=void 0;const r=n(170);t.SignallingClient=class{constructor(e,t,n,i){var r;if(this.stopSignal=!1,this.sendingBuffer=[],this.wsConnectionAttempts=0,this.socket=null,this.heartBeatIntervalRef=null,this.publicEventEmitter=n,this.internalEventEmitter=i,!e)throw new Error("Signalling Client: sessionId is required");this.sessionId=e;const{heartbeatIntervalSeconds:o,maxWsReconnectionAttempts:s,url:a}=t;if(this.heartbeatIntervalSeconds=o||5,this.maxWsReconnectionAttempts=s||5,!a.baseUrl)throw new Error("Signalling Client: baseUrl is required");const c=`${a.protocol||"https"}://${a.baseUrl}`;this.url=new URL(c),this.url.protocol="http"===a.protocol?"ws:":"wss:",a.port&&(this.url.port=a.port),this.url.pathname=null!==(r=a.signallingPath)&&void 0!==r?r:"/ws",this.url.searchParams.append("session_id",e)}stop(){this.stopSignal=!0,this.closeSocket()}connect(){return this.socket=new WebSocket(this.url.href),this.socket.onopen=this.onOpen.bind(this),this.socket.onclose=this.onClose.bind(this),this.socket.onerror=this.onError.bind(this),this.socket}sendOffer(e){return i(this,void 0,void 0,(function*(){const t={connectionDescription:e,userUid:this.sessionId},n={actionType:r.SignalMessageAction.OFFER,sessionId:this.sessionId,payload:t};this.sendSignalMessage(n)}))}sendIceCandidate(e){return i(this,void 0,void 0,(function*(){const t={actionType:r.SignalMessageAction.ICE_CANDIDATE,sessionId:this.sessionId,payload:e.toJSON()};this.sendSignalMessage(t)}))}sendSignalMessage(e){var t;if((null===(t=this.socket)||void 0===t?void 0:t.readyState)===WebSocket.OPEN)try{this.socket.send(JSON.stringify(e))}catch(e){console.error("SignallingClient - sendSignalMessage: error sending message",e)}else this.sendingBuffer.push(e)}sendTalkMessage(e){return i(this,void 0,void 0,(function*(){const t={actionType:r.SignalMessageAction.TALK_STREAM_INPUT,sessionId:this.sessionId,payload:e};this.sendSignalMessage(t)}))}closeSocket(){this.socket&&(this.socket.close(),this.socket=null),this.heartBeatIntervalRef&&(clearInterval(this.heartBeatIntervalRef),this.heartBeatIntervalRef=null)}onOpen(){return i(this,void 0,void 0,(function*(){if(!this.socket)throw new Error("SignallingClient - onOpen: socket is null");try{this.wsConnectionAttempts=0,this.flushSendingBuffer(),this.socket.onmessage=this.onMessage.bind(this),this.startSendingHeartBeats(),this.internalEventEmitter.emit(r.InternalEvent.WEB_SOCKET_OPEN)}catch(e){console.error("SignallingClient - onOpen: error in onOpen",e),this.publicEventEmitter.emit(r.AnamEvent.CONNECTION_CLOSED,r.ConnectionClosedCode.SIGNALLING_CLIENT_CONNECTION_FAILURE)}}))}onClose(){return i(this,void 0,void 0,(function*(){this.wsConnectionAttempts+=1,this.stopSignal||(this.wsConnectionAttempts<=this.maxWsReconnectionAttempts?(this.socket=null,setTimeout((()=>{this.connect()}),100*this.wsConnectionAttempts)):(this.heartBeatIntervalRef&&(clearInterval(this.heartBeatIntervalRef),this.heartBeatIntervalRef=null),this.publicEventEmitter.emit(r.AnamEvent.CONNECTION_CLOSED,r.ConnectionClosedCode.SIGNALLING_CLIENT_CONNECTION_FAILURE)))}))}onError(e){this.stopSignal||console.error("SignallingClient - onError: ",e)}flushSendingBuffer(){const e=[];this.sendingBuffer.length>0&&this.sendingBuffer.forEach((t=>{var n;(null===(n=this.socket)||void 0===n?void 0:n.readyState)===WebSocket.OPEN?this.socket.send(JSON.stringify(t)):e.push(t)})),this.sendingBuffer=e}onMessage(e){return i(this,void 0,void 0,(function*(){const t=JSON.parse(e.data);this.internalEventEmitter.emit(r.InternalEvent.SIGNAL_MESSAGE_RECEIVED,t)}))}startSendingHeartBeats(){if(!this.socket)throw new Error("SignallingClient - startSendingHeartBeats: socket is null");this.heartBeatIntervalRef&&console.warn("SignallingClient - startSendingHeartBeats: heartbeat interval already set");const e=1e3*this.heartbeatIntervalSeconds,t={actionType:r.SignalMessageAction.HEARTBEAT,sessionId:this.sessionId,payload:""},n=JSON.stringify(t);this.heartBeatIntervalRef=setInterval((()=>{var e;this.stopSignal||(null===(e=this.socket)||void 0===e?void 0:e.readyState)===WebSocket.OPEN&&this.socket.send(n)}),e)}}},577:function(e,t,n){"use strict";var i=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.StreamingClient=void 0;const r=n(958),o=n(170),s=n(711),a=n(948);t.StreamingClient=class{constructor(e,t,n,i){var s,a,c,l;this.peerConnection=null,this.connectionReceivedAnswer=!1,this.remoteIceCandidateBuffer=[],this.inputAudioStream=null,this.dataChannel=null,this.videoElement=null,this.videoStream=null,this.audioStream=null,this.inputAudioState={isMuted:!1},this.successMetricPoller=null,this.successMetricFired=!1,this.showPeerConnectionStatsReport=!1,this.peerConnectionStatsReportOutputFormat="console",this.publicEventEmitter=n,this.internalEventEmitter=i;const{inputAudio:u}=t;this.inputAudioState=u.inputAudioState,t.inputAudio.userProvidedMediaStream&&(this.inputAudioStream=t.inputAudio.userProvidedMediaStream),this.disableInputAudio=!0===t.inputAudio.disableInputAudio,this.internalEventEmitter.addListener(o.InternalEvent.WEB_SOCKET_OPEN,this.onSignallingClientConnected.bind(this)),this.internalEventEmitter.addListener(o.InternalEvent.SIGNAL_MESSAGE_RECEIVED,this.onSignalMessage.bind(this)),this.iceServers=t.iceServers,this.signallingClient=new r.SignallingClient(e,t.signalling,this.publicEventEmitter,this.internalEventEmitter),this.engineApiRestClient=new r.EngineApiRestClient(t.engine.baseUrl,e),this.audioDeviceId=t.inputAudio.audioDeviceId,this.showPeerConnectionStatsReport=null!==(a=null===(s=t.metrics)||void 0===s?void 0:s.showPeerConnectionStatsReport)&&void 0!==a&&a,this.peerConnectionStatsReportOutputFormat=null!==(l=null===(c=t.metrics)||void 0===c?void 0:c.peerConnectionStatsReportOutputFormat)&&void 0!==l?l:"console"}onInputAudioStateChange(e,t){e.isMuted!==t.isMuted&&(t.isMuted?this.muteAllAudioTracks():this.unmuteAllAudioTracks())}muteAllAudioTracks(){var e;null===(e=this.inputAudioStream)||void 0===e||e.getAudioTracks().forEach((e=>{e.enabled=!1}))}unmuteAllAudioTracks(){var e;null===(e=this.inputAudioStream)||void 0===e||e.getAudioTracks().forEach((e=>{e.enabled=!0}))}startSuccessMetricPolling(){if(this.successMetricPoller||this.successMetricFired)return;const e=setTimeout((()=>{this.successMetricPoller&&(console.warn("No video frames received, there is a problem with the connection."),clearInterval(this.successMetricPoller),this.successMetricPoller=null)}),15e3);this.successMetricPoller=setInterval((()=>i(this,void 0,void 0,(function*(){if(!this.peerConnection||this.successMetricFired)return this.successMetricPoller&&clearInterval(this.successMetricPoller),void clearTimeout(e);try{const t=yield this.peerConnection.getStats();let n=!1,i=null;t.forEach((e=>{"inbound-rtp"===e.type&&"video"===e.kind&&(void 0!==e.framesDecoded&&e.framesDecoded>0?(n=!0,i="framesDecoded"):void 0!==e.framesReceived&&e.framesReceived>0?(n=!0,i="framesReceived"):e.bytesReceived>0&&e.packetsReceived>0&&e.bytesReceived>1e5&&(n=!0,i="bytesReceived"))})),n&&!this.successMetricFired&&(this.successMetricFired=!0,(0,a.sendClientMetric)(a.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_SESSION_SUCCESS,"1",i?{detectionMethod:i}:void 0),this.successMetricPoller&&clearInterval(this.successMetricPoller),clearTimeout(e),this.successMetricPoller=null)}catch(e){}}))),500)}muteInputAudio(){const e=this.inputAudioState,t=Object.assign(Object.assign({},this.inputAudioState),{isMuted:!0});return this.inputAudioState=t,this.onInputAudioStateChange(e,t),this.inputAudioState}unmuteInputAudio(){const e=this.inputAudioState,t=Object.assign(Object.assign({},this.inputAudioState),{isMuted:!1});return this.inputAudioState=t,this.onInputAudioStateChange(e,t),this.inputAudioState}getInputAudioState(){return this.inputAudioState}getPeerConnection(){return this.peerConnection}getInputAudioStream(){return this.inputAudioStream}getVideoStream(){return this.videoStream}getAudioStream(){return this.audioStream}sendDataMessage(e){this.dataChannel&&"open"===this.dataChannel.readyState&&this.dataChannel.send(e)}setMediaStreamTargetById(e){if(e){const t=document.getElementById(e);if(!t)throw new Error(`StreamingClient: video element with id ${e} not found`);this.videoElement=t}}startConnection(){try{if(this.peerConnection)return void console.error("StreamingClient - startConnection: peer connection already exists");this.signallingClient.connect()}catch(e){console.log("StreamingClient - startConnection: error",e),this.handleWebrtcFailure(e)}}stopConnection(){return i(this,void 0,void 0,(function*(){yield this.shutdown()}))}sendTalkCommand(e){return i(this,void 0,void 0,(function*(){if(!this.peerConnection)throw new Error("StreamingClient - sendTalkCommand: peer connection is null");yield this.engineApiRestClient.sendTalkCommand(e)}))}startTalkMessageStream(e){return e||(e=Math.random().toString(36).substring(2,15)),new s.TalkMessageStream(e,this.internalEventEmitter,this.signallingClient)}initPeerConnection(){return i(this,void 0,void 0,(function*(){this.peerConnection=new RTCPeerConnection({iceServers:this.iceServers}),this.peerConnection.onicecandidate=this.onIceCandidate.bind(this),this.peerConnection.oniceconnectionstatechange=this.onIceConnectionStateChange.bind(this),this.peerConnection.onconnectionstatechange=this.onConnectionStateChange.bind(this),this.peerConnection.addEventListener("track",this.onTrackEventHandler.bind(this)),yield this.setupDataChannels(),this.peerConnection.addTransceiver("video",{direction:"recvonly"}),this.disableInputAudio?this.peerConnection.addTransceiver("audio",{direction:"recvonly"}):this.peerConnection.addTransceiver("audio",{direction:"sendrecv"})}))}onSignalMessage(e){return i(this,void 0,void 0,(function*(){if(this.peerConnection)switch(e.actionType){case o.SignalMessageAction.ANSWER:const t=e.payload;yield this.peerConnection.setRemoteDescription(t),this.connectionReceivedAnswer=!0,this.flushRemoteIceCandidateBuffer();break;case o.SignalMessageAction.ICE_CANDIDATE:const n=e.payload,i=new RTCIceCandidate(n);this.connectionReceivedAnswer?yield this.peerConnection.addIceCandidate(i):this.remoteIceCandidateBuffer.push(i);break;case o.SignalMessageAction.END_SESSION:const r=e.payload;console.log("StreamingClient - onSignalMessage: reason",r),this.publicEventEmitter.emit(o.AnamEvent.CONNECTION_CLOSED,o.ConnectionClosedCode.SERVER_CLOSED_CONNECTION,r),this.shutdown();break;case o.SignalMessageAction.WARNING:const s=e.payload;console.warn("Warning received from server: "+s),this.publicEventEmitter.emit(o.AnamEvent.SERVER_WARNING,s);break;case o.SignalMessageAction.TALK_STREAM_INTERRUPTED:const a=e.payload;this.publicEventEmitter.emit(o.AnamEvent.TALK_STREAM_INTERRUPTED,a.correlationId);break;case o.SignalMessageAction.SESSION_READY:const c=e.sessionId;this.publicEventEmitter.emit(o.AnamEvent.SESSION_READY,c);break;default:console.error("StreamingClient - onSignalMessage: unknown signal message action type. Is your anam-sdk version up to date?",e)}else console.error("StreamingClient - onSignalMessage: peerConnection is not initialized")}))}onSignallingClientConnected(){return i(this,void 0,void 0,(function*(){if(!this.peerConnection)try{yield this.initPeerConnectionAndSendOffer()}catch(e){console.error("StreamingClient - onSignallingClientConnected: Error initializing peer connection",e),this.handleWebrtcFailure(e)}}))}flushRemoteIceCandidateBuffer(){this.remoteIceCandidateBuffer.forEach((e=>{var t;null===(t=this.peerConnection)||void 0===t||t.addIceCandidate(e)})),this.remoteIceCandidateBuffer=[]}onIceCandidate(e){e.candidate&&this.signallingClient.sendIceCandidate(e.candidate)}onIceConnectionStateChange(){var e,t;"connected"!==(null===(e=this.peerConnection)||void 0===e?void 0:e.iceConnectionState)&&"completed"!==(null===(t=this.peerConnection)||void 0===t?void 0:t.iceConnectionState)||this.publicEventEmitter.emit(o.AnamEvent.CONNECTION_ESTABLISHED)}onConnectionStateChange(){var e;"closed"===(null===(e=this.peerConnection)||void 0===e?void 0:e.connectionState)&&(console.error("StreamingClient - onConnectionStateChange: Connection closed"),this.handleWebrtcFailure("The connection to our servers was lost. Please try again."))}handleWebrtcFailure(e){console.error({message:"StreamingClient - handleWebrtcFailure: ",err:e}),"NotAllowedError"===e.name&&"Permission denied"===e.message?this.publicEventEmitter.emit(o.AnamEvent.CONNECTION_CLOSED,o.ConnectionClosedCode.MICROPHONE_PERMISSION_DENIED):this.publicEventEmitter.emit(o.AnamEvent.CONNECTION_CLOSED,o.ConnectionClosedCode.WEBRTC_FAILURE);try{this.stopConnection()}catch(e){console.error("StreamingClient - handleWebrtcFailure: error stopping connection",e)}}onTrackEventHandler(e){if("video"===e.track.kind){if(this.startSuccessMetricPolling(),this.videoStream=e.streams[0],this.publicEventEmitter.emit(o.AnamEvent.VIDEO_STREAM_STARTED,this.videoStream),this.videoElement){this.videoElement.srcObject=this.videoStream;const e=this.videoElement.requestVideoFrameCallback((()=>{var t;null===(t=this.videoElement)||void 0===t||t.cancelVideoFrameCallback(e),this.publicEventEmitter.emit(o.AnamEvent.VIDEO_PLAY_STARTED),this.successMetricFired||(this.successMetricFired=!0,(0,a.sendClientMetric)(a.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_SESSION_SUCCESS,"1",{detectionMethod:"videoElement"}))}))}}else"audio"===e.track.kind&&(this.audioStream=e.streams[0],this.publicEventEmitter.emit(o.AnamEvent.AUDIO_STREAM_STARTED,this.audioStream))}setupDataChannels(){return i(this,void 0,void 0,(function*(){if(!this.peerConnection)return void console.error("StreamingClient - setupDataChannels: peer connection is not initialized");if(!this.disableInputAudio){if(this.inputAudioStream){if(!this.inputAudioStream.getAudioTracks().length)throw new Error("StreamingClient - setupDataChannels: user provided stream does not have audio tracks")}else{const e={echoCancellation:!0};this.audioDeviceId&&(e.deviceId={exact:this.audioDeviceId}),this.inputAudioStream=yield navigator.mediaDevices.getUserMedia({audio:e})}this.inputAudioState.isMuted&&this.muteAllAudioTracks();const e=this.inputAudioStream.getAudioTracks()[0];this.peerConnection.addTrack(e,this.inputAudioStream),this.publicEventEmitter.emit(o.AnamEvent.INPUT_AUDIO_STREAM_STARTED,this.inputAudioStream)}const e=this.peerConnection.createDataChannel("chat",{ordered:!0});e.onopen=()=>{this.dataChannel=null!=e?e:null},e.onclose=()=>{},e.onmessage=e=>{const t=JSON.parse(e.data);this.internalEventEmitter.emit(o.InternalEvent.WEBRTC_CHAT_MESSAGE_RECEIVED,t)}}))}initPeerConnectionAndSendOffer(){return i(this,void 0,void 0,(function*(){if(yield this.initPeerConnection(),this.peerConnection){try{const e=yield this.peerConnection.createOffer();yield this.peerConnection.setLocalDescription(e)}catch(e){console.error("StreamingClient - initPeerConnectionAndSendOffer: error creating offer",e)}if(!this.peerConnection.localDescription)throw new Error("StreamingClient - initPeerConnectionAndSendOffer: local description is null");yield this.signallingClient.sendOffer(this.peerConnection.localDescription)}else console.error("StreamingClient - initPeerConnectionAndSendOffer: peer connection is not initialized")}))}shutdown(){return i(this,void 0,void 0,(function*(){var e;if(this.showPeerConnectionStatsReport){const t=yield null===(e=this.peerConnection)||void 0===e?void 0:e.getStats();if(t){const e=(0,a.createRTCStatsReport)(t,this.peerConnectionStatsReportOutputFormat);e&&console.log(e,void 0,2)}}this.successMetricPoller&&(clearInterval(this.successMetricPoller),this.successMetricPoller=null),this.successMetricFired=!1;try{this.inputAudioStream&&this.inputAudioStream.getTracks().forEach((e=>{e.stop()})),this.inputAudioStream=null}catch(e){console.error("StreamingClient - shutdown: error stopping input audio stream",e)}try{this.signallingClient.stop()}catch(e){console.error("StreamingClient - shutdown: error stopping signallilng",e)}try{this.peerConnection&&"closed"!==this.peerConnection.connectionState&&(this.peerConnection.onconnectionstatechange=null,this.peerConnection.close(),this.peerConnection=null)}catch(e){console.error("StreamingClient - shutdown: error closing peer connection",e)}}))}}},958:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.StreamingClient=t.PublicEventEmitter=t.MessageHistoryClient=t.InternalEventEmitter=t.EngineApiRestClient=t.CoreApiRestClient=t.SignallingClient=void 0;var i=n(279);Object.defineProperty(t,"SignallingClient",{enumerable:!0,get:function(){return i.SignallingClient}});var r=n(18);Object.defineProperty(t,"CoreApiRestClient",{enumerable:!0,get:function(){return r.CoreApiRestClient}});var o=n(529);Object.defineProperty(t,"EngineApiRestClient",{enumerable:!0,get:function(){return o.EngineApiRestClient}});var s=n(259);Object.defineProperty(t,"InternalEventEmitter",{enumerable:!0,get:function(){return s.InternalEventEmitter}});var a=n(462);Object.defineProperty(t,"MessageHistoryClient",{enumerable:!0,get:function(){return a.MessageHistoryClient}});var c=n(995);Object.defineProperty(t,"PublicEventEmitter",{enumerable:!0,get:function(){return c.PublicEventEmitter}});var l=n(577);Object.defineProperty(t,"StreamingClient",{enumerable:!0,get:function(){return l.StreamingClient}})},672:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.isCustomPersonaConfig=void 0,t.isCustomPersonaConfig=function(e){return"brainType"in e||"llmId"in e}},711:function(e,t,n){"use strict";var i=this&&this.__awaiter||function(e,t,n,i){return new(n||(n=Promise))((function(r,o){function s(e){try{c(i.next(e))}catch(e){o(e)}}function a(e){try{c(i.throw(e))}catch(e){o(e)}}function c(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(s,a)}c((i=i.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.TalkMessageStream=void 0;const r=n(170),o=n(322);t.TalkMessageStream=class{constructor(e,t,n){this.state=o.TalkMessageStreamState.UNSTARTED,this.correlationId=e,this.internalEventEmitter=t,this.signallingClient=n,this.internalEventEmitter.addListener(r.InternalEvent.SIGNAL_MESSAGE_RECEIVED,this.onSignalMessage.bind(this))}onDeactivate(){this.internalEventEmitter.removeListener(r.InternalEvent.SIGNAL_MESSAGE_RECEIVED,this.onSignalMessage.bind(this))}onSignalMessage(e){return i(this,void 0,void 0,(function*(){e.actionType===r.SignalMessageAction.TALK_STREAM_INTERRUPTED&&e.payload.correlationId===this.correlationId&&(this.state=o.TalkMessageStreamState.INTERRUPTED,this.onDeactivate())}))}endMessage(){return i(this,void 0,void 0,(function*(){if(this.state===o.TalkMessageStreamState.ENDED)return void console.warn("Talk stream is already ended via end of speech. No need to call endMessage.");if(this.state!==o.TalkMessageStreamState.STREAMING)return void console.warn("Talk stream is not active state: "+this.state);const e={content:"",startOfSpeech:!1,endOfSpeech:!0,correlationId:this.correlationId};yield this.signallingClient.sendTalkMessage(e),this.state=o.TalkMessageStreamState.ENDED,this.onDeactivate()}))}streamMessageChunk(e,t){return i(this,void 0,void 0,(function*(){if(this.state!==o.TalkMessageStreamState.STREAMING&&this.state!==o.TalkMessageStreamState.UNSTARTED)throw new Error("Talk stream is not in an active state: "+this.state);const n={content:e,startOfSpeech:this.state===o.TalkMessageStreamState.UNSTARTED,endOfSpeech:t,correlationId:this.correlationId};this.state=t?o.TalkMessageStreamState.ENDED:o.TalkMessageStreamState.STREAMING,this.state===o.TalkMessageStreamState.ENDED&&this.onDeactivate(),yield this.signallingClient.sendTalkMessage(n)}))}getCorrelationId(){return this.correlationId}isActive(){return this.state===o.TalkMessageStreamState.STREAMING||this.state===o.TalkMessageStreamState.UNSTARTED}getState(){return this.state}}},322:(e,t)=>{"use strict";var n;Object.defineProperty(t,"__esModule",{value:!0}),t.TalkMessageStreamState=void 0,function(e){e[e.UNSTARTED=0]="UNSTARTED",e[e.STREAMING=1]="STREAMING",e[e.INTERRUPTED=2]="INTERRUPTED",e[e.ENDED=3]="ENDED"}(n||(t.TalkMessageStreamState=n={}))},878:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.ConnectionClosedCode=t.InternalEvent=t.AnamEvent=void 0;var i=n(21);Object.defineProperty(t,"AnamEvent",{enumerable:!0,get:function(){return i.AnamEvent}});var r=n(645);Object.defineProperty(t,"InternalEvent",{enumerable:!0,get:function(){return r.InternalEvent}});var o=n(60);Object.defineProperty(t,"ConnectionClosedCode",{enumerable:!0,get:function(){return o.ConnectionClosedCode}})},645:(e,t)=>{"use strict";var n;Object.defineProperty(t,"__esModule",{value:!0}),t.InternalEvent=void 0,function(e){e.WEB_SOCKET_OPEN="WEB_SOCKET_OPEN",e.SIGNAL_MESSAGE_RECEIVED="SIGNAL_MESSAGE_RECEIVED",e.WEBRTC_CHAT_MESSAGE_RECEIVED="WEBRTC_CHAT_MESSAGE_RECEIVED"}(n||(t.InternalEvent=n={}))},21:(e,t)=>{"use strict";var n;Object.defineProperty(t,"__esModule",{value:!0}),t.AnamEvent=void 0,function(e){e.MESSAGE_HISTORY_UPDATED="MESSAGE_HISTORY_UPDATED",e.MESSAGE_STREAM_EVENT_RECEIVED="MESSAGE_STREAM_EVENT_RECEIVED",e.CONNECTION_ESTABLISHED="CONNECTION_ESTABLISHED",e.CONNECTION_CLOSED="CONNECTION_CLOSED",e.INPUT_AUDIO_STREAM_STARTED="INPUT_AUDIO_STREAM_STARTED",e.VIDEO_STREAM_STARTED="VIDEO_STREAM_STARTED",e.VIDEO_PLAY_STARTED="VIDEO_PLAY_STARTED",e.AUDIO_STREAM_STARTED="AUDIO_STREAM_STARTED",e.TALK_STREAM_INTERRUPTED="TALK_STREAM_INTERRUPTED",e.SESSION_READY="SESSION_READY",e.SERVER_WARNING="SERVER_WARNING"}(n||(t.AnamEvent=n={}))},60:(e,t)=>{"use strict";var n;Object.defineProperty(t,"__esModule",{value:!0}),t.ConnectionClosedCode=void 0,function(e){e.NORMAL="CONNECTION_CLOSED_CODE_NORMAL",e.MICROPHONE_PERMISSION_DENIED="CONNECTION_CLOSED_CODE_MICROPHONE_PERMISSION_DENIED",e.SIGNALLING_CLIENT_CONNECTION_FAILURE="CONNECTION_CLOSED_CODE_SIGNALLING_CLIENT_CONNECTION_FAILURE",e.WEBRTC_FAILURE="CONNECTION_CLOSED_CODE_WEBRTC_FAILURE",e.SERVER_CLOSED_CONNECTION="CONNECTION_CLOSED_CODE_SERVER_CLOSED_CONNECTION"}(n||(t.ConnectionClosedCode=n={}))},170:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.ConnectionClosedCode=t.InternalEvent=t.AnamEvent=t.MessageRole=t.SignalMessageAction=void 0;var i=n(295);Object.defineProperty(t,"SignalMessageAction",{enumerable:!0,get:function(){return i.SignalMessageAction}});var r=n(822);Object.defineProperty(t,"MessageRole",{enumerable:!0,get:function(){return r.MessageRole}});var o=n(878);Object.defineProperty(t,"AnamEvent",{enumerable:!0,get:function(){return o.AnamEvent}});var s=n(878);Object.defineProperty(t,"InternalEvent",{enumerable:!0,get:function(){return s.InternalEvent}});var a=n(878);Object.defineProperty(t,"ConnectionClosedCode",{enumerable:!0,get:function(){return a.ConnectionClosedCode}})},273:(e,t)=>{"use strict";var n;Object.defineProperty(t,"__esModule",{value:!0}),t.MessageRole=void 0,function(e){e.USER="user",e.PERSONA="persona"}(n||(t.MessageRole=n={}))},822:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.MessageRole=void 0;var i=n(273);Object.defineProperty(t,"MessageRole",{enumerable:!0,get:function(){return i.MessageRole}})},656:(e,t)=>{"use strict";var n;Object.defineProperty(t,"__esModule",{value:!0}),t.SignalMessageAction=void 0,function(e){e.OFFER="offer",e.ANSWER="answer",e.ICE_CANDIDATE="icecandidate",e.END_SESSION="endsession",e.HEARTBEAT="heartbeat",e.WARNING="warning",e.TALK_STREAM_INTERRUPTED="talkinputstreaminterrupted",e.TALK_STREAM_INPUT="talkstream",e.SESSION_READY="sessionready"}(n||(t.SignalMessageAction=n={}))},295:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.SignalMessageAction=void 0;var i=n(656);Object.defineProperty(t,"SignalMessageAction",{enumerable:!0,get:function(){return i.SignalMessageAction}})}},t={},function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i].call(o.exports,o,o.exports,n),o.exports}(440);var e,t}));
anam/static/js/api.js ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function _request(url, opts = {}) {
2
+ const res = await fetch(url, opts);
3
+ if (!res.ok) {
4
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
5
+ throw new Error(`${res.status}: ${JSON.stringify(err)}`);
6
+ }
7
+ return res.json();
8
+ }
9
+
10
+ function _post(url, body) {
11
+ return _request(url, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify(body),
15
+ });
16
+ }
17
+
18
+ export const getConfig = () => _request("/api/check");
19
+ export const getPersonas = () => _request("/api/personas");
20
+ export const createToken = (data) => _post("/api/session/token", data);
21
+
22
+ export const getRagStatus = () => _request("/api/rag/status");
23
+ export const getRagDocuments = () => _request("/api/rag/documents");
24
+ export const deleteRagDocument = (id) => _request(`/api/rag/documents/${id}`, { method: "DELETE" });
25
+ export const getRagPrompt = () => _request("/api/rag/persona");
26
+ export const updateRagPrompt = (prompt) =>
27
+ _request("/api/rag/persona", {
28
+ method: "PUT",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({ system_prompt: prompt }),
31
+ });
32
+ export const resetRag = () => _post("/api/rag/reset", {});
33
+
34
+ export async function uploadRagFile(file) {
35
+ const form = new FormData();
36
+ form.append("file", file);
37
+ const res = await fetch("/api/rag/upload", { method: "POST", body: form });
38
+ if (!res.ok) throw new Error(JSON.stringify(await res.json().catch(() => res.statusText)));
39
+ return res.json();
40
+ }
41
+
42
+ // ── Local RAG (in-memory, injected into system prompt) ────────────────────────
43
+ export const listLocalDocs = () => _request("/api/local-rag/documents");
44
+ export const deleteLocalDoc = (id) => _request(`/api/local-rag/documents/${id}`, { method: "DELETE" });
45
+ export async function uploadLocalDoc(file) {
46
+ const form = new FormData();
47
+ form.append("file", file);
48
+ const res = await fetch("/api/local-rag/upload", { method: "POST", body: form });
49
+ if (!res.ok) throw new Error(JSON.stringify(await res.json().catch(() => res.statusText)));
50
+ return res.json();
51
+ }
52
+
53
+ // ── Clinical (blood test report) ───────────────────────────────────────────────
54
+ export async function uploadClinicalReport(file) {
55
+ const form = new FormData();
56
+ form.append("file", file);
57
+ const res = await fetch("/api/clinical/upload", { method: "POST", body: form });
58
+ if (!res.ok) throw new Error(JSON.stringify(await res.json().catch(() => res.statusText)));
59
+ return res.json();
60
+ }
61
+
62
+ export const getClinicalReport = () => _request("/api/clinical/current");
63
+ export const deleteClinicalReport = () => _request("/api/clinical/current", { method: "DELETE" });
64
+
65
+ // ── Telemetry ──────────────────────────────────────────────────────────────────
66
+ export const endSession = (sessionId) => _post("/api/session/end", { session_id: sessionId });
67
+ export const getTelemetrySummary = () => _request("/api/telemetry/summary");
68
+ export const getTelemetrySessions = () => _request("/api/telemetry/sessions");
69
+ export const resetTelemetry = () => _post("/api/telemetry/reset", {});
anam/static/js/app.js ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createToken, endSession, getConfig, getPersonas } from "./api.js";
2
+ import * as kb from "./kb.js";
3
+ import * as localRag from "./local_rag.js";
4
+ import * as clinical from "./clinical.js";
5
+ import * as telemetry from "./telemetry.js";
6
+
7
+ const CLINICAL_SYSTEM_PROMPT =
8
+ "You are a clinical assistant avatar for the OLMA longevity platform. " +
9
+ "You have two sources of information: the patient's blood test results (via tools) and the OLMA Biomarker Catalog (provided as RETRIEVED CONTEXT). " +
10
+ "STRICT RULE: You must ONLY use information from the RETRIEVED CONTEXT when making any assessment or recommendation. " +
11
+ "Do NOT use your own medical knowledge, do NOT suggest supplements or dosages that are not explicitly stated in the RETRIEVED CONTEXT. " +
12
+ "If the catalog does not contain specific information for a biomarker, say exactly: " +
13
+ "'The OLMA catalog does not have a specific protocol for this — please consult your physician.' " +
14
+ "Use the catalog's interpretation categories exactly as defined: Optimal, Borderline, Requires Attention, Physician Review Recommended, or Critical. " +
15
+ "When the user asks about their results, use the get_blood_report_summary tool to get their values, " +
16
+ "then match each abnormal value to the catalog's category and state only what the catalog says. " +
17
+ "Always respond in plain spoken language — never use bullet points, asterisks, " +
18
+ "pipe characters, markdown headers, or any formatting symbols.";
19
+
20
+ if (!window.anam) {
21
+ const dot = document.getElementById("status-dot");
22
+ const txt = document.getElementById("status-text");
23
+ const btn = document.getElementById("connect-btn");
24
+ if (dot) dot.className = "error";
25
+ if (txt) txt.textContent = "❌ Anam SDK failed to load — hard-refresh (Cmd+Shift+R)";
26
+ if (btn) btn.disabled = true;
27
+ throw new Error("window.anam is undefined — SDK script did not execute");
28
+ }
29
+ const { createClient, AnamEvent } = window.anam;
30
+
31
+ // ── State ─────────────────────────────────────────────────────────────────────
32
+ let anamClient = null;
33
+ let micMuted = false;
34
+ let currentMode = "voice";
35
+ let activeAgentModel = null; // OpenAI model chosen at connect time
36
+ let activeSessionId = null; // telemetry session id
37
+ let activeLocalDocIds = []; // Local RAG doc IDs for current session
38
+ let activeCloudRagGroupId = null; // Anam knowledge group ID (Cloud RAG + Custom LLM)
39
+
40
+ // ── DOM ───────────────────────────────────────────────────────────────────────
41
+ const $ = (id) => document.getElementById(id);
42
+ const videoEl = $("avatar-video");
43
+ const placeholder = $("placeholder");
44
+ const placeholderStatus = $("placeholder-status");
45
+ const micOverlay = $("mic-indicator");
46
+ const ragBadge = $("rag-active-badge");
47
+ const statusDot = $("status-dot");
48
+ const statusText = $("status-text");
49
+ const logEl = $("log");
50
+ const connectBtn = $("connect-btn");
51
+ const disconnBtn = $("disconnect-btn");
52
+ const personaSel = $("persona-select");
53
+ const modeTabs = $("mode-tabs");
54
+ const voiceRow = $("voice-mode-row");
55
+ const textRow = $("text-mode-row");
56
+ const micBtn = $("mic-btn");
57
+ const interruptBtn = $("interrupt-btn");
58
+ const voiceHint = $("voice-hint");
59
+ const msgInput = $("message-input");
60
+ const sendBtn = $("send-btn");
61
+ const sysPromptEl = $("system-prompt-input");
62
+ const advToggle = $("advanced-toggle");
63
+ const advPanel = $("advanced-panel");
64
+ const ragCheckbox = $("use-rag-checkbox");
65
+ const planSwitch = $("plan-switch");
66
+ const planBanner = $("plan-banner");
67
+ const planFreeLabel = $("plan-free-label");
68
+ const planProLabel = $("plan-pro-label");
69
+ const callTimer = $("call-timer");
70
+ const tmStats = $("telemetry-stats");
71
+ const tmSessions = $("telemetry-sessions");
72
+ const tmRateNote = $("telemetry-rate-note");
73
+ const tmResetBtn = $("telemetry-reset-btn");
74
+
75
+ // ── Helpers ───────────────────────────────────────────────────────────────────
76
+ function log(msg, type = "info") {
77
+ const el = document.createElement("div");
78
+ el.className = `log-line log-${type}`;
79
+ el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
80
+ logEl.appendChild(el);
81
+ logEl.scrollTop = logEl.scrollHeight;
82
+ }
83
+
84
+ function setStatus(msg, cls = "") {
85
+ statusText.textContent = msg;
86
+ statusDot.className = cls;
87
+ }
88
+
89
+ function resetUI() {
90
+ stopSessionCountdown();
91
+ telemetry.stopPolling();
92
+ if (activeSessionId) { endSession(activeSessionId).catch(() => {}); activeSessionId = null; }
93
+ videoEl.style.display = "none";
94
+ placeholder.classList.remove("connecting");
95
+ placeholder.style.display = "flex";
96
+ micOverlay.style.display = "none";
97
+ ragBadge.style.display = "none";
98
+ micBtn.classList.remove("muted");
99
+ micOverlay.classList.remove("muted");
100
+ connectBtn.disabled = false;
101
+ disconnBtn.disabled = true;
102
+ micBtn.disabled = true;
103
+ interruptBtn.disabled = true;
104
+ sendBtn.disabled = true;
105
+ msgInput.disabled = true;
106
+ modeTabs.style.display = "none";
107
+ voiceRow.style.display = "none";
108
+ textRow.style.display = "none";
109
+ voiceHint.textContent = "Microphone is active — speak to the avatar";
110
+ micMuted = false;
111
+ anamClient = null;
112
+ activeLocalDocIds = [];
113
+ activeCloudRagGroupId = null;
114
+ setStatus("Not connected", "");
115
+ }
116
+
117
+ // ── Plan toggle (Free = 2-minute cap, Pro = unlimited) ───────────────────────────
118
+ const FREE_PLAN_SECONDS = 120;
119
+
120
+ const isFreePlan = () => planSwitch && !planSwitch.checked;
121
+
122
+ function syncPlanUI() {
123
+ const free = isFreePlan();
124
+ planBanner?.classList.toggle("visible", free);
125
+ planFreeLabel?.classList.toggle("active", free);
126
+ planProLabel?.classList.toggle("active", !free);
127
+ }
128
+ planSwitch?.addEventListener("change", syncPlanUI);
129
+ syncPlanUI();
130
+
131
+ // ── Session countdown (free plan) ────────────────────────────────────────────────
132
+ let countdownTimer = null;
133
+
134
+ async function speakViaTalkStream(text) {
135
+ if (!anamClient) return;
136
+ try {
137
+ const ts = anamClient.createTalkMessageStream();
138
+ ts.streamMessageChunk(text, false);
139
+ ts.endMessage();
140
+ } catch (e) {
141
+ log("Could not speak countdown message: " + e.message, "warn");
142
+ }
143
+ }
144
+
145
+ function startSessionCountdown(seconds, useCustomLlm) {
146
+ let remaining = seconds;
147
+ let warned = false;
148
+ callTimer.classList.add("visible");
149
+ callTimer.classList.remove("ending");
150
+
151
+ const tick = () => {
152
+ const m = Math.floor(Math.max(remaining, 0) / 60);
153
+ const s = Math.max(remaining, 0) % 60;
154
+ callTimer.textContent = `⏱ ${m}:${String(s).padStart(2, "0")} left`;
155
+
156
+ if (remaining <= 30 && !warned) {
157
+ warned = true;
158
+ callTimer.classList.add("ending");
159
+ if (useCustomLlm) {
160
+ speakViaTalkStream("Just a heads-up — your free session ends in about thirty seconds.");
161
+ }
162
+ }
163
+
164
+ if (remaining <= 0) {
165
+ stopSessionCountdown();
166
+ log("Your 2 free minutes are up — the session has been disconnected. Switch to Pro for unlimited time.", "error");
167
+ const client = anamClient;
168
+ if (client) client.stopStreaming().catch(() => {});
169
+ resetUI();
170
+ setStatus("Free plan time limit reached", "error");
171
+ return;
172
+ }
173
+ remaining -= 1;
174
+ };
175
+
176
+ tick();
177
+ countdownTimer = setInterval(tick, 1000);
178
+ }
179
+
180
+ function stopSessionCountdown() {
181
+ if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
182
+ callTimer.classList.remove("visible", "ending");
183
+ callTimer.textContent = "";
184
+ }
185
+
186
+ // ── Advanced panel ────────────────────────────────────────────────────────────
187
+ advToggle.addEventListener("click", () => {
188
+ const open = advPanel.style.display === "flex";
189
+ advPanel.style.display = open ? "none" : "flex";
190
+ });
191
+
192
+ // ── RAG mode tabs (Anam Cloud / Local) ───────────────────────────────────────
193
+ const anamRagPanel = $("anam-rag-panel");
194
+ const localRagPanel = $("local-rag-panel");
195
+
196
+ document.querySelectorAll(".rag-mode-tab").forEach((tab) => {
197
+ tab.addEventListener("click", () => {
198
+ document.querySelectorAll(".rag-mode-tab").forEach((t) => t.classList.remove("active"));
199
+ tab.classList.add("active");
200
+ const isLocal = tab.dataset.mode === "local";
201
+ anamRagPanel.style.display = isLocal ? "none" : "flex";
202
+ localRagPanel.style.display = isLocal ? "flex" : "none";
203
+ });
204
+ });
205
+
206
+ function activeRagMode() {
207
+ const active = document.querySelector(".rag-mode-tab.active");
208
+ return active?.dataset.mode ?? "anam";
209
+ }
210
+
211
+ // ── Clinical Assistant preset ─────────────────────────────────────────────────
212
+ $("clinical-preset-btn")?.addEventListener("click", () => {
213
+ sysPromptEl.value = CLINICAL_SYSTEM_PROMPT;
214
+ if (llmSelect) {
215
+ llmSelect.value = "__agent__";
216
+ syncLlmRows();
217
+ }
218
+ advPanel.style.display = "flex";
219
+ log("Clinical Assistant preset applied — upload a blood test report below, then connect.", "rag");
220
+ });
221
+
222
+ // ── LLM dropdown — drives all LLM-related sub-rows ───────────────────────────
223
+ const llmSelect = $("llm-select");
224
+ const agentModelRow = $("agent-model-row");
225
+ const customIdRow = $("custom-id-row");
226
+
227
+ function syncLlmRows() {
228
+ const v = llmSelect?.value;
229
+ if (agentModelRow) agentModelRow.style.display = (v === "__agent__") ? "flex" : "none";
230
+ if (customIdRow) customIdRow.style.display = (v === "__custom__") ? "flex" : "none";
231
+ // keep hidden checkbox in sync so legacy code paths still work
232
+ const cb = $("custom-llm-checkbox");
233
+ if (cb) cb.checked = (v === "__agent__");
234
+ }
235
+ llmSelect?.addEventListener("change", syncLlmRows);
236
+
237
+ // ── Mode tabs ─────────────────────────────────────────────────────────────────
238
+ document.querySelectorAll(".mode-tab").forEach((tab) => {
239
+ tab.addEventListener("click", () => {
240
+ document.querySelectorAll(".mode-tab").forEach((t) => t.classList.remove("active"));
241
+ tab.classList.add("active");
242
+ currentMode = tab.dataset.mode;
243
+ voiceRow.style.display = currentMode === "voice" ? "flex" : "none";
244
+ textRow.style.display = currentMode === "text" ? "flex" : "none";
245
+ });
246
+ });
247
+
248
+ // ── Load personas ─────────────────────────────────────────────────────────────
249
+ const PERSONAS_CACHE_KEY = "anam_personas_cache";
250
+
251
+ function _renderPersonas(list) {
252
+ personaSel.innerHTML = '<option value="">— Default ephemeral —</option>';
253
+ list.forEach((p) => {
254
+ const opt = document.createElement("option");
255
+ opt.value = p.id ?? p.personaId;
256
+ opt.textContent = p.name ?? opt.value;
257
+ personaSel.appendChild(opt);
258
+ });
259
+ }
260
+
261
+ async function loadPersonas() {
262
+ // Show cached list immediately so the dropdown is never stuck on "Loading…"
263
+ try {
264
+ const cached = localStorage.getItem(PERSONAS_CACHE_KEY);
265
+ if (cached) {
266
+ _renderPersonas(JSON.parse(cached));
267
+ }
268
+ } catch (_) {}
269
+
270
+ // Fetch fresh list in the background; update + re-cache if it differs
271
+ try {
272
+ const data = await getPersonas();
273
+ const list = data?.items ?? data?.personas ?? data?.data ?? [];
274
+ _renderPersonas(list);
275
+ try { localStorage.setItem(PERSONAS_CACHE_KEY, JSON.stringify(list)); } catch (_) {}
276
+ if (list.length > 0) log(`Loaded ${list.length} persona(s)`, "success");
277
+ } catch (e) {
278
+ if (!localStorage.getItem(PERSONAS_CACHE_KEY)) {
279
+ personaSel.innerHTML = '<option value="">— Default ephemeral —</option>';
280
+ }
281
+ log("Could not load personas: " + e.message, "warn");
282
+ }
283
+ }
284
+
285
+ // ── Connect ───────────────────────────────────────────────────────────────────
286
+ connectBtn.addEventListener("click", async () => {
287
+ connectBtn.disabled = true;
288
+ setStatus("Creating session token…", "connecting");
289
+
290
+ try {
291
+ // Check mic permission before doing anything else
292
+ try {
293
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
294
+ stream.getTracks().forEach(t => t.stop());
295
+ log("Microphone permission granted", "success");
296
+ } catch (micErr) {
297
+ setStatus("🎤 Microphone blocked — allow mic access then retry", "error");
298
+ log("Microphone blocked: " + micErr.message, "error");
299
+ log("Fix: click the 🎤 icon in the browser address bar → Allow, then click Connect again", "warn");
300
+ connectBtn.disabled = false;
301
+ return;
302
+ }
303
+
304
+ const ragMode = activeRagMode();
305
+ const useRag = ragMode === "anam" && ragCheckbox.checked && kb.ragSetupReady && kb.readyDocCount > 0;
306
+ const useLocalRag = ragMode === "local" && $("use-local-rag-checkbox")?.checked && localRag.localDocIds().length > 0;
307
+ const localDocIds = useLocalRag ? localRag.localDocIds() : [];
308
+ const personaId = (useRag || useLocalRag) ? null : (personaSel.value || null);
309
+ const systemPrompt = sysPromptEl.value.trim() ||
310
+ "You are a helpful, friendly AI assistant. Keep responses concise and conversational.";
311
+
312
+ if (useLocalRag) {
313
+ log(`Connecting with Local RAG — ${localDocIds.length} doc(s) injected into context…`, "rag");
314
+ } else {
315
+ log(useRag ? "Connecting with Anam Cloud RAG knowledge base…" : "Connecting…", useRag ? "rag" : "info");
316
+ }
317
+
318
+ // ── Resolve LLM selection from dropdown ──────────────────────────────────
319
+ const llmChoice = llmSelect?.value || "";
320
+ const useCustomLlm = llmChoice === "__agent__";
321
+ activeAgentModel = useCustomLlm ? ($("agent-model-input")?.value.trim() || null) : null;
322
+ const llmId = useCustomLlm ? undefined
323
+ : llmChoice === "__custom__" ? ($("custom-llm-id-input")?.value.trim() || undefined)
324
+ : llmChoice || undefined; // Anam built-in LLM ID
325
+ const voiceId = $("voice-id-input")?.value.trim() || undefined;
326
+
327
+ const llmLabel = useCustomLlm
328
+ ? `Agent LLM${activeAgentModel ? " · " + activeAgentModel : " · gpt-4o-mini"}`
329
+ : llmChoice === "__custom__" ? "Custom LLM ID"
330
+ : llmSelect?.options[llmSelect.selectedIndex]?.text || "default";
331
+ log(`LLM: ${llmLabel}`, "info");
332
+
333
+ const tokData = await createToken({
334
+ persona_id: personaId,
335
+ system_prompt: systemPrompt,
336
+ use_rag: useRag,
337
+ local_rag_doc_ids: localDocIds,
338
+ llm_id: llmId,
339
+ voice_id: voiceId,
340
+ use_custom_llm: useCustomLlm,
341
+ plan: isFreePlan() ? "free" : "pro",
342
+ });
343
+ const sessionToken = tokData?.sessionToken;
344
+ if (!sessionToken) throw new Error("No sessionToken in response: " + JSON.stringify(tokData));
345
+ activeSessionId = tokData.sessionId ?? null;
346
+ activeLocalDocIds = localDocIds; // [] when not in Local RAG mode
347
+ activeCloudRagGroupId = tokData.ragGroupId ?? null; // set when Cloud RAG + Custom LLM
348
+ log("Session token obtained", "success");
349
+
350
+ anamClient = createClient(sessionToken);
351
+
352
+ if (useCustomLlm) {
353
+ let _streamCtrl = null; // AbortController for the in-flight fetch
354
+
355
+ anamClient.addListener(AnamEvent.MESSAGE_HISTORY_UPDATED, async (messages) => {
356
+ // Only react to user turns — the event also fires when the assistant
357
+ // response is written back into history, which would restart the stream.
358
+ const last = messages[messages.length - 1];
359
+ if (!last || last.role !== "user") return;
360
+
361
+ // Cancel any previous in-flight request (e.g. rapid speech detection)
362
+ if (_streamCtrl) _streamCtrl.abort();
363
+ _streamCtrl = new AbortController();
364
+
365
+ const talkStream = anamClient.createTalkMessageStream();
366
+ try {
367
+ const chatBody = { messages, model: activeAgentModel || undefined, system_prompt: systemPrompt };
368
+ if (activeLocalDocIds.length) chatBody.local_rag_doc_ids = activeLocalDocIds;
369
+ if (activeCloudRagGroupId) chatBody.cloud_rag_group_id = activeCloudRagGroupId;
370
+
371
+ const resp = await fetch("/api/chat-stream", {
372
+ method: "POST",
373
+ headers: { "Content-Type": "application/json" },
374
+ body: JSON.stringify(chatBody),
375
+ signal: _streamCtrl.signal,
376
+ });
377
+ const reader = resp.body.getReader();
378
+ const decoder = new TextDecoder();
379
+ while (true) {
380
+ const { done, value } = await reader.read();
381
+ if (done) break;
382
+ const lines = decoder.decode(value).split("\n").filter(Boolean);
383
+ for (const line of lines) {
384
+ try {
385
+ const { content } = JSON.parse(line);
386
+ if (content && talkStream.isActive()) {
387
+ talkStream.streamMessageChunk(content, false);
388
+ }
389
+ } catch (_) {}
390
+ }
391
+ }
392
+ } catch (e) {
393
+ if (e.name !== "AbortError") log("Stream error: " + e.message, "error");
394
+ } finally {
395
+ talkStream.endMessage();
396
+ _streamCtrl = null;
397
+ }
398
+ });
399
+ const ragLabel = activeLocalDocIds.length
400
+ ? `Local RAG (${activeLocalDocIds.length} doc${activeLocalDocIds.length !== 1 ? "s" : ""})`
401
+ : activeCloudRagGroupId
402
+ ? "Cloud RAG (per-turn search)"
403
+ : "no RAG";
404
+ log(`Agent LLM active — ${activeAgentModel || "gpt-4o-mini"} via localhost:8005 · ${ragLabel}`, "success");
405
+ }
406
+
407
+ // ── Diagnostic listeners — show STT / speaking events in the log panel ──────
408
+ const diagEvents = [
409
+ "userStartTalking", "userStopTalking",
410
+ "personaTalkingStarted", "personaTalkingStopped",
411
+ "inputAudioStreamStarted",
412
+ ];
413
+ diagEvents.forEach((ev) => {
414
+ try {
415
+ anamClient.addListener(ev, () => log(`[mic] ${ev}`, "info"));
416
+ } catch (_) {}
417
+ });
418
+
419
+ // Log every MESSAGE_HISTORY_UPDATED so we can confirm STT is firing
420
+ anamClient.addListener(AnamEvent.MESSAGE_HISTORY_UPDATED, (messages) => {
421
+ const last = messages[messages.length - 1];
422
+ if (last?.role === "user") {
423
+ log(`[STT heard] "${last.content}"`, "rag");
424
+ }
425
+ });
426
+
427
+ anamClient.addListener(AnamEvent.VIDEO_STREAM_STARTED, () => {
428
+ placeholder.classList.remove("connecting");
429
+ videoEl.style.display = "block";
430
+ placeholder.style.display = "none";
431
+ micOverlay.style.display = "flex";
432
+ if (useRag || useLocalRag) ragBadge.style.display = "block";
433
+ setStatus(
434
+ useLocalRag ? "Connected — local docs active!" :
435
+ useRag ? "Connected — ask about your documents!" :
436
+ "Connected — speak to the avatar!",
437
+ "connected"
438
+ );
439
+ log("Avatar video is live!", "success");
440
+
441
+ if (isFreePlan()) {
442
+ startSessionCountdown(FREE_PLAN_SECONDS, useCustomLlm);
443
+ log("Free plan: 2-minute session limit active", "warn");
444
+ if (useCustomLlm) {
445
+ speakViaTalkStream(
446
+ "Hi! Quick note before we start — you're on the free plan, so we have two minutes together. What would you like to talk about?"
447
+ );
448
+ }
449
+ }
450
+
451
+ telemetry.startPolling(tmStats, tmSessions, tmRateNote);
452
+ });
453
+
454
+ anamClient.addListener(AnamEvent.AUDIO_STREAM_STARTED, () => {
455
+ log("Avatar audio connected", "success");
456
+ });
457
+
458
+ anamClient.addListener(AnamEvent.CONNECTION_CLOSED, () => {
459
+ log("Connection closed by server", "info");
460
+ resetUI();
461
+ });
462
+
463
+ setStatus("Connecting to avatar…", "connecting");
464
+ placeholder.classList.add("connecting");
465
+ placeholderStatus.textContent = "Connecting to avatar…";
466
+ await anamClient.streamToVideoElement("avatar-video");
467
+
468
+ disconnBtn.disabled = false;
469
+ micBtn.disabled = false;
470
+ interruptBtn.disabled = false;
471
+ msgInput.disabled = false;
472
+ sendBtn.disabled = false;
473
+ modeTabs.style.display = "flex";
474
+ voiceRow.style.display = "flex";
475
+
476
+ } catch (err) {
477
+ placeholder.classList.remove("connecting");
478
+ log("Error: " + err.message, "error");
479
+ setStatus("Connection failed", "error");
480
+ connectBtn.disabled = false;
481
+ anamClient = null;
482
+ }
483
+ });
484
+
485
+ // ── Mic ───────────────────────────────────────────────────────────────────────
486
+ micBtn.addEventListener("click", () => {
487
+ if (!anamClient) return;
488
+ micMuted = !micMuted;
489
+ if (micMuted) {
490
+ anamClient.muteInputAudio();
491
+ micBtn.classList.add("muted");
492
+ micOverlay.classList.add("muted");
493
+ voiceHint.textContent = "Microphone muted — click to unmute";
494
+ log("Microphone muted", "info");
495
+ } else {
496
+ anamClient.unmuteInputAudio();
497
+ micBtn.classList.remove("muted");
498
+ micOverlay.classList.remove("muted");
499
+ voiceHint.textContent = "Microphone active — speak to the avatar";
500
+ log("Microphone unmuted", "success");
501
+ }
502
+ });
503
+
504
+ // ── Interrupt ─────────────────────────────────────────────────────────────────
505
+ interruptBtn.addEventListener("click", () => {
506
+ if (!anamClient) return;
507
+ anamClient.interruptPersona();
508
+ log("Avatar interrupted", "info");
509
+ });
510
+
511
+ // ── Text send ─────────────────────────────────────────────────────────────────
512
+ function sendText() {
513
+ const text = msgInput.value.trim();
514
+ if (!text || !anamClient) return;
515
+ msgInput.value = "";
516
+ log(`You: ${text}`, "user");
517
+ anamClient.sendUserMessage(text);
518
+ }
519
+
520
+ sendBtn.addEventListener("click", sendText);
521
+ msgInput.addEventListener("keydown", (e) => { if (e.key === "Enter") sendText(); });
522
+
523
+ // ── Disconnect ────────────────────────────────────────────────────────────────
524
+ disconnBtn.addEventListener("click", () => {
525
+ disconnBtn.disabled = true;
526
+ if (anamClient) {
527
+ try { anamClient.stopStreaming(); } catch (_) {}
528
+ }
529
+ log("Session stopped", "info");
530
+ resetUI();
531
+ });
532
+
533
+ // ── Init ──────────────────────────────────────────────────────────────────────
534
+ tmResetBtn?.addEventListener("click", () => telemetry.reset(tmStats, tmSessions, tmRateNote));
535
+ telemetry.refresh(tmStats, tmSessions, tmRateNote);
536
+
537
+ kb.setUploadHandler((file) => kb.uploadFile(file, log));
538
+ localRag.setUploadHandler((file) => localRag.uploadFile(file, log));
539
+ loadPersonas();
540
+ kb.checkRagStatus(log);
541
+ localRag.loadAll();
542
+
543
+ clinical.setUploadHandler((file) => clinical.uploadReport(file, log));
544
+ clinical.checkReportStatus(log);
545
+
546
+ // Pre-select the LLM dropdown based on server config
547
+ getConfig().then((cfg) => {
548
+ const provider = cfg?.llm_provider?.toUpperCase();
549
+ if (provider === "CLIENTSIDE" && llmSelect) {
550
+ llmSelect.value = "__agent__";
551
+ } else if (cfg?.default_llm_id && llmSelect) {
552
+ llmSelect.value = cfg.default_llm_id;
553
+ }
554
+ syncLlmRows();
555
+ }).catch(() => {});
anam/static/js/clinical.js ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { deleteClinicalReport, getClinicalReport, uploadClinicalReport } from "./api.js";
2
+
3
+ // ── Shared state (live binding — app.js can read this directly) ───────────────
4
+ export let reportReady = false;
5
+
6
+ // ── DOM ───────────────────────────────────────────────────────────────────────
7
+ const $ = (id) => document.getElementById(id);
8
+ const uploadStatus = $("report-upload-status");
9
+ const uploadStatusTxt = $("report-upload-status-text");
10
+ const reportSummary = $("report-summary");
11
+ const statusBadge = $("report-status-badge");
12
+ const dropZone = $("report-drop-zone");
13
+ const fileInput = $("report-file-input");
14
+
15
+ // ── Render ────────────────────────────────────────────────────────────────────
16
+ /** Build a small horizontal range-bar showing where the value sits vs. the reference range. */
17
+ function renderBar(r) {
18
+ const v = r.value_numeric;
19
+ const lo = r.ref_low;
20
+ const hi = r.ref_high;
21
+ if (v == null || (lo == null && hi == null)) return "";
22
+
23
+ const refSpan = (lo != null && hi != null) ? (hi - lo) : Math.abs((hi ?? lo) || 1) * 0.5;
24
+ let winLo = lo != null ? lo : Math.min(v, (hi ?? v) - refSpan);
25
+ let winHi = hi != null ? hi : Math.max(v, (lo ?? v) + refSpan);
26
+ const pad = Math.max((winHi - winLo) * 0.2, 0.0001);
27
+ winLo -= pad;
28
+ winHi += pad;
29
+ if (v < winLo) winLo = v - pad;
30
+ if (v > winHi) winHi = v + pad;
31
+
32
+ const pct = (x) => Math.max(0, Math.min(100, ((x - winLo) / (winHi - winLo)) * 100));
33
+ const rangeLeft = pct(lo ?? winLo);
34
+ const rangeRight = pct(hi ?? winHi);
35
+
36
+ return `
37
+ <div class="report-bar">
38
+ <div class="report-bar-track">
39
+ <div class="report-bar-range" style="left:${rangeLeft}%; width:${Math.max(rangeRight - rangeLeft, 0)}%"></div>
40
+ <div class="report-bar-marker" style="left:${pct(v)}%"></div>
41
+ </div>
42
+ </div>`;
43
+ }
44
+
45
+ function renderReport(report) {
46
+ if (!report?.has_report) {
47
+ reportReady = false;
48
+ reportSummary.innerHTML = "";
49
+ statusBadge.style.display = "none";
50
+ return;
51
+ }
52
+
53
+ reportReady = true;
54
+ statusBadge.textContent = `✓ ${report.filename || "Report"} analysed`;
55
+ statusBadge.className = "ready";
56
+ statusBadge.style.display = "inline-block";
57
+
58
+ const results = report.results || [];
59
+ if (!results.length) {
60
+ reportSummary.innerHTML = "<p style=\"font-size:13px;color:#666\">No markers could be extracted from this report.</p>";
61
+ return;
62
+ }
63
+
64
+ reportSummary.innerHTML = results.map((r) => {
65
+ const flag = (r.flag || "unknown").toLowerCase();
66
+ const valStr = [r.value, r.unit].filter(Boolean).join(" ");
67
+ const ref = r.reference_range ? ` · ref ${r.reference_range}` : "";
68
+ return `
69
+ <div class="report-line flag-${flag}">
70
+ <div class="report-line-top">
71
+ <span class="report-marker">${r.test_name || "Unknown marker"}</span>
72
+ <span class="report-value">${valStr}${ref}</span>
73
+ <span class="report-flag">${flag}</span>
74
+ </div>
75
+ ${renderBar(r)}
76
+ </div>`;
77
+ }).join("");
78
+ }
79
+
80
+ // ── Upload ────────────────────────────────────────────────────────────────────
81
+ export async function uploadReport(file, onLog) {
82
+ uploadStatus.style.display = "flex";
83
+ uploadStatusTxt.textContent = `Analysing ${file.name}…`;
84
+ onLog?.(`Uploading blood test report: ${file.name}`, "info");
85
+
86
+ try {
87
+ const result = await uploadClinicalReport(file);
88
+ renderReport({ has_report: true, ...result });
89
+ onLog?.(`Report analysed — ${(result.results || []).length} marker(s) found`, "success");
90
+ } catch (e) {
91
+ onLog?.("Report upload failed: " + e.message, "error");
92
+ } finally {
93
+ uploadStatus.style.display = "none";
94
+ }
95
+ }
96
+
97
+ // ── Check report status on startup ──────────────────────────────────────────
98
+ export async function checkReportStatus(onLog) {
99
+ try {
100
+ const report = await getClinicalReport();
101
+ if (report?.has_report) {
102
+ renderReport(report);
103
+ onLog?.(`Loaded existing blood test report: ${report.filename}`, "info");
104
+ }
105
+ } catch (e) {
106
+ onLog?.("Could not check report status: " + e.message, "warn");
107
+ }
108
+ }
109
+
110
+ // ── Reset ─────────────────────────────────────────────────────────────────────
111
+ $("report-reset-btn")?.addEventListener("click", async () => {
112
+ if (!confirm("Clear the uploaded blood test report?")) return;
113
+ try {
114
+ await deleteClinicalReport();
115
+ renderReport({ has_report: false });
116
+ } catch (e) {
117
+ console.error("Reset failed:", e.message);
118
+ }
119
+ });
120
+
121
+ // ── Drag & drop / browse ──────────────────────────────────────────────────────
122
+ dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
123
+ dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
124
+ dropZone.addEventListener("drop", (e) => {
125
+ e.preventDefault();
126
+ dropZone.classList.remove("drag-over");
127
+ Array.from(e.dataTransfer.files).forEach((f) => dropZone._onFile?.(f));
128
+ });
129
+ dropZone.addEventListener("click", (e) => { if (e.target !== $("report-browse-btn")) fileInput.click(); });
130
+ $("report-browse-btn").addEventListener("click", (e) => { e.stopPropagation(); fileInput.click(); });
131
+ fileInput.addEventListener("change", () => {
132
+ Array.from(fileInput.files).forEach((f) => dropZone._onFile?.(f));
133
+ fileInput.value = "";
134
+ });
135
+
136
+ /** Called by app.js to wire the upload handler after log() is available. */
137
+ export function setUploadHandler(fn) {
138
+ dropZone._onFile = fn;
139
+ }
anam/static/js/kb.js ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ deleteRagDocument, getRagDocuments, getRagPrompt, getRagStatus,
3
+ resetRag, updateRagPrompt, uploadRagFile,
4
+ } from "./api.js";
5
+
6
+ // ── Shared state (live bindings — app.js reads these directly) ────────────────
7
+ export let ragSetupReady = false;
8
+ export let readyDocCount = 0;
9
+
10
+ // ── DOM ───────────────────────────────────────────────────────────────────────
11
+ const $ = (id) => document.getElementById(id);
12
+ const uploadStatus = $("upload-status");
13
+ const uploadStatusTxt = $("upload-status-text");
14
+ const docList = $("doc-list");
15
+ const useRagRow = $("use-rag-row");
16
+ const ragDocCount = $("rag-doc-count");
17
+ const kbSetupBadge = $("kb-setup-badge");
18
+ const ragPromptSection = $("rag-prompt-section");
19
+ const ragPromptBody = $("rag-prompt-body");
20
+ const ragSystemPrompt = $("rag-system-prompt");
21
+ const applyRagBtn = $("apply-rag-prompt-btn");
22
+ const ragPromptHint = $("rag-prompt-hint");
23
+
24
+ let pollTimer = null;
25
+
26
+ // ── Formatting helpers ────────────────────────────────────────────────────────
27
+ function fmtBytes(b) {
28
+ if (!b) return "";
29
+ if (b < 1024) return b + " B";
30
+ if (b < 1024 * 1024) return (b / 1024).toFixed(1) + " KB";
31
+ return (b / 1024 / 1024).toFixed(1) + " MB";
32
+ }
33
+
34
+ function docIcon(fileType = "") {
35
+ const t = fileType.toLowerCase();
36
+ if (t.includes("pdf")) return "📕";
37
+ if (t.includes("word") || t.includes("docx")) return "📘";
38
+ if (t.includes("csv")) return "📊";
39
+ if (t.includes("markdown") || t.includes("md")) return "📝";
40
+ return "📄";
41
+ }
42
+
43
+ function docBadge(status) {
44
+ const map = {
45
+ READY: { cls: "ready", label: "Ready", spin: false },
46
+ PROCESSING: { cls: "processing", label: "Processing", spin: true },
47
+ UPLOADED: { cls: "uploading", label: "Uploading", spin: true },
48
+ FAILED: { cls: "failed", label: "Failed", spin: false },
49
+ };
50
+ const s = map[status] ?? { cls: "uploading", label: status, spin: true };
51
+ const spinner = s.spin ? '<span class="doc-badge-spinner"></span>' : "";
52
+ return `<span class="doc-badge ${s.cls}">${spinner}${s.label}</span>`;
53
+ }
54
+
55
+ // ── Render doc list ───────────────────────────────────────────────────────────
56
+ function renderDocs(docs) {
57
+ docList.innerHTML = "";
58
+ readyDocCount = 0;
59
+ let anyProcessing = false;
60
+
61
+ docs.forEach((doc) => {
62
+ if (doc.status === "READY") readyDocCount++;
63
+ if (doc.status === "PROCESSING" || doc.status === "UPLOADED") anyProcessing = true;
64
+
65
+ const row = document.createElement("div");
66
+ row.className = "doc-row";
67
+ const deletable = doc.status === "READY" || doc.status === "FAILED";
68
+ row.innerHTML = `
69
+ <span class="doc-icon">${docIcon(doc.fileType)}</span>
70
+ <span class="doc-name" title="${doc.filename}">${doc.filename}</span>
71
+ <span class="doc-size">${fmtBytes(doc.fileSize)}</span>
72
+ ${docBadge(doc.status)}
73
+ <button class="doc-delete" data-id="${doc.id}" title="Delete"
74
+ ${deletable ? "" : "disabled"}>×</button>
75
+ `;
76
+ docList.appendChild(row);
77
+ });
78
+
79
+ docList.querySelectorAll(".doc-delete:not([disabled])").forEach((btn) => {
80
+ btn.addEventListener("click", async () => {
81
+ btn.disabled = true;
82
+ try {
83
+ await deleteRagDocument(btn.dataset.id);
84
+ await refreshDocs();
85
+ } catch (e) {
86
+ console.error("Delete failed:", e.message);
87
+ btn.disabled = false;
88
+ }
89
+ });
90
+ });
91
+
92
+ if (!anyProcessing) uploadStatus.style.display = "none";
93
+
94
+ useRagRow.style.display = ragSetupReady && docs.length > 0 ? "flex" : "none";
95
+ ragDocCount.textContent = readyDocCount > 0
96
+ ? `${readyDocCount} doc${readyDocCount !== 1 ? "s" : ""} ready`
97
+ : "";
98
+ }
99
+
100
+ // ── Refresh ───────────────────────────────────────────────────────────────────
101
+ export async function refreshDocs() {
102
+ try {
103
+ const data = await getRagDocuments();
104
+ const docs = data?.items ?? data?.documents ?? (Array.isArray(data) ? data : []);
105
+ renderDocs(docs);
106
+ return docs;
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ // ── Polling ───────────────────────────────────────────────────────────────────
113
+ export function startPolling(onReady) {
114
+ if (pollTimer) return;
115
+ pollTimer = setInterval(async () => {
116
+ const docs = await refreshDocs();
117
+ const stillProcessing = docs.some((d) => d.status === "PROCESSING" || d.status === "UPLOADED");
118
+ if (!stillProcessing) {
119
+ clearInterval(pollTimer);
120
+ pollTimer = null;
121
+ if (readyDocCount > 0) onReady?.(readyDocCount);
122
+ }
123
+ }, 3000);
124
+ }
125
+
126
+ // ── Upload ────────────────────────────────────────────────────────────────────
127
+ export async function uploadFile(file, onLog) {
128
+ uploadStatus.style.display = "flex";
129
+ uploadStatusTxt.textContent = `Uploading ${file.name}…`;
130
+ onLog?.(`Uploading: ${file.name}`, "info");
131
+
132
+ try {
133
+ await uploadRagFile(file);
134
+ ragSetupReady = true;
135
+ onLog?.(`Uploaded: ${file.name} — processing started`, "success");
136
+ uploadStatusTxt.textContent = `Processing ${file.name}…`;
137
+ await refreshDocs();
138
+ startPolling((count) => {
139
+ onLog?.(`Knowledge base ready — ${count} document(s) indexed`, "rag");
140
+ });
141
+ maybeShowPromptSection();
142
+ } catch (e) {
143
+ onLog?.("Upload failed: " + e.message, "error");
144
+ uploadStatus.style.display = "none";
145
+ }
146
+ }
147
+
148
+ // ── Check RAG status on startup ───────────────────────────────────────────────
149
+ export async function checkRagStatus(onLog) {
150
+ try {
151
+ const data = await getRagStatus();
152
+ ragSetupReady = data.ready;
153
+ if (ragSetupReady) {
154
+ kbSetupBadge.textContent = "✓ Setup ready";
155
+ kbSetupBadge.className = "kb-setup-badge ready";
156
+ kbSetupBadge.style.display = "inline-block";
157
+ ragPromptSection.style.display = "flex";
158
+ onLog?.("RAG setup found (knowledge group + tool + persona)", "rag");
159
+ loadRagPrompt();
160
+ }
161
+ if (data.group_id) {
162
+ const docs = await refreshDocs();
163
+ if (docs.some((d) => d.status === "PROCESSING" || d.status === "UPLOADED")) {
164
+ startPolling((count) => onLog?.(`Knowledge base ready — ${count} document(s) indexed`, "rag"));
165
+ }
166
+ }
167
+ } catch (e) {
168
+ onLog?.("Could not check RAG status: " + e.message, "warn");
169
+ }
170
+ }
171
+
172
+ // ── RAG prompt editor ─────────────────────────────────────────────────────────
173
+ async function loadRagPrompt() {
174
+ try {
175
+ const data = await getRagPrompt();
176
+ ragSystemPrompt.value = data.system_prompt ?? "";
177
+ } catch {}
178
+ }
179
+
180
+ function maybeShowPromptSection() {
181
+ if (ragSetupReady) {
182
+ ragPromptSection.style.display = "flex";
183
+ loadRagPrompt();
184
+ }
185
+ }
186
+
187
+ $("rag-prompt-toggle").addEventListener("click", () => {
188
+ const open = ragPromptBody.style.display === "flex";
189
+ ragPromptBody.style.display = open ? "none" : "flex";
190
+ });
191
+
192
+ applyRagBtn.addEventListener("click", async () => {
193
+ const prompt = ragSystemPrompt.value.trim();
194
+ if (!prompt) return;
195
+ applyRagBtn.disabled = true;
196
+ ragPromptHint.textContent = "Saving…";
197
+ try {
198
+ await updateRagPrompt(prompt);
199
+ ragPromptHint.textContent = "✓ Saved — reconnect for changes to take effect.";
200
+ } catch (e) {
201
+ ragPromptHint.textContent = "Save failed: " + e.message;
202
+ } finally {
203
+ applyRagBtn.disabled = false;
204
+ }
205
+ });
206
+
207
+ // ── KB reset ──────────────────────────────────────────────────────────────────
208
+ $("kb-reset-btn").addEventListener("click", async () => {
209
+ if (!confirm("Clear RAG state? This removes local IDs but does NOT delete anything from your Anam account.")) return;
210
+ try {
211
+ await resetRag();
212
+ ragSetupReady = false;
213
+ kbSetupBadge.style.display = "none";
214
+ docList.innerHTML = "";
215
+ useRagRow.style.display = "none";
216
+ } catch (e) {
217
+ console.error("Reset failed:", e.message);
218
+ }
219
+ });
220
+
221
+ // ── Drag & drop / browse ──────────────────────────────────────────────────────
222
+ const dropZone = $("drop-zone");
223
+ const fileInput = $("file-input");
224
+
225
+ dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
226
+ dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
227
+ dropZone.addEventListener("drop", (e) => {
228
+ e.preventDefault();
229
+ dropZone.classList.remove("drag-over");
230
+ // onUpload callback set by app.js after init
231
+ Array.from(e.dataTransfer.files).forEach((f) => dropZone._onFile?.(f));
232
+ });
233
+ dropZone.addEventListener("click", (e) => { if (e.target !== $("browse-btn")) fileInput.click(); });
234
+ $("browse-btn").addEventListener("click", (e) => { e.stopPropagation(); fileInput.click(); });
235
+ fileInput.addEventListener("change", () => {
236
+ Array.from(fileInput.files).forEach((f) => dropZone._onFile?.(f));
237
+ fileInput.value = "";
238
+ });
239
+
240
+ /** Called by app.js to wire the upload handler after log() is available. */
241
+ export function setUploadHandler(fn) {
242
+ dropZone._onFile = fn;
243
+ }
anam/static/js/local_rag.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { deleteLocalDoc, listLocalDocs, uploadLocalDoc } from "./api.js";
2
+
3
+ // ── In-memory doc registry ────────────────────────────────────────────────────
4
+ const docs = {}; // id → { id, name, words, chars }
5
+
6
+ const $ = (id) => document.getElementById(id);
7
+
8
+ const uploadStatus = $("local-upload-status");
9
+ const uploadStatusTxt = $("local-upload-status-text");
10
+ const docList = $("local-doc-list");
11
+ const useLocalRagRow = $("use-local-rag-row");
12
+ const localDocCount = $("local-doc-count");
13
+
14
+ export function localDocIds() { return Object.keys(docs); }
15
+ export function localDocCount_() { return Object.keys(docs).length; }
16
+
17
+ // ── Icon helper ───────────────────────────────────────────────────────────────
18
+ function docIcon(name = "") {
19
+ const n = name.toLowerCase();
20
+ if (n.endsWith(".pdf")) return "📕";
21
+ if (n.endsWith(".docx")) return "📘";
22
+ if (n.endsWith(".csv")) return "📊";
23
+ if (n.endsWith(".md")) return "📝";
24
+ return "📄";
25
+ }
26
+
27
+ // ── Render ────────────────────────────────────────────────────────────────────
28
+ function renderDocs() {
29
+ docList.innerHTML = "";
30
+ const entries = Object.values(docs);
31
+
32
+ entries.forEach((doc) => {
33
+ const row = document.createElement("div");
34
+ row.className = "doc-row";
35
+ row.innerHTML = `
36
+ <span class="doc-icon">${docIcon(doc.name)}</span>
37
+ <span class="doc-name" title="${doc.name}">${doc.name}</span>
38
+ <span class="doc-size">${(doc.words ?? 0).toLocaleString()} words · ${doc.chunk_count ?? "?"} chunks</span>
39
+ <span class="doc-badge ready">Indexed</span>
40
+ <button class="doc-delete" data-id="${doc.id}" title="Remove">×</button>
41
+ `;
42
+ docList.appendChild(row);
43
+ });
44
+
45
+ docList.querySelectorAll(".doc-delete").forEach((btn) => {
46
+ btn.addEventListener("click", async () => {
47
+ btn.disabled = true;
48
+ try {
49
+ await deleteLocalDoc(btn.dataset.id);
50
+ delete docs[btn.dataset.id];
51
+ renderDocs();
52
+ } catch (e) {
53
+ console.error("Delete failed:", e.message);
54
+ btn.disabled = false;
55
+ }
56
+ });
57
+ });
58
+
59
+ const count = entries.length;
60
+ if (useLocalRagRow) useLocalRagRow.style.display = count > 0 ? "flex" : "none";
61
+ if (localDocCount) localDocCount.textContent = count > 0
62
+ ? `${count} doc${count !== 1 ? "s" : ""} loaded`
63
+ : "";
64
+ }
65
+
66
+ // ── Load existing docs from server ────────────────────────────────────────────
67
+ export async function loadAll() {
68
+ try {
69
+ const data = await listLocalDocs();
70
+ const items = data?.items ?? [];
71
+ items.forEach((d) => { docs[d.id] = d; });
72
+ renderDocs();
73
+ } catch {}
74
+ }
75
+
76
+ // ── Upload ────────────────────────────────────────────────────────────────────
77
+ export async function uploadFile(file, onLog) {
78
+ if (uploadStatus) uploadStatus.style.display = "flex";
79
+ if (uploadStatusTxt) uploadStatusTxt.textContent = `Parsing ${file.name}…`;
80
+ onLog?.(`Uploading: ${file.name}`, "info");
81
+
82
+ try {
83
+ const doc = await uploadLocalDoc(file);
84
+ docs[doc.id] = doc;
85
+ onLog?.(`Indexed: ${doc.name} — ${doc.chunk_count ?? "?"} chunks, ${(doc.words ?? 0).toLocaleString()} words`, "rag");
86
+ renderDocs();
87
+ } catch (e) {
88
+ onLog?.("Upload failed: " + e.message, "error");
89
+ } finally {
90
+ if (uploadStatus) uploadStatus.style.display = "none";
91
+ }
92
+ }
93
+
94
+ // ── Drag & drop / browse ──────────────────────────────────────────────────────
95
+ const dropZone = $("local-drop-zone");
96
+ const fileInput = $("local-file-input");
97
+
98
+ dropZone?.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); });
99
+ dropZone?.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
100
+ dropZone?.addEventListener("drop", (e) => {
101
+ e.preventDefault();
102
+ dropZone.classList.remove("drag-over");
103
+ Array.from(e.dataTransfer.files).forEach((f) => dropZone._onFile?.(f));
104
+ });
105
+ dropZone?.addEventListener("click", (e) => { if (e.target !== $("local-browse-btn")) fileInput?.click(); });
106
+ $("local-browse-btn")?.addEventListener("click", (e) => { e.stopPropagation(); fileInput?.click(); });
107
+ fileInput?.addEventListener("change", () => {
108
+ Array.from(fileInput.files).forEach((f) => dropZone._onFile?.(f));
109
+ fileInput.value = "";
110
+ });
111
+
112
+ export function setUploadHandler(fn) {
113
+ if (dropZone) dropZone._onFile = fn;
114
+ }
anam/static/js/telemetry.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getTelemetrySessions, getTelemetrySummary, resetTelemetry } from "./api.js";
2
+
3
+ let pollTimer = null;
4
+
5
+ const fmtMoney = (n) => `$${Number(n ?? 0).toFixed(2)}`;
6
+
7
+ function fmtDuration(seconds) {
8
+ const s = Math.max(0, Math.round(seconds ?? 0));
9
+ const m = Math.floor(s / 60);
10
+ return `${m}:${String(s % 60).padStart(2, "0")}`;
11
+ }
12
+
13
+ const fmtTime = (unixSeconds) => new Date(unixSeconds * 1000).toLocaleString();
14
+
15
+ function renderSummary(statsEl, s) {
16
+ statsEl.querySelector("#tm-total-cost").textContent = fmtMoney(s.grand_total_estimated_cost);
17
+ statsEl.querySelector("#tm-total-minutes").textContent = s.total_billed_minutes.toFixed(1);
18
+ statsEl.querySelector("#tm-session-count").textContent = s.ended_sessions + s.active_sessions;
19
+
20
+ const liveStat = statsEl.querySelector("#tm-live-stat");
21
+ if (s.active_sessions > 0) {
22
+ liveStat.style.display = "";
23
+ statsEl.querySelector("#tm-live-cost").textContent = fmtMoney(s.live_estimated_cost);
24
+ } else {
25
+ liveStat.style.display = "none";
26
+ }
27
+ }
28
+
29
+ function renderSessions(el, sessions) {
30
+ if (!sessions.length) {
31
+ el.innerHTML = '<div id="telemetry-empty">No sessions recorded yet.</div>';
32
+ return;
33
+ }
34
+ el.innerHTML = sessions.slice(0, 20).map((s) => `
35
+ <div class="telemetry-row ${s.status}">
36
+ <span class="tm-time">${fmtTime(s.started_at)}</span>
37
+ <span class="tm-persona" title="${s.persona_name}">${s.persona_name}</span>
38
+ <span class="tm-plan tm-plan-${s.plan}">${s.plan}</span>
39
+ <span class="tm-duration">${fmtDuration(s.duration_seconds)}</span>
40
+ <span class="tm-cost">${fmtMoney(s.estimated_cost)}</span>
41
+ <span class="tm-status">${s.status === "active" ? "● live" : "ended"}</span>
42
+ </div>
43
+ `).join("");
44
+ }
45
+
46
+ export async function refresh(statsEl, sessionsEl, rateNoteEl) {
47
+ try {
48
+ const [summary, sessions] = await Promise.all([getTelemetrySummary(), getTelemetrySessions()]);
49
+ renderSummary(statsEl, summary);
50
+ renderSessions(sessionsEl, sessions);
51
+ if (rateNoteEl) {
52
+ rateNoteEl.textContent =
53
+ `Estimated from session duration × $${summary.rate_per_minute.toFixed(2)}/min ` +
54
+ `(30s minimum per session). Anam doesn't expose real-time cost via API — ` +
55
+ `check Anam Lab for your actual usage. Override the rate via ANAM_RATE_PER_MINUTE in .env.`;
56
+ }
57
+ } catch (e) {
58
+ console.error("Telemetry fetch failed:", e);
59
+ }
60
+ }
61
+
62
+ export function startPolling(statsEl, sessionsEl, rateNoteEl, intervalMs = 5000) {
63
+ refresh(statsEl, sessionsEl, rateNoteEl);
64
+ stopPolling();
65
+ pollTimer = setInterval(() => refresh(statsEl, sessionsEl, rateNoteEl), intervalMs);
66
+ }
67
+
68
+ export function stopPolling() {
69
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
70
+ }
71
+
72
+ export async function reset(statsEl, sessionsEl, rateNoteEl) {
73
+ await resetTelemetry();
74
+ await refresh(statsEl, sessionsEl, rateNoteEl);
75
+ }
anam/telemetry.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+ from threading import Lock
5
+
6
+ from config import ANAM_RATE_PER_MINUTE
7
+
8
+ _FILE = Path(__file__).parent / "telemetry_data.json"
9
+ _lock = Lock()
10
+
11
+
12
+ def _load() -> list[dict]:
13
+ if not _FILE.exists():
14
+ return []
15
+ try:
16
+ return json.loads(_FILE.read_text())
17
+ except Exception:
18
+ return []
19
+
20
+
21
+ def _save(sessions: list[dict]) -> None:
22
+ _FILE.write_text(json.dumps(sessions, indent=2))
23
+
24
+
25
+ def _billed_seconds(elapsed: float) -> int:
26
+ return int(max(30.0, elapsed))
27
+
28
+
29
+ def _cost(billed_seconds: int) -> float:
30
+ return round(billed_seconds / 60 * ANAM_RATE_PER_MINUTE, 4)
31
+
32
+
33
+ def start_session(session_id: str, persona_name: str, plan: str) -> None:
34
+ with _lock:
35
+ sessions = _load()
36
+ sessions.append({
37
+ "session_id": session_id,
38
+ "persona_name": persona_name,
39
+ "plan": plan,
40
+ "started_at": time.time(),
41
+ "ended_at": None,
42
+ "duration_seconds": None,
43
+ "billed_seconds": None,
44
+ "estimated_cost": None,
45
+ "status": "active",
46
+ })
47
+ _save(sessions)
48
+
49
+
50
+ def end_session(session_id: str) -> dict | None:
51
+ with _lock:
52
+ sessions = _load()
53
+ for s in reversed(sessions):
54
+ if s["session_id"] == session_id and s["status"] == "active":
55
+ now = time.time()
56
+ elapsed = now - s["started_at"]
57
+ billed = _billed_seconds(elapsed)
58
+ s["ended_at"] = now
59
+ s["duration_seconds"] = round(elapsed, 1)
60
+ s["billed_seconds"] = billed
61
+ s["estimated_cost"] = _cost(billed)
62
+ s["status"] = "ended"
63
+ _save(sessions)
64
+ return s
65
+ return None
66
+
67
+
68
+ def summary() -> dict:
69
+ sessions = _load()
70
+ ended = [s for s in sessions if s["status"] == "ended"]
71
+ active = [s for s in sessions if s["status"] == "active"]
72
+
73
+ total_billed = sum(s["billed_seconds"] for s in ended)
74
+ total_cost = sum(s["estimated_cost"] for s in ended)
75
+
76
+ now = time.time()
77
+ live_billed = 0
78
+ live_cost = 0.0
79
+ for s in active:
80
+ b = _billed_seconds(now - s["started_at"])
81
+ live_billed += b
82
+ live_cost += _cost(b)
83
+
84
+ return {
85
+ "rate_per_minute": ANAM_RATE_PER_MINUTE,
86
+ "ended_sessions": len(ended),
87
+ "active_sessions": len(active),
88
+ "total_billed_seconds": total_billed,
89
+ "total_billed_minutes": round(total_billed / 60, 2),
90
+ "total_estimated_cost": round(total_cost, 2),
91
+ "live_billed_seconds": live_billed,
92
+ "live_estimated_cost": round(live_cost, 2),
93
+ "grand_total_estimated_cost": round(total_cost + live_cost, 2),
94
+ }
95
+
96
+
97
+ def list_sessions() -> list[dict]:
98
+ now = time.time()
99
+ out = []
100
+ for s in _load():
101
+ s = dict(s)
102
+ if s["status"] == "active":
103
+ elapsed = now - s["started_at"]
104
+ s["duration_seconds"] = round(elapsed, 1)
105
+ s["billed_seconds"] = _billed_seconds(elapsed)
106
+ s["estimated_cost"] = _cost(s["billed_seconds"])
107
+ out.append(s)
108
+ out.sort(key=lambda s: s["started_at"], reverse=True)
109
+ return out
110
+
111
+
112
+ def reset() -> None:
113
+ with _lock:
114
+ _save([])
scripts/create_vector_store.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One-off: create the persistent OpenAI Vector Store used by anam's local RAG feature.
3
+
4
+ Run once:
5
+ OPENAI_API_KEY=sk-... python scripts/create_vector_store.py
6
+
7
+ Copy the printed id into OPENAI_VECTOR_STORE_ID (anam/.env locally, or a Hugging
8
+ Face Space repository secret in production) so uploaded documents survive restarts.
9
+ """
10
+ import os
11
+ import sys
12
+
13
+ from openai import OpenAI
14
+
15
+ api_key = os.getenv("OPENAI_API_KEY")
16
+ if not api_key:
17
+ sys.exit("Set OPENAI_API_KEY in the environment before running this script.")
18
+
19
+ store = OpenAI(api_key=api_key).vector_stores.create(name="anam-local-rag")
20
+ print(f"Created vector store: {store.id}")
21
+ print(f"Set OPENAI_VECTOR_STORE_ID={store.id}")
start.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # Agentic LLM server (used by anam's chat-stream when LLM_PROVIDER=ClientSide).
5
+ # Internal only — not exposed outside the container.
6
+ uvicorn main:app --app-dir agent --host 127.0.0.1 --port 8005 &
7
+
8
+ # Anam app: FastAPI backend + static frontend. Hugging Face Spaces expects the
9
+ # public app on port 7860.
10
+ exec uvicorn main:app --app-dir anam --host 0.0.0.0 --port 7860