Spaces:
Sleeping
feat(profile + voice): KI-059 / KI-060 / KI-061 / KI-062 — opening-turn name capture, looser VAD silence, personalized welcome-back, unique persona ID
Browse filesFour user-testing fixes landed in one commit.
KI-059 — Catch name disclosed in opening message
═══════════════════════════════════════════════════════════════
User typed "Hi this is Rohit" as the very first message; bot still asked
"what should I call you?". Now an opening turn that contains a self-
introduction routes straight into the name-slot handler.
- New `_contains_self_introduction(text)` helper. Strips leading
greetings ("hi", "hello", "hey", "namaste", "yo") then matches
"I'm X" / "I am X" / "this is X" / "my name is X" / "name is X" /
"call me X" / "name's X".
- Tested 10/10 (positive + negative cases pass).
- When detected on turn 0, orchestrator synthesises
`session.awaiting_question_id = "name"` so the existing name-slot
pipeline (extract, save, welcome-back lookup) runs.
- Name-handler prefix loop widened: now strips leading
"hi[,]? " / "hello[,]? " / "hey[,]? " / etc. BEFORE the
"this is " / "i'm " / etc. loop, so "Hi, this is Rohit" → "Rohit".
KI-060 — Live VAD silence threshold loosened
═══════════════════════════════════════════════════════════════
User report: "small background noise is making the live stream auto submit.
Even if I pause for a second before I can continue speaking."
Root cause: KI-057 made the noise gate work correctly, which exposed an
underlying problem — `silenceEndFrames: 40` (~640 ms of silence) was too
aggressive for natural mid-sentence pauses. Bumped to 90 (~1.5 s). Single
config change in useLiveConversation.ts DEFAULTS.
KI-061 — Personalized welcome-back greeting
═══════════════════════════════════════════════════════════════
Previously: "Welcome back, Rohit! I've loaded your profile from last time."
Now also lists what's on file and what's missing, so the user feels
remembered AND any gap can be filled before recommending.
- `_format_known_profile_summary(profile)` returns a comma-joined
rundown ("age 34, covering you+spouse, income 10-25L, looking
for first health policy").
- `_format_missing_slots(profile)` returns human-readable labels
for high-value gaps (age, dependents, income, primary_goal,
parents' health, budget). Minor slots (location, existing cover,
health conditions) deliberately excluded to keep the greeting
focused.
- Greeting composes both: "Welcome back, X! Here's what I have on
file: ... We never got around to ... — happy to fill that in or
jump straight to a recommendation."
KI-062 — Full name + unique persona ID
═══════════════════════════════════════════════════════════════
User: "it should get my full name, Rohit Sar, for example, so that it's
easier to identify. Unique user / persona ID should also be created. That
is a combination of not just the name, which can be similar, but all the
other data points that we also collect."
- Name regex widened from 1-2 words to 1-4 words. "Rohit Sar" and
"Anjali Devi Kumar" now capture fully.
- New `compute_persona_id(profile)` in profile_store. Returns a
12-char sha1 over normalised name + age + dependents + income_band
+ location_tier + parents_age_max. Two users named "Rohit" with
different identity fields resolve to distinct IDs (verified: ids
d4ea9d29... vs c49f6838... in roundtrip test).
- `save_profile` now keys files by persona_id when there's enough
signal; falls back to name-slug for first-visit (only name known).
- `load_profile(name, persona_id=...)` does a 3-tier lookup: direct
persona-id hit → legacy name-slug → directory scan for any
persona-id-keyed file whose stored display name matches.
- Legacy name-slug file cleanup on save: when an identity is finally
resolved, the older same-name file's session history is folded
forward into the new persona-id file, then the legacy file is
unlinked. No orphan files; session lineage preserved.
Verification
═══════════════════════════════════════════════════════════════
- py_compile both backend files: OK
- 10/10 inline cases on _contains_self_introduction
- persona_id distinct for two different-identity Rohits + stable on replay
- save_profile + load_profile by persona_id roundtrip
- npx tsc --noEmit (frontend): 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/orchestrator.py +141 -5
- backend/profile_store.py +96 -14
- frontend/src/lib/useLiveConversation.ts +9 -1
|
@@ -235,6 +235,93 @@ def _family_aware_opener(user_text: str, fallback: str) -> Optional[str]:
|
|
| 235 |
return pool[idx]
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def _pick_opener(
|
| 239 |
user_text: str,
|
| 240 |
session_id: Optional[str],
|
|
@@ -500,6 +587,21 @@ async def handle_turn(
|
|
| 500 |
# of this branch to emit a "Welcome back" message instead of the
|
| 501 |
# next slot's question.
|
| 502 |
returning_visitor_greeting: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
if session.awaiting_question_id:
|
| 504 |
from backend.fact_find_normalizer import is_valid_answer, normalize_answer
|
| 505 |
qid = session.awaiting_question_id
|
|
@@ -507,6 +609,15 @@ async def handle_turn(
|
|
| 507 |
if qid == "name":
|
| 508 |
from backend.profile_store import is_valid_name, load_profile, save_profile
|
| 509 |
raw_name = user_text.strip().strip(".,!?")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
# Tolerate "I'm Rohit" / "My name is Rohit" / "call me Rohit"
|
| 511 |
for prefix in ("i'm ", "i am ", "my name is ", "name is ", "call me ", "this is "):
|
| 512 |
if raw_name.lower().startswith(prefix):
|
|
@@ -550,11 +661,36 @@ async def handle_turn(
|
|
| 550 |
session.profile.asked.append(slot_id)
|
| 551 |
session.free_form_session = True
|
| 552 |
session._flush()
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
else:
|
| 559 |
# New visitor — record an initial profile so subsequent
|
| 560 |
# turns persist; orchestrator will continue to next slot.
|
|
|
|
| 235 |
return pool[idx]
|
| 236 |
|
| 237 |
|
| 238 |
+
_SELF_INTRO_RE = re.compile(
|
| 239 |
+
# KI-062 (2026-05-15) — widened from 1-2 words to 1-4 words so full
|
| 240 |
+
# names like "Rohit Sar" or "Anjali Devi Kumar" get captured.
|
| 241 |
+
r"\b(?:i'?m|i\s+am|this\s+is|my\s+name\s+is|name\s+is|call\s+me|name'?s)\s+"
|
| 242 |
+
r"([a-zA-Z][a-zA-Z'\-]{1,30}"
|
| 243 |
+
r"(?:\s+[a-zA-Z][a-zA-Z'\-]{1,30}){0,3})\b",
|
| 244 |
+
re.IGNORECASE,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
# KI-059 (2026-05-15) — leading greeting tokens to ignore so "Hi this is X"
|
| 248 |
+
# and "Hello, my name is X" reach the introduction phrase.
|
| 249 |
+
_GREETING_LEAD = re.compile(
|
| 250 |
+
r"^\s*(?:hi|hello|hey|namaste|yo|hola)[,!.\s]+",
|
| 251 |
+
re.IGNORECASE,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _contains_self_introduction(text: str) -> bool:
|
| 256 |
+
"""True if the message volunteers a name via "I'm X" / "this is X" /
|
| 257 |
+
"my name is X" / "call me X" — with or without a greeting prefix.
|
| 258 |
+
|
| 259 |
+
Used by KI-059 to detect when a user has supplied their name in the
|
| 260 |
+
very first turn so we don't ask "what should I call you?" right after
|
| 261 |
+
they told us.
|
| 262 |
+
"""
|
| 263 |
+
if not text:
|
| 264 |
+
return False
|
| 265 |
+
stripped = _GREETING_LEAD.sub("", text.strip())
|
| 266 |
+
return bool(_SELF_INTRO_RE.search(stripped))
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# KI-061 (2026-05-15) — human-readable summaries for the welcome-back
|
| 270 |
+
# greeting. Each tuple is (field name on Profile, label, formatter).
|
| 271 |
+
_KNOWN_FIELD_FORMATTERS: tuple[tuple[str, str, "callable"], ...] = (
|
| 272 |
+
("age", "age", lambda v: f"{v}"),
|
| 273 |
+
("dependents", "covering", lambda v: str(v).replace("self+", "you+").replace("+", " + ")),
|
| 274 |
+
("income_band", "income band", lambda v: str(v)),
|
| 275 |
+
("existing_cover_inr", "existing cover", lambda v: f"₹{v:,}" if isinstance(v, int) else str(v)),
|
| 276 |
+
("primary_goal", "looking for", lambda v: str(v).replace("_", " ")),
|
| 277 |
+
("location_tier", "city tier", lambda v: str(v)),
|
| 278 |
+
("parents_age_max", "parents' age", lambda v: f"oldest {v}"),
|
| 279 |
+
("health_conditions", "health conditions", lambda v: ", ".join(v) if isinstance(v, list) and v else None),
|
| 280 |
+
("budget_band", "budget", lambda v: str(v)),
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _format_known_profile_summary(profile) -> str:
|
| 285 |
+
"""Return a comma-separated rundown of what we already know, e.g.
|
| 286 |
+
'age 34, covering you + spouse, income ₹10-25L, looking for first
|
| 287 |
+
health policy'. Returns '' if nothing meaningful is stored."""
|
| 288 |
+
parts: list[str] = []
|
| 289 |
+
for field_name, label, fmt in _KNOWN_FIELD_FORMATTERS:
|
| 290 |
+
val = getattr(profile, field_name, None)
|
| 291 |
+
if val in (None, "", []):
|
| 292 |
+
continue
|
| 293 |
+
try:
|
| 294 |
+
rendered = fmt(val)
|
| 295 |
+
except Exception:
|
| 296 |
+
rendered = str(val)
|
| 297 |
+
if rendered:
|
| 298 |
+
parts.append(f"{label} {rendered}")
|
| 299 |
+
return ", ".join(parts)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
_HELPFUL_GAP_LABELS = {
|
| 303 |
+
"age": "your age",
|
| 304 |
+
"dependents": "who you're covering",
|
| 305 |
+
"income_band": "your income band",
|
| 306 |
+
"primary_goal": "what you're shopping for",
|
| 307 |
+
"parents_age_max": "your parents' health context",
|
| 308 |
+
"budget_band": "your budget",
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _format_missing_slots(profile) -> list[str]:
|
| 313 |
+
"""Return human-readable labels for the high-value slots we never
|
| 314 |
+
captured. Limited to the ones that genuinely shape a recommendation
|
| 315 |
+
— minor slots (location, existing_cover, health_conditions) are
|
| 316 |
+
skipped to keep the welcome-back focused."""
|
| 317 |
+
missing = []
|
| 318 |
+
for field_name, label in _HELPFUL_GAP_LABELS.items():
|
| 319 |
+
val = getattr(profile, field_name, None)
|
| 320 |
+
if val in (None, "", []):
|
| 321 |
+
missing.append(label)
|
| 322 |
+
return missing
|
| 323 |
+
|
| 324 |
+
|
| 325 |
def _pick_opener(
|
| 326 |
user_text: str,
|
| 327 |
session_id: Optional[str],
|
|
|
|
| 587 |
# of this branch to emit a "Welcome back" message instead of the
|
| 588 |
# next slot's question.
|
| 589 |
returning_visitor_greeting: Optional[str] = None
|
| 590 |
+
|
| 591 |
+
# KI-059 (2026-05-15) — opening-turn name capture. If the user
|
| 592 |
+
# volunteered a name in their FIRST message ("Hi this is Rohit",
|
| 593 |
+
# "I'm Anjali", "My name is Ravi") before we asked, route it into
|
| 594 |
+
# the name-slot handler below so the existing extract + save +
|
| 595 |
+
# welcome-back logic fires — instead of asking "what should I
|
| 596 |
+
# call you?" right after they told us. Pre-condition: not yet
|
| 597 |
+
# awaiting any slot, no name on the profile, and not in
|
| 598 |
+
# free-form session.
|
| 599 |
+
if (not session.awaiting_question_id
|
| 600 |
+
and not session.profile.name
|
| 601 |
+
and not session.free_form_session
|
| 602 |
+
and _contains_self_introduction(user_text)):
|
| 603 |
+
session.set_awaiting("name")
|
| 604 |
+
|
| 605 |
if session.awaiting_question_id:
|
| 606 |
from backend.fact_find_normalizer import is_valid_answer, normalize_answer
|
| 607 |
qid = session.awaiting_question_id
|
|
|
|
| 609 |
if qid == "name":
|
| 610 |
from backend.profile_store import is_valid_name, load_profile, save_profile
|
| 611 |
raw_name = user_text.strip().strip(".,!?")
|
| 612 |
+
# KI-059 — strip a leading greeting + comma so "Hi, this is
|
| 613 |
+
# Rohit" / "Hello I'm Anjali" reduces to the introduction
|
| 614 |
+
# phrase the next loop expects.
|
| 615 |
+
for greet in ("hi there ", "hello there ", "hey there ",
|
| 616 |
+
"hi, ", "hello, ", "hey, ",
|
| 617 |
+
"hi ", "hello ", "hey ", "namaste ", "yo "):
|
| 618 |
+
if raw_name.lower().startswith(greet):
|
| 619 |
+
raw_name = raw_name[len(greet):].strip()
|
| 620 |
+
break
|
| 621 |
# Tolerate "I'm Rohit" / "My name is Rohit" / "call me Rohit"
|
| 622 |
for prefix in ("i'm ", "i am ", "my name is ", "name is ", "call me ", "this is "):
|
| 623 |
if raw_name.lower().startswith(prefix):
|
|
|
|
| 661 |
session.profile.asked.append(slot_id)
|
| 662 |
session.free_form_session = True
|
| 663 |
session._flush()
|
| 664 |
+
# KI-061 (2026-05-15) — personalized welcome-back:
|
| 665 |
+
# summarize what's on file, call out helpful gaps,
|
| 666 |
+
# offer next step. So the returning visitor sees
|
| 667 |
+
# the bot remembers them precisely, and any missing
|
| 668 |
+
# info can be filled before recommending.
|
| 669 |
+
known_summary = _format_known_profile_summary(stored)
|
| 670 |
+
missing_labels = _format_missing_slots(stored)
|
| 671 |
+
parts = [f"Welcome back, {raw_name}!"]
|
| 672 |
+
if known_summary:
|
| 673 |
+
parts.append(
|
| 674 |
+
f"Here's what I have on file from your last visit: {known_summary}."
|
| 675 |
+
)
|
| 676 |
+
if missing_labels:
|
| 677 |
+
if len(missing_labels) == 1:
|
| 678 |
+
gap_phrase = missing_labels[0]
|
| 679 |
+
elif len(missing_labels) == 2:
|
| 680 |
+
gap_phrase = " and ".join(missing_labels)
|
| 681 |
+
else:
|
| 682 |
+
gap_phrase = ", ".join(missing_labels[:-1]) + f", and {missing_labels[-1]}"
|
| 683 |
+
parts.append(
|
| 684 |
+
f"We never got around to {gap_phrase} — happy to fill that in "
|
| 685 |
+
"so I can grade policies more precisely, or you can jump straight "
|
| 686 |
+
"to a recommendation or any specific question."
|
| 687 |
+
)
|
| 688 |
+
else:
|
| 689 |
+
parts.append(
|
| 690 |
+
"Looks like we have everything we need — want me to suggest some "
|
| 691 |
+
"policies that fit, or do you have a specific question in mind?"
|
| 692 |
+
)
|
| 693 |
+
returning_visitor_greeting = " ".join(parts)
|
| 694 |
else:
|
| 695 |
# New visitor — record an initial profile so subsequent
|
| 696 |
# turns persist; orchestrator will continue to next slot.
|
|
@@ -25,6 +25,7 @@ display name is preserved inside the JSON.
|
|
| 25 |
|
| 26 |
from __future__ import annotations
|
| 27 |
|
|
|
|
| 28 |
import json
|
| 29 |
import logging
|
| 30 |
import re
|
|
@@ -47,7 +48,38 @@ def _normalise_name(name: str) -> str:
|
|
| 47 |
return cleaned[:60] # cap filename length
|
| 48 |
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
slug = _normalise_name(name)
|
| 52 |
if not slug:
|
| 53 |
return None
|
|
@@ -66,19 +98,13 @@ def is_valid_name(text: str) -> bool:
|
|
| 66 |
return alpha / max(1, len(s)) >= 0.5
|
| 67 |
|
| 68 |
|
| 69 |
-
def
|
| 70 |
-
"""
|
| 71 |
-
|
| 72 |
-
Future-proofs against Profile schema drift — any persisted fields that no
|
| 73 |
-
longer exist on the Profile dataclass are silently dropped.
|
| 74 |
-
"""
|
| 75 |
-
p = _path_for(name)
|
| 76 |
-
if not p or not p.exists():
|
| 77 |
-
return None
|
| 78 |
try:
|
| 79 |
raw = json.loads(p.read_text())
|
| 80 |
except Exception as e:
|
| 81 |
-
logging.warning("profile_store load failed
|
| 82 |
return None
|
| 83 |
prof_dict = raw.get("profile") or {}
|
| 84 |
valid_fields = set(Profile.__dataclass_fields__.keys())
|
|
@@ -86,13 +112,52 @@ def load_profile(name: str) -> Optional[Profile]:
|
|
| 86 |
try:
|
| 87 |
return Profile(**prof_dict)
|
| 88 |
except Exception as e:
|
| 89 |
-
logging.warning("profile_store reconstruct failed
|
| 90 |
return None
|
| 91 |
|
| 92 |
|
| 93 |
-
def
|
| 94 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
p = _path_for(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if not p:
|
| 97 |
return False
|
| 98 |
try:
|
|
@@ -103,6 +168,22 @@ def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = Non
|
|
| 103 |
existing = json.loads(p.read_text())
|
| 104 |
except Exception:
|
| 105 |
existing = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 107 |
sessions = list(existing.get("sessions") or [])
|
| 108 |
if session_id and session_id not in sessions:
|
|
@@ -111,6 +192,7 @@ def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = Non
|
|
| 111 |
payload = {
|
| 112 |
"name_display": (profile.name or name).strip(),
|
| 113 |
"name_slug": _normalise_name(name),
|
|
|
|
| 114 |
"profile": asdict(profile),
|
| 115 |
"first_seen": existing.get("first_seen") or now_iso,
|
| 116 |
"last_seen": now_iso,
|
|
|
|
| 25 |
|
| 26 |
from __future__ import annotations
|
| 27 |
|
| 28 |
+
import hashlib
|
| 29 |
import json
|
| 30 |
import logging
|
| 31 |
import re
|
|
|
|
| 48 |
return cleaned[:60] # cap filename length
|
| 49 |
|
| 50 |
|
| 51 |
+
# KI-062 (2026-05-15) — identity-defining fields used to disambiguate two
|
| 52 |
+
# users with the same display name. Order matters for hash stability.
|
| 53 |
+
_PERSONA_ID_FIELDS: tuple[str, ...] = (
|
| 54 |
+
"age", "dependents", "income_band", "location_tier", "parents_age_max",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def compute_persona_id(profile: Profile) -> str:
|
| 59 |
+
"""Return a 12-char hash blending the user's normalised name with their
|
| 60 |
+
identity-defining profile fields. Two users named 'Rohit' but with
|
| 61 |
+
different age/dependents/location resolve to different persona IDs.
|
| 62 |
+
|
| 63 |
+
Returns '' if there's not enough signal (no name AND no identity
|
| 64 |
+
fields). Caller falls back to name-only slug in that case.
|
| 65 |
+
|
| 66 |
+
KI-062 (2026-05-15).
|
| 67 |
+
"""
|
| 68 |
+
parts = [_normalise_name(profile.name or "")]
|
| 69 |
+
for f in _PERSONA_ID_FIELDS:
|
| 70 |
+
v = getattr(profile, f, None)
|
| 71 |
+
parts.append("" if v in (None, "", []) else str(v).strip().lower())
|
| 72 |
+
if not any(parts):
|
| 73 |
+
return ""
|
| 74 |
+
blob = "|".join(parts).encode("utf-8")
|
| 75 |
+
return hashlib.sha1(blob).hexdigest()[:12]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _path_for(name: str, *, persona_id: Optional[str] = None) -> Optional[Path]:
|
| 79 |
+
"""Resolve the JSON file path. Prefers persona_id (KI-062) when given,
|
| 80 |
+
falling back to the name slug for legacy lookups."""
|
| 81 |
+
if persona_id:
|
| 82 |
+
return _PROFILES_DIR / f"{persona_id}.json"
|
| 83 |
slug = _normalise_name(name)
|
| 84 |
if not slug:
|
| 85 |
return None
|
|
|
|
| 98 |
return alpha / max(1, len(s)) >= 0.5
|
| 99 |
|
| 100 |
|
| 101 |
+
def _load_from_path(p: Path) -> Optional[Profile]:
|
| 102 |
+
"""Read a profile file path → Profile. Drops persisted fields that no
|
| 103 |
+
longer exist on the Profile dataclass (schema-drift safety)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
try:
|
| 105 |
raw = json.loads(p.read_text())
|
| 106 |
except Exception as e:
|
| 107 |
+
logging.warning("profile_store load failed path=%s: %s", p, e)
|
| 108 |
return None
|
| 109 |
prof_dict = raw.get("profile") or {}
|
| 110 |
valid_fields = set(Profile.__dataclass_fields__.keys())
|
|
|
|
| 112 |
try:
|
| 113 |
return Profile(**prof_dict)
|
| 114 |
except Exception as e:
|
| 115 |
+
logging.warning("profile_store reconstruct failed path=%s: %s", p, e)
|
| 116 |
return None
|
| 117 |
|
| 118 |
|
| 119 |
+
def load_profile(name: str, *, persona_id: Optional[str] = None) -> Optional[Profile]:
|
| 120 |
+
"""Return the stored Profile for `name` (and optional `persona_id`).
|
| 121 |
+
|
| 122 |
+
KI-062 (2026-05-15) lookup order:
|
| 123 |
+
1. If `persona_id` given, try that file first.
|
| 124 |
+
2. Try the name-slug file (legacy + first-visit path before
|
| 125 |
+
identity fields are known).
|
| 126 |
+
3. If both miss but the name slug is set, scan the directory for
|
| 127 |
+
any persona-id-keyed file whose stored name matches — handles
|
| 128 |
+
the case where the user introduced themselves by name but no
|
| 129 |
+
persona ID is known yet client-side.
|
| 130 |
+
"""
|
| 131 |
+
# 1. Direct persona-id hit
|
| 132 |
+
if persona_id:
|
| 133 |
+
p = _path_for(name, persona_id=persona_id)
|
| 134 |
+
if p and p.exists():
|
| 135 |
+
return _load_from_path(p)
|
| 136 |
+
# 2. Legacy / first-visit name-slug file
|
| 137 |
p = _path_for(name)
|
| 138 |
+
if p and p.exists():
|
| 139 |
+
return _load_from_path(p)
|
| 140 |
+
# 3. Scan for any persona-id file whose stored display-name matches
|
| 141 |
+
slug = _normalise_name(name)
|
| 142 |
+
if slug and _PROFILES_DIR.exists():
|
| 143 |
+
for cand in _PROFILES_DIR.glob("*.json"):
|
| 144 |
+
try:
|
| 145 |
+
raw = json.loads(cand.read_text())
|
| 146 |
+
if _normalise_name(raw.get("name_display") or "") == slug:
|
| 147 |
+
return _load_from_path(cand)
|
| 148 |
+
except Exception:
|
| 149 |
+
continue
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = None) -> bool:
|
| 154 |
+
"""Persist `profile`. KI-062 (2026-05-15): files are keyed by
|
| 155 |
+
`compute_persona_id(profile)` when there's enough signal so two users
|
| 156 |
+
named 'Rohit' with different age/location don't overwrite each other.
|
| 157 |
+
Falls back to the name slug when persona_id can't be derived.
|
| 158 |
+
"""
|
| 159 |
+
persona_id = compute_persona_id(profile)
|
| 160 |
+
p = _path_for(name, persona_id=persona_id) if persona_id else _path_for(name)
|
| 161 |
if not p:
|
| 162 |
return False
|
| 163 |
try:
|
|
|
|
| 168 |
existing = json.loads(p.read_text())
|
| 169 |
except Exception:
|
| 170 |
existing = {}
|
| 171 |
+
# KI-062 — also clean up any older same-name file that was saved
|
| 172 |
+
# before we had enough identity signal to disambiguate. We move
|
| 173 |
+
# its session history into the new file rather than orphaning.
|
| 174 |
+
if persona_id:
|
| 175 |
+
legacy = _path_for(name)
|
| 176 |
+
if legacy and legacy.exists() and legacy.resolve() != p.resolve():
|
| 177 |
+
try:
|
| 178 |
+
leg_raw = json.loads(legacy.read_text())
|
| 179 |
+
legacy_sessions = list(leg_raw.get("sessions") or [])
|
| 180 |
+
existing.setdefault("sessions", [])
|
| 181 |
+
for s in legacy_sessions:
|
| 182 |
+
if s not in existing["sessions"]:
|
| 183 |
+
existing["sessions"].append(s)
|
| 184 |
+
legacy.unlink()
|
| 185 |
+
except Exception:
|
| 186 |
+
pass
|
| 187 |
now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 188 |
sessions = list(existing.get("sessions") or [])
|
| 189 |
if session_id and session_id not in sessions:
|
|
|
|
| 192 |
payload = {
|
| 193 |
"name_display": (profile.name or name).strip(),
|
| 194 |
"name_slug": _normalise_name(name),
|
| 195 |
+
"persona_id": persona_id, # KI-062
|
| 196 |
"profile": asdict(profile),
|
| 197 |
"first_seen": existing.get("first_seen") or now_iso,
|
| 198 |
"last_seen": now_iso,
|
|
@@ -5,6 +5,8 @@
|
|
| 5 |
*
|
| 6 |
* KI-044 (2026-05-14) — PCM pre-roll via AudioWorklet.
|
| 7 |
* KI-057 (2026-05-15) — Noise-robust VAD + flush-on-stop.
|
|
|
|
|
|
|
| 8 |
*
|
| 9 |
* Why KI-057 was needed
|
| 10 |
* --------------------------------------------------------------------
|
|
@@ -93,7 +95,13 @@ const DEFAULTS = {
|
|
| 93 |
// don't open a segment. KI-044's preroll buffer still captures the
|
| 94 |
// first phoneme since we look back 300 ms.
|
| 95 |
speechStartFrames: 3,
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
minUtteranceMs: 400,
|
| 98 |
// KI-044 — How much pre-trigger PCM we keep in the rolling buffer.
|
| 99 |
// 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of
|
|
|
|
| 5 |
*
|
| 6 |
* KI-044 (2026-05-14) — PCM pre-roll via AudioWorklet.
|
| 7 |
* KI-057 (2026-05-15) — Noise-robust VAD + flush-on-stop.
|
| 8 |
+
* KI-060 (2026-05-15) — Silence-end window lengthened (40 → 90 frames,
|
| 9 |
+
* ~640 ms → ~1.5 s) so natural mid-sentence pauses don't auto-submit.
|
| 10 |
*
|
| 11 |
* Why KI-057 was needed
|
| 12 |
* --------------------------------------------------------------------
|
|
|
|
| 95 |
// don't open a segment. KI-044's preroll buffer still captures the
|
| 96 |
// first phoneme since we look back 300 ms.
|
| 97 |
speechStartFrames: 3,
|
| 98 |
+
// KI-060 (2026-05-15) — bumped 40 → 90 (~1.5 s of silence) so a
|
| 99 |
+
// natural mid-sentence pause doesn't auto-close the segment.
|
| 100 |
+
// After KI-057 made noise correctly NOT keep the segment alive,
|
| 101 |
+
// the underlying silence-end timer was exposed as too aggressive —
|
| 102 |
+
// users reported that pausing for ~1 s between phrases caused the
|
| 103 |
+
// bot to submit prematurely.
|
| 104 |
+
silenceEndFrames: 90,
|
| 105 |
minUtteranceMs: 400,
|
| 106 |
// KI-044 — How much pre-trigger PCM we keep in the rolling buffer.
|
| 107 |
// 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of
|