Spaces:
Running
Running
Commit Β·
d1cde70
1
Parent(s): d4c6e81
Rename to mirror., scroll reveal animations, waveform logo, deployment config
Browse files- Renamed app from Behavioral Mirror to mirror. throughout
- Added waveform SVG logo (option 2A) to all pages replacing emoji placeholders
- Added scroll reveal animations (Reveal/RevealItem) to all pages
- Cleaned requirements.txt: removed unused anthropic, openai-whisper, whisperx, sqlalchemy
- Added Dockerfile with CPU-only torch and ffmpeg for Railway deployment
- Updated CORS to allow chrome-extension:// origins via regex + FRONTEND_URL env var
- Added .env.example template
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend/.env.example +6 -0
- backend/Dockerfile +21 -0
- backend/main.py +323 -160
- backend/pipeline/insight_generator.py +157 -234
- backend/pipeline/mirror_feed_synthesizer.py +204 -0
- backend/pipeline/personality_synthesizer.py +25 -37
- backend/pipeline/transcriber.py +62 -94
- backend/requirements.txt +3 -5
- frontend/index.html +1 -1
- frontend/package.json +1 -1
- frontend/public/favicon.svg +19 -1
- frontend/src/App.jsx +112 -42
- frontend/src/components/AuthView.jsx +70 -13
- frontend/src/components/EnrollView.jsx +21 -21
- frontend/src/components/HistoryView.jsx +35 -36
- frontend/src/components/HowItWorksView.jsx +275 -0
- frontend/src/components/OnboardingView.jsx +52 -16
- frontend/src/components/ProfileView.jsx +539 -489
- frontend/src/components/ResultsView.jsx +96 -103
- frontend/src/components/Reveal.jsx +55 -0
- frontend/src/components/UploadView.jsx +17 -16
- frontend/src/index.css +123 -18
backend/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GROQ_API_KEY=
|
| 2 |
+
HF_TOKEN=
|
| 3 |
+
SUPABASE_URL=
|
| 4 |
+
SUPABASE_ANON_KEY=
|
| 5 |
+
SUPABASE_SERVICE_KEY=
|
| 6 |
+
FRONTEND_URL=
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y \
|
| 4 |
+
ffmpeg \
|
| 5 |
+
libsndfile1 \
|
| 6 |
+
git \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install CPU-only torch first so pip doesn't pull the 2 GB CUDA build
|
| 12 |
+
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
| 13 |
+
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
EXPOSE 8000
|
| 20 |
+
|
| 21 |
+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
backend/main.py
CHANGED
|
@@ -19,18 +19,27 @@ from pipeline.dimension_scorer import DimensionScorer
|
|
| 19 |
from pipeline.voiceprint import VoiceprintMatcher
|
| 20 |
from pipeline.context_detector import ContextDetector
|
| 21 |
from pipeline.personality_synthesizer import PersonalitySynthesizer
|
|
|
|
| 22 |
from db.database import supabase_admin
|
| 23 |
|
| 24 |
app = FastAPI()
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
app.add_middleware(
|
| 27 |
CORSMiddleware,
|
| 28 |
-
allow_origins=
|
|
|
|
|
|
|
| 29 |
allow_methods=["*"],
|
| 30 |
-
allow_headers=["*"]
|
| 31 |
)
|
| 32 |
|
| 33 |
-
UPLOAD_DIR = "/tmp/
|
| 34 |
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 35 |
|
| 36 |
# In-memory store for prepare β finalize handoff.
|
|
@@ -56,18 +65,20 @@ def _cleanup_stale_cache():
|
|
| 56 |
del _prepare_cache[sid]
|
| 57 |
|
| 58 |
|
| 59 |
-
print("Loading models...
|
| 60 |
-
transcriber = Transcriber(
|
| 61 |
diarizer = Diarizer(hf_token=os.getenv("HF_TOKEN"))
|
| 62 |
insight_gen = InsightGenerator(api_key=os.getenv("GROQ_API_KEY"))
|
| 63 |
context_detector = ContextDetector(api_key=os.getenv("GROQ_API_KEY"))
|
| 64 |
dimension_scorer = DimensionScorer()
|
| 65 |
voiceprint_matcher = VoiceprintMatcher(hf_token=os.getenv("HF_TOKEN"))
|
| 66 |
personality_synth = PersonalitySynthesizer(api_key=os.getenv("GROQ_API_KEY"))
|
|
|
|
| 67 |
print("All models loaded. Server ready.")
|
| 68 |
|
| 69 |
-
# In-memory
|
| 70 |
-
_personality_cache: dict = {}
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
CONTEXT_POPULATION_NORMS: dict = {
|
|
@@ -155,33 +166,33 @@ def _compute_dim_averages(parsed: list) -> dict:
|
|
| 155 |
|
| 156 |
|
| 157 |
_CONTEXT_BLIND_SPOTS: dict = {
|
| 158 |
-
"evaluative": (1, "High
|
| 159 |
-
"
|
| 160 |
-
"composure scores are
|
| 161 |
-
"
|
| 162 |
-
"adversarial": (2, "Conflict &
|
| 163 |
"No conflict or disagreement recordings yet. We can't tell how you respond when "
|
| 164 |
"challenged or when there's real friction in the room."),
|
| 165 |
-
"collaborative": (3, "
|
| 166 |
"No team meetings or brainstorming sessions uploaded. Your listening and "
|
| 167 |
"assertiveness scores come from 1-on-1 conversations only."),
|
| 168 |
-
"influential": (4, "
|
| 169 |
-
"No pitch or
|
| 170 |
-
"move someone toward a decision."),
|
| 171 |
"negotiation": (5, "Negotiation",
|
| 172 |
-
"No negotiation recordings. How your composure and assertiveness hold under "
|
| 173 |
"competing interests is still unmeasured."),
|
| 174 |
-
"developmental": (6, "
|
| 175 |
"No coaching or feedback sessions recorded. We can't see how you structure or "
|
| 176 |
-
"deliver
|
| 177 |
-
"support": (7, "
|
| 178 |
"No support conversations yet. Your empathy dimension has no direct evidence."),
|
| 179 |
-
"intimate": (8, "Deep
|
| 180 |
-
"No emotionally
|
| 181 |
-
"be incomplete."),
|
| 182 |
-
"social": (9, "Casual &
|
| 183 |
-
"No casual social conversations recorded. We can't see your natural,
|
| 184 |
-
"communication style yet."),
|
| 185 |
}
|
| 186 |
|
| 187 |
|
|
@@ -219,28 +230,6 @@ def _compute_session_delta(old: dict, new: dict) -> dict | None:
|
|
| 219 |
return {"changes": changes[:3]}
|
| 220 |
|
| 221 |
|
| 222 |
-
def _extract_mirror_highlights(insights: dict, n: int = 3) -> list:
|
| 223 |
-
"""Pull the N most significant observations from a session's insights."""
|
| 224 |
-
highlights = []
|
| 225 |
-
|
| 226 |
-
if insights.get("notable_pattern"):
|
| 227 |
-
highlights.append(insights["notable_pattern"])
|
| 228 |
-
|
| 229 |
-
for s in insights.get("coaching_suggestions", []):
|
| 230 |
-
if len(highlights) >= n:
|
| 231 |
-
break
|
| 232 |
-
issue = (s.get("issue") or "").strip()
|
| 233 |
-
if issue and issue not in highlights:
|
| 234 |
-
highlights.append(issue)
|
| 235 |
-
|
| 236 |
-
for obs in insights.get("observations", []):
|
| 237 |
-
if len(highlights) >= n:
|
| 238 |
-
break
|
| 239 |
-
text = (obs.get("observation") or "").strip()
|
| 240 |
-
if text and text not in highlights:
|
| 241 |
-
highlights.append(text)
|
| 242 |
-
|
| 243 |
-
return highlights[:n]
|
| 244 |
|
| 245 |
|
| 246 |
def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
|
|
@@ -259,23 +248,6 @@ def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
|
|
| 259 |
except (KeyError, TypeError):
|
| 260 |
pass
|
| 261 |
|
| 262 |
-
# Extract 2-3 sample user quotes (5-20 words, spread across the conversation)
|
| 263 |
-
sample_quotes = []
|
| 264 |
-
merged = p.get("merged", [])
|
| 265 |
-
detected_speaker = p.get("detected_speaker", "SPEAKER_00")
|
| 266 |
-
if merged:
|
| 267 |
-
user_turns = [
|
| 268 |
-
seg.get("text", "").strip()
|
| 269 |
-
for seg in merged
|
| 270 |
-
if seg.get("speaker") == detected_speaker
|
| 271 |
-
and 5 <= len(seg.get("text", "").split()) <= 20
|
| 272 |
-
]
|
| 273 |
-
if len(user_turns) >= 3:
|
| 274 |
-
mid = len(user_turns) // 2
|
| 275 |
-
sample_quotes = [user_turns[0], user_turns[mid], user_turns[-1]]
|
| 276 |
-
elif user_turns:
|
| 277 |
-
sample_quotes = user_turns[:3]
|
| 278 |
-
|
| 279 |
sessions.append({
|
| 280 |
"date": date_str,
|
| 281 |
"context": p["context"],
|
|
@@ -283,7 +255,7 @@ def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
|
|
| 283 |
"filler_rate": p["sig"]["filler_words"]["rate_per_100_words"],
|
| 284 |
"talk_ratio_pct": round(p["sig"]["talk_ratio"]["user_ratio"] * 100),
|
| 285 |
"notable_pattern": p["ins"].get("notable_pattern", ""),
|
| 286 |
-
"
|
| 287 |
})
|
| 288 |
return sessions
|
| 289 |
|
|
@@ -291,7 +263,7 @@ def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
|
|
| 291 |
def _get_or_synthesize_personality(user_id: str, session_count: int,
|
| 292 |
profile_data: dict, dim_averages: dict,
|
| 293 |
sessions_data: list = None) -> dict:
|
| 294 |
-
_SYNTHESIS_VERSION =
|
| 295 |
cache_key = f"{user_id}:{session_count}:v{_SYNTHESIS_VERSION}"
|
| 296 |
|
| 297 |
if cache_key in _personality_cache:
|
|
@@ -306,13 +278,17 @@ def _get_or_synthesize_personality(user_id: str, session_count: int,
|
|
| 306 |
.eq("user_id", user_id).execute()
|
| 307 |
if result.data:
|
| 308 |
row = result.data[0]
|
| 309 |
-
|
| 310 |
-
|
|
|
|
| 311 |
_personality_cache[cache_key] = personality
|
| 312 |
return personality
|
| 313 |
-
|
| 314 |
-
# Session count changed β preserve old synthesis for delta
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
| 316 |
except Exception:
|
| 317 |
pass
|
| 318 |
|
|
@@ -356,7 +332,7 @@ async def get_current_user(authorization: Optional[str] = Header(None)) -> str:
|
|
| 356 |
|
| 357 |
@app.get("/")
|
| 358 |
def root():
|
| 359 |
-
return {"status": "
|
| 360 |
|
| 361 |
|
| 362 |
# ββ SSE Step 1: Start prepare job ββββββββββββββββββββββββββββββββ
|
|
@@ -383,6 +359,16 @@ async def start_prepare_session(
|
|
| 383 |
)
|
| 384 |
os.remove(temp_path)
|
| 385 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
job_q: stdlib_queue.Queue = stdlib_queue.Queue()
|
| 387 |
_jobs[session_id] = job_q
|
| 388 |
|
|
@@ -627,31 +613,34 @@ def _run_finalize_job(session_id, confirmed_speaker):
|
|
| 627 |
if signals is None:
|
| 628 |
signals = SignalExtractor(audio_path, merged, confirmed_speaker).extract_all()
|
| 629 |
|
| 630 |
-
baseline = _get_context_baseline(user_id, primary_context)
|
| 631 |
-
session_history = _get_user_session_history(user_id)
|
| 632 |
-
resonance_calibration = _get_resonance_calibration(user_id)
|
| 633 |
-
|
| 634 |
print(f"[{session_id}] Scoring dimensions...")
|
| 635 |
emit("scoring", "Scoring behavioral dimensionsβ¦")
|
| 636 |
dimensions = dimension_scorer.score_all(signals)
|
| 637 |
|
| 638 |
print(f"[{session_id}] Detecting conversation type and generating insights...")
|
| 639 |
emit("generating", "Generating insights with AIβ¦")
|
| 640 |
-
|
| 641 |
-
|
|
|
|
| 642 |
primary_context = conversation_types[0]
|
| 643 |
print(f"[{session_id}] Detected conversation types: {conversation_types}")
|
| 644 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
insights = insight_gen.generate(
|
| 646 |
-
signals, primary_context, baseline,
|
| 647 |
session_history=session_history,
|
| 648 |
resonance_calibration=resonance_calibration,
|
| 649 |
conversation_types=conversation_types,
|
| 650 |
)
|
| 651 |
|
|
|
|
|
|
|
| 652 |
emit("reflecting", "Generating reflection questionsβ¦")
|
| 653 |
reflection_questions = insight_gen.generate_reflection_questions(
|
| 654 |
-
signals, insights,
|
| 655 |
)
|
| 656 |
if reflection_questions:
|
| 657 |
insights["reflection_questions"] = reflection_questions
|
|
@@ -661,10 +650,25 @@ def _run_finalize_job(session_id, confirmed_speaker):
|
|
| 661 |
session_id, user_id, signals, insights, dimensions,
|
| 662 |
primary_context, filename, confirmed_speaker,
|
| 663 |
speaker_confirmed=True,
|
| 664 |
-
|
| 665 |
all_speakers_signals=all_speakers_signals
|
| 666 |
)
|
| 667 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
if os.path.exists(audio_path):
|
| 669 |
os.remove(audio_path)
|
| 670 |
del _prepare_cache[session_id]
|
|
@@ -680,7 +684,6 @@ def _run_finalize_job(session_id, confirmed_speaker):
|
|
| 680 |
"speaker_confirmed": True,
|
| 681 |
"available_speakers": all_speaker_ids,
|
| 682 |
"voiceprint_confidence": voiceprint_confidence,
|
| 683 |
-
"transcript": merged,
|
| 684 |
}
|
| 685 |
|
| 686 |
if job_key in _jobs:
|
|
@@ -764,11 +767,10 @@ async def reanalyze_session(
|
|
| 764 |
resonance_calibration = _get_resonance_calibration(user_id)
|
| 765 |
dimensions = dimension_scorer.score_all(signals)
|
| 766 |
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
)
|
| 772 |
insights = insight_gen.generate(
|
| 773 |
signals, primary_context, baseline, transcript_text, dimensions,
|
| 774 |
session_history=session_history,
|
|
@@ -776,6 +778,8 @@ async def reanalyze_session(
|
|
| 776 |
conversation_types=conversation_types,
|
| 777 |
)
|
| 778 |
|
|
|
|
|
|
|
| 779 |
reflection_questions = insight_gen.generate_reflection_questions(
|
| 780 |
signals, insights, transcript_text, primary_context
|
| 781 |
)
|
|
@@ -788,6 +792,7 @@ async def reanalyze_session(
|
|
| 788 |
"signals_json": json.dumps(signals),
|
| 789 |
"insights_json": json.dumps(insights),
|
| 790 |
"dimensions_json": json.dumps(dimensions),
|
|
|
|
| 791 |
}).eq("id", session_id).execute()
|
| 792 |
|
| 793 |
print(f"[{session_id}] Re-analysis done.")
|
|
@@ -800,7 +805,6 @@ async def reanalyze_session(
|
|
| 800 |
"detected_speaker": confirmed_speaker,
|
| 801 |
"speaker_confirmed": True,
|
| 802 |
"available_speakers": list(all_speakers_signals.keys()),
|
| 803 |
-
"transcript": merged,
|
| 804 |
}
|
| 805 |
|
| 806 |
|
|
@@ -825,7 +829,7 @@ def get_sessions(user_id: str = Depends(get_current_user)):
|
|
| 825 |
"dimensions": json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {},
|
| 826 |
"available_speakers": list(json.loads(s["all_speakers_signals_json"]).keys())
|
| 827 |
if s.get("all_speakers_signals_json") else [],
|
| 828 |
-
"
|
| 829 |
}
|
| 830 |
for s in res.data
|
| 831 |
]
|
|
@@ -873,14 +877,13 @@ def get_trends(user_id: str = Depends(get_current_user)):
|
|
| 873 |
@app.get("/api/profile")
|
| 874 |
def get_profile(user_id: str = Depends(get_current_user)):
|
| 875 |
res = supabase_admin.table("sessions").select(
|
| 876 |
-
"id, context, created_at, signals_json, dimensions_json, insights_json,
|
| 877 |
).eq("user_id", user_id).order("created_at", desc=False).execute()
|
| 878 |
|
| 879 |
-
if len(res.data) <
|
| 880 |
return {
|
| 881 |
"insufficient_data": True,
|
| 882 |
-
"session_count":
|
| 883 |
-
"sessions_needed": 3,
|
| 884 |
}
|
| 885 |
|
| 886 |
parsed = []
|
|
@@ -889,11 +892,11 @@ def get_profile(user_id: str = Depends(get_current_user)):
|
|
| 889 |
sig = json.loads(s["signals_json"])
|
| 890 |
dims = json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {}
|
| 891 |
ins = json.loads(s["insights_json"])
|
| 892 |
-
|
| 893 |
parsed.append({
|
| 894 |
"context": s["context"], "date": s["created_at"],
|
| 895 |
"sig": sig, "dims": dims, "ins": ins,
|
| 896 |
-
"
|
| 897 |
"detected_speaker": s.get("detected_speaker") or "SPEAKER_00",
|
| 898 |
})
|
| 899 |
except Exception:
|
|
@@ -931,29 +934,30 @@ def get_profile(user_id: str = Depends(get_current_user)):
|
|
| 931 |
"filler_rate": round(sum(p["sig"]["filler_words"]["rate_per_100_words"] for p in items) / len(items), 2),
|
| 932 |
}
|
| 933 |
|
| 934 |
-
# Pattern detection
|
| 935 |
patterns = []
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
|
|
|
| 957 |
|
| 958 |
# Trend detection across first vs last third (needs 6+ sessions)
|
| 959 |
trends = []
|
|
@@ -1027,21 +1031,46 @@ def get_profile(user_id: str = Depends(get_current_user)):
|
|
| 1027 |
elif completeness < 90: completeness_label = "Established"
|
| 1028 |
else: completeness_label = "Deep mirror"
|
| 1029 |
|
| 1030 |
-
#
|
| 1031 |
-
|
| 1032 |
-
|
|
|
|
|
|
|
|
|
|
| 1033 |
try:
|
| 1034 |
-
|
| 1035 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1036 |
except Exception:
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
"context": p["context"],
|
| 1043 |
-
"
|
| 1044 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
|
| 1046 |
return {
|
| 1047 |
"insufficient_data": False,
|
|
@@ -1055,7 +1084,7 @@ def get_profile(user_id: str = Depends(get_current_user)):
|
|
| 1055 |
"blind_spots": blind_spots,
|
| 1056 |
"completeness": completeness,
|
| 1057 |
"completeness_label": completeness_label,
|
| 1058 |
-
"
|
| 1059 |
}
|
| 1060 |
|
| 1061 |
|
|
@@ -1069,6 +1098,15 @@ async def delete_session(
|
|
| 1069 |
).execute()
|
| 1070 |
if not res.data:
|
| 1071 |
raise HTTPException(status_code=404, detail="Session not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
return {"status": "deleted"}
|
| 1073 |
|
| 1074 |
|
|
@@ -1296,37 +1334,40 @@ def _get_context_baseline(user_id: str, context: str) -> dict | None:
|
|
| 1296 |
return None
|
| 1297 |
|
| 1298 |
|
| 1299 |
-
def _get_user_session_history(user_id: str, limit: int =
|
| 1300 |
-
"""Return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1301 |
res = supabase_admin.table("sessions").select(
|
| 1302 |
-
"
|
| 1303 |
-
).eq("user_id", user_id).order("created_at", desc=True).limit(
|
| 1304 |
|
| 1305 |
-
|
| 1306 |
for s in reversed(res.data): # oldest first
|
| 1307 |
try:
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
"
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
"
|
| 1317 |
-
"
|
| 1318 |
-
"
|
| 1319 |
-
|
| 1320 |
-
"silence_ratio_pct": round(signals["silence_ratio"]["silence_ratio"] * 100, 1),
|
| 1321 |
-
},
|
| 1322 |
-
"top_coaching_areas": [
|
| 1323 |
-
c.get("area") for c in insights.get("coaching_suggestions", [])[:2]
|
| 1324 |
-
if c.get("area")
|
| 1325 |
-
],
|
| 1326 |
-
})
|
| 1327 |
except Exception:
|
| 1328 |
continue
|
| 1329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1330 |
|
| 1331 |
|
| 1332 |
def _get_resonance_calibration(user_id: str) -> dict:
|
|
@@ -1352,9 +1393,131 @@ def _get_resonance_calibration(user_id: str) -> dict:
|
|
| 1352 |
return {"avoid": avoid, "emphasize": emphasize}
|
| 1353 |
|
| 1354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1355 |
def _sample_transcript(merged: list, max_segments: int = 60) -> str:
|
| 1356 |
-
"""
|
| 1357 |
-
If <= max_segments, use all. Otherwise take evenly-spaced thirds with no overlap."""
|
| 1358 |
n = len(merged)
|
| 1359 |
if n <= max_segments:
|
| 1360 |
selected = merged
|
|
@@ -1379,7 +1542,7 @@ def _sample_transcript(merged: list, max_segments: int = 60) -> str:
|
|
| 1379 |
def _save_session(
|
| 1380 |
session_id, user_id, signals, insights, dimensions,
|
| 1381 |
context, filename="recording", detected_speaker="SPEAKER_00",
|
| 1382 |
-
speaker_confirmed=True,
|
| 1383 |
):
|
| 1384 |
supabase_admin.table("sessions").insert({
|
| 1385 |
"id": session_id,
|
|
@@ -1391,6 +1554,6 @@ def _save_session(
|
|
| 1391 |
"signals_json": json.dumps(signals),
|
| 1392 |
"insights_json": json.dumps(insights),
|
| 1393 |
"dimensions_json": json.dumps(dimensions),
|
| 1394 |
-
"
|
| 1395 |
"all_speakers_signals_json": json.dumps(all_speakers_signals) if all_speakers_signals else None,
|
| 1396 |
}).execute()
|
|
|
|
| 19 |
from pipeline.voiceprint import VoiceprintMatcher
|
| 20 |
from pipeline.context_detector import ContextDetector
|
| 21 |
from pipeline.personality_synthesizer import PersonalitySynthesizer
|
| 22 |
+
from pipeline.mirror_feed_synthesizer import MirrorFeedSynthesizer
|
| 23 |
from db.database import supabase_admin
|
| 24 |
|
| 25 |
app = FastAPI()
|
| 26 |
|
| 27 |
+
_ALLOWED_ORIGINS = [o for o in [
|
| 28 |
+
"http://localhost:5173",
|
| 29 |
+
"http://localhost:5174",
|
| 30 |
+
os.getenv("FRONTEND_URL", ""),
|
| 31 |
+
] if o]
|
| 32 |
+
|
| 33 |
app.add_middleware(
|
| 34 |
CORSMiddleware,
|
| 35 |
+
allow_origins=_ALLOWED_ORIGINS,
|
| 36 |
+
allow_origin_regex=r"chrome-extension://.*",
|
| 37 |
+
allow_credentials=True,
|
| 38 |
allow_methods=["*"],
|
| 39 |
+
allow_headers=["*"],
|
| 40 |
)
|
| 41 |
|
| 42 |
+
UPLOAD_DIR = "/tmp/mirror"
|
| 43 |
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 44 |
|
| 45 |
# In-memory store for prepare β finalize handoff.
|
|
|
|
| 65 |
del _prepare_cache[sid]
|
| 66 |
|
| 67 |
|
| 68 |
+
print("Loading models...")
|
| 69 |
+
transcriber = Transcriber(api_key=os.getenv("GROQ_API_KEY"))
|
| 70 |
diarizer = Diarizer(hf_token=os.getenv("HF_TOKEN"))
|
| 71 |
insight_gen = InsightGenerator(api_key=os.getenv("GROQ_API_KEY"))
|
| 72 |
context_detector = ContextDetector(api_key=os.getenv("GROQ_API_KEY"))
|
| 73 |
dimension_scorer = DimensionScorer()
|
| 74 |
voiceprint_matcher = VoiceprintMatcher(hf_token=os.getenv("HF_TOKEN"))
|
| 75 |
personality_synth = PersonalitySynthesizer(api_key=os.getenv("GROQ_API_KEY"))
|
| 76 |
+
mirror_feed_synth = MirrorFeedSynthesizer(api_key=os.getenv("GROQ_API_KEY"))
|
| 77 |
print("All models loaded. Server ready.")
|
| 78 |
|
| 79 |
+
# In-memory caches β cleared on server restart, Supabase is the durable layer
|
| 80 |
+
_personality_cache: dict = {} # key = "{user_id}:{session_count}:v{version}"
|
| 81 |
+
_mirror_feed_cache: dict = {} # key = "{user_id}:{session_count}"
|
| 82 |
|
| 83 |
|
| 84 |
CONTEXT_POPULATION_NORMS: dict = {
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
_CONTEXT_BLIND_SPOTS: dict = {
|
| 169 |
+
"evaluative": (1, "Interview & Review Β· High Stakes",
|
| 170 |
+
"No interview, presentation, or review recordings yet. Your confidence and "
|
| 171 |
+
"composure scores are built from lower-stakes conversations β they may shift "
|
| 172 |
+
"significantly when you're being assessed."),
|
| 173 |
+
"adversarial": (2, "Conflict & Friction",
|
| 174 |
"No conflict or disagreement recordings yet. We can't tell how you respond when "
|
| 175 |
"challenged or when there's real friction in the room."),
|
| 176 |
+
"collaborative": (3, "Collaborative",
|
| 177 |
"No team meetings or brainstorming sessions uploaded. Your listening and "
|
| 178 |
"assertiveness scores come from 1-on-1 conversations only."),
|
| 179 |
+
"influential": (4, "Persuading & Pitching",
|
| 180 |
+
"No pitch or persuasion conversations yet. We can't see how you hold an argument "
|
| 181 |
+
"or move someone toward a decision."),
|
| 182 |
"negotiation": (5, "Negotiation",
|
| 183 |
+
"No negotiation recordings yet. How your composure and assertiveness hold under "
|
| 184 |
"competing interests is still unmeasured."),
|
| 185 |
+
"developmental": (6, "Coaching & Feedback",
|
| 186 |
"No coaching or feedback sessions recorded. We can't see how you structure or "
|
| 187 |
+
"deliver guidance to others."),
|
| 188 |
+
"support": (7, "Supportive Listening",
|
| 189 |
"No support conversations yet. Your empathy dimension has no direct evidence."),
|
| 190 |
+
"intimate": (8, "Deep Personal",
|
| 191 |
+
"No emotionally open conversations uploaded. Your expressiveness and warmth "
|
| 192 |
+
"scores may be incomplete."),
|
| 193 |
+
"social": (9, "Casual & Low-Stakes",
|
| 194 |
+
"No casual social conversations recorded. We can't see your natural, "
|
| 195 |
+
"low-pressure communication style yet."),
|
| 196 |
}
|
| 197 |
|
| 198 |
|
|
|
|
| 230 |
return {"changes": changes[:3]}
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
|
| 235 |
def _build_sessions_data(parsed: list, dim_paths: dict) -> list:
|
|
|
|
| 248 |
except (KeyError, TypeError):
|
| 249 |
pass
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
sessions.append({
|
| 252 |
"date": date_str,
|
| 253 |
"context": p["context"],
|
|
|
|
| 255 |
"filler_rate": p["sig"]["filler_words"]["rate_per_100_words"],
|
| 256 |
"talk_ratio_pct": round(p["sig"]["talk_ratio"]["user_ratio"] * 100),
|
| 257 |
"notable_pattern": p["ins"].get("notable_pattern", ""),
|
| 258 |
+
"fingerprint": p.get("fingerprint") or "",
|
| 259 |
})
|
| 260 |
return sessions
|
| 261 |
|
|
|
|
| 263 |
def _get_or_synthesize_personality(user_id: str, session_count: int,
|
| 264 |
profile_data: dict, dim_averages: dict,
|
| 265 |
sessions_data: list = None) -> dict:
|
| 266 |
+
_SYNTHESIS_VERSION = 5 # bump when prompt changes to invalidate old cache
|
| 267 |
cache_key = f"{user_id}:{session_count}:v{_SYNTHESIS_VERSION}"
|
| 268 |
|
| 269 |
if cache_key in _personality_cache:
|
|
|
|
| 278 |
.eq("user_id", user_id).execute()
|
| 279 |
if result.data:
|
| 280 |
row = result.data[0]
|
| 281 |
+
raw_pj = row.get("personality_json")
|
| 282 |
+
if raw_pj and row.get("session_count_at_synthesis") == session_count * _SYNTHESIS_VERSION:
|
| 283 |
+
personality = json.loads(raw_pj)
|
| 284 |
_personality_cache[cache_key] = personality
|
| 285 |
return personality
|
| 286 |
+
elif raw_pj:
|
| 287 |
+
# Session count changed β preserve old synthesis for delta calculation
|
| 288 |
+
try:
|
| 289 |
+
old_personality = json.loads(raw_pj)
|
| 290 |
+
except Exception:
|
| 291 |
+
pass
|
| 292 |
except Exception:
|
| 293 |
pass
|
| 294 |
|
|
|
|
| 332 |
|
| 333 |
@app.get("/")
|
| 334 |
def root():
|
| 335 |
+
return {"status": "mirror. API is running"}
|
| 336 |
|
| 337 |
|
| 338 |
# ββ SSE Step 1: Start prepare job ββββββββββββββββββββββββββββββββ
|
|
|
|
| 359 |
)
|
| 360 |
os.remove(temp_path)
|
| 361 |
|
| 362 |
+
import wave
|
| 363 |
+
with wave.open(audio_path, "r") as _wf:
|
| 364 |
+
_duration_s = _wf.getnframes() / _wf.getframerate()
|
| 365 |
+
if _duration_s > 1200:
|
| 366 |
+
os.remove(audio_path)
|
| 367 |
+
raise HTTPException(
|
| 368 |
+
status_code=400,
|
| 369 |
+
detail="Recording exceeds the 20-minute limit. Please trim and re-upload."
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
job_q: stdlib_queue.Queue = stdlib_queue.Queue()
|
| 373 |
_jobs[session_id] = job_q
|
| 374 |
|
|
|
|
| 613 |
if signals is None:
|
| 614 |
signals = SignalExtractor(audio_path, merged, confirmed_speaker).extract_all()
|
| 615 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
print(f"[{session_id}] Scoring dimensions...")
|
| 617 |
emit("scoring", "Scoring behavioral dimensionsβ¦")
|
| 618 |
dimensions = dimension_scorer.score_all(signals)
|
| 619 |
|
| 620 |
print(f"[{session_id}] Detecting conversation type and generating insights...")
|
| 621 |
emit("generating", "Generating insights with AIβ¦")
|
| 622 |
+
sample_text = _sample_transcript(merged)
|
| 623 |
+
full_text = _full_transcript(merged)
|
| 624 |
+
conversation_types = context_detector.detect(sample_text)
|
| 625 |
primary_context = conversation_types[0]
|
| 626 |
print(f"[{session_id}] Detected conversation types: {conversation_types}")
|
| 627 |
|
| 628 |
+
baseline = _get_context_baseline(user_id, primary_context)
|
| 629 |
+
session_history = _get_user_session_history(user_id)
|
| 630 |
+
resonance_calibration = _get_resonance_calibration(user_id)
|
| 631 |
+
|
| 632 |
insights = insight_gen.generate(
|
| 633 |
+
signals, primary_context, baseline, full_text, dimensions,
|
| 634 |
session_history=session_history,
|
| 635 |
resonance_calibration=resonance_calibration,
|
| 636 |
conversation_types=conversation_types,
|
| 637 |
)
|
| 638 |
|
| 639 |
+
fingerprint = insights.pop("fingerprint", None)
|
| 640 |
+
|
| 641 |
emit("reflecting", "Generating reflection questionsβ¦")
|
| 642 |
reflection_questions = insight_gen.generate_reflection_questions(
|
| 643 |
+
signals, insights, full_text, primary_context
|
| 644 |
)
|
| 645 |
if reflection_questions:
|
| 646 |
insights["reflection_questions"] = reflection_questions
|
|
|
|
| 650 |
session_id, user_id, signals, insights, dimensions,
|
| 651 |
primary_context, filename, confirmed_speaker,
|
| 652 |
speaker_confirmed=True,
|
| 653 |
+
fingerprint=fingerprint,
|
| 654 |
all_speakers_signals=all_speakers_signals
|
| 655 |
)
|
| 656 |
|
| 657 |
+
# Trigger background consolidation if user has enough sessions
|
| 658 |
+
try:
|
| 659 |
+
count_res = supabase_admin.table("sessions").select(
|
| 660 |
+
"id", count="exact"
|
| 661 |
+
).eq("user_id", user_id).execute()
|
| 662 |
+
session_count = count_res.count or 0
|
| 663 |
+
if session_count >= 12:
|
| 664 |
+
threading.Thread(
|
| 665 |
+
target=_maybe_consolidate,
|
| 666 |
+
args=(user_id, session_count),
|
| 667 |
+
daemon=True
|
| 668 |
+
).start()
|
| 669 |
+
except Exception:
|
| 670 |
+
pass
|
| 671 |
+
|
| 672 |
if os.path.exists(audio_path):
|
| 673 |
os.remove(audio_path)
|
| 674 |
del _prepare_cache[session_id]
|
|
|
|
| 684 |
"speaker_confirmed": True,
|
| 685 |
"available_speakers": all_speaker_ids,
|
| 686 |
"voiceprint_confidence": voiceprint_confidence,
|
|
|
|
| 687 |
}
|
| 688 |
|
| 689 |
if job_key in _jobs:
|
|
|
|
| 767 |
resonance_calibration = _get_resonance_calibration(user_id)
|
| 768 |
dimensions = dimension_scorer.score_all(signals)
|
| 769 |
|
| 770 |
+
# Re-analysis uses existing fingerprint as transcript context if no merged_json
|
| 771 |
+
existing_fingerprint = json.loads(session["fingerprint_json"]) if session.get("fingerprint_json") else None
|
| 772 |
+
transcript_text = existing_fingerprint or ""
|
| 773 |
+
|
|
|
|
| 774 |
insights = insight_gen.generate(
|
| 775 |
signals, primary_context, baseline, transcript_text, dimensions,
|
| 776 |
session_history=session_history,
|
|
|
|
| 778 |
conversation_types=conversation_types,
|
| 779 |
)
|
| 780 |
|
| 781 |
+
fingerprint = insights.pop("fingerprint", existing_fingerprint)
|
| 782 |
+
|
| 783 |
reflection_questions = insight_gen.generate_reflection_questions(
|
| 784 |
signals, insights, transcript_text, primary_context
|
| 785 |
)
|
|
|
|
| 792 |
"signals_json": json.dumps(signals),
|
| 793 |
"insights_json": json.dumps(insights),
|
| 794 |
"dimensions_json": json.dumps(dimensions),
|
| 795 |
+
"fingerprint_json": json.dumps(fingerprint) if fingerprint else None,
|
| 796 |
}).eq("id", session_id).execute()
|
| 797 |
|
| 798 |
print(f"[{session_id}] Re-analysis done.")
|
|
|
|
| 805 |
"detected_speaker": confirmed_speaker,
|
| 806 |
"speaker_confirmed": True,
|
| 807 |
"available_speakers": list(all_speakers_signals.keys()),
|
|
|
|
| 808 |
}
|
| 809 |
|
| 810 |
|
|
|
|
| 829 |
"dimensions": json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {},
|
| 830 |
"available_speakers": list(json.loads(s["all_speakers_signals_json"]).keys())
|
| 831 |
if s.get("all_speakers_signals_json") else [],
|
| 832 |
+
"fingerprint": json.loads(s["fingerprint_json"]) if s.get("fingerprint_json") else None,
|
| 833 |
}
|
| 834 |
for s in res.data
|
| 835 |
]
|
|
|
|
| 877 |
@app.get("/api/profile")
|
| 878 |
def get_profile(user_id: str = Depends(get_current_user)):
|
| 879 |
res = supabase_admin.table("sessions").select(
|
| 880 |
+
"id, context, created_at, signals_json, dimensions_json, insights_json, fingerprint_json, detected_speaker"
|
| 881 |
).eq("user_id", user_id).order("created_at", desc=False).execute()
|
| 882 |
|
| 883 |
+
if len(res.data) < 1:
|
| 884 |
return {
|
| 885 |
"insufficient_data": True,
|
| 886 |
+
"session_count": 0,
|
|
|
|
| 887 |
}
|
| 888 |
|
| 889 |
parsed = []
|
|
|
|
| 892 |
sig = json.loads(s["signals_json"])
|
| 893 |
dims = json.loads(s["dimensions_json"]) if s.get("dimensions_json") else {}
|
| 894 |
ins = json.loads(s["insights_json"])
|
| 895 |
+
fingerprint = json.loads(s["fingerprint_json"]) if s.get("fingerprint_json") else None
|
| 896 |
parsed.append({
|
| 897 |
"context": s["context"], "date": s["created_at"],
|
| 898 |
"sig": sig, "dims": dims, "ins": ins,
|
| 899 |
+
"fingerprint": fingerprint,
|
| 900 |
"detected_speaker": s.get("detected_speaker") or "SPEAKER_00",
|
| 901 |
})
|
| 902 |
except Exception:
|
|
|
|
| 934 |
"filler_rate": round(sum(p["sig"]["filler_words"]["rate_per_100_words"] for p in items) / len(items), 2),
|
| 935 |
}
|
| 936 |
|
| 937 |
+
# Pattern detection (needs 2+ sessions to establish consistency)
|
| 938 |
patterns = []
|
| 939 |
+
if n >= 2:
|
| 940 |
+
talk_ratios = [p["sig"]["talk_ratio"]["user_ratio"] * 100 for p in parsed]
|
| 941 |
+
high_talk = sum(1 for r in talk_ratios if r > 60)
|
| 942 |
+
low_talk = sum(1 for r in talk_ratios if r < 40)
|
| 943 |
+
if high_talk >= n * 0.7:
|
| 944 |
+
patterns.append({"signal": "talk_ratio", "type": "consistently_high",
|
| 945 |
+
"detail": f"You speak over 60% of the time in {high_talk}/{n} sessions."})
|
| 946 |
+
elif low_talk >= n * 0.7:
|
| 947 |
+
patterns.append({"signal": "talk_ratio", "type": "consistently_low",
|
| 948 |
+
"detail": f"You speak under 40% of the time in {low_talk}/{n} sessions."})
|
| 949 |
+
|
| 950 |
+
filler_rates = [p["sig"]["filler_words"]["rate_per_100_words"] for p in parsed]
|
| 951 |
+
avg_filler = sum(filler_rates) / len(filler_rates)
|
| 952 |
+
if avg_filler > 5:
|
| 953 |
+
patterns.append({"signal": "filler_words", "type": "consistently_high",
|
| 954 |
+
"detail": f"Average filler rate {round(avg_filler, 1)}/100 words across all sessions."})
|
| 955 |
+
|
| 956 |
+
interruption_counts = [p["sig"]["interruptions"]["user_interrupted_other"] for p in parsed]
|
| 957 |
+
avg_interrupts = sum(interruption_counts) / len(interruption_counts)
|
| 958 |
+
if avg_interrupts >= 3:
|
| 959 |
+
patterns.append({"signal": "interruptions", "type": "consistently_high",
|
| 960 |
+
"detail": f"You average {round(avg_interrupts, 1)} interruptions per session."})
|
| 961 |
|
| 962 |
# Trend detection across first vs last third (needs 6+ sessions)
|
| 963 |
trends = []
|
|
|
|
| 1031 |
elif completeness < 90: completeness_label = "Established"
|
| 1032 |
else: completeness_label = "Deep mirror"
|
| 1033 |
|
| 1034 |
+
_MF_VERSION = 3 # bump when tip field or prompt changes to invalidate old cache
|
| 1035 |
+
mf_cache_key = f"{user_id}:{n}:v{_MF_VERSION}"
|
| 1036 |
+
mirror_feed = _mirror_feed_cache.get(mf_cache_key)
|
| 1037 |
+
|
| 1038 |
+
if mirror_feed is None:
|
| 1039 |
+
# Try Supabase cache
|
| 1040 |
try:
|
| 1041 |
+
mf_res = supabase_admin.table("user_profiles").select(
|
| 1042 |
+
"mirror_feed_json, mirror_feed_session_count"
|
| 1043 |
+
).eq("user_id", user_id).execute()
|
| 1044 |
+
if mf_res.data:
|
| 1045 |
+
row = mf_res.data[0]
|
| 1046 |
+
if row.get("mirror_feed_session_count") == n * _MF_VERSION and row.get("mirror_feed_json"):
|
| 1047 |
+
mirror_feed = json.loads(row["mirror_feed_json"])
|
| 1048 |
except Exception:
|
| 1049 |
+
pass
|
| 1050 |
+
|
| 1051 |
+
if mirror_feed is None:
|
| 1052 |
+
feed_sessions = [
|
| 1053 |
+
{
|
| 1054 |
"context": p["context"],
|
| 1055 |
+
"date": p["date"][:10],
|
| 1056 |
+
"fingerprint": p.get("fingerprint") or "",
|
| 1057 |
+
"notable_pattern": p["ins"].get("notable_pattern", ""),
|
| 1058 |
+
}
|
| 1059 |
+
for p in parsed
|
| 1060 |
+
]
|
| 1061 |
+
user_summary = _get_user_summary(user_id)
|
| 1062 |
+
mirror_feed = mirror_feed_synth.synthesize(feed_sessions, user_summary=user_summary)
|
| 1063 |
+
try:
|
| 1064 |
+
supabase_admin.table("user_profiles").upsert({
|
| 1065 |
+
"user_id": user_id,
|
| 1066 |
+
"mirror_feed_json": json.dumps(mirror_feed),
|
| 1067 |
+
"mirror_feed_session_count": n * _MF_VERSION,
|
| 1068 |
+
"updated_at": _utcnow(),
|
| 1069 |
+
}).execute()
|
| 1070 |
+
except Exception:
|
| 1071 |
+
pass
|
| 1072 |
+
|
| 1073 |
+
_mirror_feed_cache[mf_cache_key] = mirror_feed
|
| 1074 |
|
| 1075 |
return {
|
| 1076 |
"insufficient_data": False,
|
|
|
|
| 1084 |
"blind_spots": blind_spots,
|
| 1085 |
"completeness": completeness,
|
| 1086 |
"completeness_label": completeness_label,
|
| 1087 |
+
"mirror_feed": mirror_feed,
|
| 1088 |
}
|
| 1089 |
|
| 1090 |
|
|
|
|
| 1098 |
).execute()
|
| 1099 |
if not res.data:
|
| 1100 |
raise HTTPException(status_code=404, detail="Session not found.")
|
| 1101 |
+
|
| 1102 |
+
# Evict all cached profile data for this user β session count has changed
|
| 1103 |
+
for key in list(_personality_cache.keys()):
|
| 1104 |
+
if key.startswith(f"{user_id}:"):
|
| 1105 |
+
del _personality_cache[key]
|
| 1106 |
+
for key in list(_mirror_feed_cache.keys()):
|
| 1107 |
+
if key.startswith(f"{user_id}:"):
|
| 1108 |
+
del _mirror_feed_cache[key]
|
| 1109 |
+
|
| 1110 |
return {"status": "deleted"}
|
| 1111 |
|
| 1112 |
|
|
|
|
| 1334 |
return None
|
| 1335 |
|
| 1336 |
|
| 1337 |
+
def _get_user_session_history(user_id: str, limit: int = 8) -> list:
|
| 1338 |
+
"""Return session history for cross-session context.
|
| 1339 |
+
If a consolidated summary exists (12+ sessions), returns it + last 3 individual fingerprints.
|
| 1340 |
+
Otherwise returns last N individual fingerprints.
|
| 1341 |
+
"""
|
| 1342 |
+
user_summary = _get_user_summary(user_id)
|
| 1343 |
+
|
| 1344 |
+
fetch_limit = 3 if user_summary else limit
|
| 1345 |
res = supabase_admin.table("sessions").select(
|
| 1346 |
+
"context, created_at, fingerprint_json, insights_json"
|
| 1347 |
+
).eq("user_id", user_id).order("created_at", desc=True).limit(fetch_limit).execute()
|
| 1348 |
|
| 1349 |
+
recent = []
|
| 1350 |
for s in reversed(res.data): # oldest first
|
| 1351 |
try:
|
| 1352 |
+
fingerprint = None
|
| 1353 |
+
if s.get("fingerprint_json"):
|
| 1354 |
+
fingerprint = json.loads(s["fingerprint_json"])
|
| 1355 |
+
if not fingerprint and s.get("insights_json"):
|
| 1356 |
+
ins = json.loads(s["insights_json"])
|
| 1357 |
+
fingerprint = ins.get("summary_sentence") or ins.get("conversation_summary")
|
| 1358 |
+
if fingerprint:
|
| 1359 |
+
recent.append({
|
| 1360 |
+
"context": s["context"],
|
| 1361 |
+
"date": s["created_at"][:10],
|
| 1362 |
+
"fingerprint": fingerprint,
|
| 1363 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1364 |
except Exception:
|
| 1365 |
continue
|
| 1366 |
+
|
| 1367 |
+
if user_summary:
|
| 1368 |
+
return [{"context": "profile", "date": "consolidated",
|
| 1369 |
+
"fingerprint": f"CONSOLIDATED BEHAVIORAL PROFILE:\n{user_summary}"}] + recent
|
| 1370 |
+
return recent
|
| 1371 |
|
| 1372 |
|
| 1373 |
def _get_resonance_calibration(user_id: str) -> dict:
|
|
|
|
| 1393 |
return {"avoid": avoid, "emphasize": emphasize}
|
| 1394 |
|
| 1395 |
|
| 1396 |
+
# ββ User summary consolidation ββββββββββββββββββββββββββββββββββββββββ
|
| 1397 |
+
|
| 1398 |
+
def _generate_user_summary(fingerprints: list) -> str:
|
| 1399 |
+
"""LLM call: consolidate all session fingerprints into one behavioral summary."""
|
| 1400 |
+
ctx_map: dict = {}
|
| 1401 |
+
for fp in fingerprints:
|
| 1402 |
+
ctx_map.setdefault(fp["context"], []).append(fp)
|
| 1403 |
+
|
| 1404 |
+
blocks = []
|
| 1405 |
+
for ctx, items in ctx_map.items():
|
| 1406 |
+
label = ctx.replace("_", " ").title()
|
| 1407 |
+
block = f"{label} ({len(items)} sessions):\n"
|
| 1408 |
+
for item in items:
|
| 1409 |
+
block += f" {item['date']}: {item['fingerprint']}\n"
|
| 1410 |
+
blocks.append(block)
|
| 1411 |
+
|
| 1412 |
+
prompt = f"""You are building a consolidated behavioral profile from {len(fingerprints)} recorded conversations.
|
| 1413 |
+
|
| 1414 |
+
SESSIONS BY CONTEXT:
|
| 1415 |
+
{''.join(blocks)}
|
| 1416 |
+
|
| 1417 |
+
TASK: Write a single comprehensive behavioral summary of this person β 400β600 words.
|
| 1418 |
+
|
| 1419 |
+
Cover:
|
| 1420 |
+
- Their core behavioral tendencies that appear regardless of context
|
| 1421 |
+
- How they show up differently across different types of conversations (if multiple contexts exist)
|
| 1422 |
+
- Recurring strengths β what they do consistently well
|
| 1423 |
+
- Recurring blind spots or development areas that appear across multiple sessions
|
| 1424 |
+
- How their behavior has evolved from their earliest sessions to their most recent ones
|
| 1425 |
+
|
| 1426 |
+
RULES:
|
| 1427 |
+
1. Be specific β reference what was actually observed, not generic descriptions
|
| 1428 |
+
2. Write in third person ("this person", "they", "their") β this is a reference document, not a direct address
|
| 1429 |
+
3. Do not use raw numbers β translate signals into behavioral descriptions
|
| 1430 |
+
4. Be honest about both strengths and gaps
|
| 1431 |
+
5. Output plain text only β no JSON, no markdown headers, no bullet points"""
|
| 1432 |
+
|
| 1433 |
+
try:
|
| 1434 |
+
response = personality_synth.client.chat.completions.create(
|
| 1435 |
+
model="llama-3.3-70b-versatile",
|
| 1436 |
+
messages=[{"role": "user", "content": prompt}],
|
| 1437 |
+
temperature=0.3,
|
| 1438 |
+
max_tokens=900,
|
| 1439 |
+
)
|
| 1440 |
+
return response.choices[0].message.content.strip()
|
| 1441 |
+
except Exception:
|
| 1442 |
+
return ""
|
| 1443 |
+
|
| 1444 |
+
|
| 1445 |
+
def _maybe_consolidate(user_id: str, session_count: int) -> None:
|
| 1446 |
+
"""Run in background thread. Consolidates fingerprints into user_summary_json
|
| 1447 |
+
at 12+ sessions, then refreshes every 5 new sessions."""
|
| 1448 |
+
if session_count < 12:
|
| 1449 |
+
return
|
| 1450 |
+
try:
|
| 1451 |
+
profile_res = supabase_admin.table("user_profiles").select(
|
| 1452 |
+
"user_summary_json, summary_session_count"
|
| 1453 |
+
).eq("user_id", user_id).execute()
|
| 1454 |
+
|
| 1455 |
+
existing = profile_res.data[0] if profile_res.data else {}
|
| 1456 |
+
last_count = existing.get("summary_session_count") or 0
|
| 1457 |
+
|
| 1458 |
+
# Skip if summary is recent enough (within 5 sessions)
|
| 1459 |
+
if existing.get("user_summary_json") and (session_count - last_count) < 5:
|
| 1460 |
+
return
|
| 1461 |
+
|
| 1462 |
+
fp_res = supabase_admin.table("sessions").select(
|
| 1463 |
+
"context, created_at, fingerprint_json"
|
| 1464 |
+
).eq("user_id", user_id).order("created_at", desc=False).execute()
|
| 1465 |
+
|
| 1466 |
+
fingerprints = []
|
| 1467 |
+
for s in fp_res.data:
|
| 1468 |
+
if s.get("fingerprint_json"):
|
| 1469 |
+
try:
|
| 1470 |
+
fp = json.loads(s["fingerprint_json"])
|
| 1471 |
+
if fp:
|
| 1472 |
+
fingerprints.append({
|
| 1473 |
+
"context": s["context"],
|
| 1474 |
+
"date": s["created_at"][:10],
|
| 1475 |
+
"fingerprint": fp,
|
| 1476 |
+
})
|
| 1477 |
+
except Exception:
|
| 1478 |
+
continue
|
| 1479 |
+
|
| 1480 |
+
if len(fingerprints) < 10:
|
| 1481 |
+
return
|
| 1482 |
+
|
| 1483 |
+
summary = _generate_user_summary(fingerprints)
|
| 1484 |
+
if not summary:
|
| 1485 |
+
return
|
| 1486 |
+
|
| 1487 |
+
supabase_admin.table("user_profiles").upsert({
|
| 1488 |
+
"user_id": user_id,
|
| 1489 |
+
"user_summary_json": summary,
|
| 1490 |
+
"summary_session_count": session_count,
|
| 1491 |
+
}).execute()
|
| 1492 |
+
print(f"[consolidation] Updated user summary for {user_id} at {session_count} sessions.")
|
| 1493 |
+
except Exception as e:
|
| 1494 |
+
print(f"[consolidation] Failed for {user_id}: {e}")
|
| 1495 |
+
|
| 1496 |
+
|
| 1497 |
+
def _get_user_summary(user_id: str) -> str | None:
|
| 1498 |
+
"""Return consolidated behavioral summary if it exists."""
|
| 1499 |
+
try:
|
| 1500 |
+
res = supabase_admin.table("user_profiles").select(
|
| 1501 |
+
"user_summary_json"
|
| 1502 |
+
).eq("user_id", user_id).execute()
|
| 1503 |
+
if res.data and res.data[0].get("user_summary_json"):
|
| 1504 |
+
return res.data[0]["user_summary_json"]
|
| 1505 |
+
except Exception:
|
| 1506 |
+
pass
|
| 1507 |
+
return None
|
| 1508 |
+
|
| 1509 |
+
|
| 1510 |
+
def _full_transcript(merged: list) -> str:
|
| 1511 |
+
"""Format complete diarized transcript as speaker-labeled lines."""
|
| 1512 |
+
return "\n".join(
|
| 1513 |
+
f"{s.get('speaker', 'UNKNOWN')}: {s.get('text', '').strip()}"
|
| 1514 |
+
for s in merged
|
| 1515 |
+
if s.get("text", "").strip()
|
| 1516 |
+
)
|
| 1517 |
+
|
| 1518 |
+
|
| 1519 |
def _sample_transcript(merged: list, max_segments: int = 60) -> str:
|
| 1520 |
+
"""Short transcript sample β used only for context detection."""
|
|
|
|
| 1521 |
n = len(merged)
|
| 1522 |
if n <= max_segments:
|
| 1523 |
selected = merged
|
|
|
|
| 1542 |
def _save_session(
|
| 1543 |
session_id, user_id, signals, insights, dimensions,
|
| 1544 |
context, filename="recording", detected_speaker="SPEAKER_00",
|
| 1545 |
+
speaker_confirmed=True, fingerprint=None, all_speakers_signals=None
|
| 1546 |
):
|
| 1547 |
supabase_admin.table("sessions").insert({
|
| 1548 |
"id": session_id,
|
|
|
|
| 1554 |
"signals_json": json.dumps(signals),
|
| 1555 |
"insights_json": json.dumps(insights),
|
| 1556 |
"dimensions_json": json.dumps(dimensions),
|
| 1557 |
+
"fingerprint_json": json.dumps(fingerprint) if fingerprint else None,
|
| 1558 |
"all_speakers_signals_json": json.dumps(all_speakers_signals) if all_speakers_signals else None,
|
| 1559 |
}).execute()
|
backend/pipeline/insight_generator.py
CHANGED
|
@@ -1,60 +1,44 @@
|
|
| 1 |
import json
|
| 2 |
from groq import Groq
|
| 3 |
|
|
|
|
| 4 |
CONTEXT_COACHING_GUIDE = {
|
| 5 |
"social": (
|
| 6 |
-
"
|
| 7 |
-
"
|
| 8 |
-
"matter most. Avoid applying professional communication standards."
|
| 9 |
),
|
| 10 |
"collaborative": (
|
| 11 |
-
"
|
| 12 |
-
"
|
| 13 |
-
"dominated. Mutual engagement and shared goal orientation matter. Long silences may "
|
| 14 |
-
"indicate disengagement."
|
| 15 |
),
|
| 16 |
"evaluative": (
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"more than in casual contexts. Concise, direct answers signal competence."
|
| 21 |
),
|
| 22 |
"influential": (
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"questions draw out needs?), and clarity of the core argument. Monologuing is a red "
|
| 26 |
-
"flag β real influence requires dialogue, not broadcasting."
|
| 27 |
),
|
| 28 |
"negotiation": (
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"whether both parties had space to present their position. High interruptions and rushed "
|
| 32 |
-
"responses signal a power struggle rather than a deal-making mindset."
|
| 33 |
),
|
| 34 |
"adversarial": (
|
| 35 |
-
"
|
| 36 |
-
"
|
| 37 |
-
"by one person usually signals the other isn't being heard. Frequent interruptions "
|
| 38 |
-
"escalate tension. Long response latency can indicate careful processing or withdrawal."
|
| 39 |
),
|
| 40 |
"developmental": (
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"the person being developed had space to think. The coach or mentor should talk less, "
|
| 44 |
-
"not more β high talk ratio by the coach is often a red flag."
|
| 45 |
),
|
| 46 |
"support": (
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"listening ratio, response latency (rushing to respond prevents real empathy), question "
|
| 50 |
-
"quality (open vs. closed), and whether the supported person felt genuinely heard."
|
| 51 |
),
|
| 52 |
"intimate": (
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"both speakers, response latency (thoughtful pauses signal processing, not disengagement), "
|
| 56 |
-
"interruption patterns (interruptions feel like ruptures here), and whether both people "
|
| 57 |
-
"had equal space to go deep."
|
| 58 |
),
|
| 59 |
}
|
| 60 |
|
|
@@ -71,22 +55,18 @@ class InsightGenerator:
|
|
| 71 |
prompt = self._build_prompt(
|
| 72 |
structured_input,
|
| 73 |
transcript_text,
|
| 74 |
-
signals.get("notable_signals", []),
|
| 75 |
-
dimensions or {},
|
| 76 |
context,
|
| 77 |
user_baseline,
|
| 78 |
session_history or [],
|
| 79 |
resonance_calibration or {},
|
| 80 |
conversation_types or [context],
|
| 81 |
)
|
| 82 |
-
|
| 83 |
response = self.client.chat.completions.create(
|
| 84 |
model="llama-3.3-70b-versatile",
|
| 85 |
messages=[{"role": "user", "content": prompt}],
|
| 86 |
temperature=0.3,
|
| 87 |
-
max_tokens=
|
| 88 |
)
|
| 89 |
-
|
| 90 |
return self._parse_output(response.choices[0].message.content)
|
| 91 |
|
| 92 |
def _prepare_input(self, signals: dict, context: str, baseline: dict) -> dict:
|
|
@@ -95,24 +75,14 @@ class InsightGenerator:
|
|
| 95 |
"session_duration_minutes": round(signals["session_duration_s"] / 60, 1),
|
| 96 |
"talk_ratio_user": signals["talk_ratio"]["user_ratio"],
|
| 97 |
"speech_rate_wpm": signals["speech_rate"]["overall_wpm"],
|
| 98 |
-
"speech_rate_variability": signals["speech_rate"]["variability"],
|
| 99 |
-
"avg_response_latency_s": signals["pauses"]["response_latency"]["mean_s"],
|
| 100 |
-
"within_turn_pause_count": signals["pauses"]["within_turn_pauses"]["count"],
|
| 101 |
-
"user_interrupted_other": signals["interruptions"]["user_interrupted_other"],
|
| 102 |
-
"user_was_interrupted": signals["interruptions"]["user_was_interrupted"],
|
| 103 |
"filler_rate_per_100_words": signals["filler_words"]["rate_per_100_words"],
|
| 104 |
"top_filler_words": list(signals["filler_words"]["breakdown"].keys())[:3],
|
| 105 |
-
"
|
| 106 |
-
"
|
| 107 |
-
"
|
| 108 |
-
"speech_acceleration_trend": signals["speech_acceleration"].get("trend"),
|
| 109 |
-
"speech_delta_wpm": signals["speech_acceleration"].get("delta_wpm"),
|
| 110 |
"user_questions_asked": signals["questions"]["user_questions_asked"],
|
| 111 |
"other_questions_asked": signals["questions"]["other_questions_asked"],
|
| 112 |
-
"
|
| 113 |
-
"vocabulary_richness_ttr": signals["vocabulary_richness"]["type_token_ratio"],
|
| 114 |
-
"silence_ratio": signals["silence_ratio"]["silence_ratio"],
|
| 115 |
-
"crosstalk_ratio": signals["crosstalk"]["crosstalk_ratio"],
|
| 116 |
}
|
| 117 |
|
| 118 |
if baseline:
|
|
@@ -138,18 +108,12 @@ class InsightGenerator:
|
|
| 138 |
"context_avg": round(baseline["avg_filler_rate"], 2),
|
| 139 |
"delta_pct": _delta_pct(prepared["filler_rate_per_100_words"], baseline["avg_filler_rate"]),
|
| 140 |
},
|
| 141 |
-
"response_latency": {
|
| 142 |
-
"current": prepared["avg_response_latency_s"],
|
| 143 |
-
"context_avg": round(baseline["avg_response_latency_s"], 2),
|
| 144 |
-
"delta_pct": _delta_pct(prepared["avg_response_latency_s"], baseline["avg_response_latency_s"]),
|
| 145 |
-
},
|
| 146 |
"talk_ratio": {
|
| 147 |
"current": prepared["talk_ratio_user"],
|
| 148 |
"context_avg": round(baseline["avg_talk_ratio"], 3),
|
| 149 |
"delta_pp": round(prepared["talk_ratio_user"] - baseline["avg_talk_ratio"], 3),
|
| 150 |
},
|
| 151 |
}
|
| 152 |
-
|
| 153 |
elif source == "population_norm":
|
| 154 |
norms = baseline["norms"]
|
| 155 |
|
|
@@ -159,192 +123,160 @@ class InsightGenerator:
|
|
| 159 |
lo, hi, note = norms[key]
|
| 160 |
return (lo <= val <= hi), (round(lo * scale), round(hi * scale)), note
|
| 161 |
|
| 162 |
-
tr_in, tr_range,
|
| 163 |
-
fr_in, fr_range,
|
| 164 |
-
rl_in, rl_range, rl_note = _norm_check(prepared["avg_response_latency_s"], "avg_response_latency_s")
|
| 165 |
-
wpm_in, wpm_range, wpm_note = _norm_check(prepared["speech_rate_wpm"], "avg_speech_rate_wpm")
|
| 166 |
|
| 167 |
prepared["baseline_comparison"] = {
|
| 168 |
"type": "population_norm",
|
| 169 |
"context": baseline["context"],
|
| 170 |
-
"talk_ratio":
|
| 171 |
-
|
| 172 |
-
"
|
| 173 |
-
|
| 174 |
}
|
| 175 |
|
| 176 |
return prepared
|
| 177 |
|
| 178 |
def _build_prompt(self, data: dict, transcript_text: str,
|
| 179 |
-
notable_signals: list, dimensions: dict,
|
| 180 |
context: str, baseline: dict,
|
| 181 |
-
session_history: list
|
| 182 |
-
resonance_calibration: dict
|
| 183 |
-
conversation_types: list
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
# ββ Baseline
|
| 186 |
baseline_section = ""
|
| 187 |
if "baseline_comparison" in data:
|
| 188 |
b = data["baseline_comparison"]
|
| 189 |
btype = b.get("type")
|
| 190 |
-
|
| 191 |
-
def _fmt_delta(delta, unit=""):
|
| 192 |
-
if delta is None:
|
| 193 |
-
return "no change"
|
| 194 |
-
sign = "+" if delta > 0 else ""
|
| 195 |
-
return f"{sign}{delta}{unit}"
|
| 196 |
-
|
| 197 |
if btype == "personal_context":
|
| 198 |
n = b["session_count"]
|
| 199 |
ctx = b["context"].replace("_", " ")
|
| 200 |
-
sr, fr,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
baseline_section = f"""
|
| 202 |
-
YOUR {ctx.upper()} BASELINE (
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
Response latency: {rl['current']}s vs your {ctx} avg {rl['context_avg']}s ({_fmt_delta(rl['delta_pct'], '%')})
|
| 207 |
-
Talk ratio: {round(tr['current']*100)}% vs your {ctx} avg {round(tr['context_avg']*100)}% ({_fmt_delta(tr['delta_pp']*100, 'pp')})
|
| 208 |
"""
|
| 209 |
-
|
| 210 |
elif btype == "population_norm":
|
| 211 |
ctx = b["context"].replace("_", " ")
|
| 212 |
-
tr, fr
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
_norm_line("Response latency", f"{rl['current']}s", rl, "s"),
|
| 226 |
-
_norm_line("Speech rate", f"{sr['current']} wpm", sr, " wpm"),
|
| 227 |
-
]
|
| 228 |
-
lines = [l for l in lines if l]
|
| 229 |
-
|
| 230 |
-
baseline_section = f"""
|
| 231 |
-
CONTEXT NORMS FOR {ctx.upper()} CONVERSATIONS:
|
| 232 |
-
IMPORTANT: Do NOT flag β signals as issues β they are within the expected range for this context.
|
| 233 |
-
Coach only on β signals. Adjust all observations and suggestions accordingly.
|
| 234 |
{chr(10).join(lines)}
|
| 235 |
"""
|
| 236 |
|
| 237 |
-
# ββ Session history ββββββββββββββββββββββββββββ
|
| 238 |
history_section = ""
|
| 239 |
if session_history:
|
| 240 |
n = len(session_history)
|
| 241 |
-
lines = [
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
all_areas = [a for s in session_history for a in s.get("top_coaching_areas", [])]
|
| 253 |
-
repeated = [area for area, cnt in Counter(all_areas).most_common() if cnt >= 2]
|
| 254 |
-
repeat_note = (
|
| 255 |
-
f"\n RECURRING ISSUES (flagged in multiple sessions): {', '.join(repeated)}"
|
| 256 |
-
if repeated else ""
|
| 257 |
-
)
|
| 258 |
-
history_section = f"""
|
| 259 |
-
SESSION HISTORY β this is session {n + 1} for this user.
|
| 260 |
-
REQUIRED: mention at least one thing that changed or stayed the same vs previous sessions.
|
| 261 |
-
{chr(10).join(lines)}{repeat_note}
|
| 262 |
"""
|
| 263 |
|
| 264 |
-
# ββ Resonance calibration βββββββββββββββββββββββββββββββββ
|
| 265 |
resonance_section = ""
|
| 266 |
if resonance_calibration:
|
| 267 |
avoid = resonance_calibration.get("avoid", [])
|
| 268 |
emphasize = resonance_calibration.get("emphasize", [])
|
| 269 |
parts = []
|
| 270 |
if avoid:
|
| 271 |
-
parts.append(f" AVOID
|
| 272 |
if emphasize:
|
| 273 |
parts.append(f" PRIORITIZE observations about: {', '.join(emphasize)}")
|
| 274 |
if parts:
|
| 275 |
resonance_section = "\nUSER FEEDBACK CALIBRATION:\n" + "\n".join(parts) + "\n"
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
"""
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
transcript_section = f"""
|
| 300 |
-
CONVERSATION TRANSCRIPT (partial β quote from this where relevant):
|
| 301 |
-
{transcript_text}
|
| 302 |
-
"""
|
| 303 |
-
|
| 304 |
-
types_str = ", ".join(conversation_types or [context])
|
| 305 |
-
|
| 306 |
-
return f"""You are a behavioral communication coach analyzing a real conversation.
|
| 307 |
-
Generate a specific, honest coaching report. Write directly to the user ("you", "your").
|
| 308 |
-
|
| 309 |
-
CONVERSATION TYPES: {types_str}
|
| 310 |
-
DURATION: {data['session_duration_minutes']} minutes
|
| 311 |
-
{baseline_section}{history_section}{resonance_section}{notable_section}{dimensions_section}
|
| 312 |
-
ALL SESSION DATA:
|
| 313 |
-
{json.dumps({k: v for k, v in data.items() if k != "baseline_comparison"}, indent=2)}
|
| 314 |
-
{transcript_section}
|
| 315 |
-
CONTEXT-SPECIFIC COACHING GUIDE (primary type: {context}):
|
| 316 |
-
{coaching_guide}
|
| 317 |
-
|
| 318 |
-
LANGUAGE RULES β follow these precisely:
|
| 319 |
-
1. FACTS (measurements) β always direct, no hedging.
|
| 320 |
-
2. BEHAVIORAL INTERPRETATIONS β hedge roughly half the time for genuinely uncertain inferences.
|
| 321 |
-
3. PSYCHOLOGICAL / EMOTIONAL CLAIMS β always hedge.
|
| 322 |
-
4. COACHING SUGGESTIONS β always direct. No hedging.
|
| 323 |
-
5. Do not default to talk_ratio, speech_rate, or pauses unless they appear in MOST NOTABLE SIGNALS.
|
| 324 |
-
6. Every observation must reference a specific value from the data.
|
| 325 |
-
7. Quote from the transcript when it illustrates your point.
|
| 326 |
-
8. Never use "SPEAKER_00" or "SPEAKER_01" β always "you" and "the other person".
|
| 327 |
-
9. Output valid JSON only β no markdown, no code fences, no extra text.
|
| 328 |
|
| 329 |
Output this exact JSON:
|
| 330 |
{{
|
| 331 |
-
"
|
| 332 |
-
"
|
| 333 |
-
"summary_sentence": "One direct sentence on the overall communication pattern.",
|
| 334 |
"observations": [
|
| 335 |
{{
|
| 336 |
"signal": "signal_name",
|
| 337 |
-
"observation": "Specific observation grounded in
|
| 338 |
-
"resonance_prompt": "A genuine reflective question."
|
| 339 |
}},
|
| 340 |
{{
|
| 341 |
-
"signal": "
|
| 342 |
-
"observation": "
|
| 343 |
-
"resonance_prompt": "
|
| 344 |
}},
|
| 345 |
{{
|
| 346 |
-
"signal": "
|
| 347 |
-
"observation": "Third observation
|
| 348 |
"resonance_prompt": "Third reflective question."
|
| 349 |
}}
|
| 350 |
],
|
|
@@ -352,14 +284,14 @@ Output this exact JSON:
|
|
| 352 |
{{
|
| 353 |
"priority": 1,
|
| 354 |
"area": "area name",
|
| 355 |
-
"issue": "
|
| 356 |
"suggestion": "Exactly what to do differently next time.",
|
| 357 |
-
"why_it_matters": "Why this
|
| 358 |
}},
|
| 359 |
{{
|
| 360 |
"priority": 2,
|
| 361 |
"area": "different area",
|
| 362 |
-
"issue": "Second issue.",
|
| 363 |
"suggestion": "Second actionable suggestion.",
|
| 364 |
"why_it_matters": "Why it matters."
|
| 365 |
}},
|
|
@@ -371,19 +303,14 @@ Output this exact JSON:
|
|
| 371 |
"why_it_matters": "Why it matters."
|
| 372 |
}}
|
| 373 |
],
|
| 374 |
-
"
|
| 375 |
-
|
| 376 |
-
"relational_dynamics": "2 sentences on how the two speakers connected or didn't.",
|
| 377 |
-
"communication_effectiveness": "2 sentences on how effectively they communicated.",
|
| 378 |
-
"conversation_arc": "2 sentences on how the conversation evolved."
|
| 379 |
-
}},
|
| 380 |
-
"notable_pattern": "The single most interesting behavioral pattern from this session. One sentence.",
|
| 381 |
"data_confidence": "high"
|
| 382 |
}}"""
|
| 383 |
|
| 384 |
def generate_reflection_questions(self, signals: dict, insights: dict,
|
| 385 |
transcript_text: str, context: str) -> list:
|
| 386 |
-
prompt = self._build_reflection_prompt(
|
| 387 |
try:
|
| 388 |
response = self.client.chat.completions.create(
|
| 389 |
model="llama-3.3-70b-versatile",
|
|
@@ -395,48 +322,45 @@ Output this exact JSON:
|
|
| 395 |
except Exception:
|
| 396 |
return []
|
| 397 |
|
| 398 |
-
def _build_reflection_prompt(self,
|
| 399 |
-
transcript_text: str, context: str) -> str:
|
| 400 |
-
coaching_areas = [c.get("area", "") for c in insights.get("coaching_suggestions", [])[:3]]
|
| 401 |
observations_text = "\n".join(
|
| 402 |
f"- {o['observation']}" for o in insights.get("observations", [])[:3]
|
| 403 |
)
|
| 404 |
-
|
| 405 |
-
|
| 406 |
|
| 407 |
-
return f"""You are a behavioral coach. A person just finished a {context.replace(
|
| 408 |
-
Generate 3 reflection questions grounded in what actually happened
|
| 409 |
|
| 410 |
-
|
| 411 |
-
- Talk ratio: {talkpct}% (you spoke)
|
| 412 |
-
- Filler rate: {fillers}/100 words
|
| 413 |
-
- Coaching areas flagged: {", ".join(coaching_areas)}
|
| 414 |
|
| 415 |
Key observations:
|
| 416 |
{observations_text}
|
| 417 |
|
|
|
|
|
|
|
| 418 |
Transcript sample:
|
| 419 |
-
{transcript_text[:
|
| 420 |
|
| 421 |
Rules:
|
| 422 |
-
1. Each question must be specific to THIS conversation β not generic self-improvement advice
|
| 423 |
-
2. The answer explains what
|
| 424 |
-
3. Reference specific moments or patterns from the
|
| 425 |
-
4. Write directly to the person ("you", "your")
|
|
|
|
| 426 |
|
| 427 |
-
Output valid JSON only β no markdown, no code fences:
|
| 428 |
[
|
| 429 |
{{
|
| 430 |
-
"question": "
|
| 431 |
-
"answer": "2-3 sentences
|
| 432 |
}},
|
| 433 |
{{
|
| 434 |
-
"question": "Second
|
| 435 |
-
"answer": "2-3 sentences
|
| 436 |
}},
|
| 437 |
{{
|
| 438 |
"question": "Third question β surface something they might not have noticed.",
|
| 439 |
-
"answer": "2-3 sentences
|
| 440 |
}}
|
| 441 |
]"""
|
| 442 |
|
|
@@ -464,12 +388,11 @@ Output valid JSON only β no markdown, no code fences:
|
|
| 464 |
return json.loads(clean.strip())
|
| 465 |
except Exception:
|
| 466 |
return {
|
| 467 |
-
"
|
| 468 |
-
"
|
| 469 |
-
"summary_sentence": "Session analyzed successfully.",
|
| 470 |
"observations": [],
|
| 471 |
"coaching_suggestions": [],
|
| 472 |
-
"dimension_narrative": {},
|
| 473 |
"notable_pattern": None,
|
| 474 |
-
"
|
|
|
|
| 475 |
}
|
|
|
|
| 1 |
import json
|
| 2 |
from groq import Groq
|
| 3 |
|
| 4 |
+
|
| 5 |
CONTEXT_COACHING_GUIDE = {
|
| 6 |
"social": (
|
| 7 |
+
"Casual, low-stakes social conversation. Focus on rapport, warmth, and reciprocal "
|
| 8 |
+
"engagement. Avoid applying professional communication standards."
|
|
|
|
| 9 |
),
|
| 10 |
"collaborative": (
|
| 11 |
+
"Collaborative working conversation (meeting, brainstorming, planning). "
|
| 12 |
+
"Balanced airtime, turn-taking, and mutual engagement matter most."
|
|
|
|
|
|
|
| 13 |
),
|
| 14 |
"evaluative": (
|
| 15 |
+
"Evaluative conversation β interview, presentation, or assessment. "
|
| 16 |
+
"Confidence, directness, and concise responses signal competence. "
|
| 17 |
+
"Evaluators expect dialogue, not lectures."
|
|
|
|
| 18 |
),
|
| 19 |
"influential": (
|
| 20 |
+
"Persuasion or influence conversation (sales, pitch). Real influence "
|
| 21 |
+
"requires dialogue β monologuing is a red flag. Questions should draw out needs."
|
|
|
|
|
|
|
| 22 |
),
|
| 23 |
"negotiation": (
|
| 24 |
+
"Negotiation β competing interests seeking compromise. Balanced airtime, "
|
| 25 |
+
"strategic pauses, and space for both sides to present their position matter."
|
|
|
|
|
|
|
| 26 |
),
|
| 27 |
"adversarial": (
|
| 28 |
+
"Conflict or disagreement. Listening quality and interruption patterns are "
|
| 29 |
+
"critical. High talk ratio by one person usually means the other isn't being heard."
|
|
|
|
|
|
|
| 30 |
),
|
| 31 |
"developmental": (
|
| 32 |
+
"Coaching, mentoring, or feedback conversation. The coach should talk less, "
|
| 33 |
+
"not more. Question quality and space for reflection matter most."
|
|
|
|
|
|
|
| 34 |
),
|
| 35 |
"support": (
|
| 36 |
+
"Emotional support conversation. Presence over performance β rushing to respond "
|
| 37 |
+
"prevents real empathy. Listening ratio and open questions are key."
|
|
|
|
|
|
|
| 38 |
),
|
| 39 |
"intimate": (
|
| 40 |
+
"Psychologically intimate conversation. Balance of vulnerability between both "
|
| 41 |
+
"speakers, thoughtful pauses, and no interruptions matter deeply."
|
|
|
|
|
|
|
|
|
|
| 42 |
),
|
| 43 |
}
|
| 44 |
|
|
|
|
| 55 |
prompt = self._build_prompt(
|
| 56 |
structured_input,
|
| 57 |
transcript_text,
|
|
|
|
|
|
|
| 58 |
context,
|
| 59 |
user_baseline,
|
| 60 |
session_history or [],
|
| 61 |
resonance_calibration or {},
|
| 62 |
conversation_types or [context],
|
| 63 |
)
|
|
|
|
| 64 |
response = self.client.chat.completions.create(
|
| 65 |
model="llama-3.3-70b-versatile",
|
| 66 |
messages=[{"role": "user", "content": prompt}],
|
| 67 |
temperature=0.3,
|
| 68 |
+
max_tokens=3000,
|
| 69 |
)
|
|
|
|
| 70 |
return self._parse_output(response.choices[0].message.content)
|
| 71 |
|
| 72 |
def _prepare_input(self, signals: dict, context: str, baseline: dict) -> dict:
|
|
|
|
| 75 |
"session_duration_minutes": round(signals["session_duration_s"] / 60, 1),
|
| 76 |
"talk_ratio_user": signals["talk_ratio"]["user_ratio"],
|
| 77 |
"speech_rate_wpm": signals["speech_rate"]["overall_wpm"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"filler_rate_per_100_words": signals["filler_words"]["rate_per_100_words"],
|
| 79 |
"top_filler_words": list(signals["filler_words"]["breakdown"].keys())[:3],
|
| 80 |
+
"user_interrupted_other": signals["interruptions"]["user_interrupted_other"],
|
| 81 |
+
"user_was_interrupted": signals["interruptions"]["user_was_interrupted"],
|
| 82 |
+
"longest_monologue_s": signals["monologue"]["longest_turn_s"],
|
|
|
|
|
|
|
| 83 |
"user_questions_asked": signals["questions"]["user_questions_asked"],
|
| 84 |
"other_questions_asked": signals["questions"]["other_questions_asked"],
|
| 85 |
+
"avg_response_latency_s": signals["pauses"]["response_latency"]["mean_s"],
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
if baseline:
|
|
|
|
| 108 |
"context_avg": round(baseline["avg_filler_rate"], 2),
|
| 109 |
"delta_pct": _delta_pct(prepared["filler_rate_per_100_words"], baseline["avg_filler_rate"]),
|
| 110 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
"talk_ratio": {
|
| 112 |
"current": prepared["talk_ratio_user"],
|
| 113 |
"context_avg": round(baseline["avg_talk_ratio"], 3),
|
| 114 |
"delta_pp": round(prepared["talk_ratio_user"] - baseline["avg_talk_ratio"], 3),
|
| 115 |
},
|
| 116 |
}
|
|
|
|
| 117 |
elif source == "population_norm":
|
| 118 |
norms = baseline["norms"]
|
| 119 |
|
|
|
|
| 123 |
lo, hi, note = norms[key]
|
| 124 |
return (lo <= val <= hi), (round(lo * scale), round(hi * scale)), note
|
| 125 |
|
| 126 |
+
tr_in, tr_range, _ = _norm_check(prepared["talk_ratio_user"], "avg_talk_ratio", scale=100)
|
| 127 |
+
fr_in, fr_range, _ = _norm_check(prepared["filler_rate_per_100_words"], "avg_filler_rate")
|
|
|
|
|
|
|
| 128 |
|
| 129 |
prepared["baseline_comparison"] = {
|
| 130 |
"type": "population_norm",
|
| 131 |
"context": baseline["context"],
|
| 132 |
+
"talk_ratio": {"current_pct": round(prepared["talk_ratio_user"] * 100),
|
| 133 |
+
"expected_range": tr_range, "within_norm": tr_in},
|
| 134 |
+
"filler_rate": {"current": prepared["filler_rate_per_100_words"],
|
| 135 |
+
"expected_range": fr_range, "within_norm": fr_in},
|
| 136 |
}
|
| 137 |
|
| 138 |
return prepared
|
| 139 |
|
| 140 |
def _build_prompt(self, data: dict, transcript_text: str,
|
|
|
|
| 141 |
context: str, baseline: dict,
|
| 142 |
+
session_history: list,
|
| 143 |
+
resonance_calibration: dict,
|
| 144 |
+
conversation_types: list) -> str:
|
| 145 |
+
|
| 146 |
+
coaching_guide = CONTEXT_COACHING_GUIDE.get(context, CONTEXT_COACHING_GUIDE["social"])
|
| 147 |
+
|
| 148 |
+
# ββ Transcript (PRIMARY) ββββββββββββββββββββββββββββββββββββββ
|
| 149 |
+
transcript_section = ""
|
| 150 |
+
if transcript_text:
|
| 151 |
+
transcript_section = f"""
|
| 152 |
+
CONVERSATION TRANSCRIPT:
|
| 153 |
+
{transcript_text}
|
| 154 |
+
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
# ββ Supporting signals ββββββββββββββββββββββββββββββββββββββββ
|
| 158 |
+
fillers_str = ", ".join(data["top_filler_words"]) if data["top_filler_words"] else "none detected"
|
| 159 |
+
signals_section = f"""SUPPORTING DATA (use to ground observations β do not lead with these):
|
| 160 |
+
You spoke: {round(data['talk_ratio_user'] * 100)}% of the time
|
| 161 |
+
Speech rate: {data['speech_rate_wpm']} wpm
|
| 162 |
+
Filler words: {data['filler_rate_per_100_words']}/100 words (top: {fillers_str})
|
| 163 |
+
Interruptions you gave: {data['user_interrupted_other']} | received: {data['user_was_interrupted']}
|
| 164 |
+
Longest unbroken stretch: {data['longest_monologue_s']}s
|
| 165 |
+
Questions you asked: {data['user_questions_asked']} | they asked: {data['other_questions_asked']}
|
| 166 |
+
Response latency: {data['avg_response_latency_s']}s avg
|
| 167 |
+
Duration: {data['session_duration_minutes']} minutes
|
| 168 |
+
"""
|
| 169 |
|
| 170 |
+
# ββ Baseline ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 171 |
baseline_section = ""
|
| 172 |
if "baseline_comparison" in data:
|
| 173 |
b = data["baseline_comparison"]
|
| 174 |
btype = b.get("type")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if btype == "personal_context":
|
| 176 |
n = b["session_count"]
|
| 177 |
ctx = b["context"].replace("_", " ")
|
| 178 |
+
sr, fr, tr = b["speech_rate"], b["filler_rate"], b["talk_ratio"]
|
| 179 |
+
|
| 180 |
+
def _fmt(delta, unit=""):
|
| 181 |
+
if delta is None:
|
| 182 |
+
return "no change"
|
| 183 |
+
return f"{'+'if delta > 0 else ''}{delta}{unit}"
|
| 184 |
+
|
| 185 |
baseline_section = f"""
|
| 186 |
+
YOUR {ctx.upper()} BASELINE ({n} previous sessions):
|
| 187 |
+
Speech rate: {sr['current']} wpm vs your avg {sr['context_avg']} wpm ({_fmt(sr['delta_pct'], '%')})
|
| 188 |
+
Filler rate: {fr['current']}/100w vs your avg {fr['context_avg']}/100w ({_fmt(fr['delta_pct'], '%')})
|
| 189 |
+
Talk ratio: {round(tr['current']*100)}% vs your avg {round(tr['context_avg']*100)}% ({_fmt(tr['delta_pp']*100, 'pp')})
|
|
|
|
|
|
|
| 190 |
"""
|
|
|
|
| 191 |
elif btype == "population_norm":
|
| 192 |
ctx = b["context"].replace("_", " ")
|
| 193 |
+
tr, fr = b["talk_ratio"], b["filler_rate"]
|
| 194 |
+
mark = lambda v: "β" if v else "β"
|
| 195 |
+
lines = []
|
| 196 |
+
if tr["expected_range"]:
|
| 197 |
+
lo, hi = tr["expected_range"]
|
| 198 |
+
lines.append(f" Talk ratio: {tr['current_pct']}% (expected {lo}β{hi}%) {mark(tr['within_norm'])}")
|
| 199 |
+
if fr["expected_range"]:
|
| 200 |
+
lo, hi = fr["expected_range"]
|
| 201 |
+
lines.append(f" Filler rate: {fr['current']}/100w (expected {lo}β{hi}/100w) {mark(fr['within_norm'])}")
|
| 202 |
+
if lines:
|
| 203 |
+
baseline_section = f"""
|
| 204 |
+
CONTEXT NORMS FOR {ctx.upper()}:
|
| 205 |
+
Do NOT flag β signals as issues. Coach only on β signals.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
{chr(10).join(lines)}
|
| 207 |
"""
|
| 208 |
|
| 209 |
+
# ββ Session history (fingerprints) ββββββββββββββββββββββββββββ
|
| 210 |
history_section = ""
|
| 211 |
if session_history:
|
| 212 |
n = len(session_history)
|
| 213 |
+
lines = []
|
| 214 |
+
for i, s in enumerate(session_history):
|
| 215 |
+
ctx = s["context"].replace("_", " ")
|
| 216 |
+
fp = s.get("fingerprint") or s.get("summary", "")
|
| 217 |
+
if fp:
|
| 218 |
+
lines.append(f" Session {i+1} ({s['date']}, {ctx}):\n {fp}")
|
| 219 |
+
if lines:
|
| 220 |
+
history_section = f"""
|
| 221 |
+
PAST SESSION FINGERPRINTS β this is session {n + 1} for this user.
|
| 222 |
+
Use these to identify what's recurring vs what's new in this conversation.
|
| 223 |
+
{chr(10).join(lines)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
"""
|
| 225 |
|
| 226 |
+
# ββ Resonance calibration βββββββββββββββββββββββββββββββββββββ
|
| 227 |
resonance_section = ""
|
| 228 |
if resonance_calibration:
|
| 229 |
avoid = resonance_calibration.get("avoid", [])
|
| 230 |
emphasize = resonance_calibration.get("emphasize", [])
|
| 231 |
parts = []
|
| 232 |
if avoid:
|
| 233 |
+
parts.append(f" AVOID strong claims about: {', '.join(avoid)}")
|
| 234 |
if emphasize:
|
| 235 |
parts.append(f" PRIORITIZE observations about: {', '.join(emphasize)}")
|
| 236 |
if parts:
|
| 237 |
resonance_section = "\nUSER FEEDBACK CALIBRATION:\n" + "\n".join(parts) + "\n"
|
| 238 |
|
| 239 |
+
types_str = ", ".join(conversation_types)
|
| 240 |
+
|
| 241 |
+
return f"""You are a behavioral communication coach. Read the conversation transcript below and provide a deep, specific analysis.
|
| 242 |
+
|
| 243 |
+
CONTEXT: {types_str} β {coaching_guide}
|
| 244 |
+
{transcript_section}{signals_section}{baseline_section}{history_section}{resonance_section}
|
| 245 |
+
WHAT TO LOOK FOR (search the transcript specifically for these):
|
| 246 |
+
1. Questions the other person asked that you didn't fully or directly answer
|
| 247 |
+
2. Opinions, concerns, or points they raised that you talked past or didn't acknowledge
|
| 248 |
+
3. Moments where you spoke for an extended stretch without giving them space to respond
|
| 249 |
+
4. Interruptions β where you cut in before they finished their thought
|
| 250 |
+
5. Moments where you seemed to misread what was being asked and answered something else
|
| 251 |
+
|
| 252 |
+
RULES:
|
| 253 |
+
1. Ground every observation in something that actually happened in the transcript
|
| 254 |
+
2. Quote directly (in "quotes") when it makes the point concrete β be specific about where in the conversation
|
| 255 |
+
3. Never refer to speakers as SPEAKER_00 or SPEAKER_01 β use "you" and "the other person"
|
| 256 |
+
4. Write directly to the user ("you", "your")
|
| 257 |
+
5. Be specific and honest β no vague generalities
|
| 258 |
+
6. Don't flag signals that are within normal range for this context type
|
| 259 |
+
7. If session history exists, explicitly note what's recurring vs what's different this time
|
| 260 |
+
8. Output valid JSON only β no markdown, no code fences
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
Output this exact JSON:
|
| 263 |
{{
|
| 264 |
+
"conversation_summary": "3-4 sentences. What was this conversation actually about? What happened? What was the tone and dynamic? Reference specific things said.",
|
| 265 |
+
"summary_sentence": "One direct sentence capturing the dominant behavioral pattern in this conversation.",
|
|
|
|
| 266 |
"observations": [
|
| 267 |
{{
|
| 268 |
"signal": "signal_name",
|
| 269 |
+
"observation": "Specific observation grounded in what actually happened. Quote from transcript where it makes the point. Reference where in the conversation it occurred.",
|
| 270 |
+
"resonance_prompt": "A genuine reflective question specific to this moment β not generic."
|
| 271 |
}},
|
| 272 |
{{
|
| 273 |
+
"signal": "different_signal",
|
| 274 |
+
"observation": "Second observation on a different aspect of the conversation.",
|
| 275 |
+
"resonance_prompt": "Second reflective question."
|
| 276 |
}},
|
| 277 |
{{
|
| 278 |
+
"signal": "third_signal",
|
| 279 |
+
"observation": "Third observation.",
|
| 280 |
"resonance_prompt": "Third reflective question."
|
| 281 |
}}
|
| 282 |
],
|
|
|
|
| 284 |
{{
|
| 285 |
"priority": 1,
|
| 286 |
"area": "area name",
|
| 287 |
+
"issue": "What specifically happened in this conversation β quote or reference the moment.",
|
| 288 |
"suggestion": "Exactly what to do differently next time.",
|
| 289 |
+
"why_it_matters": "Why this matters in this type of conversation."
|
| 290 |
}},
|
| 291 |
{{
|
| 292 |
"priority": 2,
|
| 293 |
"area": "different area",
|
| 294 |
+
"issue": "Second specific issue from this conversation.",
|
| 295 |
"suggestion": "Second actionable suggestion.",
|
| 296 |
"why_it_matters": "Why it matters."
|
| 297 |
}},
|
|
|
|
| 303 |
"why_it_matters": "Why it matters."
|
| 304 |
}}
|
| 305 |
],
|
| 306 |
+
"notable_pattern": "The single most interesting or surprising behavioral pattern from this session. One sentence.",
|
| 307 |
+
"fingerprint": "200-300 words. Describe what was observed in this {context.replace('_', ' ')} conversation: how the person engaged, how they handled questions directed at them, what they avoided, how they responded under pressure or when challenged, their listening patterns, whether they gave the other person space. Write in behavioral terms β no raw numbers. This will be used as context in future AI sessions to understand this person's patterns.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
"data_confidence": "high"
|
| 309 |
}}"""
|
| 310 |
|
| 311 |
def generate_reflection_questions(self, signals: dict, insights: dict,
|
| 312 |
transcript_text: str, context: str) -> list:
|
| 313 |
+
prompt = self._build_reflection_prompt(insights, transcript_text, context)
|
| 314 |
try:
|
| 315 |
response = self.client.chat.completions.create(
|
| 316 |
model="llama-3.3-70b-versatile",
|
|
|
|
| 322 |
except Exception:
|
| 323 |
return []
|
| 324 |
|
| 325 |
+
def _build_reflection_prompt(self, insights: dict, transcript_text: str, context: str) -> str:
|
|
|
|
|
|
|
| 326 |
observations_text = "\n".join(
|
| 327 |
f"- {o['observation']}" for o in insights.get("observations", [])[:3]
|
| 328 |
)
|
| 329 |
+
coaching_areas = [c.get("area") for c in insights.get("coaching_suggestions", [])[:2] if c.get("area")]
|
| 330 |
+
summary = insights.get("conversation_summary", "")
|
| 331 |
|
| 332 |
+
return f"""You are a behavioral coach. A person just finished a {context.replace('_', ' ')} conversation.
|
| 333 |
+
Generate 3 reflection questions grounded in what actually happened.
|
| 334 |
|
| 335 |
+
Conversation summary: {summary}
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
Key observations:
|
| 338 |
{observations_text}
|
| 339 |
|
| 340 |
+
Coaching areas flagged: {", ".join(coaching_areas) if coaching_areas else "none"}
|
| 341 |
+
|
| 342 |
Transcript sample:
|
| 343 |
+
{transcript_text[:800] if transcript_text else "Not available"}
|
| 344 |
|
| 345 |
Rules:
|
| 346 |
+
1. Each question must be specific to THIS conversation β not generic self-improvement advice
|
| 347 |
+
2. The answer explains what was observed and what it might mean. 2-3 sentences.
|
| 348 |
+
3. Reference specific moments or patterns from the transcript where possible
|
| 349 |
+
4. Write directly to the person ("you", "your")
|
| 350 |
+
5. Output valid JSON only β no markdown, no code fences
|
| 351 |
|
|
|
|
| 352 |
[
|
| 353 |
{{
|
| 354 |
+
"question": "Specific reflective question grounded in this conversation.",
|
| 355 |
+
"answer": "2-3 sentences on what was observed and what it might mean."
|
| 356 |
}},
|
| 357 |
{{
|
| 358 |
+
"question": "Second question addressing a different aspect.",
|
| 359 |
+
"answer": "2-3 sentences."
|
| 360 |
}},
|
| 361 |
{{
|
| 362 |
"question": "Third question β surface something they might not have noticed.",
|
| 363 |
+
"answer": "2-3 sentences."
|
| 364 |
}}
|
| 365 |
]"""
|
| 366 |
|
|
|
|
| 388 |
return json.loads(clean.strip())
|
| 389 |
except Exception:
|
| 390 |
return {
|
| 391 |
+
"conversation_summary": "Session analyzed.",
|
| 392 |
+
"summary_sentence": "Session analyzed.",
|
|
|
|
| 393 |
"observations": [],
|
| 394 |
"coaching_suggestions": [],
|
|
|
|
| 395 |
"notable_pattern": None,
|
| 396 |
+
"fingerprint": None,
|
| 397 |
+
"data_confidence": "low",
|
| 398 |
}
|
backend/pipeline/mirror_feed_synthesizer.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from groq import Groq
|
| 3 |
+
|
| 4 |
+
VALID_TYPES = {"pattern", "context_contrast", "trend_up", "trend_down"}
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class MirrorFeedSynthesizer:
|
| 8 |
+
def __init__(self, api_key: str):
|
| 9 |
+
self.client = Groq(api_key=api_key)
|
| 10 |
+
|
| 11 |
+
def synthesize(self, sessions: list, user_summary: str = None) -> list:
|
| 12 |
+
"""
|
| 13 |
+
sessions: list of dicts with keys:
|
| 14 |
+
context, date, fingerprint (str), notable_pattern (str)
|
| 15 |
+
user_summary: consolidated behavioral summary (present after 12+ sessions)
|
| 16 |
+
Returns list of mirror feed insight dicts, empty if < 2 usable sessions.
|
| 17 |
+
"""
|
| 18 |
+
usable = [
|
| 19 |
+
s for s in sessions
|
| 20 |
+
if s.get("fingerprint") or s.get("notable_pattern")
|
| 21 |
+
]
|
| 22 |
+
if not user_summary and len(usable) < 2:
|
| 23 |
+
return []
|
| 24 |
+
|
| 25 |
+
prompt = (
|
| 26 |
+
self._build_consolidated_prompt(user_summary, usable)
|
| 27 |
+
if user_summary
|
| 28 |
+
else self._build_prompt(usable)
|
| 29 |
+
)
|
| 30 |
+
try:
|
| 31 |
+
response = self.client.chat.completions.create(
|
| 32 |
+
model="llama-3.3-70b-versatile",
|
| 33 |
+
messages=[{"role": "user", "content": prompt}],
|
| 34 |
+
temperature=0.3,
|
| 35 |
+
max_tokens=1200,
|
| 36 |
+
)
|
| 37 |
+
return self._parse(response.choices[0].message.content)
|
| 38 |
+
except Exception:
|
| 39 |
+
return []
|
| 40 |
+
|
| 41 |
+
def _build_consolidated_prompt(self, user_summary: str, recent_sessions: list) -> str:
|
| 42 |
+
recent_block = ""
|
| 43 |
+
if recent_sessions:
|
| 44 |
+
recent_block = "\nMOST RECENT SESSIONS:\n"
|
| 45 |
+
for s in recent_sessions:
|
| 46 |
+
content = (s.get("fingerprint") or s.get("notable_pattern") or "").strip()
|
| 47 |
+
ctx = s["context"].replace("_", " ")
|
| 48 |
+
if content:
|
| 49 |
+
recent_block += f" {s['date']} ({ctx}): {content}\n"
|
| 50 |
+
|
| 51 |
+
return f"""You are identifying behavioral patterns for a person with an established behavioral history.
|
| 52 |
+
|
| 53 |
+
CONSOLIDATED BEHAVIORAL PROFILE (built from all past sessions):
|
| 54 |
+
{user_summary}
|
| 55 |
+
{recent_block}
|
| 56 |
+
TASK: Write 2β4 cross-session insights that reflect this person's established patterns and any recent shifts.
|
| 57 |
+
|
| 58 |
+
BEHAVIORAL DIMENSIONS TO DRAW FROM (pick different ones for each insight):
|
| 59 |
+
- Airtime & talk ratio β how much space they take vs. give
|
| 60 |
+
- Listening & acknowledgment β whether they receive and respond to what was said
|
| 61 |
+
- Emotional tone & composure β nervousness, reactivity, warmth
|
| 62 |
+
- Clarity & structure β how organized and clear their speech is
|
| 63 |
+
- Filler words & delivery confidence
|
| 64 |
+
- Adaptability β how they adjust when the context or conversation shifts
|
| 65 |
+
- Turn-taking & interruptions β how they manage conversation flow
|
| 66 |
+
- Trend over time β how early sessions compare to recent ones
|
| 67 |
+
- Context-specific vs universal tendencies
|
| 68 |
+
|
| 69 |
+
RULES:
|
| 70 |
+
1. Ground every insight in what the behavioral profile actually shows β no generic observations
|
| 71 |
+
2. CRITICAL β each insight must address a DIFFERENT behavioral dimension from the list above. If one insight covers communication dominance, the others must come from genuinely different dimensions (emotional tone, clarity, adaptation, trend over time, etc.). Never rephrase the same root behavior as multiple separate insights.
|
| 72 |
+
3. If only one or two strong patterns exist, write 2 insights β a focused feed beats padded repetition
|
| 73 |
+
4. If recent sessions differ from the established profile, name the shift explicitly
|
| 74 |
+
5. Write directly to the person ("you", "your")
|
| 75 |
+
6. Be specific and honest β the person has enough history for direct, confident observations
|
| 76 |
+
7. Output valid JSON only β no markdown, no code fences
|
| 77 |
+
|
| 78 |
+
Output a JSON array of 2β4 objects:
|
| 79 |
+
[
|
| 80 |
+
{{
|
| 81 |
+
"type": "pattern",
|
| 82 |
+
"text": "2-3 sentences. Specific observation grounded in the behavioral history.",
|
| 83 |
+
"tip": "One sentence. A concrete, actionable thing to try β include a brief example scenario so the person can picture it.",
|
| 84 |
+
"signal": "short_unique_slug"
|
| 85 |
+
}}
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
type must be exactly one of:
|
| 89 |
+
- "pattern" β behavior consistent across the history
|
| 90 |
+
- "context_contrast" β clear difference in behavior between contexts
|
| 91 |
+
- "trend_up" β something that has improved over time or in recent sessions
|
| 92 |
+
- "trend_down" β something that has declined or worsened"""
|
| 93 |
+
|
| 94 |
+
def _build_prompt(self, sessions: list) -> str:
|
| 95 |
+
n = len(sessions)
|
| 96 |
+
|
| 97 |
+
if n <= 3:
|
| 98 |
+
depth_note = (
|
| 99 |
+
"you have limited data β keep observations tentative. "
|
| 100 |
+
"Describe what you have noticed so far, not established patterns."
|
| 101 |
+
)
|
| 102 |
+
elif n <= 7:
|
| 103 |
+
depth_note = "patterns are beginning to emerge. Describe them with moderate confidence."
|
| 104 |
+
else:
|
| 105 |
+
depth_note = (
|
| 106 |
+
"you have enough data to describe established behavioral tendencies "
|
| 107 |
+
"with confidence."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
ctx_map: dict = {}
|
| 111 |
+
for s in sessions:
|
| 112 |
+
ctx_map.setdefault(s["context"], []).append(s)
|
| 113 |
+
|
| 114 |
+
n_contexts = len(ctx_map)
|
| 115 |
+
|
| 116 |
+
blocks = []
|
| 117 |
+
for ctx, ctx_sessions in ctx_map.items():
|
| 118 |
+
label = ctx.replace("_", " ").title()
|
| 119 |
+
block = f"{label} ({len(ctx_sessions)} session{'s' if len(ctx_sessions) > 1 else ''}):\n"
|
| 120 |
+
for s in ctx_sessions:
|
| 121 |
+
content = (s.get("fingerprint") or s.get("notable_pattern") or "").strip()
|
| 122 |
+
if content:
|
| 123 |
+
block += f" {s['date']}: {content}\n"
|
| 124 |
+
blocks.append(block)
|
| 125 |
+
|
| 126 |
+
context_diff_line = (
|
| 127 |
+
f"- How their behavior differs between the {n_contexts} contexts they have recorded in\n"
|
| 128 |
+
if n_contexts >= 2 else ""
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return f"""You are identifying behavioral patterns across {n} conversations recorded by the same person.
|
| 132 |
+
|
| 133 |
+
SESSIONS BY CONTEXT:
|
| 134 |
+
{''.join(blocks)}
|
| 135 |
+
TASK: Write 2β4 cross-session insights. With {n} sessions, {depth_note}
|
| 136 |
+
|
| 137 |
+
BEHAVIORAL DIMENSIONS TO DRAW FROM (pick different ones for each insight):
|
| 138 |
+
- Airtime & talk ratio β how much space they take vs. give
|
| 139 |
+
- Listening & acknowledgment β whether they receive and respond to what was said
|
| 140 |
+
- Emotional tone & composure β nervousness, reactivity, warmth
|
| 141 |
+
- Clarity & structure β how organized and clear their speech is
|
| 142 |
+
- Filler words & delivery confidence
|
| 143 |
+
- Adaptability β how they adjust when the context or conversation shifts
|
| 144 |
+
- Turn-taking & interruptions β how they manage conversation flow
|
| 145 |
+
- Trend over time β how early sessions compare to recent ones
|
| 146 |
+
{context_diff_line}
|
| 147 |
+
RULES:
|
| 148 |
+
1. Every insight must require multiple sessions to observe β nothing you could say from one conversation alone
|
| 149 |
+
2. CRITICAL β each insight must address a DIFFERENT behavioral dimension from the list above. If airtime/dominance is one insight, the others must come from different dimensions (e.g. emotional tone, clarity, trend over time). Never write two insights that are both about the same root behavior rephrased differently.
|
| 150 |
+
3. If you can only find one strong pattern, write 2 insights total β a focused feed beats padded repetition
|
| 151 |
+
4. Be specific β reference what was actually observed, not generic advice
|
| 152 |
+
5. Write directly to the person ("you", "your")
|
| 153 |
+
6. With few sessions, be honest about uncertainty β say "across your sessions so far" not "you always"
|
| 154 |
+
7. Never refer to SPEAKER_00 or SPEAKER_01 β use "you" and "the other person"
|
| 155 |
+
8. Output valid JSON only β no markdown, no code fences, no extra text
|
| 156 |
+
|
| 157 |
+
Output a JSON array of 2β4 objects:
|
| 158 |
+
[
|
| 159 |
+
{{
|
| 160 |
+
"type": "pattern",
|
| 161 |
+
"text": "2-3 sentences. Specific cross-session observation written directly to the person. Ground it in what was actually observed across multiple sessions.",
|
| 162 |
+
"tip": "One sentence. A concrete, actionable thing to try β include a brief example scenario so the person can picture it.",
|
| 163 |
+
"signal": "short_unique_slug"
|
| 164 |
+
}}
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
type must be exactly one of:
|
| 168 |
+
- "pattern" β behavior that is consistent across multiple sessions
|
| 169 |
+
- "context_contrast" β behavior that noticeably differs between contexts (only use if 2+ contexts exist with a clear, observable difference)
|
| 170 |
+
- "trend_up" β something that has clearly improved from early sessions to recent ones
|
| 171 |
+
- "trend_down" β something that has declined or worsened from early to recent sessions"""
|
| 172 |
+
|
| 173 |
+
def _parse(self, raw: str) -> list:
|
| 174 |
+
try:
|
| 175 |
+
clean = raw.strip()
|
| 176 |
+
if "```" in clean:
|
| 177 |
+
clean = clean.split("```")[1]
|
| 178 |
+
if clean.startswith("json"):
|
| 179 |
+
clean = clean[4:]
|
| 180 |
+
data = json.loads(clean.strip())
|
| 181 |
+
if not isinstance(data, list):
|
| 182 |
+
return []
|
| 183 |
+
result, seen = [], set()
|
| 184 |
+
for item in data:
|
| 185 |
+
if not isinstance(item, dict):
|
| 186 |
+
continue
|
| 187 |
+
t = item.get("type", "pattern")
|
| 188 |
+
if t not in VALID_TYPES:
|
| 189 |
+
t = "pattern"
|
| 190 |
+
text = str(item.get("text", "")).strip()
|
| 191 |
+
tip = str(item.get("tip", "")).strip()
|
| 192 |
+
signal = str(item.get("signal", f"insight_{len(result)}")).strip()
|
| 193 |
+
if not text or signal in seen:
|
| 194 |
+
continue
|
| 195 |
+
seen.add(signal)
|
| 196 |
+
entry = {"type": t, "text": text, "signal": signal}
|
| 197 |
+
if tip:
|
| 198 |
+
entry["tip"] = tip
|
| 199 |
+
result.append(entry)
|
| 200 |
+
if len(result) >= 4:
|
| 201 |
+
break
|
| 202 |
+
return result
|
| 203 |
+
except Exception:
|
| 204 |
+
return []
|
backend/pipeline/personality_synthesizer.py
CHANGED
|
@@ -24,7 +24,6 @@ class PersonalitySynthesizer:
|
|
| 24 |
patterns = profile_data.get("patterns", [])
|
| 25 |
trends = profile_data.get("trends", [])
|
| 26 |
|
| 27 |
-
# Paragraph depth instruction scales with session count
|
| 28 |
if n <= 5:
|
| 29 |
depth_note = (
|
| 30 |
"You have limited data (few sessions). Keep the portrait observational β "
|
|
@@ -63,23 +62,19 @@ class PersonalitySynthesizer:
|
|
| 63 |
for k, v in dim_avgs.items():
|
| 64 |
dim_lines += f" {k}: {v}\n"
|
| 65 |
|
| 66 |
-
# Per-session log for grounding narratives in specific conversations
|
| 67 |
session_log = ""
|
| 68 |
if sessions_data:
|
| 69 |
-
session_log = "\nSESSION LOG (
|
| 70 |
for i, s in enumerate(sessions_data[-10:], 1):
|
| 71 |
scores = ", ".join(f"{k}={v}" for k, v in s.get("dim_scores", {}).items())
|
| 72 |
notable = s.get("notable_pattern", "")
|
| 73 |
notable_str = f' | pattern: "{notable}"' if notable else ""
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
if quotes:
|
| 77 |
-
q_list = " / ".join(f'"{q}"' for q in quotes[:2])
|
| 78 |
-
quotes_str = f'\n quotes: {q_list}'
|
| 79 |
session_log += (
|
| 80 |
f" {i}. {s['context']} | {scores} "
|
| 81 |
f"| fillers={s['filler_rate']}/100w | talk={s['talk_ratio_pct']}%"
|
| 82 |
-
f"{notable_str}{
|
| 83 |
)
|
| 84 |
|
| 85 |
return f"""You are building a behavioral personality portrait from {n} recorded conversations.
|
|
@@ -87,7 +82,7 @@ class PersonalitySynthesizer:
|
|
| 87 |
{depth_note}
|
| 88 |
{context_lines}{pattern_lines}{trend_lines}{dim_lines}{session_log}
|
| 89 |
|
| 90 |
-
TASK: Write a personality portrait paragraph
|
| 91 |
|
| 92 |
PARAGRAPH RULES:
|
| 93 |
1. 3β4 sentences maximum. Write as if describing this person to someone who knows them.
|
|
@@ -98,17 +93,13 @@ PARAGRAPH RULES:
|
|
| 98 |
5. Use "you" / "your" β address the person directly.
|
| 99 |
6. Never write "seems to" or "appears to" β be direct about observed patterns.
|
| 100 |
|
| 101 |
-
|
| 102 |
-
1.
|
| 103 |
-
2.
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
5. Keep it human β write like a perceptive coach, not an analyst.
|
| 109 |
-
6. If a session quote from the log illustrates the pattern, embed it inline using curly quotes β e.g.
|
| 110 |
-
'You tend to take charge β "let me explain how this works" is characteristic of how you steer conversations.'
|
| 111 |
-
Use at most one quote per narrative. Only use it if it genuinely supports the point.
|
| 112 |
|
| 113 |
SCORING β derive dimension scores (integers 0β100) from the averages:
|
| 114 |
- confidence: map confidence avg (1β5) β (avg-1)/4*100, reduce by 5pts per filler point above 3.0/100w
|
|
@@ -125,38 +116,34 @@ Output valid JSON only β no markdown, no code fences, no extra text:
|
|
| 125 |
"key": "confidence",
|
| 126 |
"name": "Confidence",
|
| 127 |
"score": <integer 0-100>,
|
| 128 |
-
"label": "1β3 word label matching the score"
|
| 129 |
-
"narrative": "2β3 sentences. References a specific session from the log. Explains what happened."
|
| 130 |
}},
|
| 131 |
{{
|
| 132 |
"key": "assertiveness",
|
| 133 |
"name": "Assertiveness",
|
| 134 |
"score": <integer 0-100>,
|
| 135 |
-
"label": "short label"
|
| 136 |
-
"narrative": "2β3 sentences referencing specific session(s)."
|
| 137 |
}},
|
| 138 |
{{
|
| 139 |
"key": "listening",
|
| 140 |
"name": "Listening Quality",
|
| 141 |
"score": <integer 0-100>,
|
| 142 |
-
"label": "short label"
|
| 143 |
-
"narrative": "2β3 sentences referencing specific session(s)."
|
| 144 |
}},
|
| 145 |
{{
|
| 146 |
"key": "composure",
|
| 147 |
"name": "Composure",
|
| 148 |
"score": <integer 0-100>,
|
| 149 |
-
"label": "short label"
|
| 150 |
-
"narrative": "2β3 sentences referencing specific session(s)."
|
| 151 |
}},
|
| 152 |
{{
|
| 153 |
"key": "clarity",
|
| 154 |
"name": "Communication Clarity",
|
| 155 |
"score": <integer 0-100>,
|
| 156 |
-
"label": "short label"
|
| 157 |
-
"narrative": "2β3 sentences referencing specific session(s)."
|
| 158 |
}}
|
| 159 |
-
]
|
|
|
|
| 160 |
}}"""
|
| 161 |
|
| 162 |
def _parse(self, raw: str, dim_avgs: dict) -> dict:
|
|
@@ -176,11 +163,12 @@ Output valid JSON only β no markdown, no code fences, no extra text:
|
|
| 176 |
|
| 177 |
return {
|
| 178 |
"paragraph": "Your behavioral profile is being built. Upload more sessions for deeper insights.",
|
|
|
|
| 179 |
"dimensions": [
|
| 180 |
-
{"key": "confidence", "name": "Confidence",
|
| 181 |
-
{"key": "assertiveness", "name": "Assertiveness",
|
| 182 |
-
{"key": "listening", "name": "Listening Quality",
|
| 183 |
-
{"key": "composure", "name": "Composure",
|
| 184 |
-
{"key": "clarity", "name": "Communication Clarity","score": _scale("clarity"),
|
| 185 |
],
|
| 186 |
}
|
|
|
|
| 24 |
patterns = profile_data.get("patterns", [])
|
| 25 |
trends = profile_data.get("trends", [])
|
| 26 |
|
|
|
|
| 27 |
if n <= 5:
|
| 28 |
depth_note = (
|
| 29 |
"You have limited data (few sessions). Keep the portrait observational β "
|
|
|
|
| 62 |
for k, v in dim_avgs.items():
|
| 63 |
dim_lines += f" {k}: {v}\n"
|
| 64 |
|
|
|
|
| 65 |
session_log = ""
|
| 66 |
if sessions_data:
|
| 67 |
+
session_log = "\nSESSION LOG (reference by context type when writing the shape narrative):\n"
|
| 68 |
for i, s in enumerate(sessions_data[-10:], 1):
|
| 69 |
scores = ", ".join(f"{k}={v}" for k, v in s.get("dim_scores", {}).items())
|
| 70 |
notable = s.get("notable_pattern", "")
|
| 71 |
notable_str = f' | pattern: "{notable}"' if notable else ""
|
| 72 |
+
fingerprint = s.get("fingerprint", "")
|
| 73 |
+
fp_str = f'\n behavioral summary: {fingerprint[:200]}β¦' if fingerprint else ""
|
|
|
|
|
|
|
|
|
|
| 74 |
session_log += (
|
| 75 |
f" {i}. {s['context']} | {scores} "
|
| 76 |
f"| fillers={s['filler_rate']}/100w | talk={s['talk_ratio_pct']}%"
|
| 77 |
+
f"{notable_str}{fp_str}\n"
|
| 78 |
)
|
| 79 |
|
| 80 |
return f"""You are building a behavioral personality portrait from {n} recorded conversations.
|
|
|
|
| 82 |
{depth_note}
|
| 83 |
{context_lines}{pattern_lines}{trend_lines}{dim_lines}{session_log}
|
| 84 |
|
| 85 |
+
TASK: Write a personality portrait paragraph, 5 dimension scores with labels, and one shape narrative.
|
| 86 |
|
| 87 |
PARAGRAPH RULES:
|
| 88 |
1. 3β4 sentences maximum. Write as if describing this person to someone who knows them.
|
|
|
|
| 93 |
5. Use "you" / "your" β address the person directly.
|
| 94 |
6. Never write "seems to" or "appears to" β be direct about observed patterns.
|
| 95 |
|
| 96 |
+
SHAPE NARRATIVE RULES (one paragraph shown below all 5 scores):
|
| 97 |
+
1. 3β4 sentences. Synthesize what the COMBINATION of all 5 scores reveals β not a summary of each one individually.
|
| 98 |
+
2. Focus on what the pattern of scores means together. High assertiveness + high listening together is unusual and worth naming. High composure + low confidence is a tension worth surfacing.
|
| 99 |
+
3. If one score is notably higher or lower than the others, explain what that asymmetry likely means behaviourally.
|
| 100 |
+
4. End with one concrete thing worth paying attention to, grounded in the overall score pattern.
|
| 101 |
+
5. Write directly to the person ("you", "your"). Sound like a perceptive coach, not an analyst.
|
| 102 |
+
6. Never list the dimension names. Synthesize them into a flowing, human observation.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
SCORING β derive dimension scores (integers 0β100) from the averages:
|
| 105 |
- confidence: map confidence avg (1β5) β (avg-1)/4*100, reduce by 5pts per filler point above 3.0/100w
|
|
|
|
| 116 |
"key": "confidence",
|
| 117 |
"name": "Confidence",
|
| 118 |
"score": <integer 0-100>,
|
| 119 |
+
"label": "1β3 word label matching the score"
|
|
|
|
| 120 |
}},
|
| 121 |
{{
|
| 122 |
"key": "assertiveness",
|
| 123 |
"name": "Assertiveness",
|
| 124 |
"score": <integer 0-100>,
|
| 125 |
+
"label": "short label"
|
|
|
|
| 126 |
}},
|
| 127 |
{{
|
| 128 |
"key": "listening",
|
| 129 |
"name": "Listening Quality",
|
| 130 |
"score": <integer 0-100>,
|
| 131 |
+
"label": "short label"
|
|
|
|
| 132 |
}},
|
| 133 |
{{
|
| 134 |
"key": "composure",
|
| 135 |
"name": "Composure",
|
| 136 |
"score": <integer 0-100>,
|
| 137 |
+
"label": "short label"
|
|
|
|
| 138 |
}},
|
| 139 |
{{
|
| 140 |
"key": "clarity",
|
| 141 |
"name": "Communication Clarity",
|
| 142 |
"score": <integer 0-100>,
|
| 143 |
+
"label": "short label"
|
|
|
|
| 144 |
}}
|
| 145 |
+
],
|
| 146 |
+
"shape_narrative": "3β4 sentences on what the combination of these 5 scores reveals. Name tensions, unusual pairings, and what the overall pattern means for how this person shows up."
|
| 147 |
}}"""
|
| 148 |
|
| 149 |
def _parse(self, raw: str, dim_avgs: dict) -> dict:
|
|
|
|
| 163 |
|
| 164 |
return {
|
| 165 |
"paragraph": "Your behavioral profile is being built. Upload more sessions for deeper insights.",
|
| 166 |
+
"shape_narrative": "",
|
| 167 |
"dimensions": [
|
| 168 |
+
{"key": "confidence", "name": "Confidence", "score": _scale("confidence"), "label": "Moderate"},
|
| 169 |
+
{"key": "assertiveness", "name": "Assertiveness", "score": _scale("assertiveness"), "label": "Moderate"},
|
| 170 |
+
{"key": "listening", "name": "Listening Quality", "score": _scale("listening_quality"), "label": "Moderate"},
|
| 171 |
+
{"key": "composure", "name": "Composure", "score": _scale("nervousness", invert=True), "label": "Moderate"},
|
| 172 |
+
{"key": "clarity", "name": "Communication Clarity", "score": _scale("clarity"), "label": "Moderate"},
|
| 173 |
],
|
| 174 |
}
|
backend/pipeline/transcriber.py
CHANGED
|
@@ -1,109 +1,77 @@
|
|
| 1 |
-
import whisper
|
| 2 |
-
import subprocess
|
| 3 |
import os
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
class Transcriber:
|
| 6 |
-
def __init__(self, model_size="base"):
|
| 7 |
-
print(f"Loading Whisper model: {model_size}")
|
| 8 |
-
self.model = whisper.load_model(model_size)
|
| 9 |
-
print("Whisper model loaded.")
|
| 10 |
-
|
| 11 |
-
def convert_to_wav(self, audio_path: str) -> str:
|
| 12 |
-
"""Convert any audio format to WAV using ffmpeg."""
|
| 13 |
-
wav_path = audio_path.replace(".m4a", ".wav").replace(".mp3", ".wav").replace(".mp4", ".wav")
|
| 14 |
-
if wav_path == audio_path:
|
| 15 |
-
wav_path = audio_path + ".wav"
|
| 16 |
-
|
| 17 |
-
subprocess.run([
|
| 18 |
-
"ffmpeg", "-i", audio_path,
|
| 19 |
-
"-ar", "16000",
|
| 20 |
-
"-ac", "1",
|
| 21 |
-
"-y", wav_path
|
| 22 |
-
], capture_output=True)
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def transcribe(self, audio_path: str) -> dict:
|
| 27 |
-
print(f"Transcribing: {audio_path}")
|
| 28 |
-
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
result = self.model.transcribe(
|
| 37 |
-
wav_path,
|
| 38 |
-
word_timestamps=True,
|
| 39 |
-
verbose=False
|
| 40 |
)
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
|
|
|
| 46 |
segments = []
|
| 47 |
-
for seg in
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
segments.append({
|
| 58 |
-
"start": round(
|
| 59 |
-
"end": round(
|
| 60 |
-
"text":
|
| 61 |
-
"words":
|
| 62 |
})
|
| 63 |
|
|
|
|
|
|
|
|
|
|
| 64 |
return {
|
| 65 |
"segments": segments,
|
| 66 |
-
"language":
|
| 67 |
}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# import whisper
|
| 72 |
-
# import json
|
| 73 |
-
|
| 74 |
-
# class Transcriber:
|
| 75 |
-
# def __init__(self, model_size="base"):
|
| 76 |
-
# print(f"Loading Whisper model: {model_size}")
|
| 77 |
-
# self.model = whisper.load_model(model_size)
|
| 78 |
-
# print("Whisper model loaded.")
|
| 79 |
-
|
| 80 |
-
# def transcribe(self, audio_path: str) -> dict:
|
| 81 |
-
# print(f"Transcribing: {audio_path}")
|
| 82 |
-
# result = self.model.transcribe(
|
| 83 |
-
# audio_path,
|
| 84 |
-
# word_timestamps=True,
|
| 85 |
-
# verbose=False
|
| 86 |
-
# )
|
| 87 |
-
|
| 88 |
-
# segments = []
|
| 89 |
-
# for seg in result["segments"]:
|
| 90 |
-
# words = []
|
| 91 |
-
# for w in seg.get("words", []):
|
| 92 |
-
# words.append({
|
| 93 |
-
# "word": w["word"].strip(),
|
| 94 |
-
# "start": round(w["start"], 3),
|
| 95 |
-
# "end": round(w["end"], 3),
|
| 96 |
-
# "score": round(w.get("probability", 0), 3)
|
| 97 |
-
# })
|
| 98 |
-
|
| 99 |
-
# segments.append({
|
| 100 |
-
# "start": round(seg["start"], 3),
|
| 101 |
-
# "end": round(seg["end"], 3),
|
| 102 |
-
# "text": seg["text"].strip(),
|
| 103 |
-
# "words": words
|
| 104 |
-
# })
|
| 105 |
-
|
| 106 |
-
# return {
|
| 107 |
-
# "segments": segments,
|
| 108 |
-
# "language": result.get("language", "en")
|
| 109 |
-
# }
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
import subprocess
|
| 3 |
+
from groq import Groq
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
class Transcriber:
|
| 7 |
+
def __init__(self, api_key: str):
|
| 8 |
+
self.client = Groq(api_key=api_key)
|
| 9 |
+
print("Groq Whisper transcriber ready.")
|
| 10 |
|
| 11 |
def transcribe(self, audio_path: str) -> dict:
|
| 12 |
+
print(f"Transcribing via Groq Whisper large-v3: {audio_path}")
|
| 13 |
+
|
| 14 |
+
# WAV at 16kHz mono can be ~37MB for a 20-min session, exceeding Groq's 25MB limit.
|
| 15 |
+
# Convert to MP3 at 64kbps (~9MB for 20 min) before uploading.
|
| 16 |
+
mp3_path = audio_path.rsplit(".", 1)[0] + "_upload.mp3"
|
| 17 |
+
subprocess.run(
|
| 18 |
+
["ffmpeg", "-i", audio_path, "-ar", "16000", "-ac", "1",
|
| 19 |
+
"-b:a", "64k", "-y", mp3_path],
|
| 20 |
+
capture_output=True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
)
|
| 22 |
|
| 23 |
+
try:
|
| 24 |
+
with open(mp3_path, "rb") as f:
|
| 25 |
+
response = self.client.audio.transcriptions.create(
|
| 26 |
+
file=(os.path.basename(mp3_path), f),
|
| 27 |
+
model="whisper-large-v3",
|
| 28 |
+
response_format="verbose_json",
|
| 29 |
+
timestamp_granularities=["word", "segment"],
|
| 30 |
+
temperature=0.0,
|
| 31 |
+
)
|
| 32 |
+
finally:
|
| 33 |
+
if os.path.exists(mp3_path):
|
| 34 |
+
os.remove(mp3_path)
|
| 35 |
+
|
| 36 |
+
# Groq verbose_json returns words at top level (not nested in segments).
|
| 37 |
+
# Map each word into its parent segment by timestamp overlap.
|
| 38 |
+
raw_words = getattr(response, "words", None) or []
|
| 39 |
+
if raw_words and isinstance(raw_words[0], dict):
|
| 40 |
+
word_list = [
|
| 41 |
+
{"word": w["word"], "start": round(w["start"], 3),
|
| 42 |
+
"end": round(w["end"], 3), "score": 1.0}
|
| 43 |
+
for w in raw_words
|
| 44 |
+
]
|
| 45 |
+
else:
|
| 46 |
+
word_list = [
|
| 47 |
+
{"word": w.word, "start": round(w.start, 3),
|
| 48 |
+
"end": round(w.end, 3), "score": 1.0}
|
| 49 |
+
for w in raw_words
|
| 50 |
+
]
|
| 51 |
|
| 52 |
+
raw_segments = getattr(response, "segments", None) or []
|
| 53 |
segments = []
|
| 54 |
+
for seg in raw_segments:
|
| 55 |
+
if isinstance(seg, dict):
|
| 56 |
+
s_start, s_end, s_text = seg["start"], seg["end"], seg["text"]
|
| 57 |
+
else:
|
| 58 |
+
s_start, s_end, s_text = seg.start, seg.end, seg.text
|
| 59 |
+
|
| 60 |
+
seg_words = [
|
| 61 |
+
w for w in word_list
|
| 62 |
+
if w["start"] >= s_start - 0.05 and w["start"] < s_end + 0.05
|
| 63 |
+
]
|
| 64 |
segments.append({
|
| 65 |
+
"start": round(s_start, 3),
|
| 66 |
+
"end": round(s_end, 3),
|
| 67 |
+
"text": s_text.strip(),
|
| 68 |
+
"words": seg_words,
|
| 69 |
})
|
| 70 |
|
| 71 |
+
detected_language = getattr(response, "language", "unknown")
|
| 72 |
+
print(f"Detected language: {detected_language}")
|
| 73 |
+
|
| 74 |
return {
|
| 75 |
"segments": segments,
|
| 76 |
+
"language": detected_language,
|
| 77 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/requirements.txt
CHANGED
|
@@ -1,14 +1,12 @@
|
|
| 1 |
fastapi
|
| 2 |
-
uvicorn
|
| 3 |
python-multipart
|
| 4 |
-
|
| 5 |
-
|
| 6 |
pydantic
|
| 7 |
python-dotenv
|
| 8 |
pydub
|
| 9 |
librosa
|
| 10 |
numpy
|
| 11 |
scipy
|
| 12 |
-
openai-whisper
|
| 13 |
pyannote.audio
|
| 14 |
-
whisperx
|
|
|
|
| 1 |
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
python-multipart
|
| 4 |
+
groq
|
| 5 |
+
supabase
|
| 6 |
pydantic
|
| 7 |
python-dotenv
|
| 8 |
pydub
|
| 9 |
librosa
|
| 10 |
numpy
|
| 11 |
scipy
|
|
|
|
| 12 |
pyannote.audio
|
|
|
frontend/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>mirror.</title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
frontend/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"private": true,
|
| 4 |
"version": "0.0.0",
|
| 5 |
"type": "module",
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "mirror",
|
| 3 |
"private": true,
|
| 4 |
"version": "0.0.0",
|
| 5 |
"type": "module",
|
frontend/public/favicon.svg
CHANGED
|
|
|
|
frontend/src/App.jsx
CHANGED
|
@@ -8,8 +8,7 @@ import UploadView from "./components/UploadView"
|
|
| 8 |
import ResultsView from "./components/ResultsView"
|
| 9 |
import HistoryView from "./components/HistoryView"
|
| 10 |
import ProfileView from "./components/ProfileView"
|
| 11 |
-
|
| 12 |
-
const G = "linear-gradient(135deg, #d946ef 0%, #f97316 100%)"
|
| 13 |
|
| 14 |
export default function App() {
|
| 15 |
const [session, setSession] = useState(null)
|
|
@@ -25,6 +24,16 @@ export default function App() {
|
|
| 25 |
const [deleting, setDeleting] = useState(false)
|
| 26 |
const [showReenroll, setShowReenroll] = useState(false)
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const handleDeleteAccount = async () => {
|
| 29 |
setDeleting(true)
|
| 30 |
try {
|
|
@@ -54,14 +63,12 @@ export default function App() {
|
|
| 54 |
api.get("/api/voiceprint/status")
|
| 55 |
.then(res => setEnrollState(res.data.enrolled ? "done" : "needed"))
|
| 56 |
.catch((e) => {
|
| 57 |
-
// Network error = backend offline β let user through to main app
|
| 58 |
-
// API error (4xx) = backend up but not enrolled β show enrollment
|
| 59 |
setEnrollState(e.response ? "needed" : "done")
|
| 60 |
})
|
| 61 |
}, [session])
|
| 62 |
|
| 63 |
if (authLoading || (session && enrollState === "checking")) return (
|
| 64 |
-
<div style={{ textAlign: "center", padding: 80, color: "#
|
| 65 |
Loadingβ¦
|
| 66 |
</div>
|
| 67 |
)
|
|
@@ -74,35 +81,80 @@ export default function App() {
|
|
| 74 |
|
| 75 |
if (enrollState === "needed") return (
|
| 76 |
<div style={{ maxWidth: 780, margin: "0 auto", padding: 24 }}>
|
| 77 |
-
<div style={{ marginBottom: 28 }}>
|
| 78 |
-
<
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
</h1>
|
| 82 |
-
<p style={{ color: "#4a4865", fontSize: 13, margin: "4px 0 0" }}>
|
| 83 |
-
Reflective insights from your conversations
|
| 84 |
-
</p>
|
| 85 |
</div>
|
| 86 |
<EnrollView onEnrolled={() => setEnrollState("done")} onSkip={() => setEnrollState("done")} />
|
| 87 |
</div>
|
| 88 |
)
|
| 89 |
|
| 90 |
return (
|
| 91 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
{/* Header */}
|
| 94 |
<div style={{ marginBottom: 28, display: "flex",
|
| 95 |
justifyContent: "space-between", alignItems: "flex-start" }}>
|
| 96 |
-
<div>
|
| 97 |
-
<
|
| 98 |
-
<
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</h1>
|
| 103 |
-
<p style={{ color: "#4a4865", fontSize: 13, margin: "4px 0 0" }}>
|
| 104 |
-
Reflective insights from your conversations
|
| 105 |
-
</p>
|
| 106 |
</div>
|
| 107 |
|
| 108 |
<div style={{ position: "relative" }}>
|
|
@@ -126,21 +178,24 @@ export default function App() {
|
|
| 126 |
) : (
|
| 127 |
<>
|
| 128 |
<button onClick={() => setShowAccountMenu(v => !v)}
|
| 129 |
-
style={{ background: "#
|
| 130 |
padding: "6px 14px", cursor: "pointer", fontSize: 13, color: "#8b89aa" }}>
|
| 131 |
Account βΎ
|
| 132 |
</button>
|
| 133 |
{showAccountMenu && (
|
| 134 |
<div style={{ position: "absolute", right: 0, top: "110%",
|
| 135 |
-
background: "#
|
| 136 |
-
boxShadow: "0 8px 40px rgba(0,0,0,0.
|
| 137 |
minWidth: 200, overflow: "hidden" }}>
|
| 138 |
<div style={{ padding: "8px 14px 6px", fontSize: 11,
|
| 139 |
-
color: "#
|
| 140 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 141 |
Voice β {enrollState === "done" ? "enrolled" : "not enrolled"}
|
| 142 |
</div>
|
| 143 |
{[
|
|
|
|
|
|
|
|
|
|
| 144 |
{ label: enrollState === "done" ? "Retrain your voice" : "Enroll your voice",
|
| 145 |
onClick: () => { setShowReenroll(true); setShowAccountMenu(false) },
|
| 146 |
color: "#f0eeff" },
|
|
@@ -154,7 +209,7 @@ export default function App() {
|
|
| 154 |
<button key={label} onClick={onClick}
|
| 155 |
style={{ display: "block", width: "100%", padding: "10px 14px",
|
| 156 |
textAlign: "left", background: "none", border: "none",
|
| 157 |
-
borderBottom: "1px solid #
|
| 158 |
cursor: "pointer", fontSize: 13, color }}>
|
| 159 |
{label}
|
| 160 |
</button>
|
|
@@ -168,18 +223,18 @@ export default function App() {
|
|
| 168 |
|
| 169 |
{/* Nav */}
|
| 170 |
<nav style={{ display: "flex", gap: 0, marginBottom: 32,
|
| 171 |
-
borderBottom: "1px solid #
|
| 172 |
{[
|
| 173 |
{ key: "profile", label: "Profile" },
|
| 174 |
{ key: "upload", label: "Upload" },
|
| 175 |
{ key: "history", label: "History" },
|
| 176 |
].map(({ key, label }) => (
|
| 177 |
<button key={key} onClick={() => setView(key)}
|
|
|
|
| 178 |
style={{ background: "none", border: "none", cursor: "pointer",
|
| 179 |
fontWeight: view === key ? 600 : 400, fontSize: 14,
|
| 180 |
-
color: view === key ? "#
|
| 181 |
-
|
| 182 |
-
padding: "0 16px 12px", transition: "color 0.15s" }}>
|
| 183 |
{label}
|
| 184 |
</button>
|
| 185 |
))}
|
|
@@ -187,9 +242,9 @@ export default function App() {
|
|
| 187 |
|
| 188 |
{/* Re-enroll modal */}
|
| 189 |
{showReenroll && (
|
| 190 |
-
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.
|
| 191 |
zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
| 192 |
-
<div style={{ background: "#
|
| 193 |
borderRadius: 16, maxWidth: 500, width: "90%", maxHeight: "90vh",
|
| 194 |
overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.7)" }}>
|
| 195 |
<div style={{ display: "flex", justifyContent: "space-between",
|
|
@@ -199,7 +254,7 @@ export default function App() {
|
|
| 199 |
</span>
|
| 200 |
<button onClick={() => setShowReenroll(false)}
|
| 201 |
style={{ background: "none", border: "none", cursor: "pointer",
|
| 202 |
-
fontSize: 20, color: "#
|
| 203 |
</div>
|
| 204 |
<EnrollView
|
| 205 |
onEnrolled={() => { setShowReenroll(false); setEnrollState("done") }}
|
|
@@ -209,22 +264,37 @@ export default function App() {
|
|
| 209 |
</div>
|
| 210 |
)}
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
<div style={{ display: view === "profile" ? "block" : "none" }}>
|
| 213 |
-
<
|
|
|
|
|
|
|
| 214 |
</div>
|
| 215 |
<div style={{ display: view === "upload" ? "block" : "none" }}>
|
| 216 |
-
<
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
| 220 |
</div>
|
| 221 |
{view === "results" && results && (
|
| 222 |
-
<
|
|
|
|
|
|
|
| 223 |
)}
|
| 224 |
<div style={{ display: view === "history" ? "block" : "none" }}>
|
| 225 |
-
<
|
| 226 |
-
|
|
|
|
|
|
|
| 227 |
</div>
|
| 228 |
</div>
|
|
|
|
| 229 |
)
|
| 230 |
}
|
|
|
|
| 8 |
import ResultsView from "./components/ResultsView"
|
| 9 |
import HistoryView from "./components/HistoryView"
|
| 10 |
import ProfileView from "./components/ProfileView"
|
| 11 |
+
import HowItWorksView from "./components/HowItWorksView"
|
|
|
|
| 12 |
|
| 13 |
export default function App() {
|
| 14 |
const [session, setSession] = useState(null)
|
|
|
|
| 24 |
const [deleting, setDeleting] = useState(false)
|
| 25 |
const [showReenroll, setShowReenroll] = useState(false)
|
| 26 |
|
| 27 |
+
// Each key tracks how many times we've navigated TO that view β re-mounting
|
| 28 |
+
// the wrapper div causes the .view-enter animation to fire every tab switch.
|
| 29 |
+
const [viewKeys, setViewKeys] = useState({ profile: 0, upload: 0, history: 0 })
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
if (view === "profile" || view === "upload" || view === "history") {
|
| 33 |
+
setViewKeys(k => ({ ...k, [view]: k[view] + 1 }))
|
| 34 |
+
}
|
| 35 |
+
}, [view])
|
| 36 |
+
|
| 37 |
const handleDeleteAccount = async () => {
|
| 38 |
setDeleting(true)
|
| 39 |
try {
|
|
|
|
| 63 |
api.get("/api/voiceprint/status")
|
| 64 |
.then(res => setEnrollState(res.data.enrolled ? "done" : "needed"))
|
| 65 |
.catch((e) => {
|
|
|
|
|
|
|
| 66 |
setEnrollState(e.response ? "needed" : "done")
|
| 67 |
})
|
| 68 |
}, [session])
|
| 69 |
|
| 70 |
if (authLoading || (session && enrollState === "checking")) return (
|
| 71 |
+
<div style={{ textAlign: "center", padding: 80, color: "#2e3464" }}>
|
| 72 |
Loadingβ¦
|
| 73 |
</div>
|
| 74 |
)
|
|
|
|
| 81 |
|
| 82 |
if (enrollState === "needed") return (
|
| 83 |
<div style={{ maxWidth: 780, margin: "0 auto", padding: 24 }}>
|
| 84 |
+
<div style={{ marginBottom: 28, display: "flex", alignItems: "center", gap: 10 }}>
|
| 85 |
+
<svg width="26" height="26" viewBox="0 0 32 32" fill="none">
|
| 86 |
+
<defs>
|
| 87 |
+
<linearGradient id="enr-g" x1="0" y1="0" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
| 88 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 89 |
+
</linearGradient>
|
| 90 |
+
</defs>
|
| 91 |
+
<rect x="2" y="12" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".4"/>
|
| 92 |
+
<rect x="8" y="8" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".65"/>
|
| 93 |
+
<rect x="14" y="4" width="4" height="12" rx="2" fill="url(#enr-g)"/>
|
| 94 |
+
<rect x="20" y="8" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".65"/>
|
| 95 |
+
<rect x="26" y="12" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".4"/>
|
| 96 |
+
<line x1="0" y1="17.5" x2="32" y2="17.5" stroke="#1e2438" strokeWidth="1"/>
|
| 97 |
+
<rect x="2" y="18" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".18"/>
|
| 98 |
+
<rect x="8" y="18" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".3"/>
|
| 99 |
+
<rect x="14" y="18" width="4" height="12" rx="2" fill="url(#enr-g)" opacity=".38"/>
|
| 100 |
+
<rect x="20" y="18" width="4" height="8" rx="2" fill="url(#enr-g)" opacity=".3"/>
|
| 101 |
+
<rect x="26" y="18" width="4" height="4" rx="2" fill="url(#enr-g)" opacity=".18"/>
|
| 102 |
+
</svg>
|
| 103 |
+
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, letterSpacing: "-0.3px", color: "#f0eeff" }}>
|
| 104 |
+
mirror<span style={{ color: "#1d4ed8" }}>.</span>
|
| 105 |
</h1>
|
|
|
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
<EnrollView onEnrolled={() => setEnrollState("done")} onSkip={() => setEnrollState("done")} />
|
| 108 |
</div>
|
| 109 |
)
|
| 110 |
|
| 111 |
return (
|
| 112 |
+
<>
|
| 113 |
+
{/* Ambient background blobs */}
|
| 114 |
+
<div style={{ position: "fixed", inset: 0, overflow: "hidden", pointerEvents: "none", zIndex: 0 }}>
|
| 115 |
+
<div style={{
|
| 116 |
+
position: "absolute", top: "-15%", right: "-8%",
|
| 117 |
+
width: 650, height: 650, borderRadius: "50%",
|
| 118 |
+
background: "radial-gradient(circle, rgba(29,78,216,0.09) 0%, transparent 68%)",
|
| 119 |
+
filter: "blur(1px)",
|
| 120 |
+
animation: "blobDrift1 22s ease-in-out infinite",
|
| 121 |
+
}} />
|
| 122 |
+
<div style={{
|
| 123 |
+
position: "absolute", bottom: "-12%", left: "-6%",
|
| 124 |
+
width: 550, height: 550, borderRadius: "50%",
|
| 125 |
+
background: "radial-gradient(circle, rgba(8,145,178,0.07) 0%, transparent 68%)",
|
| 126 |
+
filter: "blur(1px)",
|
| 127 |
+
animation: "blobDrift2 28s ease-in-out infinite",
|
| 128 |
+
}} />
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<div style={{ maxWidth: 780, margin: "0 auto", padding: 24, position: "relative", zIndex: 1 }}>
|
| 132 |
|
| 133 |
{/* Header */}
|
| 134 |
<div style={{ marginBottom: 28, display: "flex",
|
| 135 |
justifyContent: "space-between", alignItems: "flex-start" }}>
|
| 136 |
+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
| 137 |
+
<svg width="26" height="26" viewBox="0 0 32 32" fill="none">
|
| 138 |
+
<defs>
|
| 139 |
+
<linearGradient id="hdr-g" x1="0" y1="0" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
| 140 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 141 |
+
</linearGradient>
|
| 142 |
+
</defs>
|
| 143 |
+
<rect x="2" y="12" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".4"/>
|
| 144 |
+
<rect x="8" y="8" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".65"/>
|
| 145 |
+
<rect x="14" y="4" width="4" height="12" rx="2" fill="url(#hdr-g)"/>
|
| 146 |
+
<rect x="20" y="8" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".65"/>
|
| 147 |
+
<rect x="26" y="12" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".4"/>
|
| 148 |
+
<line x1="0" y1="17.5" x2="32" y2="17.5" stroke="#1e2438" strokeWidth="1"/>
|
| 149 |
+
<rect x="2" y="18" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".18"/>
|
| 150 |
+
<rect x="8" y="18" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".3"/>
|
| 151 |
+
<rect x="14" y="18" width="4" height="12" rx="2" fill="url(#hdr-g)" opacity=".38"/>
|
| 152 |
+
<rect x="20" y="18" width="4" height="8" rx="2" fill="url(#hdr-g)" opacity=".3"/>
|
| 153 |
+
<rect x="26" y="18" width="4" height="4" rx="2" fill="url(#hdr-g)" opacity=".18"/>
|
| 154 |
+
</svg>
|
| 155 |
+
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, letterSpacing: "-0.3px", color: "#f0eeff" }}>
|
| 156 |
+
mirror<span style={{ color: "#1d4ed8" }}>.</span>
|
| 157 |
</h1>
|
|
|
|
|
|
|
|
|
|
| 158 |
</div>
|
| 159 |
|
| 160 |
<div style={{ position: "relative" }}>
|
|
|
|
| 178 |
) : (
|
| 179 |
<>
|
| 180 |
<button onClick={() => setShowAccountMenu(v => !v)}
|
| 181 |
+
style={{ background: "#151922", border: "1px solid #1e2438", borderRadius: 7,
|
| 182 |
padding: "6px 14px", cursor: "pointer", fontSize: 13, color: "#8b89aa" }}>
|
| 183 |
Account βΎ
|
| 184 |
</button>
|
| 185 |
{showAccountMenu && (
|
| 186 |
<div style={{ position: "absolute", right: 0, top: "110%",
|
| 187 |
+
background: "#151922", border: "1px solid #1e2438", borderRadius: 10,
|
| 188 |
+
boxShadow: "0 8px 40px rgba(0,0,0,0.7)", zIndex: 20,
|
| 189 |
minWidth: 200, overflow: "hidden" }}>
|
| 190 |
<div style={{ padding: "8px 14px 6px", fontSize: 11,
|
| 191 |
+
color: "#4a4d6a", borderBottom: "1px solid #1e2438",
|
| 192 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 193 |
Voice β {enrollState === "done" ? "enrolled" : "not enrolled"}
|
| 194 |
</div>
|
| 195 |
{[
|
| 196 |
+
{ label: "How it works",
|
| 197 |
+
onClick: () => { setView("howItWorks"); setShowAccountMenu(false) },
|
| 198 |
+
color: "#f0eeff" },
|
| 199 |
{ label: enrollState === "done" ? "Retrain your voice" : "Enroll your voice",
|
| 200 |
onClick: () => { setShowReenroll(true); setShowAccountMenu(false) },
|
| 201 |
color: "#f0eeff" },
|
|
|
|
| 209 |
<button key={label} onClick={onClick}
|
| 210 |
style={{ display: "block", width: "100%", padding: "10px 14px",
|
| 211 |
textAlign: "left", background: "none", border: "none",
|
| 212 |
+
borderBottom: "1px solid #1e2438",
|
| 213 |
cursor: "pointer", fontSize: 13, color }}>
|
| 214 |
{label}
|
| 215 |
</button>
|
|
|
|
| 223 |
|
| 224 |
{/* Nav */}
|
| 225 |
<nav style={{ display: "flex", gap: 0, marginBottom: 32,
|
| 226 |
+
borderBottom: "1px solid #1e2438" }}>
|
| 227 |
{[
|
| 228 |
{ key: "profile", label: "Profile" },
|
| 229 |
{ key: "upload", label: "Upload" },
|
| 230 |
{ key: "history", label: "History" },
|
| 231 |
].map(({ key, label }) => (
|
| 232 |
<button key={key} onClick={() => setView(key)}
|
| 233 |
+
className={`nav-tab${view === key ? " active" : ""}`}
|
| 234 |
style={{ background: "none", border: "none", cursor: "pointer",
|
| 235 |
fontWeight: view === key ? 600 : 400, fontSize: 14,
|
| 236 |
+
color: view === key ? "#5b9cf6" : "#8b89aa",
|
| 237 |
+
padding: "0 16px 12px" }}>
|
|
|
|
| 238 |
{label}
|
| 239 |
</button>
|
| 240 |
))}
|
|
|
|
| 242 |
|
| 243 |
{/* Re-enroll modal */}
|
| 244 |
{showReenroll && (
|
| 245 |
+
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.85)",
|
| 246 |
zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
| 247 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 248 |
borderRadius: 16, maxWidth: 500, width: "90%", maxHeight: "90vh",
|
| 249 |
overflowY: "auto", boxShadow: "0 20px 60px rgba(0,0,0,0.7)" }}>
|
| 250 |
<div style={{ display: "flex", justifyContent: "space-between",
|
|
|
|
| 254 |
</span>
|
| 255 |
<button onClick={() => setShowReenroll(false)}
|
| 256 |
style={{ background: "none", border: "none", cursor: "pointer",
|
| 257 |
+
fontSize: 20, color: "#4a4d6a", lineHeight: 1 }}>Γ</button>
|
| 258 |
</div>
|
| 259 |
<EnrollView
|
| 260 |
onEnrolled={() => { setShowReenroll(false); setEnrollState("done") }}
|
|
|
|
| 264 |
</div>
|
| 265 |
)}
|
| 266 |
|
| 267 |
+
{view === "howItWorks" && (
|
| 268 |
+
<HowItWorksView onBack={() => setView("profile")} />
|
| 269 |
+
)}
|
| 270 |
+
|
| 271 |
+
{/* Persistent views β kept mounted, but wrapper remounts each tab visit
|
| 272 |
+
so .view-enter animation fires every time you switch to that tab */}
|
| 273 |
<div style={{ display: view === "profile" ? "block" : "none" }}>
|
| 274 |
+
<div key={`profile-${viewKeys.profile}`} className="view-enter">
|
| 275 |
+
<ProfileView active={view === "profile"} onUpload={() => setView("upload")} />
|
| 276 |
+
</div>
|
| 277 |
</div>
|
| 278 |
<div style={{ display: view === "upload" ? "block" : "none" }}>
|
| 279 |
+
<div key={`upload-${viewKeys.upload}`} className="view-enter">
|
| 280 |
+
<UploadView
|
| 281 |
+
onResults={(r) => { setResults(r); setView("results") }}
|
| 282 |
+
onActivate={() => setView("upload")}
|
| 283 |
+
/>
|
| 284 |
+
</div>
|
| 285 |
</div>
|
| 286 |
{view === "results" && results && (
|
| 287 |
+
<div className="view-enter">
|
| 288 |
+
<ResultsView results={results} onBack={() => setView("history")} />
|
| 289 |
+
</div>
|
| 290 |
)}
|
| 291 |
<div style={{ display: view === "history" ? "block" : "none" }}>
|
| 292 |
+
<div key={`history-${viewKeys.history}`} className="view-enter">
|
| 293 |
+
<HistoryView active={view === "history"}
|
| 294 |
+
onSelect={(r) => { setResults(r); setView("results") }} />
|
| 295 |
+
</div>
|
| 296 |
</div>
|
| 297 |
</div>
|
| 298 |
+
</>
|
| 299 |
)
|
| 300 |
}
|
frontend/src/components/AuthView.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useState, useRef } from "react"
|
| 2 |
import { supabase } from "../lib/supabase"
|
| 3 |
|
| 4 |
-
const G = "linear-gradient(135deg, #
|
| 5 |
|
| 6 |
export default function AuthView({ onAuth }) {
|
| 7 |
const [mode, setMode] = useState("login")
|
|
@@ -46,19 +46,47 @@ export default function AuthView({ onAuth }) {
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return (
|
| 50 |
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
|
| 51 |
justifyContent: "center", padding: 24 }}>
|
| 52 |
-
<div style={{ width: "100%", maxWidth: 360 }}>
|
| 53 |
|
| 54 |
{/* Logo */}
|
| 55 |
<div style={{ textAlign: "center", marginBottom: 36 }}>
|
| 56 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
<h1 style={{ fontSize: 24, fontWeight: 700, margin: "0 0 6px",
|
| 58 |
-
letterSpacing: "-0.5px",
|
| 59 |
-
|
| 60 |
-
backgroundClip: "text" }}>
|
| 61 |
-
mirror
|
| 62 |
</h1>
|
| 63 |
<p style={{ color: "#4a4865", fontSize: 13, margin: 0 }}>
|
| 64 |
{mode === "login"
|
|
@@ -68,9 +96,12 @@ export default function AuthView({ onAuth }) {
|
|
| 68 |
</div>
|
| 69 |
|
| 70 |
{/* Card */}
|
| 71 |
-
<div style={{
|
|
|
|
|
|
|
| 72 |
borderRadius: 16, padding: 28,
|
| 73 |
-
boxShadow: "0 8px 40px rgba(0,0,0,0.5)"
|
|
|
|
| 74 |
|
| 75 |
<form onSubmit={handleSubmit}>
|
| 76 |
<div style={{ marginBottom: 16 }}>
|
|
@@ -118,18 +149,44 @@ export default function AuthView({ onAuth }) {
|
|
| 118 |
)}
|
| 119 |
|
| 120 |
<button type="submit" disabled={loading}
|
|
|
|
| 121 |
style={{ width: "100%", padding: "13px 24px",
|
| 122 |
-
background: loading ? "#
|
| 123 |
color: loading ? "#4a4865" : "white",
|
| 124 |
-
border: loading ? "1px solid #
|
| 125 |
borderRadius: 8, fontSize: 15,
|
| 126 |
cursor: loading ? "not-allowed" : "pointer", fontWeight: 600,
|
| 127 |
-
boxShadow: loading ? "none" : "0 0 24px rgba(
|
| 128 |
-
transition: "all 0.15s" }}>
|
| 129 |
{loading ? "β¦" : mode === "login" ? "Sign in" : "Create account"}
|
| 130 |
</button>
|
| 131 |
</form>
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
<div style={{ textAlign: "center", marginTop: 20 }}>
|
| 134 |
<button
|
| 135 |
onClick={() => { setMode(mode === "login" ? "signup" : "login"); setError(""); setMessage("") }}
|
|
|
|
| 1 |
import { useState, useRef } from "react"
|
| 2 |
import { supabase } from "../lib/supabase"
|
| 3 |
|
| 4 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 5 |
|
| 6 |
export default function AuthView({ onAuth }) {
|
| 7 |
const [mode, setMode] = useState("login")
|
|
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
+
const handleGoogle = async () => {
|
| 50 |
+
setLoading(true)
|
| 51 |
+
setError("")
|
| 52 |
+
const { error } = await supabase.auth.signInWithOAuth({
|
| 53 |
+
provider: "google",
|
| 54 |
+
options: { redirectTo: window.location.origin },
|
| 55 |
+
})
|
| 56 |
+
if (error) {
|
| 57 |
+
setError(error.message)
|
| 58 |
+
setLoading(false)
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
return (
|
| 63 |
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
|
| 64 |
justifyContent: "center", padding: 24 }}>
|
| 65 |
+
<div style={{ width: "100%", maxWidth: 360 }} className="view-enter">
|
| 66 |
|
| 67 |
{/* Logo */}
|
| 68 |
<div style={{ textAlign: "center", marginBottom: 36 }}>
|
| 69 |
+
<svg width="48" height="48" viewBox="0 0 52 52" fill="none" style={{ marginBottom: 14 }}>
|
| 70 |
+
<defs>
|
| 71 |
+
<linearGradient id="auth-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
|
| 72 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 73 |
+
</linearGradient>
|
| 74 |
+
</defs>
|
| 75 |
+
<rect x="2" y="20" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".35"/>
|
| 76 |
+
<rect x="11" y="13" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".6"/>
|
| 77 |
+
<rect x="20" y="6" width="8" height="20" rx="4" fill="url(#auth-g)"/>
|
| 78 |
+
<rect x="31" y="13" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".6"/>
|
| 79 |
+
<rect x="40" y="20" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".35"/>
|
| 80 |
+
<line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
|
| 81 |
+
<rect x="2" y="29" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".15"/>
|
| 82 |
+
<rect x="11" y="29" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".27"/>
|
| 83 |
+
<rect x="20" y="29" width="8" height="20" rx="4" fill="url(#auth-g)" opacity=".33"/>
|
| 84 |
+
<rect x="31" y="29" width="6" height="13" rx="3" fill="url(#auth-g)" opacity=".27"/>
|
| 85 |
+
<rect x="40" y="29" width="6" height="6" rx="3" fill="url(#auth-g)" opacity=".15"/>
|
| 86 |
+
</svg>
|
| 87 |
<h1 style={{ fontSize: 24, fontWeight: 700, margin: "0 0 6px",
|
| 88 |
+
letterSpacing: "-0.5px", color: "#f0eeff" }}>
|
| 89 |
+
mirror<span style={{ color: "#1d4ed8" }}>.</span>
|
|
|
|
|
|
|
| 90 |
</h1>
|
| 91 |
<p style={{ color: "#4a4865", fontSize: 13, margin: 0 }}>
|
| 92 |
{mode === "login"
|
|
|
|
| 96 |
</div>
|
| 97 |
|
| 98 |
{/* Card */}
|
| 99 |
+
<div style={{
|
| 100 |
+
background: "linear-gradient(#151922, #151922) padding-box, linear-gradient(135deg, rgba(29,78,216,0.35), rgba(34,211,238,0.35)) border-box",
|
| 101 |
+
border: "1px solid transparent",
|
| 102 |
borderRadius: 16, padding: 28,
|
| 103 |
+
boxShadow: "0 8px 40px rgba(0,0,0,0.5)"
|
| 104 |
+
}}>
|
| 105 |
|
| 106 |
<form onSubmit={handleSubmit}>
|
| 107 |
<div style={{ marginBottom: 16 }}>
|
|
|
|
| 149 |
)}
|
| 150 |
|
| 151 |
<button type="submit" disabled={loading}
|
| 152 |
+
className={loading ? "" : "btn-grad"}
|
| 153 |
style={{ width: "100%", padding: "13px 24px",
|
| 154 |
+
background: loading ? "#151922" : G,
|
| 155 |
color: loading ? "#4a4865" : "white",
|
| 156 |
+
border: loading ? "1px solid #1e2438" : "none",
|
| 157 |
borderRadius: 8, fontSize: 15,
|
| 158 |
cursor: loading ? "not-allowed" : "pointer", fontWeight: 600,
|
| 159 |
+
boxShadow: loading ? "none" : "0 0 24px rgba(29,78,216,0.3)" }}>
|
|
|
|
| 160 |
{loading ? "β¦" : mode === "login" ? "Sign in" : "Create account"}
|
| 161 |
</button>
|
| 162 |
</form>
|
| 163 |
|
| 164 |
+
{/* Divider */}
|
| 165 |
+
<div style={{ display: "flex", alignItems: "center", gap: 12, margin: "20px 0" }}>
|
| 166 |
+
<div style={{ flex: 1, height: 1, background: "#1e2438" }} />
|
| 167 |
+
<span style={{ fontSize: 12, color: "#4a4865" }}>or</span>
|
| 168 |
+
<div style={{ flex: 1, height: 1, background: "#1e2438" }} />
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
{/* Google OAuth */}
|
| 172 |
+
<button type="button" onClick={handleGoogle} disabled={loading}
|
| 173 |
+
style={{ width: "100%", padding: "11px 24px", fontSize: 14, fontWeight: 500,
|
| 174 |
+
cursor: loading ? "not-allowed" : "pointer",
|
| 175 |
+
background: "#0e1320", color: "#d4d2e8",
|
| 176 |
+
border: "1px solid #1e2438", borderRadius: 8,
|
| 177 |
+
display: "flex", alignItems: "center", justifyContent: "center", gap: 10,
|
| 178 |
+
transition: "border-color 0.15s", opacity: loading ? 0.5 : 1 }}
|
| 179 |
+
onMouseEnter={e => e.currentTarget.style.borderColor = "#4a4865"}
|
| 180 |
+
onMouseLeave={e => e.currentTarget.style.borderColor = "#1e2438"}>
|
| 181 |
+
<svg width="17" height="17" viewBox="0 0 24 24">
|
| 182 |
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
| 183 |
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
| 184 |
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
| 185 |
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
| 186 |
+
</svg>
|
| 187 |
+
Continue with Google
|
| 188 |
+
</button>
|
| 189 |
+
|
| 190 |
<div style={{ textAlign: "center", marginTop: 20 }}>
|
| 191 |
<button
|
| 192 |
onClick={() => { setMode(mode === "login" ? "signup" : "login"); setError(""); setMessage("") }}
|
frontend/src/components/EnrollView.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useState, useRef } from "react"
|
| 2 |
import api from "../lib/api"
|
| 3 |
|
| 4 |
-
const G = "linear-gradient(135deg, #
|
| 5 |
const MIN_SECONDS = 20
|
| 6 |
const MAX_SECONDS = 60
|
| 7 |
|
|
@@ -114,7 +114,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 114 |
<button onClick={onEnrolled}
|
| 115 |
style={{ padding: "12px 40px", background: G, color: "white",
|
| 116 |
border: "none", borderRadius: 8, fontSize: 15, cursor: "pointer",
|
| 117 |
-
fontWeight: 600, boxShadow: "0 0 24px rgba(
|
| 118 |
Continue to app
|
| 119 |
</button>
|
| 120 |
</div>
|
|
@@ -138,8 +138,8 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 138 |
{[1, 2, 3].map(r => (
|
| 139 |
<div key={r} style={{
|
| 140 |
width: 10, height: 10, borderRadius: "50%",
|
| 141 |
-
background: r < round ? G : r === round ? "rgba(
|
| 142 |
-
boxShadow: r <= round ? "0 0 6px rgba(
|
| 143 |
transition: "all 0.3s",
|
| 144 |
}} />
|
| 145 |
))}
|
|
@@ -151,15 +151,15 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 151 |
</div>
|
| 152 |
|
| 153 |
{/* Round content */}
|
| 154 |
-
<div style={{ background: "#
|
| 155 |
-
marginBottom: 14, border: "1px solid #
|
| 156 |
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
|
| 157 |
<span style={{ fontSize: 11, fontWeight: 700, color: "#8b89aa",
|
| 158 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 159 |
Round {round}
|
| 160 |
</span>
|
| 161 |
-
<span style={{ fontSize: 11, background: "rgba(
|
| 162 |
-
color: "#
|
| 163 |
borderRadius: 20, padding: "2px 8px", fontWeight: 600 }}>
|
| 164 |
{roundConfig.badge}
|
| 165 |
</span>
|
|
@@ -178,7 +178,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 178 |
))
|
| 179 |
) : (
|
| 180 |
<p style={{ margin: "0 0 4px", fontSize: 14, color: "#8b89aa", lineHeight: 1.7,
|
| 181 |
-
background: "#
|
| 182 |
padding: "12px 14px" }}>
|
| 183 |
{roundConfig.content}
|
| 184 |
</p>
|
|
@@ -188,14 +188,14 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 188 |
</div>
|
| 189 |
|
| 190 |
{/* Recording area */}
|
| 191 |
-
<div style={{ background: "#
|
| 192 |
-
marginBottom: 18, textAlign: "center", border: "1px solid #
|
| 193 |
|
| 194 |
{state === "idle" && (
|
| 195 |
<>
|
| 196 |
<div style={{ fontSize: 44, marginBottom: 12 }}>π€</div>
|
| 197 |
<p style={{ fontSize: 13, color: "#8b89aa", margin: 0, lineHeight: 1.6 }}>
|
| 198 |
-
Click <strong style={{ color: "#
|
| 199 |
then read the sentences above clearly.
|
| 200 |
</p>
|
| 201 |
</>
|
|
@@ -208,13 +208,13 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 208 |
fontVariantNumeric: "tabular-nums" }}>
|
| 209 |
{elapsed}s
|
| 210 |
</div>
|
| 211 |
-
<div style={{ height: 5, background: "#
|
| 212 |
margin: "14px 0 10px", overflow: "hidden" }}>
|
| 213 |
<div style={{
|
| 214 |
height: "100%", borderRadius: 3,
|
| 215 |
background: canStop ? "#34d399" : G,
|
| 216 |
boxShadow: canStop ? "0 0 8px rgba(52,211,153,0.5)"
|
| 217 |
-
: "0 0 8px rgba(
|
| 218 |
width: `${progressPct}%`,
|
| 219 |
transition: "width 0.8s linear, background 0.4s",
|
| 220 |
}} />
|
|
@@ -265,7 +265,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 265 |
style={{ width: "100%", padding: "13px 24px", background: G,
|
| 266 |
color: "white", border: "none", borderRadius: 8, fontSize: 15,
|
| 267 |
cursor: "pointer", fontWeight: 600, marginBottom: 10,
|
| 268 |
-
boxShadow: "0 0 24px rgba(
|
| 269 |
Start recording
|
| 270 |
</button>
|
| 271 |
)}
|
|
@@ -273,13 +273,13 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 273 |
{state === "recording" && (
|
| 274 |
<button onClick={stopRecording} disabled={!canStop}
|
| 275 |
style={{ width: "100%", padding: "13px 24px",
|
| 276 |
-
background: canStop ? G : "#
|
| 277 |
-
color: canStop ? "white" : "#
|
| 278 |
-
border: canStop ? "none" : "1px solid #
|
| 279 |
borderRadius: 8, fontSize: 15,
|
| 280 |
cursor: canStop ? "pointer" : "not-allowed",
|
| 281 |
fontWeight: 600, marginBottom: 10,
|
| 282 |
-
boxShadow: canStop ? "0 0 24px rgba(
|
| 283 |
transition: "all 0.3s" }}>
|
| 284 |
{canStop ? `Stop β save round ${round}` : `Stop (${MIN_SECONDS - elapsed}s left)`}
|
| 285 |
</button>
|
|
@@ -290,7 +290,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 290 |
style={{ width: "100%", padding: "13px 24px", background: G,
|
| 291 |
color: "white", border: "none", borderRadius: 8, fontSize: 15,
|
| 292 |
cursor: "pointer", fontWeight: 600, marginBottom: 10,
|
| 293 |
-
boxShadow: "0 0 24px rgba(
|
| 294 |
{round < 3 ? `Continue to Round ${round + 1} β` : "Submit voice enrollment"}
|
| 295 |
</button>
|
| 296 |
)}
|
|
@@ -298,7 +298,7 @@ export default function EnrollView({ onEnrolled, onSkip }) {
|
|
| 298 |
{(state === "idle" || state === "recording") && (
|
| 299 |
<button onClick={onSkip} disabled={state === "recording"}
|
| 300 |
style={{ width: "100%", padding: "10px 24px", background: "none",
|
| 301 |
-
color: state === "recording" ? "#
|
| 302 |
border: "none", fontSize: 13,
|
| 303 |
cursor: state === "recording" ? "not-allowed" : "pointer" }}>
|
| 304 |
Skip for now β analysis will still work, speaker detection may be less accurate
|
|
|
|
| 1 |
import { useState, useRef } from "react"
|
| 2 |
import api from "../lib/api"
|
| 3 |
|
| 4 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 5 |
const MIN_SECONDS = 20
|
| 6 |
const MAX_SECONDS = 60
|
| 7 |
|
|
|
|
| 114 |
<button onClick={onEnrolled}
|
| 115 |
style={{ padding: "12px 40px", background: G, color: "white",
|
| 116 |
border: "none", borderRadius: 8, fontSize: 15, cursor: "pointer",
|
| 117 |
+
fontWeight: 600, boxShadow: "0 0 24px rgba(29,78,216,0.3)" }}>
|
| 118 |
Continue to app
|
| 119 |
</button>
|
| 120 |
</div>
|
|
|
|
| 138 |
{[1, 2, 3].map(r => (
|
| 139 |
<div key={r} style={{
|
| 140 |
width: 10, height: 10, borderRadius: "50%",
|
| 141 |
+
background: r < round ? G : r === round ? "rgba(29,78,216,0.35)" : "#1e2438",
|
| 142 |
+
boxShadow: r <= round ? "0 0 6px rgba(29,78,216,0.35)" : "none",
|
| 143 |
transition: "all 0.3s",
|
| 144 |
}} />
|
| 145 |
))}
|
|
|
|
| 151 |
</div>
|
| 152 |
|
| 153 |
{/* Round content */}
|
| 154 |
+
<div style={{ background: "#151922", borderRadius: 12, padding: 20,
|
| 155 |
+
marginBottom: 14, border: "1px solid #1e2438" }}>
|
| 156 |
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
|
| 157 |
<span style={{ fontSize: 11, fontWeight: 700, color: "#8b89aa",
|
| 158 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 159 |
Round {round}
|
| 160 |
</span>
|
| 161 |
+
<span style={{ fontSize: 11, background: "rgba(29,78,216,0.1)",
|
| 162 |
+
color: "#5b9cf6", border: "1px solid rgba(29,78,216,0.25)",
|
| 163 |
borderRadius: 20, padding: "2px 8px", fontWeight: 600 }}>
|
| 164 |
{roundConfig.badge}
|
| 165 |
</span>
|
|
|
|
| 178 |
))
|
| 179 |
) : (
|
| 180 |
<p style={{ margin: "0 0 4px", fontSize: 14, color: "#8b89aa", lineHeight: 1.7,
|
| 181 |
+
background: "#131827", border: "1px solid #1e2438", borderRadius: 8,
|
| 182 |
padding: "12px 14px" }}>
|
| 183 |
{roundConfig.content}
|
| 184 |
</p>
|
|
|
|
| 188 |
</div>
|
| 189 |
|
| 190 |
{/* Recording area */}
|
| 191 |
+
<div style={{ background: "#151922", borderRadius: 14, padding: 28,
|
| 192 |
+
marginBottom: 18, textAlign: "center", border: "1px solid #1e2438" }}>
|
| 193 |
|
| 194 |
{state === "idle" && (
|
| 195 |
<>
|
| 196 |
<div style={{ fontSize: 44, marginBottom: 12 }}>π€</div>
|
| 197 |
<p style={{ fontSize: 13, color: "#8b89aa", margin: 0, lineHeight: 1.6 }}>
|
| 198 |
+
Click <strong style={{ color: "#5b9cf6" }}>Start recording</strong>,
|
| 199 |
then read the sentences above clearly.
|
| 200 |
</p>
|
| 201 |
</>
|
|
|
|
| 208 |
fontVariantNumeric: "tabular-nums" }}>
|
| 209 |
{elapsed}s
|
| 210 |
</div>
|
| 211 |
+
<div style={{ height: 5, background: "#1e2438", borderRadius: 3,
|
| 212 |
margin: "14px 0 10px", overflow: "hidden" }}>
|
| 213 |
<div style={{
|
| 214 |
height: "100%", borderRadius: 3,
|
| 215 |
background: canStop ? "#34d399" : G,
|
| 216 |
boxShadow: canStop ? "0 0 8px rgba(52,211,153,0.5)"
|
| 217 |
+
: "0 0 8px rgba(29,78,216,0.4)",
|
| 218 |
width: `${progressPct}%`,
|
| 219 |
transition: "width 0.8s linear, background 0.4s",
|
| 220 |
}} />
|
|
|
|
| 265 |
style={{ width: "100%", padding: "13px 24px", background: G,
|
| 266 |
color: "white", border: "none", borderRadius: 8, fontSize: 15,
|
| 267 |
cursor: "pointer", fontWeight: 600, marginBottom: 10,
|
| 268 |
+
boxShadow: "0 0 24px rgba(29,78,216,0.3)" }}>
|
| 269 |
Start recording
|
| 270 |
</button>
|
| 271 |
)}
|
|
|
|
| 273 |
{state === "recording" && (
|
| 274 |
<button onClick={stopRecording} disabled={!canStop}
|
| 275 |
style={{ width: "100%", padding: "13px 24px",
|
| 276 |
+
background: canStop ? G : "#151922",
|
| 277 |
+
color: canStop ? "white" : "#1e2438",
|
| 278 |
+
border: canStop ? "none" : "1px solid #1e2438",
|
| 279 |
borderRadius: 8, fontSize: 15,
|
| 280 |
cursor: canStop ? "pointer" : "not-allowed",
|
| 281 |
fontWeight: 600, marginBottom: 10,
|
| 282 |
+
boxShadow: canStop ? "0 0 24px rgba(29,78,216,0.3)" : "none",
|
| 283 |
transition: "all 0.3s" }}>
|
| 284 |
{canStop ? `Stop β save round ${round}` : `Stop (${MIN_SECONDS - elapsed}s left)`}
|
| 285 |
</button>
|
|
|
|
| 290 |
style={{ width: "100%", padding: "13px 24px", background: G,
|
| 291 |
color: "white", border: "none", borderRadius: 8, fontSize: 15,
|
| 292 |
cursor: "pointer", fontWeight: 600, marginBottom: 10,
|
| 293 |
+
boxShadow: "0 0 24px rgba(29,78,216,0.3)" }}>
|
| 294 |
{round < 3 ? `Continue to Round ${round + 1} β` : "Submit voice enrollment"}
|
| 295 |
</button>
|
| 296 |
)}
|
|
|
|
| 298 |
{(state === "idle" || state === "recording") && (
|
| 299 |
<button onClick={onSkip} disabled={state === "recording"}
|
| 300 |
style={{ width: "100%", padding: "10px 24px", background: "none",
|
| 301 |
+
color: state === "recording" ? "#1e2438" : "#4a4865",
|
| 302 |
border: "none", fontSize: 13,
|
| 303 |
cursor: state === "recording" ? "not-allowed" : "pointer" }}>
|
| 304 |
Skip for now β analysis will still work, speaker detection may be less accurate
|
frontend/src/components/HistoryView.jsx
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
import { useState, useEffect } from "react"
|
| 2 |
import api from "../lib/api"
|
|
|
|
| 3 |
|
| 4 |
const CONTEXT_LABELS = {
|
| 5 |
-
social: "
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
|
|
@@ -21,7 +24,7 @@ function MiniScoreBar({ score }) {
|
|
| 21 |
{Array.from({ length: 5 }).map((_, i) => (
|
| 22 |
<div key={i} style={{
|
| 23 |
width: 10, height: 4, borderRadius: 2,
|
| 24 |
-
background: i < score ? color : "#
|
| 25 |
}} />
|
| 26 |
))}
|
| 27 |
</div>
|
|
@@ -94,9 +97,9 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 94 |
<button key={ctx} onClick={() => setContextFilter(ctx)}
|
| 95 |
style={{ padding: "4px 12px", borderRadius: 20, fontSize: 11,
|
| 96 |
cursor: "pointer", fontWeight: isActive ? 600 : 400,
|
| 97 |
-
background: isActive ? "rgba(
|
| 98 |
-
color: isActive ? "#
|
| 99 |
-
border: isActive ? "1px solid rgba(
|
| 100 |
transition: "all 0.15s" }}>
|
| 101 |
{ctx === "all" ? "All" : (CONTEXT_LABELS[ctx] || ctx)}
|
| 102 |
</button>
|
|
@@ -106,7 +109,7 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 106 |
</div>
|
| 107 |
|
| 108 |
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 109 |
-
{filtered.map(s => {
|
| 110 |
const dims = s.dimensions || {}
|
| 111 |
const dimSummary = [
|
| 112 |
{ label: "Emotional", score: dims.emotional_state?.confidence?.score },
|
|
@@ -116,17 +119,10 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 116 |
const sessionTypes = getTypes(s)
|
| 117 |
|
| 118 |
return (
|
| 119 |
-
<
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
e.currentTarget.style.borderColor = "#3d3d60"
|
| 124 |
-
e.currentTarget.style.background = "#1a1a2e"
|
| 125 |
-
}}
|
| 126 |
-
onMouseLeave={e => {
|
| 127 |
-
e.currentTarget.style.borderColor = "#2a2a42"
|
| 128 |
-
e.currentTarget.style.background = "#14141f"
|
| 129 |
-
}}>
|
| 130 |
|
| 131 |
<div onClick={() => onSelect({
|
| 132 |
signals: s.signals, insights: s.insights,
|
|
@@ -136,7 +132,7 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 136 |
speaker_confirmed: s.speaker_confirmed || false,
|
| 137 |
session_id: s.session_id,
|
| 138 |
available_speakers: s.available_speakers || [],
|
| 139 |
-
|
| 140 |
})} style={{ cursor: "pointer" }}>
|
| 141 |
|
| 142 |
{/* Top row */}
|
|
@@ -146,10 +142,10 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 146 |
{sessionTypes.map((t, i) => (
|
| 147 |
<span key={t} style={{
|
| 148 |
fontSize: 11, fontWeight: i === 0 ? 700 : 500,
|
| 149 |
-
color: i === 0 ? "#
|
| 150 |
textTransform: "uppercase", letterSpacing: 0.4,
|
| 151 |
-
background: i === 0 ? "rgba(
|
| 152 |
-
border: `1px solid ${i === 0 ? "rgba(
|
| 153 |
padding: "2px 8px", borderRadius: 4,
|
| 154 |
}}>
|
| 155 |
{CONTEXT_LABELS[t] || t}
|
|
@@ -188,7 +184,7 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 188 |
{/* Dimension score bars */}
|
| 189 |
{dimSummary.length > 0 && (
|
| 190 |
<div style={{ display: "flex", gap: 18, paddingTop: 10,
|
| 191 |
-
borderTop: "1px solid #
|
| 192 |
{dimSummary.map(({ label, score }) => (
|
| 193 |
<div key={label} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
| 194 |
<span style={{ fontSize: 10, color: "#4a4865" }}>{label}</span>
|
|
@@ -200,7 +196,7 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 200 |
</div>
|
| 201 |
|
| 202 |
{/* Delete controls */}
|
| 203 |
-
<div style={{ marginTop: 10, paddingTop: 8, borderTop: "1px solid #
|
| 204 |
display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8 }}>
|
| 205 |
{confirmDeleteId === s.session_id ? (
|
| 206 |
<>
|
|
@@ -227,18 +223,21 @@ export default function HistoryView({ onSelect, active = false }) {
|
|
| 227 |
)}
|
| 228 |
</div>
|
| 229 |
</div>
|
|
|
|
| 230 |
)
|
| 231 |
})}
|
| 232 |
</div>
|
| 233 |
|
| 234 |
{sessions.length >= 3 && (
|
| 235 |
-
<
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
| 242 |
)}
|
| 243 |
</div>
|
| 244 |
)
|
|
|
|
| 1 |
import { useState, useEffect } from "react"
|
| 2 |
import api from "../lib/api"
|
| 3 |
+
import Reveal from "./Reveal"
|
| 4 |
|
| 5 |
const CONTEXT_LABELS = {
|
| 6 |
+
social: "Casual & Low-Stakes", collaborative: "Collaborative",
|
| 7 |
+
evaluative: "Interview & Review Β· High Stakes", influential: "Persuading & Pitching",
|
| 8 |
+
negotiation: "Negotiation", adversarial: "Conflict & Friction",
|
| 9 |
+
developmental: "Coaching & Feedback", support: "Supportive Listening",
|
| 10 |
+
intimate: "Deep Personal", casual: "Casual & Low-Stakes", meeting: "Meeting",
|
| 11 |
+
job_interview: "Interview & Review Β· High Stakes", disagreement: "Conflict & Friction",
|
| 12 |
+
presentation: "Interview & Review Β· High Stakes", sales_call: "Persuading & Pitching",
|
| 13 |
+
feedback_conversation: "Coaching & Feedback", coaching_call: "Coaching & Feedback",
|
| 14 |
+
first_date: "Deep Personal",
|
| 15 |
}
|
| 16 |
|
| 17 |
const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
|
|
|
|
| 24 |
{Array.from({ length: 5 }).map((_, i) => (
|
| 25 |
<div key={i} style={{
|
| 26 |
width: 10, height: 4, borderRadius: 2,
|
| 27 |
+
background: i < score ? color : "#1e2438",
|
| 28 |
}} />
|
| 29 |
))}
|
| 30 |
</div>
|
|
|
|
| 97 |
<button key={ctx} onClick={() => setContextFilter(ctx)}
|
| 98 |
style={{ padding: "4px 12px", borderRadius: 20, fontSize: 11,
|
| 99 |
cursor: "pointer", fontWeight: isActive ? 600 : 400,
|
| 100 |
+
background: isActive ? "rgba(29,78,216,0.12)" : "#151922",
|
| 101 |
+
color: isActive ? "#5b9cf6" : "#8b89aa",
|
| 102 |
+
border: isActive ? "1px solid rgba(29,78,216,0.3)" : "1px solid #1e2438",
|
| 103 |
transition: "all 0.15s" }}>
|
| 104 |
{ctx === "all" ? "All" : (CONTEXT_LABELS[ctx] || ctx)}
|
| 105 |
</button>
|
|
|
|
| 109 |
</div>
|
| 110 |
|
| 111 |
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 112 |
+
{filtered.map((s, i) => {
|
| 113 |
const dims = s.dimensions || {}
|
| 114 |
const dimSummary = [
|
| 115 |
{ label: "Emotional", score: dims.emotional_state?.confidence?.score },
|
|
|
|
| 119 |
const sessionTypes = getTypes(s)
|
| 120 |
|
| 121 |
return (
|
| 122 |
+
<Reveal key={s.session_id} delay={Math.min(i * 80, 280)}>
|
| 123 |
+
<div className="card"
|
| 124 |
+
style={{ border: "1px solid #1e2438", borderRadius: 12, padding: 16,
|
| 125 |
+
background: "#151922", boxShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
<div onClick={() => onSelect({
|
| 128 |
signals: s.signals, insights: s.insights,
|
|
|
|
| 132 |
speaker_confirmed: s.speaker_confirmed || false,
|
| 133 |
session_id: s.session_id,
|
| 134 |
available_speakers: s.available_speakers || [],
|
| 135 |
+
fingerprint: s.fingerprint || null,
|
| 136 |
})} style={{ cursor: "pointer" }}>
|
| 137 |
|
| 138 |
{/* Top row */}
|
|
|
|
| 142 |
{sessionTypes.map((t, i) => (
|
| 143 |
<span key={t} style={{
|
| 144 |
fontSize: 11, fontWeight: i === 0 ? 700 : 500,
|
| 145 |
+
color: i === 0 ? "#5b9cf6" : "#8b89aa",
|
| 146 |
textTransform: "uppercase", letterSpacing: 0.4,
|
| 147 |
+
background: i === 0 ? "rgba(29,78,216,0.1)" : "#131827",
|
| 148 |
+
border: `1px solid ${i === 0 ? "rgba(29,78,216,0.25)" : "#1e2438"}`,
|
| 149 |
padding: "2px 8px", borderRadius: 4,
|
| 150 |
}}>
|
| 151 |
{CONTEXT_LABELS[t] || t}
|
|
|
|
| 184 |
{/* Dimension score bars */}
|
| 185 |
{dimSummary.length > 0 && (
|
| 186 |
<div style={{ display: "flex", gap: 18, paddingTop: 10,
|
| 187 |
+
borderTop: "1px solid #1e2438" }}>
|
| 188 |
{dimSummary.map(({ label, score }) => (
|
| 189 |
<div key={label} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
| 190 |
<span style={{ fontSize: 10, color: "#4a4865" }}>{label}</span>
|
|
|
|
| 196 |
</div>
|
| 197 |
|
| 198 |
{/* Delete controls */}
|
| 199 |
+
<div style={{ marginTop: 10, paddingTop: 8, borderTop: "1px solid #1e2438",
|
| 200 |
display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8 }}>
|
| 201 |
{confirmDeleteId === s.session_id ? (
|
| 202 |
<>
|
|
|
|
| 223 |
)}
|
| 224 |
</div>
|
| 225 |
</div>
|
| 226 |
+
</Reveal>
|
| 227 |
)
|
| 228 |
})}
|
| 229 |
</div>
|
| 230 |
|
| 231 |
{sessions.length >= 3 && (
|
| 232 |
+
<Reveal>
|
| 233 |
+
<div style={{ marginTop: 20, padding: 14,
|
| 234 |
+
background: "rgba(52,211,153,0.06)",
|
| 235 |
+
border: "1px solid rgba(52,211,153,0.2)",
|
| 236 |
+
borderRadius: 10, fontSize: 13, color: "#34d399" }}>
|
| 237 |
+
β¨ {sessions.length} sessions recorded β your behavioral profile is active.
|
| 238 |
+
Check the Profile tab.
|
| 239 |
+
</div>
|
| 240 |
+
</Reveal>
|
| 241 |
)}
|
| 242 |
</div>
|
| 243 |
)
|
frontend/src/components/HowItWorksView.jsx
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Reveal, { RevealItem } from "./Reveal"
|
| 2 |
+
|
| 3 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 4 |
+
|
| 5 |
+
const FEATURES = [
|
| 6 |
+
{
|
| 7 |
+
icon: "π€",
|
| 8 |
+
color: "#5b9cf6",
|
| 9 |
+
title: "Real Conversation Analysis",
|
| 10 |
+
body: "We read what was actually said, not just extracted numbers. The AI identifies missed questions, interrupted thoughts, and moments you talked past each other.",
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
icon: "π",
|
| 14 |
+
color: "#34d399",
|
| 15 |
+
title: "Mirror Feed",
|
| 16 |
+
body: "Patterns that only emerge across multiple sessions. After a few conversations, the mirror starts noticing what you consistently do β and don't do.",
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
icon: "π―",
|
| 20 |
+
color: "#f59e0b",
|
| 21 |
+
title: "Context-Aware Insights",
|
| 22 |
+
body: "An interview is not a casual chat. We detect the type of conversation automatically and calibrate what good looks like for that specific setting.",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
icon: "π",
|
| 26 |
+
color: "#818cf8",
|
| 27 |
+
title: "Your Behavioral Shape",
|
| 28 |
+
body: "Five dimensions tracked across all your sessions β Confidence, Assertiveness, Listening Quality, Composure, and Clarity β shown as a radar you can watch shift.",
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
icon: "ποΈ",
|
| 32 |
+
color: "#fb923c",
|
| 33 |
+
title: "Voice Recognition",
|
| 34 |
+
body: "Enroll your voice once. We identify which speaker is you across all future sessions automatically β no manual tagging needed.",
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
icon: "π",
|
| 38 |
+
color: "#5b9cf6",
|
| 39 |
+
title: "Privacy by Design",
|
| 40 |
+
body: "Your audio is processed and discarded. Transcripts are analyzed and never stored. Only your behavioral patterns are kept β never your actual words.",
|
| 41 |
+
},
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
const CONTEXT_TYPES = [
|
| 45 |
+
{ label: "Interview & Review Β· High Stakes", desc: "Job interviews, performance reviews, presentations" },
|
| 46 |
+
{ label: "Conflict & Friction", desc: "Disagreements, pushback, difficult conversations" },
|
| 47 |
+
{ label: "Collaborative", desc: "Team meetings, brainstorming, joint problem-solving" },
|
| 48 |
+
{ label: "Persuading & Pitching", desc: "Sales calls, pitches, convincing others" },
|
| 49 |
+
{ label: "Negotiation", desc: "Deals, salary discussions, competing interests" },
|
| 50 |
+
{ label: "Coaching & Feedback", desc: "Mentoring, giving criticism, guiding others" },
|
| 51 |
+
{ label: "Supportive Listening", desc: "Emotional support, being there for someone" },
|
| 52 |
+
{ label: "Deep Personal", desc: "Vulnerable, emotionally open conversations" },
|
| 53 |
+
{ label: "Casual & Low-Stakes", desc: "Everyday chat, catching up, informal exchanges" },
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
function Section({ label, title, subtitle, children }) {
|
| 57 |
+
return (
|
| 58 |
+
<div style={{ marginBottom: 64 }}>
|
| 59 |
+
{label && (
|
| 60 |
+
<div style={{ fontSize: 11, fontWeight: 700, color: "#5b9cf6",
|
| 61 |
+
textTransform: "uppercase", letterSpacing: 1.2, marginBottom: 10,
|
| 62 |
+
textAlign: "center" }}>
|
| 63 |
+
{label}
|
| 64 |
+
</div>
|
| 65 |
+
)}
|
| 66 |
+
{title && (
|
| 67 |
+
<h2 style={{ fontSize: 24, fontWeight: 800, color: "#f0eeff",
|
| 68 |
+
margin: "0 0 10px", letterSpacing: "-0.5px", textAlign: "center",
|
| 69 |
+
lineHeight: 1.25 }}>
|
| 70 |
+
{title}
|
| 71 |
+
</h2>
|
| 72 |
+
)}
|
| 73 |
+
{subtitle && (
|
| 74 |
+
<p style={{ fontSize: 14, color: "#6b6888", textAlign: "center",
|
| 75 |
+
margin: "0 0 32px", lineHeight: 1.75, maxWidth: 520, marginInline: "auto" }}>
|
| 76 |
+
{subtitle}
|
| 77 |
+
</p>
|
| 78 |
+
)}
|
| 79 |
+
{children}
|
| 80 |
+
</div>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export default function HowItWorksView({ onBack }) {
|
| 85 |
+
return (
|
| 86 |
+
<div style={{ paddingBottom: 80 }} className="view-enter">
|
| 87 |
+
|
| 88 |
+
{/* Back */}
|
| 89 |
+
<button onClick={onBack}
|
| 90 |
+
style={{ background: "none", border: "none", cursor: "pointer",
|
| 91 |
+
color: "#4a4865", fontSize: 13, padding: 0, marginBottom: 40,
|
| 92 |
+
display: "flex", alignItems: "center", gap: 5 }}>
|
| 93 |
+
β Back
|
| 94 |
+
</button>
|
| 95 |
+
|
| 96 |
+
{/* ββ Hero ββ */}
|
| 97 |
+
<Reveal>
|
| 98 |
+
<div style={{ textAlign: "center", marginBottom: 72 }}>
|
| 99 |
+
<div style={{ display: "flex", justifyContent: "center", marginBottom: 20 }}>
|
| 100 |
+
<svg width="52" height="52" viewBox="0 0 52 52" fill="none">
|
| 101 |
+
<defs>
|
| 102 |
+
<linearGradient id="hiw-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
|
| 103 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 104 |
+
</linearGradient>
|
| 105 |
+
</defs>
|
| 106 |
+
<rect x="2" y="20" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".35"/>
|
| 107 |
+
<rect x="11" y="13" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".6"/>
|
| 108 |
+
<rect x="20" y="6" width="8" height="20" rx="4" fill="url(#hiw-g)"/>
|
| 109 |
+
<rect x="31" y="13" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".6"/>
|
| 110 |
+
<rect x="40" y="20" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".35"/>
|
| 111 |
+
<line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
|
| 112 |
+
<rect x="2" y="29" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".15"/>
|
| 113 |
+
<rect x="11" y="29" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".27"/>
|
| 114 |
+
<rect x="20" y="29" width="8" height="20" rx="4" fill="url(#hiw-g)" opacity=".33"/>
|
| 115 |
+
<rect x="31" y="29" width="6" height="13" rx="3" fill="url(#hiw-g)" opacity=".27"/>
|
| 116 |
+
<rect x="40" y="29" width="6" height="6" rx="3" fill="url(#hiw-g)" opacity=".15"/>
|
| 117 |
+
</svg>
|
| 118 |
+
</div>
|
| 119 |
+
<h1 style={{ fontSize: 30, fontWeight: 800, margin: "0 0 6px",
|
| 120 |
+
letterSpacing: "-0.7px", lineHeight: 1.2, color: "#f0eeff" }}>
|
| 121 |
+
Your conversations,
|
| 122 |
+
</h1>
|
| 123 |
+
<h1 style={{ fontSize: 30, fontWeight: 800, margin: "0 0 20px",
|
| 124 |
+
letterSpacing: "-0.7px", lineHeight: 1.2,
|
| 125 |
+
background: G, WebkitBackgroundClip: "text",
|
| 126 |
+
WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
|
| 127 |
+
reflected back.
|
| 128 |
+
</h1>
|
| 129 |
+
<p style={{ fontSize: 14, color: "#6b6888", lineHeight: 1.85,
|
| 130 |
+
maxWidth: 460, margin: "0 auto" }}>
|
| 131 |
+
Most people go through hundreds of conversations without ever seeing their
|
| 132 |
+
own patterns. mirror changes that β not by telling you what to say,
|
| 133 |
+
but by showing you how you actually show up.
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
</Reveal>
|
| 137 |
+
|
| 138 |
+
{/* ββ The Problem ββ */}
|
| 139 |
+
<Reveal>
|
| 140 |
+
<Section label="The Problem" title="You can't see yourself clearly in the moment.">
|
| 141 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
| 142 |
+
{[
|
| 143 |
+
<>In every conversation you're focused on what to say next. Nobody tells you that you interrupted four times, spoke for 70% of the conversation, or always go quiet when you're challenged.</>,
|
| 144 |
+
<><span style={{ color: "#c4c2d8", fontWeight: 600 }}>Good coaches cost hundreds per hour.</span> Therapy takes years. Most feedback from the people around you is filtered through politeness or filtered out entirely.</>,
|
| 145 |
+
<><span style={{ color: "#c4c2d8", fontWeight: 600 }}>That's why mirror exists.</span> Upload a recording and we show you exactly what's happening β not what you think is happening.</>,
|
| 146 |
+
].map((text, i) => (
|
| 147 |
+
<p key={i} style={{ margin: 0, fontSize: 14, color: "#8b89aa",
|
| 148 |
+
lineHeight: 1.85, textAlign: "center", maxWidth: 540, marginInline: "auto" }}>
|
| 149 |
+
{text}
|
| 150 |
+
</p>
|
| 151 |
+
))}
|
| 152 |
+
</div>
|
| 153 |
+
</Section>
|
| 154 |
+
</Reveal>
|
| 155 |
+
|
| 156 |
+
{/* ββ How It Works ββ */}
|
| 157 |
+
<Reveal>
|
| 158 |
+
<Section label="How It Works" title="It gets smarter with every session.">
|
| 159 |
+
<div style={{ display: "flex", flexDirection: "column",
|
| 160 |
+
maxWidth: 520, margin: "0 auto" }}>
|
| 161 |
+
{[
|
| 162 |
+
{
|
| 163 |
+
num: "1",
|
| 164 |
+
title: "Upload a recording",
|
| 165 |
+
body: "Any conversation β a meeting, a call, a catch-up. 2 to 20 minutes. Hindi or English. What you talked about doesn't matter. How you showed up does.",
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
num: "2",
|
| 169 |
+
title: "AI reads what was actually said",
|
| 170 |
+
body: "Not just numbers β the actual conversation. We find what you missed, what you repeated, how you listened, and where you lost the thread.",
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
num: "3",
|
| 174 |
+
title: "Your profile builds over time",
|
| 175 |
+
body: "One session gives immediate feedback. Three sessions reveals patterns. Ten sessions and the mirror knows your behavioral tendencies better than most people who know you.",
|
| 176 |
+
},
|
| 177 |
+
].map(({ num, title, body }, i, arr) => (
|
| 178 |
+
<div key={num} style={{ display: "flex", gap: 20, position: "relative",
|
| 179 |
+
paddingBottom: i < arr.length - 1 ? 34 : 0 }}>
|
| 180 |
+
{i < arr.length - 1 && (
|
| 181 |
+
<div style={{ position: "absolute", left: 17, top: 42,
|
| 182 |
+
width: 2, height: "calc(100% - 14px)",
|
| 183 |
+
background: "linear-gradient(to bottom, rgba(29,78,216,0.25), transparent)" }} />
|
| 184 |
+
)}
|
| 185 |
+
<div style={{ width: 36, height: 36, borderRadius: "50%", flexShrink: 0,
|
| 186 |
+
background: "rgba(29,78,216,0.1)", border: "1.5px solid rgba(29,78,216,0.3)",
|
| 187 |
+
display: "flex", alignItems: "center", justifyContent: "center",
|
| 188 |
+
fontSize: 14, fontWeight: 700, color: "#5b9cf6", zIndex: 1 }}>
|
| 189 |
+
{num}
|
| 190 |
+
</div>
|
| 191 |
+
<div style={{ paddingTop: 5 }}>
|
| 192 |
+
<div style={{ fontSize: 14, fontWeight: 700, color: "#f0eeff",
|
| 193 |
+
marginBottom: 6 }}>{title}</div>
|
| 194 |
+
<div style={{ fontSize: 13, color: "#6b6888", lineHeight: 1.7 }}>{body}</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
))}
|
| 198 |
+
</div>
|
| 199 |
+
</Section>
|
| 200 |
+
</Reveal>
|
| 201 |
+
|
| 202 |
+
{/* ββ Features Grid ββ */}
|
| 203 |
+
<Reveal>
|
| 204 |
+
<Section label="What You Get" title="Everything the mirror sees."
|
| 205 |
+
subtitle="Each session adds signal. Here's what we track and what it builds into.">
|
| 206 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
| 207 |
+
{FEATURES.map(({ icon, color, title, body }, idx) => (
|
| 208 |
+
<RevealItem key={title} index={idx}>
|
| 209 |
+
<div className="card"
|
| 210 |
+
style={{ background: "#151922",
|
| 211 |
+
border: "1px solid #1e2438", borderRadius: 14, padding: "20px 18px" }}>
|
| 212 |
+
<div style={{ width: 42, height: 42, borderRadius: 11,
|
| 213 |
+
background: `${color}12`, border: `1px solid ${color}28`,
|
| 214 |
+
display: "flex", alignItems: "center", justifyContent: "center",
|
| 215 |
+
fontSize: 20, marginBottom: 14 }}>
|
| 216 |
+
{icon}
|
| 217 |
+
</div>
|
| 218 |
+
<div style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff",
|
| 219 |
+
marginBottom: 7, lineHeight: 1.35 }}>
|
| 220 |
+
{title}
|
| 221 |
+
</div>
|
| 222 |
+
<div style={{ fontSize: 12, color: "#6b6888", lineHeight: 1.65 }}>
|
| 223 |
+
{body}
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
</RevealItem>
|
| 227 |
+
))}
|
| 228 |
+
</div>
|
| 229 |
+
</Section>
|
| 230 |
+
</Reveal>
|
| 231 |
+
|
| 232 |
+
{/* ββ Context Types ββ */}
|
| 233 |
+
<Reveal>
|
| 234 |
+
<Section label="Context Types" title="Every setting is different."
|
| 235 |
+
subtitle="We automatically detect what kind of conversation you had and adjust the feedback. The more types you record in, the fuller the picture.">
|
| 236 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
| 237 |
+
{CONTEXT_TYPES.map(({ label, desc }) => (
|
| 238 |
+
<div key={label} style={{ display: "flex", justifyContent: "space-between",
|
| 239 |
+
alignItems: "center", padding: "11px 16px",
|
| 240 |
+
background: "#151922", border: "1px solid #1e2438", borderRadius: 10,
|
| 241 |
+
gap: 12 }}>
|
| 242 |
+
<span style={{ fontSize: 13, fontWeight: 600, color: "#c4c2d8",
|
| 243 |
+
flexShrink: 0 }}>
|
| 244 |
+
{label}
|
| 245 |
+
</span>
|
| 246 |
+
<span style={{ fontSize: 12, color: "#4a4865", textAlign: "right" }}>
|
| 247 |
+
{desc}
|
| 248 |
+
</span>
|
| 249 |
+
</div>
|
| 250 |
+
))}
|
| 251 |
+
</div>
|
| 252 |
+
</Section>
|
| 253 |
+
</Reveal>
|
| 254 |
+
|
| 255 |
+
{/* ββ Privacy ββ */}
|
| 256 |
+
<Reveal>
|
| 257 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 258 |
+
borderRadius: 14, padding: "28px 24px", textAlign: "center" }}>
|
| 259 |
+
<div style={{ fontSize: 32, marginBottom: 14 }}>π</div>
|
| 260 |
+
<h3 style={{ fontSize: 17, fontWeight: 700, color: "#f0eeff",
|
| 261 |
+
margin: "0 0 10px" }}>
|
| 262 |
+
Privacy by design
|
| 263 |
+
</h3>
|
| 264 |
+
<p style={{ fontSize: 13, color: "#6b6888", lineHeight: 1.8, margin: 0,
|
| 265 |
+
maxWidth: 420, marginInline: "auto" }}>
|
| 266 |
+
Your audio is processed and immediately discarded. Transcripts are analyzed
|
| 267 |
+
and never stored. The only thing we keep is a behavioral summary β
|
| 268 |
+
never your actual words.
|
| 269 |
+
</p>
|
| 270 |
+
</div>
|
| 271 |
+
</Reveal>
|
| 272 |
+
|
| 273 |
+
</div>
|
| 274 |
+
)
|
| 275 |
+
}
|
frontend/src/components/OnboardingView.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useState } from "react"
|
| 2 |
|
| 3 |
-
const G = "linear-gradient(135deg, #
|
| 4 |
|
| 5 |
const MOCK_DIMENSIONS = [
|
| 6 |
{ name: "Confidence", score: 68, label: "Assured" },
|
|
@@ -23,7 +23,7 @@ function MockDimBar({ name, score, label }) {
|
|
| 23 |
{score}
|
| 24 |
</span>
|
| 25 |
</div>
|
| 26 |
-
<div style={{ height: 4, background: "#
|
| 27 |
<div style={{ height: "100%", borderRadius: 2,
|
| 28 |
width: `${score}%`, background: G }} />
|
| 29 |
</div>
|
|
@@ -34,7 +34,26 @@ function MockDimBar({ name, score, label }) {
|
|
| 34 |
function StepConcept() {
|
| 35 |
return (
|
| 36 |
<div>
|
| 37 |
-
<div style={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
<h1 style={{ fontSize: 26, fontWeight: 700, color: "#f0eeff",
|
| 39 |
margin: "0 0 14px", lineHeight: 1.25, textAlign: "center",
|
| 40 |
letterSpacing: "-0.4px" }}>
|
|
@@ -43,7 +62,7 @@ function StepConcept() {
|
|
| 43 |
<p style={{ fontSize: 14, color: "#8b89aa", lineHeight: 1.8,
|
| 44 |
margin: "0 0 28px", textAlign: "center" }}>
|
| 45 |
Most people go through hundreds of conversations without ever seeing their
|
| 46 |
-
own patterns.
|
| 47 |
say, but by reflecting how you actually show up.
|
| 48 |
</p>
|
| 49 |
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
@@ -56,7 +75,7 @@ function StepConcept() {
|
|
| 56 |
"Most people only get this from years of therapy or a very honest coach. This is a faster mirror."],
|
| 57 |
].map(([title, body]) => (
|
| 58 |
<div key={title} style={{ display: "flex", gap: 14, padding: "14px 16px",
|
| 59 |
-
background: "#
|
| 60 |
<div style={{ width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
|
| 61 |
background: G, marginTop: 5 }} />
|
| 62 |
<div>
|
|
@@ -104,13 +123,13 @@ function StepHow() {
|
|
| 104 |
{i < arr.length - 1 && (
|
| 105 |
<div style={{ position: "absolute", left: 16, top: 38,
|
| 106 |
width: 2, height: "calc(100% - 12px)",
|
| 107 |
-
background: "#
|
| 108 |
)}
|
| 109 |
<div style={{ width: 34, height: 34, borderRadius: "50%", flexShrink: 0,
|
| 110 |
-
background: "rgba(
|
| 111 |
-
border: "1px solid rgba(
|
| 112 |
display: "flex", alignItems: "center", justifyContent: "center",
|
| 113 |
-
fontSize: 13, fontWeight: 700, color: "#
|
| 114 |
{num}
|
| 115 |
</div>
|
| 116 |
<div style={{ paddingBottom: i < arr.length - 1 ? 26 : 0 }}>
|
|
@@ -137,7 +156,7 @@ function StepPreview() {
|
|
| 137 |
not generic advice.
|
| 138 |
</p>
|
| 139 |
|
| 140 |
-
<div style={{ background: "#
|
| 141 |
borderRadius: 12, padding: "18px 20px", position: "relative" }}>
|
| 142 |
<div style={{ position: "absolute", top: 12, right: 14, fontSize: 10,
|
| 143 |
color: "#3a3a52", textTransform: "uppercase", letterSpacing: 0.6,
|
|
@@ -146,12 +165,29 @@ function StepPreview() {
|
|
| 146 |
</div>
|
| 147 |
|
| 148 |
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
|
| 149 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
<span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>The Mirror</span>
|
| 151 |
</div>
|
| 152 |
|
| 153 |
<p style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.85,
|
| 154 |
-
margin: "0 0 18px", borderLeft: "2px solid rgba(
|
| 155 |
paddingLeft: 12 }}>
|
| 156 |
You lead conversations confidently in collaborative settings, but pull back
|
| 157 |
noticeably when you're being assessed. Your listening quality is consistently
|
|
@@ -164,7 +200,7 @@ function StepPreview() {
|
|
| 164 |
</div>
|
| 165 |
|
| 166 |
<div style={{ marginTop: 14, padding: "12px 14px",
|
| 167 |
-
background: "#
|
| 168 |
display: "flex", gap: 12, alignItems: "flex-start" }}>
|
| 169 |
<div style={{ fontSize: 13, color: "#4a4865", flexShrink: 0 }}>?</div>
|
| 170 |
<div>
|
|
@@ -201,7 +237,7 @@ export default function OnboardingView({ onDone }) {
|
|
| 201 |
|
| 202 |
return (
|
| 203 |
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
|
| 204 |
-
justifyContent: "center", padding: "32px 24px", background: "#
|
| 205 |
<div style={{ maxWidth: 500, width: "100%" }}>
|
| 206 |
|
| 207 |
{/* Step indicators */}
|
|
@@ -213,7 +249,7 @@ export default function OnboardingView({ onDone }) {
|
|
| 213 |
width: i === step ? 24 : 8,
|
| 214 |
background: i <= step
|
| 215 |
? G
|
| 216 |
-
: "#
|
| 217 |
transition: "all 0.3s ease",
|
| 218 |
}} />
|
| 219 |
))}
|
|
@@ -241,7 +277,7 @@ export default function OnboardingView({ onDone }) {
|
|
| 241 |
onClick={step < STEPS.length - 1 ? () => setStep(s => s + 1) : finish}
|
| 242 |
style={{ padding: "12px 28px", background: G, color: "white",
|
| 243 |
border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
|
| 244 |
-
fontWeight: 600, boxShadow: "0 0 24px rgba(
|
| 245 |
{step < STEPS.length - 1 ? "Next β" : "Get started β"}
|
| 246 |
</button>
|
| 247 |
</div>
|
|
|
|
| 1 |
import { useState } from "react"
|
| 2 |
|
| 3 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 4 |
|
| 5 |
const MOCK_DIMENSIONS = [
|
| 6 |
{ name: "Confidence", score: 68, label: "Assured" },
|
|
|
|
| 23 |
{score}
|
| 24 |
</span>
|
| 25 |
</div>
|
| 26 |
+
<div style={{ height: 4, background: "#1e2438", borderRadius: 2 }}>
|
| 27 |
<div style={{ height: "100%", borderRadius: 2,
|
| 28 |
width: `${score}%`, background: G }} />
|
| 29 |
</div>
|
|
|
|
| 34 |
function StepConcept() {
|
| 35 |
return (
|
| 36 |
<div>
|
| 37 |
+
<div style={{ display: "flex", justifyContent: "center", marginBottom: 18 }}>
|
| 38 |
+
<svg width="48" height="48" viewBox="0 0 52 52" fill="none">
|
| 39 |
+
<defs>
|
| 40 |
+
<linearGradient id="onb-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
|
| 41 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 42 |
+
</linearGradient>
|
| 43 |
+
</defs>
|
| 44 |
+
<rect x="2" y="20" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".35"/>
|
| 45 |
+
<rect x="11" y="13" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".6"/>
|
| 46 |
+
<rect x="20" y="6" width="8" height="20" rx="4" fill="url(#onb-g)"/>
|
| 47 |
+
<rect x="31" y="13" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".6"/>
|
| 48 |
+
<rect x="40" y="20" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".35"/>
|
| 49 |
+
<line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
|
| 50 |
+
<rect x="2" y="29" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".15"/>
|
| 51 |
+
<rect x="11" y="29" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".27"/>
|
| 52 |
+
<rect x="20" y="29" width="8" height="20" rx="4" fill="url(#onb-g)" opacity=".33"/>
|
| 53 |
+
<rect x="31" y="29" width="6" height="13" rx="3" fill="url(#onb-g)" opacity=".27"/>
|
| 54 |
+
<rect x="40" y="29" width="6" height="6" rx="3" fill="url(#onb-g)" opacity=".15"/>
|
| 55 |
+
</svg>
|
| 56 |
+
</div>
|
| 57 |
<h1 style={{ fontSize: 26, fontWeight: 700, color: "#f0eeff",
|
| 58 |
margin: "0 0 14px", lineHeight: 1.25, textAlign: "center",
|
| 59 |
letterSpacing: "-0.4px" }}>
|
|
|
|
| 62 |
<p style={{ fontSize: 14, color: "#8b89aa", lineHeight: 1.8,
|
| 63 |
margin: "0 0 28px", textAlign: "center" }}>
|
| 64 |
Most people go through hundreds of conversations without ever seeing their
|
| 65 |
+
own patterns. mirror. changes that β not by telling you what to
|
| 66 |
say, but by reflecting how you actually show up.
|
| 67 |
</p>
|
| 68 |
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
|
|
| 75 |
"Most people only get this from years of therapy or a very honest coach. This is a faster mirror."],
|
| 76 |
].map(([title, body]) => (
|
| 77 |
<div key={title} style={{ display: "flex", gap: 14, padding: "14px 16px",
|
| 78 |
+
background: "#151922", border: "1px solid #1e2438", borderRadius: 10 }}>
|
| 79 |
<div style={{ width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
|
| 80 |
background: G, marginTop: 5 }} />
|
| 81 |
<div>
|
|
|
|
| 123 |
{i < arr.length - 1 && (
|
| 124 |
<div style={{ position: "absolute", left: 16, top: 38,
|
| 125 |
width: 2, height: "calc(100% - 12px)",
|
| 126 |
+
background: "#1e2438", zIndex: 0 }} />
|
| 127 |
)}
|
| 128 |
<div style={{ width: 34, height: 34, borderRadius: "50%", flexShrink: 0,
|
| 129 |
+
background: "rgba(29,78,216,0.1)",
|
| 130 |
+
border: "1px solid rgba(29,78,216,0.3)",
|
| 131 |
display: "flex", alignItems: "center", justifyContent: "center",
|
| 132 |
+
fontSize: 13, fontWeight: 700, color: "#5b9cf6", zIndex: 1 }}>
|
| 133 |
{num}
|
| 134 |
</div>
|
| 135 |
<div style={{ paddingBottom: i < arr.length - 1 ? 26 : 0 }}>
|
|
|
|
| 156 |
not generic advice.
|
| 157 |
</p>
|
| 158 |
|
| 159 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 160 |
borderRadius: 12, padding: "18px 20px", position: "relative" }}>
|
| 161 |
<div style={{ position: "absolute", top: 12, right: 14, fontSize: 10,
|
| 162 |
color: "#3a3a52", textTransform: "uppercase", letterSpacing: 0.6,
|
|
|
|
| 165 |
</div>
|
| 166 |
|
| 167 |
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
|
| 168 |
+
<svg width="14" height="14" viewBox="0 0 32 32" fill="none" style={{ flexShrink: 0 }}>
|
| 169 |
+
<defs>
|
| 170 |
+
<linearGradient id="onb-sm-g" x1="0" y1="0" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
| 171 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 172 |
+
</linearGradient>
|
| 173 |
+
</defs>
|
| 174 |
+
<rect x="2" y="12" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".4"/>
|
| 175 |
+
<rect x="8" y="8" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".65"/>
|
| 176 |
+
<rect x="14" y="4" width="4" height="12" rx="2" fill="url(#onb-sm-g)"/>
|
| 177 |
+
<rect x="20" y="8" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".65"/>
|
| 178 |
+
<rect x="26" y="12" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".4"/>
|
| 179 |
+
<line x1="0" y1="17.5" x2="32" y2="17.5" stroke="#1e2438" strokeWidth="1"/>
|
| 180 |
+
<rect x="2" y="18" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".18"/>
|
| 181 |
+
<rect x="8" y="18" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".3"/>
|
| 182 |
+
<rect x="14" y="18" width="4" height="12" rx="2" fill="url(#onb-sm-g)" opacity=".38"/>
|
| 183 |
+
<rect x="20" y="18" width="4" height="8" rx="2" fill="url(#onb-sm-g)" opacity=".3"/>
|
| 184 |
+
<rect x="26" y="18" width="4" height="4" rx="2" fill="url(#onb-sm-g)" opacity=".18"/>
|
| 185 |
+
</svg>
|
| 186 |
<span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>The Mirror</span>
|
| 187 |
</div>
|
| 188 |
|
| 189 |
<p style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.85,
|
| 190 |
+
margin: "0 0 18px", borderLeft: "2px solid rgba(29,78,216,0.35)",
|
| 191 |
paddingLeft: 12 }}>
|
| 192 |
You lead conversations confidently in collaborative settings, but pull back
|
| 193 |
noticeably when you're being assessed. Your listening quality is consistently
|
|
|
|
| 200 |
</div>
|
| 201 |
|
| 202 |
<div style={{ marginTop: 14, padding: "12px 14px",
|
| 203 |
+
background: "#0e1320", border: "1px dashed #1e2438", borderRadius: 8,
|
| 204 |
display: "flex", gap: 12, alignItems: "flex-start" }}>
|
| 205 |
<div style={{ fontSize: 13, color: "#4a4865", flexShrink: 0 }}>?</div>
|
| 206 |
<div>
|
|
|
|
| 237 |
|
| 238 |
return (
|
| 239 |
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center",
|
| 240 |
+
justifyContent: "center", padding: "32px 24px", background: "#0f1117" }}>
|
| 241 |
<div style={{ maxWidth: 500, width: "100%" }}>
|
| 242 |
|
| 243 |
{/* Step indicators */}
|
|
|
|
| 249 |
width: i === step ? 24 : 8,
|
| 250 |
background: i <= step
|
| 251 |
? G
|
| 252 |
+
: "#1e2438",
|
| 253 |
transition: "all 0.3s ease",
|
| 254 |
}} />
|
| 255 |
))}
|
|
|
|
| 277 |
onClick={step < STEPS.length - 1 ? () => setStep(s => s + 1) : finish}
|
| 278 |
style={{ padding: "12px 28px", background: G, color: "white",
|
| 279 |
border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
|
| 280 |
+
fontWeight: 600, boxShadow: "0 0 24px rgba(29,78,216,0.25)" }}>
|
| 281 |
{step < STEPS.length - 1 ? "Next β" : "Get started β"}
|
| 282 |
</button>
|
| 283 |
</div>
|
frontend/src/components/ProfileView.jsx
CHANGED
|
@@ -1,20 +1,24 @@
|
|
| 1 |
import { useState, useEffect } from "react"
|
| 2 |
import {
|
|
|
|
| 3 |
LineChart, Line, XAxis, YAxis, Tooltip,
|
| 4 |
ResponsiveContainer, CartesianGrid,
|
| 5 |
} from "recharts"
|
| 6 |
import api from "../lib/api"
|
|
|
|
| 7 |
|
| 8 |
-
const G = "linear-gradient(135deg, #
|
| 9 |
|
| 10 |
const CONTEXT_LABELS = {
|
| 11 |
-
social: "
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const SIGNAL_OPTIONS = [
|
|
@@ -26,193 +30,390 @@ const SIGNAL_OPTIONS = [
|
|
| 26 |
{ key: "response_latency", label: "Response Latency", unit: "s" },
|
| 27 |
]
|
| 28 |
|
| 29 |
-
const
|
| 30 |
-
|
| 31 |
-
influential:
|
| 32 |
-
developmental:
|
| 33 |
-
casual: "#818cf8", meeting: "#a78bfa", job_interview: "#f87171",
|
| 34 |
-
disagreement: "#fb923c", presentation: "#34d399",
|
| 35 |
-
sales_call: "#f59e0b", feedback_conversation: "#818cf8",
|
| 36 |
-
coaching_call: "#2dd4bf", first_date: "#f472b6",
|
| 37 |
}
|
| 38 |
|
| 39 |
-
const
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
-
function
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return (
|
| 47 |
-
<div style={{
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
<span style={{ fontSize: 13, fontWeight: 600, color: "#f0eeff" }}>{dim.name}</span>
|
| 55 |
-
<span style={{ fontSize: 11, color: "#4a4865" }}>{dim.label}</span>
|
| 56 |
-
</div>
|
| 57 |
-
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
| 58 |
-
<span style={{ fontSize: 15, fontWeight: 700,
|
| 59 |
-
background: G, WebkitBackgroundClip: "text",
|
| 60 |
-
WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
|
| 61 |
-
{dim.score}
|
| 62 |
-
</span>
|
| 63 |
-
<span style={{ fontSize: 10, color: "#4a4865" }}>{expanded ? "β²" : "βΌ"}</span>
|
| 64 |
-
</div>
|
| 65 |
-
</div>
|
| 66 |
-
<div style={{ height: 4, background: "#2a2a42", borderRadius: 2, margin: "10px 0 0" }}>
|
| 67 |
-
<div style={{
|
| 68 |
-
height: "100%", borderRadius: 2,
|
| 69 |
-
width: `${dim.score}%`,
|
| 70 |
-
background: G,
|
| 71 |
-
transition: "width 0.6s ease",
|
| 72 |
-
boxShadow: "0 0 6px rgba(217,70,239,0.3)",
|
| 73 |
-
}} />
|
| 74 |
-
</div>
|
| 75 |
-
{expanded && dim.narrative && (
|
| 76 |
-
<div style={{ fontSize: 13, color: "#8b89aa", lineHeight: 1.75,
|
| 77 |
-
marginTop: 12, borderTop: "1px solid #2a2a42", paddingTop: 12 }}>
|
| 78 |
-
{dim.narrative}
|
| 79 |
-
</div>
|
| 80 |
-
)}
|
| 81 |
</div>
|
| 82 |
)
|
| 83 |
}
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
return (
|
| 91 |
-
<div style={{ background: "#
|
| 92 |
-
borderRadius: 12, overflow: "hidden"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
{expanded ? "β² close" : "βΌ expand"}
|
| 108 |
-
</span>
|
| 109 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
-
|
| 112 |
-
/* Collapsed: most recent session β 2 full + 3rd fading */
|
| 113 |
-
<div style={{ padding: "14px 18px 16px" }}>
|
| 114 |
-
<div style={{ fontSize: 11, marginBottom: 12,
|
| 115 |
-
display: "flex", alignItems: "center", gap: 6 }}>
|
| 116 |
-
<span style={{ color: "#4a4865" }}>{latest.date}</span>
|
| 117 |
-
<span style={{ color: "#2a2a42" }}>Β·</span>
|
| 118 |
-
<span style={{ color: CONTEXT_COLORS[latest.context] || "#8b89aa", fontWeight: 500 }}>
|
| 119 |
-
{CONTEXT_LABELS[latest.context] || latest.context}
|
| 120 |
-
</span>
|
| 121 |
-
</div>
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
<span style={{ color: "#3a3a52", fontSize: 13, marginTop: 2, flexShrink: 0 }}>β</span>
|
| 126 |
-
<span style={{ fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>
|
| 127 |
-
{latest.highlights[0]}
|
| 128 |
-
</span>
|
| 129 |
-
</div>
|
| 130 |
-
)}
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
{latest.highlights[1]}
|
| 137 |
-
</span>
|
| 138 |
-
</div>
|
| 139 |
-
)}
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
</div>
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
/* Expanded: scrollable across all sessions */
|
| 157 |
-
<div style={{ maxHeight: 440, overflowY: "auto", padding: "16px 18px",
|
| 158 |
-
scrollbarWidth: "thin", scrollbarColor: "#2a2a42 transparent" }}>
|
| 159 |
-
{sessions.map((s, si) => (
|
| 160 |
-
<div key={si} style={{ marginBottom: si < sessions.length - 1 ? 20 : 0 }}>
|
| 161 |
-
<div style={{ fontSize: 11, marginBottom: 10,
|
| 162 |
-
display: "flex", alignItems: "center", gap: 6 }}>
|
| 163 |
-
<span style={{ color: "#4a4865" }}>{s.date}</span>
|
| 164 |
-
<span style={{ color: "#2a2a42" }}>Β·</span>
|
| 165 |
-
<span style={{ color: CONTEXT_COLORS[s.context] || "#8b89aa", fontWeight: 500 }}>
|
| 166 |
-
{CONTEXT_LABELS[s.context] || s.context}
|
| 167 |
-
</span>
|
| 168 |
</div>
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
))}
|
| 176 |
-
{si < sessions.length - 1 && (
|
| 177 |
-
<div style={{ height: 1, background: "#1e1e2e", margin: "0 0 0" }} />
|
| 178 |
-
)}
|
| 179 |
</div>
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
</div>
|
| 182 |
-
|
| 183 |
</div>
|
| 184 |
)
|
| 185 |
}
|
| 186 |
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
return (
|
| 189 |
-
<div
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
-
{sub && <div style={{ fontSize: 11, color: "#4a4865", marginTop: 3 }}>{sub}</div>}
|
| 198 |
</div>
|
| 199 |
)
|
| 200 |
}
|
| 201 |
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
return (
|
| 204 |
-
<div style={{
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
)
|
| 209 |
}
|
| 210 |
|
|
|
|
|
|
|
| 211 |
export default function ProfileView({ active, onUpload }) {
|
| 212 |
const [profile, setProfile] = useState(null)
|
| 213 |
const [trends, setTrends] = useState([])
|
| 214 |
const [loading, setLoading] = useState(true)
|
| 215 |
-
const [
|
| 216 |
|
| 217 |
useEffect(() => {
|
| 218 |
if (!active) return
|
|
@@ -233,46 +434,48 @@ export default function ProfileView({ active, onUpload }) {
|
|
| 233 |
)
|
| 234 |
|
| 235 |
if (!profile || profile.insufficient_data) {
|
| 236 |
-
const count = profile?.session_count || 0
|
| 237 |
-
const needed = 3 - count
|
| 238 |
return (
|
| 239 |
<div style={{ textAlign: "center", padding: "60px 20px" }}>
|
| 240 |
-
<div style={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px", color: "#f0eeff" }}>
|
| 242 |
-
Your mirror is
|
| 243 |
</h2>
|
| 244 |
-
<p style={{ fontSize: 14, color: "#8b89aa", margin: "0 0
|
| 245 |
-
|
| 246 |
-
? "Upload your first conversation to get started."
|
| 247 |
-
: `${count} session${count > 1 ? "s" : ""} recorded. Upload ${needed} more to unlock your behavioral profile.`}
|
| 248 |
</p>
|
| 249 |
-
{
|
| 250 |
-
<div style={{ display: "flex", justifyContent: "center", gap: 10,
|
| 251 |
-
margin: "20px 0 28px" }}>
|
| 252 |
-
{Array.from({ length: 3 }).map((_, i) => (
|
| 253 |
-
<div key={i} style={{
|
| 254 |
-
width: 12, height: 12, borderRadius: "50%",
|
| 255 |
-
background: i < count ? G : "#2a2a42",
|
| 256 |
-
boxShadow: i < count ? "0 0 8px rgba(217,70,239,0.4)" : "none",
|
| 257 |
-
}} />
|
| 258 |
-
))}
|
| 259 |
-
</div>
|
| 260 |
-
)}
|
| 261 |
-
<button onClick={onUpload}
|
| 262 |
style={{ padding: "12px 28px", background: G, color: "white",
|
| 263 |
border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
|
| 264 |
-
fontWeight: 600, boxShadow: "0 0 24px rgba(
|
| 265 |
Upload a conversation
|
| 266 |
</button>
|
| 267 |
</div>
|
| 268 |
)
|
| 269 |
}
|
| 270 |
|
| 271 |
-
const {
|
| 272 |
-
|
| 273 |
-
completeness, completeness_label, session_highlights } = profile
|
| 274 |
|
| 275 |
-
const
|
| 276 |
|
| 277 |
const chartData = trends.map((point, i) => ({
|
| 278 |
...point,
|
|
@@ -280,355 +483,202 @@ export default function ProfileView({ active, onUpload }) {
|
|
| 280 |
date: new Date(point.date).toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
|
| 281 |
}))
|
| 282 |
|
|
|
|
|
|
|
| 283 |
return (
|
| 284 |
-
<div style={{ display: "flex", flexDirection: "column", gap:
|
| 285 |
|
| 286 |
{/* Header */}
|
| 287 |
-
<
|
| 288 |
-
|
|
|
|
| 289 |
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
|
| 290 |
Your Behavioral Profile
|
| 291 |
</h2>
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
</div>
|
| 296 |
-
|
|
|
|
| 297 |
style={{ padding: "8px 18px", background: G, color: "white",
|
| 298 |
border: "none", borderRadius: 7, fontSize: 13, cursor: "pointer",
|
| 299 |
-
fontWeight: 600, boxShadow: "0 0 18px rgba(
|
|
|
|
| 300 |
+ Upload
|
| 301 |
</button>
|
| 302 |
</div>
|
|
|
|
| 303 |
|
| 304 |
-
{/*
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
<
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
</span>
|
| 313 |
-
<span style={{ fontSize: 13, fontWeight: 700,
|
| 314 |
-
background: G, WebkitBackgroundClip: "text",
|
| 315 |
-
WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
|
| 316 |
-
{completeness}%
|
| 317 |
-
</span>
|
| 318 |
-
</div>
|
| 319 |
-
<div style={{ height: 5, background: "#2a2a42", borderRadius: 3 }}>
|
| 320 |
-
<div style={{
|
| 321 |
-
height: "100%", borderRadius: 3,
|
| 322 |
-
width: `${completeness}%`,
|
| 323 |
-
background: G,
|
| 324 |
-
boxShadow: "0 0 8px rgba(217,70,239,0.3)",
|
| 325 |
-
transition: "width 0.6s ease",
|
| 326 |
-
}} />
|
| 327 |
-
</div>
|
| 328 |
-
<div style={{ fontSize: 11, color: "#4a4865", marginTop: 7 }}>
|
| 329 |
-
{completeness < 100
|
| 330 |
-
? "Add more sessions and cover different conversation types to deepen the model."
|
| 331 |
-
: "The mirror has a strong picture of who you are across contexts."}
|
| 332 |
-
</div>
|
| 333 |
</div>
|
| 334 |
-
|
|
|
|
|
|
|
| 335 |
|
| 336 |
-
{/*
|
| 337 |
{personality && (
|
| 338 |
-
<
|
| 339 |
-
|
| 340 |
-
<
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
{personality.last_delta.changes.map((c, i) => (
|
| 362 |
-
<div key={i} style={{
|
| 363 |
-
display: "flex", alignItems: "center", gap: 5,
|
| 364 |
-
padding: "4px 10px", borderRadius: 20, fontSize: 12, fontWeight: 600,
|
| 365 |
-
background: c.direction === "up"
|
| 366 |
-
? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
|
| 367 |
-
border: `1px solid ${c.direction === "up"
|
| 368 |
-
? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
|
| 369 |
-
color: c.direction === "up" ? "#34d399" : "#f87171",
|
| 370 |
-
}}>
|
| 371 |
-
<span>{c.direction === "up" ? "β" : "β"}</span>
|
| 372 |
-
<span>{c.dimension}</span>
|
| 373 |
-
<span style={{ opacity: 0.7 }}>
|
| 374 |
-
{c.direction === "up" ? "+" : ""}{c.diff} pts
|
| 375 |
</span>
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
</div>
|
| 380 |
-
)}
|
| 381 |
-
|
| 382 |
-
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 383 |
-
{personality.dimensions?.map(dim => (
|
| 384 |
-
<DimensionCard key={dim.key} dim={dim} />
|
| 385 |
-
))}
|
| 386 |
</div>
|
| 387 |
</div>
|
|
|
|
| 388 |
)}
|
| 389 |
|
| 390 |
-
{/*
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
{/* Averages */}
|
| 394 |
-
<div>
|
| 395 |
-
<SectionHeader title="Your Averages" sub="Across all sessions" />
|
| 396 |
-
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
| 397 |
-
<StatCard label="Talk Ratio" value={overall.talk_ratio} unit="%" sub="avg across sessions" />
|
| 398 |
-
<StatCard label="Speech Rate" value={overall.wpm} unit=" wpm" sub="words per minute" />
|
| 399 |
-
<StatCard label="Filler Rate" value={overall.filler_rate} unit="/100w" sub="avg filler words" />
|
| 400 |
-
<StatCard label="Interruptions" value={overall.interruptions_given} unit="x" sub="per session avg" />
|
| 401 |
-
<StatCard label="Response Latency" value={overall.response_latency} unit="s" sub="avg pause before reply" />
|
| 402 |
-
<StatCard label="Silence" value={overall.silence_ratio} unit="%" sub="of conversation" />
|
| 403 |
-
</div>
|
| 404 |
-
</div>
|
| 405 |
-
|
| 406 |
-
{/* Consistent patterns */}
|
| 407 |
-
{patterns?.length > 0 && (
|
| 408 |
<div>
|
| 409 |
-
<
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
background: G, WebkitBackgroundClip: "text",
|
| 418 |
-
WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
|
| 419 |
-
{PATTERN_ICONS[p.type] || "β’"}
|
| 420 |
-
</span>
|
| 421 |
-
<div>
|
| 422 |
-
<div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
|
| 423 |
-
textTransform: "uppercase", letterSpacing: 0.4, marginBottom: 3 }}>
|
| 424 |
-
{p.signal.replace(/_/g, " ")}
|
| 425 |
-
</div>
|
| 426 |
-
<div style={{ fontSize: 13, color: "#f0eeff" }}>{p.detail}</div>
|
| 427 |
-
</div>
|
| 428 |
-
</div>
|
| 429 |
-
))}
|
| 430 |
</div>
|
|
|
|
| 431 |
</div>
|
|
|
|
| 432 |
)}
|
| 433 |
|
| 434 |
-
{/*
|
| 435 |
-
{
|
|
|
|
| 436 |
<div>
|
| 437 |
-
<
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
|
| 446 |
-
cursor: "pointer", fontWeight: isActive ? 600 : 400,
|
| 447 |
-
background: isActive ? "rgba(217,70,239,0.12)" : "#14141f",
|
| 448 |
-
color: isActive ? "#e879f9" : "#8b89aa",
|
| 449 |
-
border: isActive ? "1px solid rgba(217,70,239,0.3)" : "1px solid #2a2a42",
|
| 450 |
-
transition: "all 0.15s" }}>
|
| 451 |
-
{s.label}
|
| 452 |
-
</button>
|
| 453 |
-
)
|
| 454 |
-
})}
|
| 455 |
</div>
|
| 456 |
|
| 457 |
-
{
|
| 458 |
-
|
| 459 |
-
{trendLines.filter(t => t.signal === activeSignal).map((t, i) => (
|
| 460 |
-
<div key={i} style={{
|
| 461 |
-
display: "inline-flex", alignItems: "center", gap: 6,
|
| 462 |
-
padding: "4px 12px", borderRadius: 20, fontSize: 12, marginBottom: 8,
|
| 463 |
-
background: t.direction === "improved"
|
| 464 |
-
? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
|
| 465 |
-
border: `1px solid ${t.direction === "improved"
|
| 466 |
-
? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
|
| 467 |
-
color: t.direction === "improved" ? "#34d399" : "#f87171",
|
| 468 |
-
}}>
|
| 469 |
-
{t.direction === "improved" ? "β" : "β"}
|
| 470 |
-
{signalConfig?.label} {t.direction}: {t.old}{signalConfig?.unit} β {t.new}{signalConfig?.unit}
|
| 471 |
-
</div>
|
| 472 |
-
))}
|
| 473 |
-
</div>
|
| 474 |
-
)}
|
| 475 |
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
<
|
| 480 |
-
<
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
<YAxis fontSize={11} tick={{ fill: "#8b89aa" }} width={38}
|
| 484 |
-
axisLine={{ stroke: "#2a2a42" }} tickLine={{ stroke: "#2a2a42" }}
|
| 485 |
-
tickFormatter={v => `${v}${signalConfig?.unit || ""}`} />
|
| 486 |
-
<Tooltip
|
| 487 |
-
contentStyle={{ background: "#14141f", border: "1px solid #2a2a42",
|
| 488 |
-
borderRadius: 8 }}
|
| 489 |
-
labelStyle={{ color: "#8b89aa" }}
|
| 490 |
-
itemStyle={{ color: "#f0eeff" }}
|
| 491 |
-
formatter={(v) => [`${v}${signalConfig?.unit || ""}`, signalConfig?.label]}
|
| 492 |
-
labelFormatter={(_, payload) => payload?.[0]?.payload?.date || ""}
|
| 493 |
-
/>
|
| 494 |
-
<Line type="monotone" dataKey={activeSignal}
|
| 495 |
-
stroke="#d946ef" strokeWidth={2.5}
|
| 496 |
-
dot={{ r: 4, fill: "#d946ef", strokeWidth: 0 }}
|
| 497 |
-
activeDot={{ r: 6, fill: "#f97316" }}
|
| 498 |
-
/>
|
| 499 |
-
</LineChart>
|
| 500 |
-
</ResponsiveContainer>
|
| 501 |
</div>
|
| 502 |
-
</div>
|
| 503 |
-
)}
|
| 504 |
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
<div style={{ display: "flex", justifyContent: "space-between",
|
| 515 |
-
alignItems: "center", marginBottom: 10 }}>
|
| 516 |
-
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
| 517 |
-
<div style={{ width: 8, height: 8, borderRadius: "50%",
|
| 518 |
-
background: CONTEXT_COLORS[ctx] || "#8b89aa",
|
| 519 |
-
boxShadow: `0 0 6px ${CONTEXT_COLORS[ctx] || "#8b89aa"}60` }} />
|
| 520 |
-
<span style={{ fontSize: 13, fontWeight: 600, color: "#f0eeff" }}>
|
| 521 |
-
{CONTEXT_LABELS[ctx] || ctx}
|
| 522 |
-
</span>
|
| 523 |
-
</div>
|
| 524 |
-
<span style={{ fontSize: 11, color: "#4a4865" }}>
|
| 525 |
-
{data.count} session{data.count > 1 ? "s" : ""}
|
| 526 |
-
</span>
|
| 527 |
-
</div>
|
| 528 |
-
<div style={{ display: "flex", gap: 24 }}>
|
| 529 |
-
{[
|
| 530 |
-
{ label: "Talk", value: `${data.talk_ratio}%` },
|
| 531 |
-
{ label: "WPM", value: `${data.wpm}` },
|
| 532 |
-
{ label: "Fillers", value: `${data.filler_rate}/100w` },
|
| 533 |
-
].map(({ label, value }) => (
|
| 534 |
-
<div key={label}>
|
| 535 |
-
<div style={{ fontSize: 11, color: "#4a4865" }}>{label}</div>
|
| 536 |
-
<div style={{ fontSize: 14, fontWeight: 600, color: "#f0eeff" }}>{value}</div>
|
| 537 |
-
</div>
|
| 538 |
-
))}
|
| 539 |
-
</div>
|
| 540 |
</div>
|
| 541 |
-
|
| 542 |
-
|
|
|
|
|
|
|
|
|
|
| 543 |
</div>
|
|
|
|
| 544 |
)}
|
| 545 |
|
| 546 |
-
{/*
|
| 547 |
-
{
|
| 548 |
-
<
|
| 549 |
-
<
|
| 550 |
-
|
| 551 |
-
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 552 |
-
{recurring_coaching.map((item, i) => {
|
| 553 |
-
const intensity = Math.min(item.count / session_count, 1)
|
| 554 |
-
const barColor = intensity > 0.6 ? "#f87171" : intensity > 0.3 ? "#f59e0b" : "#34d399"
|
| 555 |
-
return (
|
| 556 |
-
<div key={i} style={{ padding: "12px 16px", background: "#14141f",
|
| 557 |
-
border: "1px solid #2a2a42", borderRadius: 10,
|
| 558 |
-
borderLeft: `3px solid ${barColor}` }}>
|
| 559 |
-
<div style={{ display: "flex", justifyContent: "space-between",
|
| 560 |
-
alignItems: "center", marginBottom: 10 }}>
|
| 561 |
-
<span style={{ fontSize: 13, fontWeight: 600, color: "#f0eeff" }}>
|
| 562 |
-
{item.area}
|
| 563 |
-
</span>
|
| 564 |
-
<span style={{ fontSize: 11, color: "#4a4865" }}>
|
| 565 |
-
flagged in {item.count}/{session_count} sessions
|
| 566 |
-
</span>
|
| 567 |
-
</div>
|
| 568 |
-
<div style={{ height: 4, background: "#2a2a42",
|
| 569 |
-
borderRadius: 2, overflow: "hidden" }}>
|
| 570 |
-
<div style={{
|
| 571 |
-
height: "100%", borderRadius: 2,
|
| 572 |
-
width: `${Math.round(intensity * 100)}%`,
|
| 573 |
-
background: barColor,
|
| 574 |
-
boxShadow: `0 0 6px ${barColor}60`,
|
| 575 |
-
}} />
|
| 576 |
-
</div>
|
| 577 |
-
</div>
|
| 578 |
-
)
|
| 579 |
-
})}
|
| 580 |
-
</div>
|
| 581 |
-
</div>
|
| 582 |
)}
|
| 583 |
|
| 584 |
-
{
|
| 585 |
-
|
| 586 |
-
<
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
Patterns will appear as you add more sessions.
|
| 590 |
-
Keep uploading conversations to build a richer picture.
|
| 591 |
-
</p>
|
| 592 |
-
</div>
|
| 593 |
)}
|
| 594 |
|
| 595 |
-
{/* Blind spots */}
|
| 596 |
-
{blind_spots?.length > 0 && (
|
| 597 |
-
<div>
|
| 598 |
-
<SectionHeader title="What the mirror can't see yet"
|
| 599 |
-
sub="Upload these conversation types to fill in the gaps" />
|
| 600 |
-
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 601 |
-
{blind_spots.map((spot, i) => (
|
| 602 |
-
<div key={i} style={{ display: "flex", gap: 14, alignItems: "flex-start",
|
| 603 |
-
padding: "14px 16px", background: "#14141f",
|
| 604 |
-
border: "1px solid #2a2a42", borderRadius: 10 }}>
|
| 605 |
-
<div style={{ width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
|
| 606 |
-
border: "1.5px dashed #3a3a52",
|
| 607 |
-
display: "flex", alignItems: "center", justifyContent: "center",
|
| 608 |
-
fontSize: 13, color: "#4a4865", marginTop: 1 }}>
|
| 609 |
-
?
|
| 610 |
-
</div>
|
| 611 |
-
<div>
|
| 612 |
-
<div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
|
| 613 |
-
textTransform: "uppercase", letterSpacing: 0.4, marginBottom: 4 }}>
|
| 614 |
-
{spot.label}
|
| 615 |
-
</div>
|
| 616 |
-
<div style={{ fontSize: 13, color: "#6b6888", lineHeight: 1.65 }}>
|
| 617 |
-
{spot.message}
|
| 618 |
-
</div>
|
| 619 |
-
</div>
|
| 620 |
-
</div>
|
| 621 |
-
))}
|
| 622 |
-
</div>
|
| 623 |
-
<button onClick={onUpload}
|
| 624 |
-
style={{ marginTop: 12, width: "100%", padding: "10px 0",
|
| 625 |
-
background: "transparent", color: "#8b89aa",
|
| 626 |
-
border: "1px dashed #3a3a52", borderRadius: 8,
|
| 627 |
-
fontSize: 13, cursor: "pointer", fontWeight: 500 }}>
|
| 628 |
-
Upload a conversation β
|
| 629 |
-
</button>
|
| 630 |
-
</div>
|
| 631 |
-
)}
|
| 632 |
</div>
|
| 633 |
)
|
| 634 |
}
|
|
|
|
| 1 |
import { useState, useEffect } from "react"
|
| 2 |
import {
|
| 3 |
+
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
|
| 4 |
LineChart, Line, XAxis, YAxis, Tooltip,
|
| 5 |
ResponsiveContainer, CartesianGrid,
|
| 6 |
} from "recharts"
|
| 7 |
import api from "../lib/api"
|
| 8 |
+
import Reveal, { RevealItem } from "./Reveal"
|
| 9 |
|
| 10 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 11 |
|
| 12 |
const CONTEXT_LABELS = {
|
| 13 |
+
social: "Casual & Low-Stakes", collaborative: "Collaborative",
|
| 14 |
+
evaluative: "Interview & Review Β· High Stakes", influential: "Persuading & Pitching",
|
| 15 |
+
negotiation: "Negotiation", adversarial: "Conflict & Friction",
|
| 16 |
+
developmental: "Coaching & Feedback", support: "Supportive Listening",
|
| 17 |
+
intimate: "Deep Personal", casual: "Casual & Low-Stakes", meeting: "Meeting",
|
| 18 |
+
job_interview: "Interview & Review Β· High Stakes", disagreement: "Conflict & Friction",
|
| 19 |
+
presentation: "Interview & Review Β· High Stakes", sales_call: "Persuading & Pitching",
|
| 20 |
+
feedback_conversation: "Coaching & Feedback", coaching_call: "Coaching & Feedback",
|
| 21 |
+
first_date: "Deep Personal",
|
| 22 |
}
|
| 23 |
|
| 24 |
const SIGNAL_OPTIONS = [
|
|
|
|
| 30 |
{ key: "response_latency", label: "Response Latency", unit: "s" },
|
| 31 |
]
|
| 32 |
|
| 33 |
+
const TALK_RATIO_NORMS = {
|
| 34 |
+
evaluative: [55, 80], collaborative: [30, 55], social: [35, 65],
|
| 35 |
+
influential: [48, 68], negotiation: [35, 55], adversarial: [35, 55],
|
| 36 |
+
developmental: [25, 45], support: [15, 40], intimate: [35, 60],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
+
const DIMENSION_KEYWORDS = {
|
| 40 |
+
confidence: s => s >= 65 ? "Confident" : s >= 40 ? "Measured" : "Cautious",
|
| 41 |
+
assertiveness: s => s >= 65 ? "Direct" : s >= 40 ? "Balanced" : "Reserved",
|
| 42 |
+
listening: s => s >= 65 ? "Present" : s >= 40 ? "Attentive" : "Directive",
|
| 43 |
+
composure: s => s >= 65 ? "Composed" : s >= 40 ? "Steady" : "Reactive",
|
| 44 |
+
clarity: s => s >= 65 ? "Clear" : s >= 40 ? "Articulate" : "Complex",
|
| 45 |
}
|
| 46 |
|
| 47 |
+
function deriveKeywords(dimensions) {
|
| 48 |
+
if (!dimensions?.length) return []
|
| 49 |
+
return [...dimensions]
|
| 50 |
+
.sort((a, b) => b.score - a.score)
|
| 51 |
+
.slice(0, 3)
|
| 52 |
+
.map(d => DIMENSION_KEYWORDS[d.key]?.(d.score))
|
| 53 |
+
.filter(Boolean)
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function useCountUp(target, duration = 900) {
|
| 57 |
+
const [value, setValue] = useState(0)
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
if (!target) return
|
| 60 |
+
const start = Date.now()
|
| 61 |
+
const tick = () => {
|
| 62 |
+
const elapsed = Date.now() - start
|
| 63 |
+
const progress = Math.min(elapsed / duration, 1)
|
| 64 |
+
const eased = 1 - Math.pow(1 - progress, 3)
|
| 65 |
+
setValue(Math.round(eased * target))
|
| 66 |
+
if (progress < 1) requestAnimationFrame(tick)
|
| 67 |
+
}
|
| 68 |
+
requestAnimationFrame(tick)
|
| 69 |
+
}, [target])
|
| 70 |
+
return value
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// ββ Section label βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
+
|
| 75 |
+
function SectionLabel({ children }) {
|
| 76 |
return (
|
| 77 |
+
<div style={{
|
| 78 |
+
fontSize: 10, fontWeight: 700, letterSpacing: 1.4,
|
| 79 |
+
textTransform: "uppercase", marginBottom: 4,
|
| 80 |
+
background: G, WebkitBackgroundClip: "text",
|
| 81 |
+
WebkitTextFillColor: "transparent", backgroundClip: "text",
|
| 82 |
+
}}>
|
| 83 |
+
{children}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
)
|
| 86 |
}
|
| 87 |
|
| 88 |
+
// ββ Mirror Feed βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
const FEED_TYPE_CONFIG = {
|
| 91 |
+
context_contrast: { color: "#818cf8", label: "Context contrast", icon: "β" },
|
| 92 |
+
trend_up: { color: "#34d399", label: "Improving", icon: "β" },
|
| 93 |
+
trend_down: { color: "#fb923c", label: "Declining", icon: "β" },
|
| 94 |
+
pattern: { color: "#f59e0b", label: "Consistent pattern", icon: "β" },
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function MirrorFeed({ insights }) {
|
| 98 |
+
if (!insights) return null
|
| 99 |
+
|
| 100 |
+
if (!insights.length) {
|
| 101 |
+
return (
|
| 102 |
+
<p style={{ fontSize: 13, color: "#4a4d6a", margin: 0, lineHeight: 1.7 }}>
|
| 103 |
+
Upload conversations across different contexts β patterns that span multiple
|
| 104 |
+
sessions will appear here.
|
| 105 |
+
</p>
|
| 106 |
+
)
|
| 107 |
+
}
|
| 108 |
|
| 109 |
return (
|
| 110 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 111 |
+
borderRadius: 12, overflow: "hidden",
|
| 112 |
+
boxShadow: "0 2px 16px rgba(0,0,0,0.3)" }}>
|
| 113 |
+
{insights.map((insight, i) => {
|
| 114 |
+
const cfg = FEED_TYPE_CONFIG[insight.type] || FEED_TYPE_CONFIG.pattern
|
| 115 |
+
return (
|
| 116 |
+
<RevealItem key={i} index={i}>
|
| 117 |
+
<div style={{
|
| 118 |
+
display: "flex", gap: 14, padding: "14px 18px",
|
| 119 |
+
borderBottom: i < insights.length - 1 ? "1px solid #131827" : "none",
|
| 120 |
+
alignItems: "flex-start",
|
| 121 |
+
}}>
|
| 122 |
+
<div style={{
|
| 123 |
+
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
|
| 124 |
+
background: `${cfg.color}18`,
|
| 125 |
+
border: `1px solid ${cfg.color}30`,
|
| 126 |
+
display: "flex", alignItems: "center", justifyContent: "center",
|
| 127 |
+
fontSize: 14, color: cfg.color, fontWeight: 700, marginTop: 1,
|
| 128 |
+
}}>
|
| 129 |
+
{cfg.icon}
|
| 130 |
+
</div>
|
| 131 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 132 |
+
<div style={{ fontSize: 11, fontWeight: 700, color: cfg.color,
|
| 133 |
+
textTransform: "uppercase", letterSpacing: 0.6, marginBottom: 5 }}>
|
| 134 |
+
{cfg.label}
|
| 135 |
+
</div>
|
| 136 |
+
<p style={{ margin: 0, fontSize: 13, color: "#c4c2d8", lineHeight: 1.75 }}>
|
| 137 |
+
{insight.text}
|
| 138 |
+
</p>
|
| 139 |
+
{insight.tip && (
|
| 140 |
+
<p style={{
|
| 141 |
+
margin: "10px 0 0", fontSize: 12, color: "#6b6888", lineHeight: 1.65,
|
| 142 |
+
paddingTop: 10, borderTop: "1px solid #131827",
|
| 143 |
+
}}>
|
| 144 |
+
<span style={{ color: "#4a4865", fontWeight: 600 }}>β </span>
|
| 145 |
+
{insight.tip}
|
| 146 |
+
</p>
|
| 147 |
+
)}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</RevealItem>
|
| 151 |
+
)
|
| 152 |
+
})}
|
| 153 |
+
</div>
|
| 154 |
+
)
|
| 155 |
+
}
|
| 156 |
|
| 157 |
+
// ββ Flat dimension bar βββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββ
|
| 158 |
+
|
| 159 |
+
function FlatBar({ d }) {
|
| 160 |
+
const score = useCountUp(d.score)
|
| 161 |
+
return (
|
| 162 |
+
<div style={{ display: "flex", alignItems: "center", gap: 14,
|
| 163 |
+
padding: "10px 0", borderBottom: "1px solid #131827" }}>
|
| 164 |
+
<span style={{ fontSize: 13, fontWeight: 500, color: "#c4c2d8",
|
| 165 |
+
minWidth: 130, flexShrink: 0 }}>{d.name}</span>
|
| 166 |
+
<div style={{ flex: 1, height: 4, background: "#1e2438", borderRadius: 2 }}>
|
| 167 |
+
<div style={{ height: "100%", borderRadius: 2, width: `${score}%`,
|
| 168 |
+
background: G, transition: "width 0.5s ease",
|
| 169 |
+
boxShadow: "0 0 8px rgba(59,130,246,0.4)" }} />
|
|
|
|
|
|
|
| 170 |
</div>
|
| 171 |
+
<span style={{ fontSize: 14, fontWeight: 700, minWidth: 30, textAlign: "right",
|
| 172 |
+
background: G, WebkitBackgroundClip: "text",
|
| 173 |
+
WebkitTextFillColor: "transparent", backgroundClip: "text" }}>
|
| 174 |
+
{score}
|
| 175 |
+
</span>
|
| 176 |
+
</div>
|
| 177 |
+
)
|
| 178 |
+
}
|
| 179 |
|
| 180 |
+
// ββ Pentagon radar chart ββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
function PentagonChart({ dimensions }) {
|
| 183 |
+
if (!dimensions?.length) return null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
+
const SHORT = {
|
| 186 |
+
"Listening Quality": "Listening",
|
| 187 |
+
"Communication Clarity": "Clarity",
|
| 188 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
+
const radarData = dimensions.map(d => ({
|
| 191 |
+
subject: SHORT[d.name] || d.name,
|
| 192 |
+
score: d.score,
|
| 193 |
+
fullMark: 100,
|
| 194 |
+
}))
|
| 195 |
+
|
| 196 |
+
return (
|
| 197 |
+
<div style={{
|
| 198 |
+
background: "#151922", border: "1px solid #1e2438",
|
| 199 |
+
borderRadius: 12, padding: "8px 0",
|
| 200 |
+
boxShadow: "0 2px 16px rgba(0,0,0,0.3)"
|
| 201 |
+
}}>
|
| 202 |
+
<ResponsiveContainer width="100%" height={220}>
|
| 203 |
+
<RadarChart data={radarData} cx="50%" cy="50%" outerRadius="66%">
|
| 204 |
+
<PolarGrid stroke="#1e2438" />
|
| 205 |
+
<PolarAngleAxis dataKey="subject" tick={{ fill: "#8b89aa", fontSize: 11 }} />
|
| 206 |
+
<PolarRadiusAxis domain={[0, 100]} tick={false} axisLine={false} />
|
| 207 |
+
<Radar dataKey="score"
|
| 208 |
+
stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.18} strokeWidth={2.5} />
|
| 209 |
+
</RadarChart>
|
| 210 |
+
</ResponsiveContainer>
|
| 211 |
+
</div>
|
| 212 |
+
)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// ββ You Across Contexts βββββββββββββββββββββββββββββββββββββββββββ
|
| 216 |
+
|
| 217 |
+
function ContextComparison({ byContext }) {
|
| 218 |
+
const contexts = Object.keys(byContext || {})
|
| 219 |
+
const [activeCtx, setActiveCtx] = useState(contexts[0] || null)
|
| 220 |
+
if (!contexts.length || !activeCtx) return null
|
| 221 |
+
|
| 222 |
+
const data = byContext[activeCtx]
|
| 223 |
+
const norm = TALK_RATIO_NORMS[activeCtx]
|
| 224 |
+
const withinNorm = norm ? (data.talk_ratio >= norm[0] && data.talk_ratio <= norm[1]) : null
|
| 225 |
+
const normColor = withinNorm === null ? "#8b89aa" : withinNorm ? "#34d399" : "#f59e0b"
|
| 226 |
+
|
| 227 |
+
return (
|
| 228 |
+
<div>
|
| 229 |
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
|
| 230 |
+
{contexts.map(ctx => (
|
| 231 |
+
<button key={ctx} onClick={() => setActiveCtx(ctx)}
|
| 232 |
+
style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
|
| 233 |
+
cursor: "pointer", border: "1px solid", transition: "all 0.15s",
|
| 234 |
+
background: activeCtx === ctx ? "rgba(59,130,246,0.12)" : "#151922",
|
| 235 |
+
color: activeCtx === ctx ? "#60a5fa" : "#8b89aa",
|
| 236 |
+
borderColor: activeCtx === ctx ? "rgba(59,130,246,0.3)" : "#1e2438" }}>
|
| 237 |
+
{CONTEXT_LABELS[ctx] || ctx}
|
| 238 |
+
<span style={{ opacity: 0.5, marginLeft: 5, fontSize: 10 }}>
|
| 239 |
+
{byContext[ctx].count}Γ
|
| 240 |
+
</span>
|
| 241 |
+
</button>
|
| 242 |
+
))}
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 246 |
+
borderRadius: 10, padding: "16px 18px" }}>
|
| 247 |
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16 }}>
|
| 248 |
+
<div>
|
| 249 |
+
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>Talk ratio</div>
|
| 250 |
+
<div style={{ fontSize: 22, fontWeight: 700, color: "#f0eeff", lineHeight: 1 }}>
|
| 251 |
+
{data.talk_ratio}<span style={{ fontSize: 13, color: "#8b89aa" }}>%</span>
|
| 252 |
</div>
|
| 253 |
+
{norm && (
|
| 254 |
+
<div style={{ fontSize: 11, color: normColor, marginTop: 4 }}>
|
| 255 |
+
{withinNorm ? "β" : "β"} norm {norm[0]}β{norm[1]}%
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
</div>
|
| 257 |
+
)}
|
| 258 |
+
</div>
|
| 259 |
+
<div>
|
| 260 |
+
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>Speech rate</div>
|
| 261 |
+
<div style={{ fontSize: 22, fontWeight: 700, color: "#f0eeff", lineHeight: 1 }}>
|
| 262 |
+
{data.wpm}<span style={{ fontSize: 13, color: "#8b89aa" }}> wpm</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
</div>
|
| 264 |
+
</div>
|
| 265 |
+
<div>
|
| 266 |
+
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>Filler rate</div>
|
| 267 |
+
<div style={{ fontSize: 22, fontWeight: 700, color: "#f0eeff", lineHeight: 1 }}>
|
| 268 |
+
{data.filler_rate}<span style={{ fontSize: 13, color: "#8b89aa" }}>/100w</span>
|
| 269 |
+
</div>
|
| 270 |
+
</div>
|
| 271 |
</div>
|
| 272 |
+
</div>
|
| 273 |
</div>
|
| 274 |
)
|
| 275 |
}
|
| 276 |
|
| 277 |
+
// ββ What's Changed ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 278 |
+
|
| 279 |
+
function WhatsChanged({ trendLines, lastDelta }) {
|
| 280 |
+
const dimChanges = lastDelta?.changes || []
|
| 281 |
+
const signalTrends = trendLines || []
|
| 282 |
+
if (!dimChanges.length && !signalTrends.length) return null
|
| 283 |
+
|
| 284 |
return (
|
| 285 |
+
<div>
|
| 286 |
+
<div style={{ marginBottom: 12 }}>
|
| 287 |
+
<SectionLabel>Trends</SectionLabel>
|
| 288 |
+
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
|
| 289 |
+
What's Changed
|
| 290 |
+
</h2>
|
| 291 |
+
<p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
|
| 292 |
+
Movement detected across your sessions
|
| 293 |
+
</p>
|
| 294 |
+
</div>
|
| 295 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
| 296 |
+
{dimChanges.map((c, i) => (
|
| 297 |
+
<div key={i} style={{
|
| 298 |
+
display: "flex", alignItems: "center", gap: 5,
|
| 299 |
+
padding: "7px 13px", borderRadius: 20, fontSize: 12, fontWeight: 600,
|
| 300 |
+
background: c.direction === "up" ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
|
| 301 |
+
border: `1px solid ${c.direction === "up" ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
|
| 302 |
+
color: c.direction === "up" ? "#34d399" : "#f87171",
|
| 303 |
+
}}>
|
| 304 |
+
{c.direction === "up" ? "β" : "β"} {c.dimension}
|
| 305 |
+
<span style={{ opacity: 0.7 }}>
|
| 306 |
+
{" "}{c.direction === "up" ? "+" : ""}{c.diff}pts
|
| 307 |
+
</span>
|
| 308 |
+
</div>
|
| 309 |
+
))}
|
| 310 |
+
{signalTrends.map((t, i) => (
|
| 311 |
+
<div key={`t${i}`} style={{
|
| 312 |
+
display: "flex", alignItems: "center", gap: 5,
|
| 313 |
+
padding: "7px 13px", borderRadius: 20, fontSize: 12, fontWeight: 600,
|
| 314 |
+
background: t.direction === "improved" ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
|
| 315 |
+
border: `1px solid ${t.direction === "improved" ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
|
| 316 |
+
color: t.direction === "improved" ? "#34d399" : "#f87171",
|
| 317 |
+
}}>
|
| 318 |
+
{t.direction === "improved" ? "β" : "β"}
|
| 319 |
+
{" "}{t.signal.replace(/_/g, " ")}
|
| 320 |
+
<span style={{ opacity: 0.7 }}>
|
| 321 |
+
{" "}{t.old}{t.unit} β {t.new}{t.unit}
|
| 322 |
+
</span>
|
| 323 |
+
</div>
|
| 324 |
+
))}
|
| 325 |
</div>
|
|
|
|
| 326 |
</div>
|
| 327 |
)
|
| 328 |
}
|
| 329 |
|
| 330 |
+
// ββ Signal Trends (collapsible) βββββββββββββββββββββββββββββββββββ
|
| 331 |
+
|
| 332 |
+
function SignalTrends({ chartData, trendLines }) {
|
| 333 |
+
const [open, setOpen] = useState(false)
|
| 334 |
+
const [activeSignal, setActiveSignal] = useState("talk_ratio")
|
| 335 |
+
if (chartData.length < 2) return null
|
| 336 |
+
|
| 337 |
+
const signalConfig = SIGNAL_OPTIONS.find(s => s.key === activeSignal)
|
| 338 |
+
|
| 339 |
return (
|
| 340 |
+
<div className="card" style={{ border: "1px solid #1e2438", borderRadius: 12,
|
| 341 |
+
background: "#151922", overflow: "hidden" }}>
|
| 342 |
+
<div onClick={() => setOpen(v => !v)}
|
| 343 |
+
style={{ padding: "14px 18px", display: "flex", justifyContent: "space-between",
|
| 344 |
+
alignItems: "center", cursor: "pointer",
|
| 345 |
+
borderBottom: open ? "1px solid #1e2438" : "none" }}>
|
| 346 |
+
<span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff" }}>Signal Trends</span>
|
| 347 |
+
<span style={{ fontSize: 11, color: "#4a4865" }}>{open ? "β² close" : "βΌ expand"}</span>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
{open && (
|
| 351 |
+
<div style={{ padding: "16px 18px" }}>
|
| 352 |
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 14 }}>
|
| 353 |
+
{SIGNAL_OPTIONS.map(s => {
|
| 354 |
+
const isActive = activeSignal === s.key
|
| 355 |
+
return (
|
| 356 |
+
<button key={s.key} onClick={() => setActiveSignal(s.key)}
|
| 357 |
+
style={{ padding: "5px 12px", borderRadius: 20, fontSize: 12,
|
| 358 |
+
cursor: "pointer", fontWeight: isActive ? 600 : 400,
|
| 359 |
+
background: isActive ? "rgba(59,130,246,0.12)" : "#0e1320",
|
| 360 |
+
color: isActive ? "#60a5fa" : "#8b89aa",
|
| 361 |
+
border: isActive ? "1px solid rgba(59,130,246,0.3)" : "1px solid #1e2438",
|
| 362 |
+
transition: "all 0.15s" }}>
|
| 363 |
+
{s.label}
|
| 364 |
+
</button>
|
| 365 |
+
)
|
| 366 |
+
})}
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
{trendLines?.filter(t => t.signal === activeSignal).map((t, i) => (
|
| 370 |
+
<div key={i} style={{
|
| 371 |
+
display: "inline-flex", alignItems: "center", gap: 6,
|
| 372 |
+
padding: "4px 12px", borderRadius: 20, fontSize: 12, marginBottom: 10,
|
| 373 |
+
background: t.direction === "improved" ? "rgba(52,211,153,0.08)" : "rgba(248,113,113,0.08)",
|
| 374 |
+
border: `1px solid ${t.direction === "improved" ? "rgba(52,211,153,0.25)" : "rgba(248,113,113,0.25)"}`,
|
| 375 |
+
color: t.direction === "improved" ? "#34d399" : "#f87171",
|
| 376 |
+
}}>
|
| 377 |
+
{t.direction === "improved" ? "β" : "β"}
|
| 378 |
+
{" "}{signalConfig?.label} {t.direction}: {t.old}{signalConfig?.unit} β {t.new}{signalConfig?.unit}
|
| 379 |
+
</div>
|
| 380 |
+
))}
|
| 381 |
+
|
| 382 |
+
<div style={{ borderRadius: 8, padding: "12px 0" }}>
|
| 383 |
+
<ResponsiveContainer width="100%" height={180}>
|
| 384 |
+
<LineChart data={chartData}>
|
| 385 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#1e2438" />
|
| 386 |
+
<XAxis dataKey="label" fontSize={11} tick={{ fill: "#8b89aa" }}
|
| 387 |
+
axisLine={{ stroke: "#1e2438" }} tickLine={{ stroke: "#1e2438" }} />
|
| 388 |
+
<YAxis fontSize={11} tick={{ fill: "#8b89aa" }} width={38}
|
| 389 |
+
axisLine={{ stroke: "#1e2438" }} tickLine={{ stroke: "#1e2438" }}
|
| 390 |
+
tickFormatter={v => `${v}${signalConfig?.unit || ""}`} />
|
| 391 |
+
<Tooltip
|
| 392 |
+
contentStyle={{ background: "#151922", border: "1px solid #1e2438", borderRadius: 8 }}
|
| 393 |
+
labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
|
| 394 |
+
formatter={v => [`${v}${signalConfig?.unit || ""}`, signalConfig?.label]}
|
| 395 |
+
labelFormatter={(_, p) => p?.[0]?.payload?.date || ""}
|
| 396 |
+
/>
|
| 397 |
+
<Line type="monotone" dataKey={activeSignal}
|
| 398 |
+
stroke="#3b82f6" strokeWidth={2.5}
|
| 399 |
+
dot={{ r: 4, fill: "#3b82f6", strokeWidth: 0 }}
|
| 400 |
+
activeDot={{ r: 6, fill: "#22d3ee" }} />
|
| 401 |
+
</LineChart>
|
| 402 |
+
</ResponsiveContainer>
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
)}
|
| 406 |
</div>
|
| 407 |
)
|
| 408 |
}
|
| 409 |
|
| 410 |
+
// ββ Main ProfileView ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
+
|
| 412 |
export default function ProfileView({ active, onUpload }) {
|
| 413 |
const [profile, setProfile] = useState(null)
|
| 414 |
const [trends, setTrends] = useState([])
|
| 415 |
const [loading, setLoading] = useState(true)
|
| 416 |
+
const [blindSpotsOpen, setBlindSpotsOpen] = useState(false)
|
| 417 |
|
| 418 |
useEffect(() => {
|
| 419 |
if (!active) return
|
|
|
|
| 434 |
)
|
| 435 |
|
| 436 |
if (!profile || profile.insufficient_data) {
|
|
|
|
|
|
|
| 437 |
return (
|
| 438 |
<div style={{ textAlign: "center", padding: "60px 20px" }}>
|
| 439 |
+
<div style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}>
|
| 440 |
+
<svg width="52" height="52" viewBox="0 0 52 52" fill="none">
|
| 441 |
+
<defs>
|
| 442 |
+
<linearGradient id="pv-empty-g" x1="0" y1="0" x2="52" y2="0" gradientUnits="userSpaceOnUse">
|
| 443 |
+
<stop stopColor="#1d4ed8"/><stop offset="1" stopColor="#0891b2"/>
|
| 444 |
+
</linearGradient>
|
| 445 |
+
</defs>
|
| 446 |
+
<rect x="2" y="20" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".35"/>
|
| 447 |
+
<rect x="11" y="13" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".6"/>
|
| 448 |
+
<rect x="20" y="6" width="8" height="20" rx="4" fill="url(#pv-empty-g)"/>
|
| 449 |
+
<rect x="31" y="13" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".6"/>
|
| 450 |
+
<rect x="40" y="20" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".35"/>
|
| 451 |
+
<line x1="0" y1="28" x2="52" y2="28" stroke="#1e2438" strokeWidth="1.25"/>
|
| 452 |
+
<rect x="2" y="29" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".15"/>
|
| 453 |
+
<rect x="11" y="29" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".27"/>
|
| 454 |
+
<rect x="20" y="29" width="8" height="20" rx="4" fill="url(#pv-empty-g)" opacity=".33"/>
|
| 455 |
+
<rect x="31" y="29" width="6" height="13" rx="3" fill="url(#pv-empty-g)" opacity=".27"/>
|
| 456 |
+
<rect x="40" y="29" width="6" height="6" rx="3" fill="url(#pv-empty-g)" opacity=".15"/>
|
| 457 |
+
</svg>
|
| 458 |
+
</div>
|
| 459 |
<h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px", color: "#f0eeff" }}>
|
| 460 |
+
Your mirror is waiting
|
| 461 |
</h2>
|
| 462 |
+
<p style={{ fontSize: 14, color: "#8b89aa", margin: "0 0 24px", lineHeight: 1.6 }}>
|
| 463 |
+
Upload your first conversation to see your behavioral profile.
|
|
|
|
|
|
|
| 464 |
</p>
|
| 465 |
+
<button onClick={onUpload} className="btn-grad"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
style={{ padding: "12px 28px", background: G, color: "white",
|
| 467 |
border: "none", borderRadius: 8, fontSize: 14, cursor: "pointer",
|
| 468 |
+
fontWeight: 600, boxShadow: "0 0 24px rgba(59,130,246,0.3)" }}>
|
| 469 |
Upload a conversation
|
| 470 |
</button>
|
| 471 |
</div>
|
| 472 |
)
|
| 473 |
}
|
| 474 |
|
| 475 |
+
const { by_context, trends: trendLines, session_count, personality,
|
| 476 |
+
blind_spots, completeness, completeness_label, mirror_feed } = profile
|
|
|
|
| 477 |
|
| 478 |
+
const keywords = deriveKeywords(personality?.dimensions)
|
| 479 |
|
| 480 |
const chartData = trends.map((point, i) => ({
|
| 481 |
...point,
|
|
|
|
| 483 |
date: new Date(point.date).toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
|
| 484 |
}))
|
| 485 |
|
| 486 |
+
const hasContextData = by_context && Object.keys(by_context).length > 0
|
| 487 |
+
|
| 488 |
return (
|
| 489 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
| 490 |
|
| 491 |
{/* Header */}
|
| 492 |
+
<Reveal>
|
| 493 |
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
| 494 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 495 |
<h2 style={{ fontSize: 16, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
|
| 496 |
Your Behavioral Profile
|
| 497 |
</h2>
|
| 498 |
+
|
| 499 |
+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6 }}>
|
| 500 |
+
<span style={{ fontSize: 12, color: "#4a4865" }}>
|
| 501 |
+
{session_count} session{session_count > 1 ? "s" : ""}
|
| 502 |
+
</span>
|
| 503 |
+
{completeness_label && (
|
| 504 |
+
<>
|
| 505 |
+
<span style={{ color: "#1e2438", fontSize: 12 }}>Β·</span>
|
| 506 |
+
<span style={{ fontSize: 12, color: "#4a4865" }}>{completeness_label}</span>
|
| 507 |
+
<div style={{ width: 60, height: 3, background: "#1e2438",
|
| 508 |
+
borderRadius: 2, overflow: "hidden" }}>
|
| 509 |
+
<div style={{ height: "100%", borderRadius: 2,
|
| 510 |
+
width: `${completeness}%`, background: G,
|
| 511 |
+
transition: "width 0.6s ease" }} />
|
| 512 |
+
</div>
|
| 513 |
+
</>
|
| 514 |
+
)}
|
| 515 |
+
</div>
|
| 516 |
+
|
| 517 |
+
{blind_spots?.length > 0 && (
|
| 518 |
+
<div style={{ marginTop: 10 }}>
|
| 519 |
+
<button
|
| 520 |
+
onClick={() => setBlindSpotsOpen(v => !v)}
|
| 521 |
+
style={{ background: "none", border: "none", padding: 0, cursor: "pointer",
|
| 522 |
+
display: "flex", alignItems: "center", gap: 5 }}>
|
| 523 |
+
<span style={{ fontSize: 10, color: "#3a3a52", display: "inline-block",
|
| 524 |
+
transform: blindSpotsOpen ? "rotate(90deg)" : "rotate(0deg)",
|
| 525 |
+
transition: "transform 0.2s" }}>βΆ</span>
|
| 526 |
+
<span style={{ fontSize: 12, color: "#4a4865" }}>
|
| 527 |
+
{blind_spots.length} context{blind_spots.length > 1 ? "s" : ""} missing
|
| 528 |
+
</span>
|
| 529 |
+
</button>
|
| 530 |
+
{blindSpotsOpen && (
|
| 531 |
+
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 6 }}>
|
| 532 |
+
{blind_spots.map((spot, i) => (
|
| 533 |
+
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
|
| 534 |
+
<span style={{ fontSize: 11, color: "#3a3a52", marginTop: 1, flexShrink: 0 }}>Β·</span>
|
| 535 |
+
<div>
|
| 536 |
+
<span style={{ fontSize: 12, fontWeight: 600, color: "#6b6888" }}>{spot.label}</span>
|
| 537 |
+
<span style={{ fontSize: 12, color: "#4a4865" }}> β {spot.message}</span>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
))}
|
| 541 |
+
</div>
|
| 542 |
+
)}
|
| 543 |
+
</div>
|
| 544 |
+
)}
|
| 545 |
</div>
|
| 546 |
+
|
| 547 |
+
<button onClick={onUpload} className="btn-grad"
|
| 548 |
style={{ padding: "8px 18px", background: G, color: "white",
|
| 549 |
border: "none", borderRadius: 7, fontSize: 13, cursor: "pointer",
|
| 550 |
+
fontWeight: 600, boxShadow: "0 0 18px rgba(59,130,246,0.25)", flexShrink: 0,
|
| 551 |
+
marginLeft: 16 }}>
|
| 552 |
+ Upload
|
| 553 |
</button>
|
| 554 |
</div>
|
| 555 |
+
</Reveal>
|
| 556 |
|
| 557 |
+
{/* Mirror Feed */}
|
| 558 |
+
<Reveal delay={80}>
|
| 559 |
+
<div>
|
| 560 |
+
<div style={{ marginBottom: 12 }}>
|
| 561 |
+
<SectionLabel>Cross-Session</SectionLabel>
|
| 562 |
+
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
|
| 563 |
+
Mirror Feed
|
| 564 |
+
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
</div>
|
| 566 |
+
<MirrorFeed insights={mirror_feed} />
|
| 567 |
+
</div>
|
| 568 |
+
</Reveal>
|
| 569 |
|
| 570 |
+
{/* Your Portrait */}
|
| 571 |
{personality && (
|
| 572 |
+
<Reveal>
|
| 573 |
+
<div>
|
| 574 |
+
<SectionLabel>Your Portrait</SectionLabel>
|
| 575 |
+
<div style={{
|
| 576 |
+
borderLeft: "3px solid rgba(59,130,246,0.65)",
|
| 577 |
+
paddingLeft: 16, marginTop: 10,
|
| 578 |
+
}}>
|
| 579 |
+
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
|
| 580 |
+
<p style={{ flex: 1, fontSize: 14, color: "#c4c2d8",
|
| 581 |
+
lineHeight: 1.9, margin: 0 }}>
|
| 582 |
+
{personality.paragraph}
|
| 583 |
+
</p>
|
| 584 |
+
{keywords.length > 0 && (
|
| 585 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 6, flexShrink: 0 }}>
|
| 586 |
+
{keywords.map((kw, i) => (
|
| 587 |
+
<span key={i} style={{
|
| 588 |
+
fontSize: 12, fontWeight: 600, padding: "5px 14px",
|
| 589 |
+
borderRadius: 6, letterSpacing: 0.2,
|
| 590 |
+
background: "linear-gradient(#151922, #151922) padding-box, linear-gradient(135deg, rgba(59,130,246,0.5), rgba(34,211,238,0.5)) border-box",
|
| 591 |
+
border: "1px solid transparent",
|
| 592 |
+
color: "#60a5fa", whiteSpace: "nowrap",
|
| 593 |
+
}}>
|
| 594 |
+
{kw}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
</span>
|
| 596 |
+
))}
|
| 597 |
+
</div>
|
| 598 |
+
)}
|
| 599 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
</div>
|
| 601 |
</div>
|
| 602 |
+
</Reveal>
|
| 603 |
)}
|
| 604 |
|
| 605 |
+
{/* You Across Contexts */}
|
| 606 |
+
{hasContextData && (
|
| 607 |
+
<Reveal>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
<div>
|
| 609 |
+
<div style={{ marginBottom: 16 }}>
|
| 610 |
+
<SectionLabel>Context Breakdown</SectionLabel>
|
| 611 |
+
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
|
| 612 |
+
You Across Contexts
|
| 613 |
+
</h2>
|
| 614 |
+
<p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
|
| 615 |
+
How your patterns shift depending on the room
|
| 616 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
</div>
|
| 618 |
+
<ContextComparison byContext={by_context} />
|
| 619 |
</div>
|
| 620 |
+
</Reveal>
|
| 621 |
)}
|
| 622 |
|
| 623 |
+
{/* Your Shape β pentagon + bars */}
|
| 624 |
+
{personality?.dimensions?.length > 0 && (
|
| 625 |
+
<Reveal>
|
| 626 |
<div>
|
| 627 |
+
<div style={{ marginBottom: 16 }}>
|
| 628 |
+
<SectionLabel>5 Dimensions</SectionLabel>
|
| 629 |
+
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0, color: "#f0eeff" }}>
|
| 630 |
+
Your Shape
|
| 631 |
+
</h2>
|
| 632 |
+
<p style={{ fontSize: 12, color: "#4a4d6a", margin: "4px 0 0" }}>
|
| 633 |
+
How you show up across five behavioral axes
|
| 634 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
</div>
|
| 636 |
|
| 637 |
+
{/* Pentagon chart */}
|
| 638 |
+
<PentagonChart dimensions={personality.dimensions} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 639 |
|
| 640 |
+
{/* All 5 flat bars */}
|
| 641 |
+
<div style={{ marginTop: 20 }}>
|
| 642 |
+
{personality.dimensions.map((d, i) => (
|
| 643 |
+
<RevealItem key={d.key} index={i}>
|
| 644 |
+
<FlatBar d={d} />
|
| 645 |
+
</RevealItem>
|
| 646 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
</div>
|
|
|
|
|
|
|
| 648 |
|
| 649 |
+
{/* Shape narrative */}
|
| 650 |
+
{personality.shape_narrative && (
|
| 651 |
+
<div style={{ marginTop: 14, padding: "14px 16px",
|
| 652 |
+
background: "#0e1320", border: "1px solid #1e2438",
|
| 653 |
+
borderLeft: "3px solid rgba(29,78,216,0.4)",
|
| 654 |
+
borderRadius: 10 }}>
|
| 655 |
+
<div style={{ fontSize: 11, fontWeight: 600, color: "#4a4865",
|
| 656 |
+
textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8 }}>
|
| 657 |
+
What this shape means
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
</div>
|
| 659 |
+
<p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.75 }}>
|
| 660 |
+
{personality.shape_narrative}
|
| 661 |
+
</p>
|
| 662 |
+
</div>
|
| 663 |
+
)}
|
| 664 |
</div>
|
| 665 |
+
</Reveal>
|
| 666 |
)}
|
| 667 |
|
| 668 |
+
{/* What's Changed */}
|
| 669 |
+
{((personality?.last_delta?.changes?.length ?? 0) > 0 || (trendLines?.length ?? 0) > 0) && (
|
| 670 |
+
<Reveal>
|
| 671 |
+
<WhatsChanged trendLines={trendLines} lastDelta={personality?.last_delta} />
|
| 672 |
+
</Reveal>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
)}
|
| 674 |
|
| 675 |
+
{/* Signal Trends β collapsible */}
|
| 676 |
+
{chartData.length >= 2 && (
|
| 677 |
+
<Reveal>
|
| 678 |
+
<SignalTrends chartData={chartData} trendLines={trendLines} />
|
| 679 |
+
</Reveal>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
)}
|
| 681 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
</div>
|
| 683 |
)
|
| 684 |
}
|
frontend/src/components/ResultsView.jsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import { useState } from "react"
|
|
|
|
| 2 |
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"
|
| 3 |
import api from "../lib/api"
|
| 4 |
|
| 5 |
-
const G = "linear-gradient(135deg, #
|
| 6 |
|
| 7 |
const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
|
| 8 |
|
|
@@ -13,7 +14,7 @@ function ScoreBar({ score, max = 5 }) {
|
|
| 13 |
{Array.from({ length: max }).map((_, i) => (
|
| 14 |
<div key={i} style={{
|
| 15 |
width: 20, height: 7, borderRadius: 4,
|
| 16 |
-
background: i < score ? color : "#
|
| 17 |
boxShadow: i < score ? `0 0 6px ${color}50` : "none",
|
| 18 |
transition: "all 0.2s",
|
| 19 |
}} />
|
|
@@ -26,8 +27,8 @@ function DimensionCard({ title, icon, items, narrative }) {
|
|
| 26 |
const [expanded, setExpanded] = useState(false)
|
| 27 |
|
| 28 |
return (
|
| 29 |
-
<div style={{ border: "1px solid #
|
| 30 |
-
background: "#
|
| 31 |
<div onClick={() => setExpanded(!expanded)}
|
| 32 |
style={{ padding: "14px 16px", cursor: "pointer",
|
| 33 |
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
@@ -52,8 +53,8 @@ function DimensionCard({ title, icon, items, narrative }) {
|
|
| 52 |
</div>
|
| 53 |
|
| 54 |
{expanded && narrative && (
|
| 55 |
-
<div style={{ padding: "14px 16px", borderTop: "1px solid #
|
| 56 |
-
background: "#
|
| 57 |
{narrative}
|
| 58 |
</div>
|
| 59 |
)}
|
|
@@ -66,8 +67,8 @@ function CoachingCard({ suggestion }) {
|
|
| 66 |
const color = priorityColors[suggestion.priority] || "#8b89aa"
|
| 67 |
|
| 68 |
return (
|
| 69 |
-
<div style={{ border: "1px solid #
|
| 70 |
-
padding: 16, background: "#
|
| 71 |
<span style={{ fontSize: 11, fontWeight: 700, color,
|
| 72 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 73 |
#{suggestion.priority} β {suggestion.area}
|
|
@@ -81,7 +82,7 @@ function CoachingCard({ suggestion }) {
|
|
| 81 |
π‘ {suggestion.suggestion}
|
| 82 |
</div>
|
| 83 |
<div style={{ fontSize: 12, color: "#8b89aa", lineHeight: 1.5,
|
| 84 |
-
padding: "10px 12px", background: "#
|
| 85 |
<strong style={{ color: "#f0eeff" }}>Why it matters:</strong> {suggestion.why_it_matters}
|
| 86 |
</div>
|
| 87 |
</div>
|
|
@@ -91,18 +92,18 @@ function CoachingCard({ suggestion }) {
|
|
| 91 |
function ObservationCard({ obs, sessionId }) {
|
| 92 |
const [resonance, setResonance] = useState(null)
|
| 93 |
const signalColors = {
|
| 94 |
-
talk_ratio: "#818cf8", speech_rate: "#
|
| 95 |
speech_acceleration: "#f472b6", pauses: "#f59e0b",
|
| 96 |
interruptions: "#f87171", filler_words: "#fb923c",
|
| 97 |
-
vocal_energy: "#
|
| 98 |
monologue: "#818cf8", vocabulary_richness: "#a3e635",
|
| 99 |
-
silence_ratio: "#8b89aa", pitch: "#
|
| 100 |
}
|
| 101 |
const color = signalColors[obs.signal] || "#8b89aa"
|
| 102 |
|
| 103 |
return (
|
| 104 |
-
<div style={{ border: "1px solid #
|
| 105 |
-
padding: 16, background: "#
|
| 106 |
<div style={{ fontSize: 11, color, fontWeight: 600,
|
| 107 |
textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 }}>
|
| 108 |
{obs.signal.replace(/_/g, " ")}
|
|
@@ -132,8 +133,8 @@ function ObservationCard({ obs, sessionId }) {
|
|
| 132 |
} catch {}
|
| 133 |
}
|
| 134 |
}}
|
| 135 |
-
style={{ padding: "5px 14px", border: "1px solid #
|
| 136 |
-
borderRadius: 20, background: "#
|
| 137 |
fontSize: 12, color: "#8b89aa" }}>
|
| 138 |
{label}
|
| 139 |
</button>
|
|
@@ -149,10 +150,10 @@ function ObservationCard({ obs, sessionId }) {
|
|
| 149 |
|
| 150 |
function ReflectCard({ item, index }) {
|
| 151 |
const [open, setOpen] = useState(false)
|
| 152 |
-
const accent = ["#818cf8", "#
|
| 153 |
return (
|
| 154 |
-
<div style={{ border: "1px solid #
|
| 155 |
-
background: "#
|
| 156 |
onClick={() => setOpen(v => !v)}>
|
| 157 |
<div style={{ padding: "16px 18px", display: "flex",
|
| 158 |
justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
|
@@ -169,8 +170,8 @@ function ReflectCard({ item, index }) {
|
|
| 169 |
</span>
|
| 170 |
</div>
|
| 171 |
{open && (
|
| 172 |
-
<div style={{ padding: "14px 18px", borderTop: "1px solid #
|
| 173 |
-
background: "#
|
| 174 |
<p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
|
| 175 |
{item.answer}
|
| 176 |
</p>
|
|
@@ -202,7 +203,7 @@ export default function ResultsView({ results, onBack }) {
|
|
| 202 |
const [activeTab, setActiveTab] = useState("overview")
|
| 203 |
|
| 204 |
const { signals, insights, dimensions, filename, detected_speaker,
|
| 205 |
-
session_id, voiceprint_confidence,
|
| 206 |
|
| 207 |
const confPct = voiceprint_confidence != null ? Math.round(voiceprint_confidence * 100) : null
|
| 208 |
const confLabel = confPct == null ? null : confPct >= 55 ? "high" : confPct >= 40 ? "medium" : "low"
|
|
@@ -232,7 +233,7 @@ export default function ResultsView({ results, onBack }) {
|
|
| 232 |
}
|
| 233 |
}
|
| 234 |
|
| 235 |
-
const tabs = ["overview", "dimensions", "coaching", "reflect", "signals", "
|
| 236 |
const otherSpeakers = (liveResults.available_speakers || []).filter(s => s !== detected_speaker)
|
| 237 |
|
| 238 |
return (
|
|
@@ -258,21 +259,21 @@ export default function ResultsView({ results, onBack }) {
|
|
| 258 |
{otherSpeakers.length > 0 && (
|
| 259 |
<div style={{ position: "relative" }}>
|
| 260 |
<button onClick={() => setShowSpeakerSwitch(v => !v)}
|
| 261 |
-
style={{ fontSize: 12, color: "#8b89aa", background: "#
|
| 262 |
-
border: "1px solid #
|
| 263 |
padding: "3px 12px", cursor: "pointer" }}>
|
| 264 |
{reanalyzing ? "Switchingβ¦" : "Not you?"}
|
| 265 |
</button>
|
| 266 |
{showSpeakerSwitch && !reanalyzing && (
|
| 267 |
<div style={{ position: "absolute", right: 0, top: "110%",
|
| 268 |
-
background: "#
|
| 269 |
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", zIndex: 10,
|
| 270 |
minWidth: 160, overflow: "hidden" }}>
|
| 271 |
{otherSpeakers.map(s => (
|
| 272 |
<button key={s} onClick={() => handleReanalyze(s)}
|
| 273 |
style={{ display: "block", width: "100%", padding: "10px 14px",
|
| 274 |
textAlign: "left", background: "none", border: "none",
|
| 275 |
-
borderBottom: "1px solid #
|
| 276 |
cursor: "pointer", fontSize: 13, color: "#f0eeff" }}>
|
| 277 |
Switch to {speakerLabel(s)}
|
| 278 |
</button>
|
|
@@ -304,15 +305,14 @@ export default function ResultsView({ results, onBack }) {
|
|
| 304 |
)}
|
| 305 |
|
| 306 |
{/* Tabs */}
|
| 307 |
-
<div style={{ display: "flex", marginBottom: 20, borderBottom: "1px solid #
|
| 308 |
{tabs.map(tab => (
|
| 309 |
<button key={tab} onClick={() => setActiveTab(tab)}
|
|
|
|
| 310 |
style={{ background: "none", border: "none", cursor: "pointer",
|
| 311 |
padding: "8px 14px", fontSize: 13, textTransform: "capitalize",
|
| 312 |
fontWeight: activeTab === tab ? 600 : 400,
|
| 313 |
-
color: activeTab === tab ? "#
|
| 314 |
-
borderBottom: activeTab === tab ? "2px solid #e879f9" : "2px solid transparent",
|
| 315 |
-
transition: "color 0.15s" }}>
|
| 316 |
{tab}
|
| 317 |
</button>
|
| 318 |
))}
|
|
@@ -320,7 +320,7 @@ export default function ResultsView({ results, onBack }) {
|
|
| 320 |
|
| 321 |
{/* ββ OVERVIEW TAB ββ */}
|
| 322 |
{activeTab === "overview" && (
|
| 323 |
-
<
|
| 324 |
{/* Type chips */}
|
| 325 |
{(() => {
|
| 326 |
const types = insights.conversation_types || []
|
|
@@ -332,9 +332,9 @@ export default function ResultsView({ results, onBack }) {
|
|
| 332 |
<span key={t} style={{
|
| 333 |
fontSize: 11, fontWeight: i === 0 ? 700 : 500,
|
| 334 |
padding: "3px 10px", borderRadius: 20,
|
| 335 |
-
background: i === 0 ? "rgba(
|
| 336 |
-
color: i === 0 ? "#
|
| 337 |
-
border: `1px solid ${i === 0 ? "rgba(
|
| 338 |
textTransform: "uppercase", letterSpacing: 0.4,
|
| 339 |
}}>
|
| 340 |
{LABELS[t] || t}
|
|
@@ -362,11 +362,11 @@ export default function ResultsView({ results, onBack }) {
|
|
| 362 |
|
| 363 |
{/* Notable pattern β promoted to headline position */}
|
| 364 |
{insights.notable_pattern && (
|
| 365 |
-
<div style={{ background: "rgba(
|
| 366 |
-
border: "1px solid rgba(
|
| 367 |
borderRadius: 10, padding: 16, marginBottom: 14 }}>
|
| 368 |
<div style={{ fontSize: 11, fontWeight: 600,
|
| 369 |
-
color: "#
|
| 370 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 371 |
Notable Pattern
|
| 372 |
</div>
|
|
@@ -377,9 +377,9 @@ export default function ResultsView({ results, onBack }) {
|
|
| 377 |
)}
|
| 378 |
|
| 379 |
{/* Communication Pattern */}
|
| 380 |
-
<div style={{ background: "#
|
| 381 |
-
padding: 18, marginBottom: 16, borderLeft: "3px solid #
|
| 382 |
-
<div style={{ fontSize: 11, fontWeight: 600, color: "#
|
| 383 |
marginBottom: 8, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 384 |
Communication Pattern
|
| 385 |
</div>
|
|
@@ -413,7 +413,7 @@ export default function ResultsView({ results, onBack }) {
|
|
| 413 |
{ label: "Fillers", value: `${signals.filler_words.rate_per_100_words}`, unit: "/100w" },
|
| 414 |
].map(({ label, value, unit }) => (
|
| 415 |
<div key={label} style={{ textAlign: "center", padding: "16px 8px",
|
| 416 |
-
border: "1px solid #
|
| 417 |
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 6 }}>{label}</div>
|
| 418 |
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1,
|
| 419 |
background: G, WebkitBackgroundClip: "text",
|
|
@@ -436,7 +436,7 @@ export default function ResultsView({ results, onBack }) {
|
|
| 436 |
? "β" : signals.vocal_energy.trend },
|
| 437 |
].map(({ label, value }) => (
|
| 438 |
<div key={label} style={{ textAlign: "center", padding: "14px 8px",
|
| 439 |
-
border: "1px solid #
|
| 440 |
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>{label}</div>
|
| 441 |
<div style={{ fontSize: 18, fontWeight: 700, color: "#f0eeff",
|
| 442 |
textTransform: "capitalize" }}>{value}</div>
|
|
@@ -444,15 +444,15 @@ export default function ResultsView({ results, onBack }) {
|
|
| 444 |
))}
|
| 445 |
</div>
|
| 446 |
|
| 447 |
-
</
|
| 448 |
)}
|
| 449 |
|
| 450 |
{/* ββ DIMENSIONS TAB ββ */}
|
| 451 |
{activeTab === "dimensions" && dimensions && (
|
| 452 |
-
<
|
| 453 |
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
|
| 454 |
Scores are probabilistic proxies based on behavioral signals β
|
| 455 |
-
not psychological diagnoses.
|
| 456 |
</p>
|
| 457 |
{dimensions.emotional_state && (
|
| 458 |
<DimensionCard title="Your Emotional State" icon="π§ "
|
|
@@ -490,8 +490,8 @@ export default function ResultsView({ results, onBack }) {
|
|
| 490 |
|
| 491 |
{/* Conversation Arc β moved from Overview */}
|
| 492 |
{dimensions?.conversation_arc && (
|
| 493 |
-
<div style={{ border: "1px solid #
|
| 494 |
-
padding: 16, background: "#
|
| 495 |
<div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
|
| 496 |
marginBottom: 14, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 497 |
Conversation Arc
|
|
@@ -512,7 +512,7 @@ export default function ResultsView({ results, onBack }) {
|
|
| 512 |
</div>
|
| 513 |
{dimensions.conversation_arc.turning_point?.detected && (
|
| 514 |
<div style={{ marginTop: 12, fontSize: 12, color: "#8b89aa",
|
| 515 |
-
background: "#
|
| 516 |
β‘ {dimensions.conversation_arc.turning_point.detail}
|
| 517 |
</div>
|
| 518 |
)}
|
|
@@ -524,19 +524,21 @@ export default function ResultsView({ results, onBack }) {
|
|
| 524 |
)}
|
| 525 |
</div>
|
| 526 |
)}
|
| 527 |
-
</
|
| 528 |
)}
|
| 529 |
|
| 530 |
{/* ββ COACHING TAB ββ */}
|
| 531 |
{activeTab === "coaching" && (
|
| 532 |
-
<
|
| 533 |
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
|
| 534 |
Specific suggestions based on patterns observed in this conversation.
|
| 535 |
Ranked by potential impact.
|
| 536 |
</p>
|
| 537 |
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 538 |
{insights.coaching_suggestions?.map((s, i) => (
|
| 539 |
-
<
|
|
|
|
|
|
|
| 540 |
))}
|
| 541 |
{(!insights.coaching_suggestions || insights.coaching_suggestions.length === 0) && (
|
| 542 |
<div style={{ textAlign: "center", padding: 32, color: "#4a4865" }}>
|
|
@@ -544,12 +546,12 @@ export default function ResultsView({ results, onBack }) {
|
|
| 544 |
</div>
|
| 545 |
)}
|
| 546 |
</div>
|
| 547 |
-
</
|
| 548 |
)}
|
| 549 |
|
| 550 |
{/* ββ REFLECT TAB ββ */}
|
| 551 |
{activeTab === "reflect" && (
|
| 552 |
-
<
|
| 553 |
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 20, lineHeight: 1.6 }}>
|
| 554 |
Questions grounded in what happened in this conversation. Tap any to read the insight.
|
| 555 |
</p>
|
|
@@ -566,39 +568,41 @@ export default function ResultsView({ results, onBack }) {
|
|
| 566 |
) : (
|
| 567 |
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 568 |
{insights.reflection_questions.map((item, i) => (
|
| 569 |
-
<
|
|
|
|
|
|
|
| 570 |
))}
|
| 571 |
</div>
|
| 572 |
)}
|
| 573 |
-
</
|
| 574 |
)}
|
| 575 |
|
| 576 |
{/* ββ SIGNALS TAB ββ */}
|
| 577 |
{activeTab === "signals" && (
|
| 578 |
-
<
|
| 579 |
{signals.timeline?.length > 1 && (
|
| 580 |
<div style={{ marginBottom: 24 }}>
|
| 581 |
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#f0eeff" }}>
|
| 582 |
Speech Rate Over Time
|
| 583 |
</h3>
|
| 584 |
-
<div style={{ background: "#
|
| 585 |
borderRadius: 10, padding: 16 }}>
|
| 586 |
<ResponsiveContainer width="100%" height={150}>
|
| 587 |
<LineChart data={signals.timeline}>
|
| 588 |
<XAxis dataKey="window_start_s" tickFormatter={formatTime} fontSize={11}
|
| 589 |
-
tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#
|
| 590 |
-
tickLine={{ stroke: "#
|
| 591 |
<YAxis domain={["auto", "auto"]} fontSize={11} width={35}
|
| 592 |
-
tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#
|
| 593 |
-
tickLine={{ stroke: "#
|
| 594 |
<Tooltip
|
| 595 |
-
contentStyle={{ background: "#
|
| 596 |
borderRadius: 8 }}
|
| 597 |
labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
|
| 598 |
labelFormatter={v => `At ${formatTime(v)}`}
|
| 599 |
formatter={v => [`${Math.round(v)} wpm`, "Speech rate"]} />
|
| 600 |
<Line type="monotone" dataKey="speech_rate_wpm"
|
| 601 |
-
stroke="#
|
| 602 |
</LineChart>
|
| 603 |
</ResponsiveContainer>
|
| 604 |
</div>
|
|
@@ -624,62 +628,51 @@ export default function ResultsView({ results, onBack }) {
|
|
| 624 |
{ label: "Within-turn pauses", value: signals.pauses.within_turn_pauses.count },
|
| 625 |
{ label: "Your turns", value: signals.turn_dynamics.user_turns },
|
| 626 |
].map(({ label, value }) => (
|
| 627 |
-
<div key={label} style={{ padding: 12, border: "1px solid #
|
| 628 |
-
borderRadius: 8, background: "#
|
| 629 |
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 4 }}>{label}</div>
|
| 630 |
<div style={{ fontSize: 16, fontWeight: 600, color: "#f0eeff" }}>{value}</div>
|
| 631 |
</div>
|
| 632 |
))}
|
| 633 |
</div>
|
| 634 |
-
</
|
| 635 |
)}
|
| 636 |
|
| 637 |
-
{/* ββ
|
| 638 |
-
{activeTab === "
|
| 639 |
-
<
|
| 640 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
<div style={{ textAlign: "center", padding: 48, color: "#4a4865" }}>
|
| 642 |
<div style={{ fontSize: 32, marginBottom: 12 }}>π</div>
|
| 643 |
<p style={{ fontSize: 14, color: "#8b89aa" }}>
|
| 644 |
-
|
|
|
|
|
|
|
|
|
|
| 645 |
</p>
|
| 646 |
-
</div>
|
| 647 |
-
) : (
|
| 648 |
-
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
| 649 |
-
{transcript.map((seg, i) => {
|
| 650 |
-
const isUser = seg.speaker === detected_speaker
|
| 651 |
-
return (
|
| 652 |
-
<div key={i} style={{
|
| 653 |
-
display: "flex", gap: 12, padding: "8px 12px", borderRadius: 8,
|
| 654 |
-
background: isUser ? "rgba(217,70,239,0.06)" : "#14141f",
|
| 655 |
-
border: `1px solid ${isUser ? "rgba(217,70,239,0.18)" : "#2a2a42"}`,
|
| 656 |
-
}}>
|
| 657 |
-
<div style={{ minWidth: 72, flexShrink: 0 }}>
|
| 658 |
-
<div style={{ fontSize: 11, fontWeight: 600,
|
| 659 |
-
color: isUser ? "#e879f9" : "#8b89aa" }}>
|
| 660 |
-
{isUser ? "You" : speakerLabel(seg.speaker)}
|
| 661 |
-
</div>
|
| 662 |
-
<div style={{ fontSize: 10, color: "#4a4865", marginTop: 1 }}>
|
| 663 |
-
{seg.start != null
|
| 664 |
-
? `${Math.floor(seg.start / 60)}:${String(Math.round(seg.start % 60)).padStart(2, "0")}`
|
| 665 |
-
: ""}
|
| 666 |
-
</div>
|
| 667 |
-
</div>
|
| 668 |
-
<p style={{ margin: 0, fontSize: 13, color: "#f0eeff", lineHeight: 1.6 }}>
|
| 669 |
-
{seg.text}
|
| 670 |
-
</p>
|
| 671 |
-
</div>
|
| 672 |
-
)
|
| 673 |
-
})}
|
| 674 |
</div>
|
| 675 |
)}
|
| 676 |
-
</
|
| 677 |
)}
|
| 678 |
|
| 679 |
{/* Disclaimer */}
|
| 680 |
<div style={{ fontSize: 11, color: "#4a4865", padding: 14, marginTop: 20,
|
| 681 |
-
background: "#
|
| 682 |
-
border: "1px solid #
|
| 683 |
<strong style={{ color: "#8b89aa" }}>Note:</strong> All scores and observations are
|
| 684 |
probabilistic proxies based on acoustic and linguistic patterns. They are not validated
|
| 685 |
psychological assessments. Use as prompts for self-reflection only.
|
|
|
|
| 1 |
import { useState } from "react"
|
| 2 |
+
import Reveal, { RevealItem } from "./Reveal"
|
| 3 |
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"
|
| 4 |
import api from "../lib/api"
|
| 5 |
|
| 6 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 7 |
|
| 8 |
const SCORE_COLORS = ["#f87171", "#fb923c", "#f59e0b", "#34d399", "#10b981"]
|
| 9 |
|
|
|
|
| 14 |
{Array.from({ length: max }).map((_, i) => (
|
| 15 |
<div key={i} style={{
|
| 16 |
width: 20, height: 7, borderRadius: 4,
|
| 17 |
+
background: i < score ? color : "#1e2438",
|
| 18 |
boxShadow: i < score ? `0 0 6px ${color}50` : "none",
|
| 19 |
transition: "all 0.2s",
|
| 20 |
}} />
|
|
|
|
| 27 |
const [expanded, setExpanded] = useState(false)
|
| 28 |
|
| 29 |
return (
|
| 30 |
+
<div style={{ border: "1px solid #1e2438", borderRadius: 12,
|
| 31 |
+
background: "#151922", marginBottom: 10, overflow: "hidden" }}>
|
| 32 |
<div onClick={() => setExpanded(!expanded)}
|
| 33 |
style={{ padding: "14px 16px", cursor: "pointer",
|
| 34 |
display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
|
|
| 53 |
</div>
|
| 54 |
|
| 55 |
{expanded && narrative && (
|
| 56 |
+
<div style={{ padding: "14px 16px", borderTop: "1px solid #1e2438",
|
| 57 |
+
background: "#131827", fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
|
| 58 |
{narrative}
|
| 59 |
</div>
|
| 60 |
)}
|
|
|
|
| 67 |
const color = priorityColors[suggestion.priority] || "#8b89aa"
|
| 68 |
|
| 69 |
return (
|
| 70 |
+
<div style={{ border: "1px solid #1e2438", borderRadius: 12,
|
| 71 |
+
padding: 16, background: "#151922", borderLeft: `3px solid ${color}` }}>
|
| 72 |
<span style={{ fontSize: 11, fontWeight: 700, color,
|
| 73 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 74 |
#{suggestion.priority} β {suggestion.area}
|
|
|
|
| 82 |
π‘ {suggestion.suggestion}
|
| 83 |
</div>
|
| 84 |
<div style={{ fontSize: 12, color: "#8b89aa", lineHeight: 1.5,
|
| 85 |
+
padding: "10px 12px", background: "#131827", borderRadius: 8 }}>
|
| 86 |
<strong style={{ color: "#f0eeff" }}>Why it matters:</strong> {suggestion.why_it_matters}
|
| 87 |
</div>
|
| 88 |
</div>
|
|
|
|
| 92 |
function ObservationCard({ obs, sessionId }) {
|
| 93 |
const [resonance, setResonance] = useState(null)
|
| 94 |
const signalColors = {
|
| 95 |
+
talk_ratio: "#818cf8", speech_rate: "#5b9cf6",
|
| 96 |
speech_acceleration: "#f472b6", pauses: "#f59e0b",
|
| 97 |
interruptions: "#f87171", filler_words: "#fb923c",
|
| 98 |
+
vocal_energy: "#0891b2", questions: "#34d399",
|
| 99 |
monologue: "#818cf8", vocabulary_richness: "#a3e635",
|
| 100 |
+
silence_ratio: "#8b89aa", pitch: "#0891b2", engagement: "#5b9cf6"
|
| 101 |
}
|
| 102 |
const color = signalColors[obs.signal] || "#8b89aa"
|
| 103 |
|
| 104 |
return (
|
| 105 |
+
<div style={{ border: "1px solid #1e2438", borderRadius: 10,
|
| 106 |
+
padding: 16, background: "#151922", borderLeft: `3px solid ${color}` }}>
|
| 107 |
<div style={{ fontSize: 11, color, fontWeight: 600,
|
| 108 |
textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 }}>
|
| 109 |
{obs.signal.replace(/_/g, " ")}
|
|
|
|
| 133 |
} catch {}
|
| 134 |
}
|
| 135 |
}}
|
| 136 |
+
style={{ padding: "5px 14px", border: "1px solid #1e2438",
|
| 137 |
+
borderRadius: 20, background: "#131827", cursor: "pointer",
|
| 138 |
fontSize: 12, color: "#8b89aa" }}>
|
| 139 |
{label}
|
| 140 |
</button>
|
|
|
|
| 150 |
|
| 151 |
function ReflectCard({ item, index }) {
|
| 152 |
const [open, setOpen] = useState(false)
|
| 153 |
+
const accent = ["#818cf8", "#5b9cf6", "#0891b2"][index % 3]
|
| 154 |
return (
|
| 155 |
+
<div style={{ border: "1px solid #1e2438", borderRadius: 12,
|
| 156 |
+
background: "#151922", overflow: "hidden", cursor: "pointer" }}
|
| 157 |
onClick={() => setOpen(v => !v)}>
|
| 158 |
<div style={{ padding: "16px 18px", display: "flex",
|
| 159 |
justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
|
|
|
| 170 |
</span>
|
| 171 |
</div>
|
| 172 |
{open && (
|
| 173 |
+
<div style={{ padding: "14px 18px", borderTop: "1px solid #1e2438",
|
| 174 |
+
background: "#131827" }}>
|
| 175 |
<p style={{ margin: 0, fontSize: 13, color: "#8b89aa", lineHeight: 1.7 }}>
|
| 176 |
{item.answer}
|
| 177 |
</p>
|
|
|
|
| 203 |
const [activeTab, setActiveTab] = useState("overview")
|
| 204 |
|
| 205 |
const { signals, insights, dimensions, filename, detected_speaker,
|
| 206 |
+
session_id, voiceprint_confidence, fingerprint } = liveResults
|
| 207 |
|
| 208 |
const confPct = voiceprint_confidence != null ? Math.round(voiceprint_confidence * 100) : null
|
| 209 |
const confLabel = confPct == null ? null : confPct >= 55 ? "high" : confPct >= 40 ? "medium" : "low"
|
|
|
|
| 233 |
}
|
| 234 |
}
|
| 235 |
|
| 236 |
+
const tabs = ["overview", "dimensions", "coaching", "reflect", "signals", "summary"]
|
| 237 |
const otherSpeakers = (liveResults.available_speakers || []).filter(s => s !== detected_speaker)
|
| 238 |
|
| 239 |
return (
|
|
|
|
| 259 |
{otherSpeakers.length > 0 && (
|
| 260 |
<div style={{ position: "relative" }}>
|
| 261 |
<button onClick={() => setShowSpeakerSwitch(v => !v)}
|
| 262 |
+
style={{ fontSize: 12, color: "#8b89aa", background: "#151922",
|
| 263 |
+
border: "1px solid #1e2438", borderRadius: 20,
|
| 264 |
padding: "3px 12px", cursor: "pointer" }}>
|
| 265 |
{reanalyzing ? "Switchingβ¦" : "Not you?"}
|
| 266 |
</button>
|
| 267 |
{showSpeakerSwitch && !reanalyzing && (
|
| 268 |
<div style={{ position: "absolute", right: 0, top: "110%",
|
| 269 |
+
background: "#151922", border: "1px solid #1e2438", borderRadius: 10,
|
| 270 |
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", zIndex: 10,
|
| 271 |
minWidth: 160, overflow: "hidden" }}>
|
| 272 |
{otherSpeakers.map(s => (
|
| 273 |
<button key={s} onClick={() => handleReanalyze(s)}
|
| 274 |
style={{ display: "block", width: "100%", padding: "10px 14px",
|
| 275 |
textAlign: "left", background: "none", border: "none",
|
| 276 |
+
borderBottom: "1px solid #1e2438",
|
| 277 |
cursor: "pointer", fontSize: 13, color: "#f0eeff" }}>
|
| 278 |
Switch to {speakerLabel(s)}
|
| 279 |
</button>
|
|
|
|
| 305 |
)}
|
| 306 |
|
| 307 |
{/* Tabs */}
|
| 308 |
+
<div style={{ display: "flex", marginBottom: 20, borderBottom: "1px solid #1e2438" }}>
|
| 309 |
{tabs.map(tab => (
|
| 310 |
<button key={tab} onClick={() => setActiveTab(tab)}
|
| 311 |
+
className={`nav-tab${activeTab === tab ? " active" : ""}`}
|
| 312 |
style={{ background: "none", border: "none", cursor: "pointer",
|
| 313 |
padding: "8px 14px", fontSize: 13, textTransform: "capitalize",
|
| 314 |
fontWeight: activeTab === tab ? 600 : 400,
|
| 315 |
+
color: activeTab === tab ? "#5b9cf6" : "#8b89aa" }}>
|
|
|
|
|
|
|
| 316 |
{tab}
|
| 317 |
</button>
|
| 318 |
))}
|
|
|
|
| 320 |
|
| 321 |
{/* ββ OVERVIEW TAB ββ */}
|
| 322 |
{activeTab === "overview" && (
|
| 323 |
+
<Reveal>
|
| 324 |
{/* Type chips */}
|
| 325 |
{(() => {
|
| 326 |
const types = insights.conversation_types || []
|
|
|
|
| 332 |
<span key={t} style={{
|
| 333 |
fontSize: 11, fontWeight: i === 0 ? 700 : 500,
|
| 334 |
padding: "3px 10px", borderRadius: 20,
|
| 335 |
+
background: i === 0 ? "rgba(29,78,216,0.12)" : "#131827",
|
| 336 |
+
color: i === 0 ? "#5b9cf6" : "#8b89aa",
|
| 337 |
+
border: `1px solid ${i === 0 ? "rgba(29,78,216,0.3)" : "#1e2438"}`,
|
| 338 |
textTransform: "uppercase", letterSpacing: 0.4,
|
| 339 |
}}>
|
| 340 |
{LABELS[t] || t}
|
|
|
|
| 362 |
|
| 363 |
{/* Notable pattern β promoted to headline position */}
|
| 364 |
{insights.notable_pattern && (
|
| 365 |
+
<div style={{ background: "rgba(29,78,216,0.06)",
|
| 366 |
+
border: "1px solid rgba(29,78,216,0.2)",
|
| 367 |
borderRadius: 10, padding: 16, marginBottom: 14 }}>
|
| 368 |
<div style={{ fontSize: 11, fontWeight: 600,
|
| 369 |
+
color: "#5b9cf6", marginBottom: 6,
|
| 370 |
textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 371 |
Notable Pattern
|
| 372 |
</div>
|
|
|
|
| 377 |
)}
|
| 378 |
|
| 379 |
{/* Communication Pattern */}
|
| 380 |
+
<div style={{ background: "#151922", borderRadius: 12,
|
| 381 |
+
padding: 18, marginBottom: 16, borderLeft: "3px solid #1d4ed8" }}>
|
| 382 |
+
<div style={{ fontSize: 11, fontWeight: 600, color: "#5b9cf6",
|
| 383 |
marginBottom: 8, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 384 |
Communication Pattern
|
| 385 |
</div>
|
|
|
|
| 413 |
{ label: "Fillers", value: `${signals.filler_words.rate_per_100_words}`, unit: "/100w" },
|
| 414 |
].map(({ label, value, unit }) => (
|
| 415 |
<div key={label} style={{ textAlign: "center", padding: "16px 8px",
|
| 416 |
+
border: "1px solid #1e2438", borderRadius: 10, background: "#151922" }}>
|
| 417 |
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 6 }}>{label}</div>
|
| 418 |
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1,
|
| 419 |
background: G, WebkitBackgroundClip: "text",
|
|
|
|
| 436 |
? "β" : signals.vocal_energy.trend },
|
| 437 |
].map(({ label, value }) => (
|
| 438 |
<div key={label} style={{ textAlign: "center", padding: "14px 8px",
|
| 439 |
+
border: "1px solid #1e2438", borderRadius: 10, background: "#151922" }}>
|
| 440 |
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 5 }}>{label}</div>
|
| 441 |
<div style={{ fontSize: 18, fontWeight: 700, color: "#f0eeff",
|
| 442 |
textTransform: "capitalize" }}>{value}</div>
|
|
|
|
| 444 |
))}
|
| 445 |
</div>
|
| 446 |
|
| 447 |
+
</Reveal>
|
| 448 |
)}
|
| 449 |
|
| 450 |
{/* ββ DIMENSIONS TAB ββ */}
|
| 451 |
{activeTab === "dimensions" && dimensions && (
|
| 452 |
+
<Reveal>
|
| 453 |
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
|
| 454 |
Scores are probabilistic proxies based on behavioral signals β
|
| 455 |
+
not psychological diagnoses. Expand any card to read the interpretation.
|
| 456 |
</p>
|
| 457 |
{dimensions.emotional_state && (
|
| 458 |
<DimensionCard title="Your Emotional State" icon="π§ "
|
|
|
|
| 490 |
|
| 491 |
{/* Conversation Arc β moved from Overview */}
|
| 492 |
{dimensions?.conversation_arc && (
|
| 493 |
+
<div style={{ border: "1px solid #1e2438", borderRadius: 12,
|
| 494 |
+
padding: 16, background: "#151922" }}>
|
| 495 |
<div style={{ fontSize: 12, fontWeight: 600, color: "#8b89aa",
|
| 496 |
marginBottom: 14, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
| 497 |
Conversation Arc
|
|
|
|
| 512 |
</div>
|
| 513 |
{dimensions.conversation_arc.turning_point?.detected && (
|
| 514 |
<div style={{ marginTop: 12, fontSize: 12, color: "#8b89aa",
|
| 515 |
+
background: "#131827", padding: "8px 12px", borderRadius: 8 }}>
|
| 516 |
β‘ {dimensions.conversation_arc.turning_point.detail}
|
| 517 |
</div>
|
| 518 |
)}
|
|
|
|
| 524 |
)}
|
| 525 |
</div>
|
| 526 |
)}
|
| 527 |
+
</Reveal>
|
| 528 |
)}
|
| 529 |
|
| 530 |
{/* ββ COACHING TAB ββ */}
|
| 531 |
{activeTab === "coaching" && (
|
| 532 |
+
<Reveal>
|
| 533 |
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
|
| 534 |
Specific suggestions based on patterns observed in this conversation.
|
| 535 |
Ranked by potential impact.
|
| 536 |
</p>
|
| 537 |
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 538 |
{insights.coaching_suggestions?.map((s, i) => (
|
| 539 |
+
<RevealItem key={i} index={i}>
|
| 540 |
+
<CoachingCard suggestion={s} />
|
| 541 |
+
</RevealItem>
|
| 542 |
))}
|
| 543 |
{(!insights.coaching_suggestions || insights.coaching_suggestions.length === 0) && (
|
| 544 |
<div style={{ textAlign: "center", padding: 32, color: "#4a4865" }}>
|
|
|
|
| 546 |
</div>
|
| 547 |
)}
|
| 548 |
</div>
|
| 549 |
+
</Reveal>
|
| 550 |
)}
|
| 551 |
|
| 552 |
{/* ββ REFLECT TAB ββ */}
|
| 553 |
{activeTab === "reflect" && (
|
| 554 |
+
<Reveal>
|
| 555 |
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 20, lineHeight: 1.6 }}>
|
| 556 |
Questions grounded in what happened in this conversation. Tap any to read the insight.
|
| 557 |
</p>
|
|
|
|
| 568 |
) : (
|
| 569 |
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 570 |
{insights.reflection_questions.map((item, i) => (
|
| 571 |
+
<RevealItem key={i} index={i}>
|
| 572 |
+
<ReflectCard item={item} index={i} />
|
| 573 |
+
</RevealItem>
|
| 574 |
))}
|
| 575 |
</div>
|
| 576 |
)}
|
| 577 |
+
</Reveal>
|
| 578 |
)}
|
| 579 |
|
| 580 |
{/* ββ SIGNALS TAB ββ */}
|
| 581 |
{activeTab === "signals" && (
|
| 582 |
+
<Reveal>
|
| 583 |
{signals.timeline?.length > 1 && (
|
| 584 |
<div style={{ marginBottom: 24 }}>
|
| 585 |
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: "#f0eeff" }}>
|
| 586 |
Speech Rate Over Time
|
| 587 |
</h3>
|
| 588 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 589 |
borderRadius: 10, padding: 16 }}>
|
| 590 |
<ResponsiveContainer width="100%" height={150}>
|
| 591 |
<LineChart data={signals.timeline}>
|
| 592 |
<XAxis dataKey="window_start_s" tickFormatter={formatTime} fontSize={11}
|
| 593 |
+
tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#1e2438" }}
|
| 594 |
+
tickLine={{ stroke: "#1e2438" }} />
|
| 595 |
<YAxis domain={["auto", "auto"]} fontSize={11} width={35}
|
| 596 |
+
tick={{ fill: "#8b89aa" }} axisLine={{ stroke: "#1e2438" }}
|
| 597 |
+
tickLine={{ stroke: "#1e2438" }} />
|
| 598 |
<Tooltip
|
| 599 |
+
contentStyle={{ background: "#151922", border: "1px solid #1e2438",
|
| 600 |
borderRadius: 8 }}
|
| 601 |
labelStyle={{ color: "#8b89aa" }} itemStyle={{ color: "#f0eeff" }}
|
| 602 |
labelFormatter={v => `At ${formatTime(v)}`}
|
| 603 |
formatter={v => [`${Math.round(v)} wpm`, "Speech rate"]} />
|
| 604 |
<Line type="monotone" dataKey="speech_rate_wpm"
|
| 605 |
+
stroke="#1d4ed8" strokeWidth={2} dot={false} />
|
| 606 |
</LineChart>
|
| 607 |
</ResponsiveContainer>
|
| 608 |
</div>
|
|
|
|
| 628 |
{ label: "Within-turn pauses", value: signals.pauses.within_turn_pauses.count },
|
| 629 |
{ label: "Your turns", value: signals.turn_dynamics.user_turns },
|
| 630 |
].map(({ label, value }) => (
|
| 631 |
+
<div key={label} style={{ padding: 12, border: "1px solid #1e2438",
|
| 632 |
+
borderRadius: 8, background: "#151922" }}>
|
| 633 |
<div style={{ fontSize: 11, color: "#4a4865", marginBottom: 4 }}>{label}</div>
|
| 634 |
<div style={{ fontSize: 16, fontWeight: 600, color: "#f0eeff" }}>{value}</div>
|
| 635 |
</div>
|
| 636 |
))}
|
| 637 |
</div>
|
| 638 |
+
</Reveal>
|
| 639 |
)}
|
| 640 |
|
| 641 |
+
{/* ββ SUMMARY TAB ββ */}
|
| 642 |
+
{activeTab === "summary" && (
|
| 643 |
+
<Reveal>
|
| 644 |
+
<p style={{ fontSize: 13, color: "#8b89aa", marginBottom: 16, lineHeight: 1.6 }}>
|
| 645 |
+
A compact behavioral summary of this session β used to build your cross-session profile over time.
|
| 646 |
+
</p>
|
| 647 |
+
{fingerprint ? (
|
| 648 |
+
<div style={{ background: "#151922", border: "1px solid #1e2438",
|
| 649 |
+
borderRadius: 12, padding: 20 }}>
|
| 650 |
+
<div style={{ fontSize: 11, fontWeight: 600, color: "#4a4865",
|
| 651 |
+
textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 12 }}>
|
| 652 |
+
Behavioral Fingerprint
|
| 653 |
+
</div>
|
| 654 |
+
<p style={{ margin: 0, fontSize: 14, color: "#d4d2e8", lineHeight: 1.85 }}>
|
| 655 |
+
{fingerprint}
|
| 656 |
+
</p>
|
| 657 |
+
</div>
|
| 658 |
+
) : (
|
| 659 |
<div style={{ textAlign: "center", padding: 48, color: "#4a4865" }}>
|
| 660 |
<div style={{ fontSize: 32, marginBottom: 12 }}>π</div>
|
| 661 |
<p style={{ fontSize: 14, color: "#8b89aa" }}>
|
| 662 |
+
Summary not available for this session.
|
| 663 |
+
</p>
|
| 664 |
+
<p style={{ fontSize: 12, color: "#4a4865", marginTop: 4 }}>
|
| 665 |
+
New sessions will include this automatically.
|
| 666 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
</div>
|
| 668 |
)}
|
| 669 |
+
</Reveal>
|
| 670 |
)}
|
| 671 |
|
| 672 |
{/* Disclaimer */}
|
| 673 |
<div style={{ fontSize: 11, color: "#4a4865", padding: 14, marginTop: 20,
|
| 674 |
+
background: "#151922", borderRadius: 8, lineHeight: 1.7,
|
| 675 |
+
border: "1px solid #1e2438" }}>
|
| 676 |
<strong style={{ color: "#8b89aa" }}>Note:</strong> All scores and observations are
|
| 677 |
probabilistic proxies based on acoustic and linguistic patterns. They are not validated
|
| 678 |
psychological assessments. Use as prompts for self-reflection only.
|
frontend/src/components/Reveal.jsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
| 2 |
+
|
| 3 |
+
const RevealCtx = createContext(false)
|
| 4 |
+
|
| 5 |
+
// Used inside a <Reveal> to stagger child elements.
|
| 6 |
+
// Opacity-only so it works safely inside overflow:hidden containers.
|
| 7 |
+
export function RevealItem({ children, index = 0, style }) {
|
| 8 |
+
const visible = useContext(RevealCtx)
|
| 9 |
+
return (
|
| 10 |
+
<div style={{
|
| 11 |
+
opacity: visible ? 1 : 0,
|
| 12 |
+
transition: `opacity 0.4s ease ${index * 70}ms`,
|
| 13 |
+
...style,
|
| 14 |
+
}}>
|
| 15 |
+
{children}
|
| 16 |
+
</div>
|
| 17 |
+
)
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function Reveal({ children, delay = 0, style }) {
|
| 21 |
+
const ref = useRef(null)
|
| 22 |
+
const [visible, setVisible] = useState(false)
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const el = ref.current
|
| 26 |
+
if (!el) return
|
| 27 |
+
const observer = new IntersectionObserver(
|
| 28 |
+
([entry]) => {
|
| 29 |
+
if (entry.isIntersecting) {
|
| 30 |
+
setVisible(true)
|
| 31 |
+
observer.disconnect()
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
{ threshold: 0.12 }
|
| 35 |
+
)
|
| 36 |
+
observer.observe(el)
|
| 37 |
+
return () => observer.disconnect()
|
| 38 |
+
}, [])
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<RevealCtx.Provider value={visible}>
|
| 42 |
+
<div
|
| 43 |
+
ref={ref}
|
| 44 |
+
style={{
|
| 45 |
+
opacity: visible ? 1 : 0,
|
| 46 |
+
transform: visible ? "translateY(0)" : "translateY(44px)",
|
| 47 |
+
transition: `opacity 0.65s cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 0.65s cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
|
| 48 |
+
...style,
|
| 49 |
+
}}
|
| 50 |
+
>
|
| 51 |
+
{children}
|
| 52 |
+
</div>
|
| 53 |
+
</RevealCtx.Provider>
|
| 54 |
+
)
|
| 55 |
+
}
|
frontend/src/components/UploadView.jsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
import { useState, useEffect, useRef } from "react"
|
| 2 |
import api from "../lib/api"
|
|
|
|
| 3 |
|
| 4 |
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
| 5 |
-
const G = "linear-gradient(135deg, #
|
| 6 |
|
| 7 |
const PREPARE_STEPS = [
|
| 8 |
{ key: "transcribing", label: "Transcribing audio" },
|
|
@@ -36,14 +37,14 @@ function StepProgress({ steps, currentStep, title, onCancel }) {
|
|
| 36 |
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
| 37 |
<div style={{
|
| 38 |
width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
|
| 39 |
-
background: isDone ? G : isCurrent ? "rgba(
|
| 40 |
-
border: `2px solid ${isDone ? "transparent" : isCurrent ? "#
|
| 41 |
display: "flex", alignItems: "center", justifyContent: "center",
|
| 42 |
-
boxShadow: isCurrent ? "0 0 16px rgba(
|
| 43 |
transition: "all 0.3s",
|
| 44 |
}}>
|
| 45 |
{isDone && <span style={{ color: "white", fontSize: 12, fontWeight: 700 }}>β</span>}
|
| 46 |
-
{isCurrent && <div style={{ width: 8, height: 8, background: "#
|
| 47 |
borderRadius: "50%" }} />}
|
| 48 |
</div>
|
| 49 |
<div>
|
|
@@ -55,7 +56,7 @@ function StepProgress({ steps, currentStep, title, onCancel }) {
|
|
| 55 |
{step.label}
|
| 56 |
</span>
|
| 57 |
{isCurrent && (
|
| 58 |
-
<span style={{ fontSize: 12, color: "#
|
| 59 |
β in progressβ¦
|
| 60 |
</span>
|
| 61 |
)}
|
|
@@ -69,7 +70,7 @@ function StepProgress({ steps, currentStep, title, onCancel }) {
|
|
| 69 |
</p>
|
| 70 |
{onCancel && (
|
| 71 |
<button onClick={onCancel}
|
| 72 |
-
style={{ marginTop: 12, background: "none", border: "1px solid #
|
| 73 |
borderRadius: 6, padding: "6px 18px", fontSize: 12, color: "#4a4865",
|
| 74 |
cursor: "pointer" }}>
|
| 75 |
Cancel
|
|
@@ -287,17 +288,17 @@ export default function UploadView({ onResults, onActivate }) {
|
|
| 287 |
const hasFile = !!file
|
| 288 |
|
| 289 |
return (
|
| 290 |
-
<
|
| 291 |
{/* Drop zone */}
|
| 292 |
<div
|
| 293 |
onClick={() => document.getElementById("file-input").click()}
|
| 294 |
style={{
|
| 295 |
-
border: `2px dashed ${hasFile ? "#
|
| 296 |
borderRadius: 16, padding: "52px 32px",
|
| 297 |
textAlign: "center", marginBottom: 20, cursor: "pointer",
|
| 298 |
-
background: hasFile ? "rgba(
|
| 299 |
transition: "all 0.2s",
|
| 300 |
-
boxShadow: hasFile ? "inset 0 0 40px rgba(
|
| 301 |
}}>
|
| 302 |
<input id="file-input" type="file" accept="*/*"
|
| 303 |
style={{ display: "none" }}
|
|
@@ -335,20 +336,20 @@ export default function UploadView({ onResults, onActivate }) {
|
|
| 335 |
)}
|
| 336 |
|
| 337 |
<button onClick={handlePrepare} disabled={!hasFile}
|
|
|
|
| 338 |
style={{ width: "100%", padding: "15px 24px",
|
| 339 |
-
background: hasFile ? G : "#
|
| 340 |
color: hasFile ? "white" : "#2a2a42",
|
| 341 |
-
border: hasFile ? "none" : "1px solid #
|
| 342 |
borderRadius: 10, fontSize: 15,
|
| 343 |
cursor: hasFile ? "pointer" : "not-allowed", fontWeight: 600,
|
| 344 |
-
boxShadow: hasFile ? "0 0 28px rgba(
|
| 345 |
-
transition: "all 0.2s" }}>
|
| 346 |
Analyze Conversation
|
| 347 |
</button>
|
| 348 |
|
| 349 |
<p style={{ fontSize: 11, color: "#4a4865", textAlign: "center", marginTop: 12 }}>
|
| 350 |
π Audio is deleted immediately after analysis. Only behavioral signals are stored.
|
| 351 |
</p>
|
| 352 |
-
</
|
| 353 |
)
|
| 354 |
}
|
|
|
|
| 1 |
import { useState, useEffect, useRef } from "react"
|
| 2 |
import api from "../lib/api"
|
| 3 |
+
import Reveal from "./Reveal"
|
| 4 |
|
| 5 |
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
| 6 |
+
const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
|
| 7 |
|
| 8 |
const PREPARE_STEPS = [
|
| 9 |
{ key: "transcribing", label: "Transcribing audio" },
|
|
|
|
| 37 |
<div key={step.key} style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
| 38 |
<div style={{
|
| 39 |
width: 28, height: 28, borderRadius: "50%", flexShrink: 0,
|
| 40 |
+
background: isDone ? G : isCurrent ? "rgba(29,78,216,0.1)" : "#151922",
|
| 41 |
+
border: `2px solid ${isDone ? "transparent" : isCurrent ? "#1d4ed8" : "#1e2438"}`,
|
| 42 |
display: "flex", alignItems: "center", justifyContent: "center",
|
| 43 |
+
boxShadow: isCurrent ? "0 0 16px rgba(29,78,216,0.4)" : "none",
|
| 44 |
transition: "all 0.3s",
|
| 45 |
}}>
|
| 46 |
{isDone && <span style={{ color: "white", fontSize: 12, fontWeight: 700 }}>β</span>}
|
| 47 |
+
{isCurrent && <div style={{ width: 8, height: 8, background: "#1d4ed8",
|
| 48 |
borderRadius: "50%" }} />}
|
| 49 |
</div>
|
| 50 |
<div>
|
|
|
|
| 56 |
{step.label}
|
| 57 |
</span>
|
| 58 |
{isCurrent && (
|
| 59 |
+
<span style={{ fontSize: 12, color: "#5b9cf6", marginLeft: 6 }}>
|
| 60 |
β in progressβ¦
|
| 61 |
</span>
|
| 62 |
)}
|
|
|
|
| 70 |
</p>
|
| 71 |
{onCancel && (
|
| 72 |
<button onClick={onCancel}
|
| 73 |
+
style={{ marginTop: 12, background: "none", border: "1px solid #1e2438",
|
| 74 |
borderRadius: 6, padding: "6px 18px", fontSize: 12, color: "#4a4865",
|
| 75 |
cursor: "pointer" }}>
|
| 76 |
Cancel
|
|
|
|
| 288 |
const hasFile = !!file
|
| 289 |
|
| 290 |
return (
|
| 291 |
+
<Reveal>
|
| 292 |
{/* Drop zone */}
|
| 293 |
<div
|
| 294 |
onClick={() => document.getElementById("file-input").click()}
|
| 295 |
style={{
|
| 296 |
+
border: `2px dashed ${hasFile ? "#1d4ed8" : "#1e2438"}`,
|
| 297 |
borderRadius: 16, padding: "52px 32px",
|
| 298 |
textAlign: "center", marginBottom: 20, cursor: "pointer",
|
| 299 |
+
background: hasFile ? "rgba(29,78,216,0.04)" : "#151922",
|
| 300 |
transition: "all 0.2s",
|
| 301 |
+
boxShadow: hasFile ? "inset 0 0 40px rgba(29,78,216,0.04)" : "none",
|
| 302 |
}}>
|
| 303 |
<input id="file-input" type="file" accept="*/*"
|
| 304 |
style={{ display: "none" }}
|
|
|
|
| 336 |
)}
|
| 337 |
|
| 338 |
<button onClick={handlePrepare} disabled={!hasFile}
|
| 339 |
+
className={hasFile ? "btn-grad" : ""}
|
| 340 |
style={{ width: "100%", padding: "15px 24px",
|
| 341 |
+
background: hasFile ? G : "#151922",
|
| 342 |
color: hasFile ? "white" : "#2a2a42",
|
| 343 |
+
border: hasFile ? "none" : "1px solid #1e2438",
|
| 344 |
borderRadius: 10, fontSize: 15,
|
| 345 |
cursor: hasFile ? "pointer" : "not-allowed", fontWeight: 600,
|
| 346 |
+
boxShadow: hasFile ? "0 0 28px rgba(29,78,216,0.3)" : "none" }}>
|
|
|
|
| 347 |
Analyze Conversation
|
| 348 |
</button>
|
| 349 |
|
| 350 |
<p style={{ fontSize: 11, color: "#4a4865", textAlign: "center", marginTop: 12 }}>
|
| 351 |
π Audio is deleted immediately after analysis. Only behavioral signals are stored.
|
| 352 |
</p>
|
| 353 |
+
</Reveal>
|
| 354 |
)
|
| 355 |
}
|
frontend/src/index.css
CHANGED
|
@@ -5,50 +5,155 @@
|
|
| 5 |
}
|
| 6 |
|
| 7 |
body {
|
| 8 |
-
background: #
|
|
|
|
|
|
|
| 9 |
color: #f0eeff;
|
| 10 |
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 11 |
-webkit-font-smoothing: antialiased;
|
| 12 |
-moz-osx-font-smoothing: grayscale;
|
| 13 |
}
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
input, textarea, select {
|
| 16 |
font-family: inherit;
|
| 17 |
-
background: #
|
| 18 |
color: #f0eeff;
|
| 19 |
-
border: 1px solid #
|
| 20 |
border-radius: 8px;
|
| 21 |
outline: none;
|
| 22 |
-
transition: border-color 0.
|
| 23 |
}
|
| 24 |
|
| 25 |
input:focus, textarea:focus, select:focus {
|
| 26 |
-
border-color: #
|
| 27 |
-
box-shadow: 0 0 0
|
| 28 |
}
|
| 29 |
|
| 30 |
input::placeholder, textarea::placeholder {
|
| 31 |
-
color: #
|
| 32 |
}
|
| 33 |
|
| 34 |
button {
|
| 35 |
font-family: inherit;
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
width: 6px;
|
| 40 |
-
height: 6px;
|
| 41 |
-
}
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
-
::
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
}
|
| 6 |
|
| 7 |
body {
|
| 8 |
+
background: #0f1117;
|
| 9 |
+
background-image: radial-gradient(rgba(255,255,255,0.028) 1px, transparent 1px);
|
| 10 |
+
background-size: 28px 28px;
|
| 11 |
color: #f0eeff;
|
| 12 |
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 13 |
-webkit-font-smoothing: antialiased;
|
| 14 |
-moz-osx-font-smoothing: grayscale;
|
| 15 |
}
|
| 16 |
|
| 17 |
+
/* ββ Animations ββββββββββββββββββββββββββββββββββββββββββ */
|
| 18 |
+
|
| 19 |
+
@keyframes viewEnter {
|
| 20 |
+
from { opacity: 0; transform: translateY(16px); }
|
| 21 |
+
to { opacity: 1; transform: translateY(0); }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
@keyframes fadeUp {
|
| 25 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 26 |
+
to { opacity: 1; transform: translateY(0); }
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
@keyframes fadeIn {
|
| 30 |
+
from { opacity: 0; }
|
| 31 |
+
to { opacity: 1; }
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
@keyframes pulse-glow {
|
| 35 |
+
0%, 100% { box-shadow: 0 0 20px rgba(29,78,216,0.3); }
|
| 36 |
+
50% { box-shadow: 0 0 40px rgba(29,78,216,0.6); }
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@keyframes shimmer {
|
| 40 |
+
from { background-position: -200% center; }
|
| 41 |
+
to { background-position: 200% center; }
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
@keyframes blobDrift1 {
|
| 45 |
+
0% { transform: translate(0, 0) scale(1); }
|
| 46 |
+
30% { transform: translate(40px, -30px) scale(1.06); }
|
| 47 |
+
65% { transform: translate(-20px, 25px) scale(0.96); }
|
| 48 |
+
100% { transform: translate(25px, 15px) scale(1.03); }
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
@keyframes blobDrift2 {
|
| 52 |
+
0% { transform: translate(0, 0) scale(1); }
|
| 53 |
+
30% { transform: translate(-35px, 40px) scale(1.04); }
|
| 54 |
+
65% { transform: translate(25px, -20px) scale(0.97); }
|
| 55 |
+
100% { transform: translate(-15px, 30px) scale(1.05); }
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ββ View transitions ββββββββββββββββββββββββββββββββββββ */
|
| 59 |
+
|
| 60 |
+
.view-enter {
|
| 61 |
+
animation: viewEnter 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.fade-in {
|
| 65 |
+
animation: fadeIn 0.2s ease forwards;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* ββ Cards βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 69 |
+
|
| 70 |
+
.card {
|
| 71 |
+
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
| 72 |
+
will-change: transform;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.card:hover {
|
| 76 |
+
transform: translateY(-4px);
|
| 77 |
+
border-color: #2e3464 !important;
|
| 78 |
+
box-shadow: 0 16px 48px rgba(0,0,0,0.6), 0 0 0 1px rgba(29,78,216,0.15) !important;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* ββ Buttons ββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 82 |
+
|
| 83 |
+
.btn-grad {
|
| 84 |
+
transition: transform 0.15s ease, box-shadow 0.2s ease;
|
| 85 |
+
cursor: pointer;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.btn-grad:hover {
|
| 89 |
+
transform: translateY(-2px);
|
| 90 |
+
box-shadow: 0 10px 32px rgba(29,78,216,0.45) !important;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.btn-grad:active {
|
| 94 |
+
transform: scale(0.97) !important;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.btn-ghost {
|
| 98 |
+
transition: color 0.15s ease, background 0.15s ease;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.btn-ghost:hover {
|
| 102 |
+
color: #c4c2d8 !important;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/* ββ Inputs βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 106 |
+
|
| 107 |
input, textarea, select {
|
| 108 |
font-family: inherit;
|
| 109 |
+
background: #0e1320;
|
| 110 |
color: #f0eeff;
|
| 111 |
+
border: 1px solid #1e2438;
|
| 112 |
border-radius: 8px;
|
| 113 |
outline: none;
|
| 114 |
+
transition: border-color 0.18s, box-shadow 0.18s;
|
| 115 |
}
|
| 116 |
|
| 117 |
input:focus, textarea:focus, select:focus {
|
| 118 |
+
border-color: #1d4ed8;
|
| 119 |
+
box-shadow: 0 0 0 3px rgba(29,78,216,0.15);
|
| 120 |
}
|
| 121 |
|
| 122 |
input::placeholder, textarea::placeholder {
|
| 123 |
+
color: #2e3a52;
|
| 124 |
}
|
| 125 |
|
| 126 |
button {
|
| 127 |
font-family: inherit;
|
| 128 |
}
|
| 129 |
|
| 130 |
+
/* ββ Nav tab indicator ββββββββββββββββββββββββββββββββββββ */
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
+
.nav-tab {
|
| 133 |
+
position: relative;
|
| 134 |
+
transition: color 0.18s ease;
|
| 135 |
}
|
| 136 |
|
| 137 |
+
.nav-tab::after {
|
| 138 |
+
content: '';
|
| 139 |
+
position: absolute;
|
| 140 |
+
bottom: -1px;
|
| 141 |
+
left: 0;
|
| 142 |
+
right: 0;
|
| 143 |
+
height: 2px;
|
| 144 |
+
border-radius: 2px 2px 0 0;
|
| 145 |
+
background: linear-gradient(90deg, #1d4ed8, #0891b2);
|
| 146 |
+
transform: scaleX(0);
|
| 147 |
+
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
| 148 |
}
|
| 149 |
|
| 150 |
+
.nav-tab.active::after {
|
| 151 |
+
transform: scaleX(1);
|
| 152 |
}
|
| 153 |
+
|
| 154 |
+
/* ββ Scrollbar ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 155 |
+
|
| 156 |
+
::-webkit-scrollbar { width: 5px; height: 5px; }
|
| 157 |
+
::-webkit-scrollbar-track { background: #0f1117; }
|
| 158 |
+
::-webkit-scrollbar-thumb { background: #1e2438; border-radius: 3px; }
|
| 159 |
+
::-webkit-scrollbar-thumb:hover { background: #2e3464; }
|