Spaces:
Running
Running
Merge pull request #11 from TheAnkurGoswami/gestures
Browse files- agent.py +14 -4
- app.py +31 -3
- assessor.py +5 -1
- gestures.py +92 -0
- 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
|
| 26 |
-
# character.md), precomputed once at scenario load time
|
| 27 |
-
#
|
|
|
|
| 28 |
prompt = ChatPromptTemplate.from_messages(
|
| 29 |
[
|
| 30 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 < / >.
|
| 48 |
+
_ESCAPED_GESTURE_RE = re.compile(r"<(\w+)>(.*?)</\1>", 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 |
-
|
|
|
|
| 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 |
|