A small Gradio chat app that uses OpenAI function calling to role-play a “digital twin” from YAML profile documents under me/. Pushover delivers notifications when someone leaves contact details or asks something the profile does not cover.
The program lives in app.py. This section describes how it behaves end to end.
Visitors chat in a browser. The model answers as the person described in your profile data:
- Display name comes from the YAML file whose root has
document_type: profile_summaryand anamefield (e.g.me/profile_summary.yml). If none is found, the app falls back to the stringthis person. - Context is built from every
*.yml/*.yamlfile inme/: each file is loaded withyaml.safe_load, then dumped back as readable YAML text and concatenated into a single “structured profile documents” block in the system prompt.
There is no PDF or plain-text summary file in the current pipeline—the profile is entirely YAML on disk.
The assistant is told to treat those documents as the sole source of truth, stay in character, nudge toward email when it fits, and follow strict rules around record_unknown_question when an answer is not in the docs.
| Piece | Role |
|---|---|
| Gradio | gr.ChatInterface with Soft theme (teal / slate), default dark mode (__theme=dark on first load), and a custom placeholder on the textbox |
| OpenAI API | OpenAI() client; chat completions with tools (parallel_tool_calls=False) |
| python-dotenv | Loads .env at startup (load_dotenv(override=True)) |
| PyYAML | Loads and re-serializes profile files under me/ |
| requests | POST to Pushover when tools run |
- Environment variables are loaded from
.env. - Constructing
Me()runs_load_me_yaml_chunks(ME_DIR)whereME_DIRis theme/folder next toapp.py:- Collects
me/*.ymlandme/*.yaml(sorted). - Raises
FileNotFoundErrorif there are no such files. - Sets
self.namefrom the first dict withdocument_type == "profile_summary"and a non-emptynamestring; otherwisethis person. - Sets
self.structured_contextto all files combined (each introduced by a### filenameheader), separated by horizontal rules.
- Collects
Me.chat is a generator: Gradio’s ChatInterface treats it as a streaming handler and updates the assistant bubble as each prefix is yielded.
Gradio passes (message, history). History is normalized with Me._history_to_messages: either already OpenAI-style {"role","content"} dicts, or legacy [user, assistant] pairs per turn.
check_input_guardrail(message, self.name)runs first. If it returns a string, that reply is streamed to the user and the OpenAI API is not called (saves cost and keeps junk out of the model context).- Build
messages: system prompt, prior turns, then the new user message. - Loop:
_stream_collect_one_completioncallschat.completions.create(..., stream=True)and reads the full stream for that round (tool-call deltas are merged by index before any JSON is parsed).- On
tool_calls, runhandle_tool_call, append the assistant message (withtool_calls) and tool results tomessages, then repeat. No text is yielded during these rounds (they are usually tool-only). - On
stop(or other non-tool finish), take the assembled assistantcontent, apply the_assistant_admits_missing_docsfallback if needed, thenyield from _yield_stream_chunksso the reply appears progressively in the UI (the API response is buffered per round first so tool vs. text is unambiguous).
- Fallback: If the model never called
record_unknown_questionbut the final assistant text looks like “no information in the profile” (see_assistant_admits_missing_docsheuristics), the app callsrecord_unknown_question(user_question)once so you still get a Pushover ping.
Lightweight, local checks (regex + heuristics, no second LLM call) tuned for a single-person career chat:
- Length: soft cap (default 6000 chars) asks the visitor to shorten; hard cap (default 48000 chars) refuses huge pastes.
- Structure: very many lines or blank-line runs suggest a dump, not a question.
- Prompt-injection phrases: common jailbreak / “ignore instructions” / fake system markers.
- Code & scripts: obvious patterns such as
<script,eval(, fenced ``` pastes over a size threshold, etc. - Jargon / noise: long inputs with very low letters+spaces ratio, or very few real words, get a polite redirect.
- Repetition: the same long snippet repeated many times is treated as spam.
Replies stay in character (they reference the profile person’s name). Tune limits with GUARDRAIL_SOFT_CHARS, GUARDRAIL_HARD_CHARS, and GUARDRAIL_MAX_LINES in the environment.
Guardrails reduce risk but are not a full security boundary—combine with rate limits, auth, and monitoring for a public deployment.
| Tool | Role |
|---|---|
record_user_details |
After the user shares email (and optionally name/notes) |
record_unknown_question |
When the answer is not in the structured docs; the system prompt requires a tool-only turn first in that case |
Both invoke push(text), which posts to Pushover’s messages.json API using PUSHOVER_TOKEN and PUSHOVER_USER. Set both in .env if you want notifications; otherwise tool calls may hit the API with empty credentials.
Instructs the model to act as self.name, use only the structured profile block as truth, follow mandatory record_unknown_question behavior when information is missing, and use record_user_details when steering toward contact by email.
demo = gr.ChatInterface(me.chat, textbox=gr.Textbox(placeholder=…), theme=Soft(teal/slate)) # Gradio 4
demo.launch(theme=…) # Gradio 5 may set theme here instead
The placeholder is built from me.name. Theme: gr.themes.Soft(primary_hue="teal", neutral_hue="slate"). A small head script adds ?__theme=dark on first load so the UI opens in dark mode; visitors can still switch appearance in Gradio’s settings. Extra CSS keeps the chat Send control visible in dark mode, and submit_btn="Send" (when supported) uses a text label instead of an easy-to-hide icon. The input uses lines=1 with max_lines=8 so the default layout matches Gradio’s send control; use Shift+Enter for a newline. inspect.signature routes theme / head / css to ChatInterface or launch() by Gradio version.
Uses Gradio’s defaults for bind address and port (typically 127.0.0.1:7860 locally). For Docker or a cloud VM you usually need the UI to listen on all interfaces—e.g. launch(server_name="0.0.0.0", server_port=7860)—or equivalent reverse proxy setup.
| Variable | Required | Used for |
|---|---|---|
OPENAI_API_KEY |
Yes (for real API calls) | OpenAI client authentication |
PUSHOVER_TOKEN |
For Pushover notifications | Pushover application token |
PUSHOVER_USER |
For Pushover notifications | Pushover user key |
GUARDRAIL_SOFT_CHARS |
No | Soft max message length before asking for a shorter question (default 6000). |
GUARDRAIL_HARD_CHARS |
No | Hard max length; longer messages get a firm refusal (default 48000). |
GUARDRAIL_MAX_LINES |
No | Max newline count before treating input as a paste (default 120). |
| Path | Role |
|---|---|
me/*.yml / me/*.yaml |
At least one file; together they define the twin’s context. Prefer one profile_summary document with name for the persona label. |
- Name: edit the
profile_summaryYAML (name/document_type) or change fallback logic in_load_me_yaml_chunks. - Model: change
model="gpt-4o-mini"inMe.chat. - Profile content: add or edit YAML files under
me/(loaded automatically by glob).
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Ensure me/*.yml exist; set OPENAI_API_KEY and optional PUSHOVER_* in .env
python app.pyOpen the URL Gradio prints.
docker build -t digital-twin-me .
docker run --rm -p 7860:7860 --env-file .env digital-twin-meOr with Compose:
docker compose up -d --buildThe image copies the me/ tree (including your YAML profile files). Ensure OPENAI_API_KEY (and Pushover keys if used) are supplied at runtime via --env-file or your orchestrator.
Optional: set GRADIO_PUBLISH_PORT in .env for Compose host port mapping (see docker-compose.yml).
Note: If the app still uses Gradio’s default bind (127.0.0.1), port publishing from a container may not be reachable from the host until launch(server_name="0.0.0.0") is set in code or you terminate TLS/proxy in front.
On push to main (or Run workflow manually), .github/workflows/deploy.yml rsyncs the repo to the server and runs docker compose up -d --build. The server must already have Docker, Docker Compose, a clone-compatible directory at DEPLOY_PATH, and a .env file there (rsync excludes .env so it is never overwritten from CI).
Configure these in the repo’s Settings → Secrets and variables → Actions:
| Secret | Required | Description |
|---|---|---|
SSH_PRIVATE_KEY |
Yes | Private key for the deploy user (full PEM, including BEGIN/END lines). |
SSH_HOST |
Yes | Server hostname or IP (often the same value as CLOUD_SERVER_IP in server .env). |
SSH_USER |
Yes | SSH login user (e.g. ubuntu, debian). |
DEPLOY_PATH |
Yes | Absolute path on the server where the app lives (e.g. /home/ubuntu/digital_twin_me). No trailing slash. |
SSH_PORT |
No | SSH port if not 22. |
App secrets (OPENAI_API_KEY, PUSHOVER_*, etc.) stay only in the server’s .env, not in GitHub, unless you add a separate workflow step to manage them.