ΰ€…ΰ€‚kur ΰ€—ΰ₯‹swami commited on
Commit
fb07f3c
Β·
unverified Β·
2 Parent(s): 96d46127d27a6a

Merge pull request #11 from TheAnkurGoswami/gestures

Browse files
Files changed (5) hide show
  1. agent.py +14 -4
  2. app.py +31 -3
  3. assessor.py +5 -1
  4. gestures.py +92 -0
  5. main.py +4 -2
agent.py CHANGED
@@ -1,9 +1,11 @@
1
  import logging
 
2
  import time
3
  from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
4
  from langchain_core.output_parsers import StrOutputParser
5
  from langchain_core.messages import HumanMessage, AIMessage
6
  from langchain_core.runnables import RunnableLambda
 
7
  from scenario import get_scenario, DEFAULT_SCENARIO_ID
8
  from model import get_model
9
 
@@ -22,12 +24,20 @@ def _log_llm_output(message):
22
  _logger = RunnableLambda(_log_llm_output)
23
 
24
 
25
- # The system prompt is just the scenario's assembled persona (context.md +
26
- # character.md), precomputed once at scenario load time. No per-turn emotion
27
- # variable.
 
28
  prompt = ChatPromptTemplate.from_messages(
29
  [
30
- ("system", "{persona}"),
 
 
 
 
 
 
 
31
  MessagesPlaceholder(variable_name="history"),
32
  ("human", "{learner_text}"),
33
  ]
 
1
  import logging
2
+ import textwrap
3
  import time
4
  from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
5
  from langchain_core.output_parsers import StrOutputParser
6
  from langchain_core.messages import HumanMessage, AIMessage
7
  from langchain_core.runnables import RunnableLambda
8
+ from gestures import GESTURE_INSTRUCTIONS
9
  from scenario import get_scenario, DEFAULT_SCENARIO_ID
10
  from model import get_model
11
 
 
24
  _logger = RunnableLambda(_log_llm_output)
25
 
26
 
27
+ # The system prompt is the scenario's assembled persona (context.md +
28
+ # character.md), precomputed once at scenario load time, plus the gesture
29
+ # vocabulary every character can draw on (empty when GESTURES_ENABLED is
30
+ # off β€” see gestures.py). No per-turn emotion variable.
31
  prompt = ChatPromptTemplate.from_messages(
32
  [
33
+ (
34
+ "system",
35
+ textwrap.dedent(f"""
36
+ {{persona}}
37
+
38
+ {GESTURE_INSTRUCTIONS}
39
+ """).strip(),
40
+ ),
41
  MessagesPlaceholder(variable_name="history"),
42
  ("human", "{learner_text}"),
43
  ]
app.py CHANGED
@@ -1,5 +1,6 @@
1
  import html
2
  import os
 
3
  import sys
4
  import time
5
 
@@ -42,6 +43,20 @@ from auto import TraineeAgent
42
  from trainee_archetypes import ARCHETYPE_IDS
43
  from scenario import get_scenario as _local_scenario, DEFAULT_SCENARIO_ID
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  st.set_page_config(
46
  page_title="ALAS β€” Manager Coaching Demo",
47
  page_icon="πŸŽ“",
@@ -106,6 +121,19 @@ st.markdown(
106
  max-width: 80%;
107
  font-size: 0.95rem;
108
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  .bubble-student {
110
  background-color: #E8E0CE;
111
  color: #1a1a1a;
@@ -561,7 +589,7 @@ def render_messages():
561
  unsafe_allow_html=True,
562
  )
563
  st.markdown(
564
- f"<div class='bubble-manager'>{msg['text']}</div>",
565
  unsafe_allow_html=True,
566
  )
567
 
@@ -635,11 +663,11 @@ def _stream_and_append(trainee_text: str, turn_num: int):
635
  for token in stream_message(st.session_state.chat_id, trainee_text):
636
  accumulated += token
637
  placeholder.markdown(
638
- f"<div class='bubble-manager'>{accumulated}β–Œ</div>",
639
  unsafe_allow_html=True,
640
  )
641
  placeholder.markdown(
642
- f"<div class='bubble-manager'>{accumulated}</div>",
643
  unsafe_allow_html=True,
644
  )
645
  st.session_state.messages.append(
 
1
  import html
2
  import os
3
+ import re
4
  import sys
5
  import time
6
 
 
43
  from trainee_archetypes import ARCHETYPE_IDS
44
  from scenario import get_scenario as _local_scenario, DEFAULT_SCENARIO_ID
45
 
46
+ # Same "<tag>...</tag>" shape as gestures._GESTURE_TAG_RE, but matched after
47
+ # html.escape(), so the brackets arrive as &lt; / &gt;.
48
+ _ESCAPED_GESTURE_RE = re.compile(r"&lt;(\w+)&gt;(.*?)&lt;/\1&gt;", re.DOTALL)
49
+
50
+
51
+ def _render_reply(text: str) -> str:
52
+ """Escape an AI reply for the chat bubble and show its gesture tags as
53
+ visible labels. Unescaped, the browser swallows <nodding> etc. as unknown
54
+ HTML elements and the gestures silently disappear."""
55
+ return _ESCAPED_GESTURE_RE.sub(
56
+ r"<span class='gesture-label'>\1</span><span class='gesture-span'>\2</span>",
57
+ html.escape(text),
58
+ )
59
+
60
  st.set_page_config(
61
  page_title="ALAS β€” Manager Coaching Demo",
62
  page_icon="πŸŽ“",
 
121
  max-width: 80%;
122
  font-size: 0.95rem;
123
  }
124
+ .gesture-label {
125
+ font-size: 0.7rem;
126
+ font-style: italic;
127
+ opacity: 0.75;
128
+ border: 1px solid currentColor;
129
+ border-radius: 8px;
130
+ padding: 0 6px;
131
+ margin-right: 4px;
132
+ }
133
+ .gesture-span {
134
+ text-decoration: underline dotted;
135
+ text-underline-offset: 3px;
136
+ }
137
  .bubble-student {
138
  background-color: #E8E0CE;
139
  color: #1a1a1a;
 
589
  unsafe_allow_html=True,
590
  )
591
  st.markdown(
592
+ f"<div class='bubble-manager'>{_render_reply(msg['text'])}</div>",
593
  unsafe_allow_html=True,
594
  )
595
 
 
663
  for token in stream_message(st.session_state.chat_id, trainee_text):
664
  accumulated += token
665
  placeholder.markdown(
666
+ f"<div class='bubble-manager'>{_render_reply(accumulated)}β–Œ</div>",
667
  unsafe_allow_html=True,
668
  )
669
  placeholder.markdown(
670
+ f"<div class='bubble-manager'>{_render_reply(accumulated)}</div>",
671
  unsafe_allow_html=True,
672
  )
673
  st.session_state.messages.append(
assessor.py CHANGED
@@ -2,6 +2,7 @@ import logging
2
 
3
  from langchain_core.output_parsers import JsonOutputParser
4
 
 
5
  from rubric import get_rubric, Rubric
6
  from agent import BACKEND
7
  from model import get_model
@@ -13,10 +14,13 @@ logger = logging.getLogger("alas.assessor")
13
 
14
 
15
  def build_transcript(messages: list[dict], scenario: Scenario) -> str:
 
 
16
  lines = []
17
  for msg in messages:
18
  role = scenario.learner_name if msg["role"] == "trainee" else scenario.ai_name
19
- lines.append(f"{role}: {msg['text']}")
 
20
  return "\n\n".join(lines)
21
 
22
 
 
2
 
3
  from langchain_core.output_parsers import JsonOutputParser
4
 
5
+ from gestures import strip_gestures
6
  from rubric import get_rubric, Rubric
7
  from agent import BACKEND
8
  from model import get_model
 
14
 
15
 
16
  def build_transcript(messages: list[dict], scenario: Scenario) -> str:
17
+ """Gesture tags are stripped from manager turns: the assessor grades the
18
+ words spoken, not the avatar's stage directions."""
19
  lines = []
20
  for msg in messages:
21
  role = scenario.learner_name if msg["role"] == "trainee" else scenario.ai_name
22
+ text = msg["text"] if msg["role"] == "trainee" else strip_gestures(msg["text"])
23
+ lines.append(f"{role}: {text}")
24
  return "\n\n".join(lines)
25
 
26
 
gestures.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gesture vocabulary for the roleplay avatar.
3
+
4
+ A gesture is a paired tag β€” <tag>...</tag> β€” that the character LLM wraps
5
+ around the words it's saying while performing that gesture, e.g.:
6
+
7
+ <nodding>Sure, that makes sense.</nodding> But I still need the numbers.
8
+
9
+ The wrapped text is still part of the spoken reply; the tag itself is
10
+ metadata for the avatar layer to key its animation off of, not something a
11
+ trainee should ever see literally.
12
+
13
+ Each tag below names an animation clip the avatar renderer actually has
14
+ ("Nodding.mp4", "Distracted.mp4", "LooksAtPhone.mp4", ...). "Idle" is
15
+ deliberately not a gesture here β€” it's the renderer's default rest state,
16
+ played whenever no tag is active, not something the LLM opts into.
17
+ Transition clips between states (e.g. "IdleToNodding.mp4") are the
18
+ renderer's concern, not the LLM's β€” it only ever picks a destination state.
19
+
20
+ Kept in one shared file, not per-scenario, because the tag vocabulary is a
21
+ property of the avatar renderer, not of any one character β€” every scenario's
22
+ persona references the same GESTURES list via gestures_block().
23
+ """
24
+
25
+ import os
26
+ import re
27
+ import textwrap
28
+ from dataclasses import dataclass
29
+
30
+ # Off switch for the whole gesture vocabulary β€” set GESTURES_ENABLED=false
31
+ # to have characters reply as plain text, e.g. for a text-only frontend
32
+ # that has no avatar to animate.
33
+ GESTURES_ENABLED = os.getenv("GESTURES_ENABLED", "true").strip().lower() == "true"
34
+
35
+ # Matches "<tag>...</tag>", tag name captured and reused to find the closing
36
+ # tag, DOTALL so a wrapped span can cross a sentence break.
37
+ _GESTURE_TAG_RE = re.compile(r"<(\w+)>(.*?)</\1>", re.DOTALL)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Gesture:
42
+ tag: str # token name, used as <tag>...</tag>
43
+ description: str # when the character should reach for this gesture
44
+
45
+
46
+ GESTURES: list[Gesture] = [
47
+ Gesture("nodding", "Agreement, acknowledgement, or active listening."),
48
+ Gesture("distracted", "Trailing off, momentarily elsewhere, not fully present in the conversation."),
49
+ Gesture("looks_at_phone", "Checking a phone β€” impatience, a real interruption, or divided attention."),
50
+ ]
51
+
52
+ _GESTURE_TAGS = {g.tag for g in GESTURES}
53
+
54
+
55
+ def gestures_block() -> str:
56
+ """All gestures as one prompt-injectable block, one line per tag."""
57
+ return "\n".join(f"- <{g.tag}>...</{g.tag}> β€” {g.description}" for g in GESTURES)
58
+
59
+
60
+ def gesture_tags() -> list[str]:
61
+ return [g.tag for g in GESTURES]
62
+
63
+
64
+ # The block a character's system prompt appends to gain gesture control.
65
+ # Empty when GESTURES_ENABLED is off, so callers can splice this in
66
+ # unconditionally rather than checking the flag themselves. Built once at
67
+ # import time β€” the vocabulary is fixed, not per-scenario or per-turn.
68
+ GESTURE_INSTRUCTIONS = (
69
+ textwrap.dedent(f"""\
70
+ ## Gestures
71
+ You control an avatar that can perform physical gestures while it
72
+ speaks. Available gestures:
73
+ {gestures_block()}
74
+
75
+ To perform one, wrap the words you're saying while doing it in a tag
76
+ named after the gesture, e.g. <nodding>Sure, that makes sense.</nodding>
77
+ A reply may contain several tagged spans, or none β€” most turns need no
78
+ gesture at all. Only use a gesture where it would naturally read on a
79
+ real person's face or body in that moment. Never invent a tag that
80
+ isn't in the list above, and never mention the tags or describe the
81
+ gesture in words β€” the tag itself is the only signal.
82
+ """)
83
+ if GESTURES_ENABLED
84
+ else ""
85
+ )
86
+
87
+
88
+ def strip_gestures(text: str) -> str:
89
+ """Drop gesture tags but keep the words they wrapped. For feeding a
90
+ character's past reply back into a non-avatar-facing prompt (grading,
91
+ transcripts) where the tags are just noise."""
92
+ return _GESTURE_TAG_RE.sub(r"\2", text)
main.py CHANGED
@@ -19,6 +19,7 @@ import threading
19
  import uuid
20
  from fastapi import BackgroundTasks, FastAPI, HTTPException
21
  from fastapi.responses import StreamingResponse
 
22
  from rubric import get_rubric
23
  from agent import CharacterAgent
24
  from assessor import Assessor
@@ -79,10 +80,11 @@ def _get_session(chat_id: str) -> dict:
79
  def _last_ai_text(messages: list) -> str:
80
  """The AI turn that preceded (and prompted) the trainee's next message β€”
81
  never the AI's reply to that same message, which doesn't exist yet when
82
- the trainee is judged."""
 
83
  for m in reversed(messages):
84
  if m["role"] == "manager":
85
- return m["text"]
86
  return ""
87
 
88
 
 
19
  import uuid
20
  from fastapi import BackgroundTasks, FastAPI, HTTPException
21
  from fastapi.responses import StreamingResponse
22
+ from gestures import strip_gestures
23
  from rubric import get_rubric
24
  from agent import CharacterAgent
25
  from assessor import Assessor
 
80
  def _last_ai_text(messages: list) -> str:
81
  """The AI turn that preceded (and prompted) the trainee's next message β€”
82
  never the AI's reply to that same message, which doesn't exist yet when
83
+ the trainee is judged. Gesture tags are stripped: this text feeds the
84
+ router/judge prompts as plain context, not the avatar."""
85
  for m in reversed(messages):
86
  if m["role"] == "manager":
87
+ return strip_gestures(m["text"])
88
  return ""
89
 
90