Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions mashinsonvv/SUBMISSION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# mashinsonvv — Build a Mentor That Knows You Learned

**Subject:** Negotiation · 10 lessons · two agents (mentor **Nadia**, student **Sam**) · model **DeepSeek `deepseek-chat`**

**Deliverable PDF:** [`mashinsonvv_submission.pdf`](./mashinsonvv_submission.pdf) — the single five-section submission.
**Runnable code:** [`mentor_student/`](./mentor_student) — `python -m mentor_student.run_course` then `python -m mentor_student.build_pdf`.

Two AI agents run a ten-lesson negotiation course entirely through conversation.
The student is built to **fake** having practised on lessons 3, 6 and 9 (and to cut a
corner on 2 and 8); the mentor must catch the bluff and refuse to advance without real
evidence of **application** (not recall).

## The five sections (also in the PDF)

1. **Mentor prompt** — [`mentor_student/prompts/mentor_system.md`](./mentor_student/prompts/mentor_system.md)
2. **Student prompt** (incl. the bluff mechanics) — [`mentor_student/prompts/student_system.md`](./mentor_student/prompts/student_system.md)
3. **Tools used** — [`mentor_student/submission/tools.md`](./mentor_student/submission/tools.md)
4. **The dialogue** (full, untrimmed ten-lesson transcript) — [`mentor_student/output/transcript.md`](./mentor_student/output/transcript.md)
5. **Rationale & self-evaluation** — [`mentor_student/submission/rationale.md`](./mentor_student/submission/rationale.md)

## How it works (in one breath)

- The mentor verifies by conversation with three moves: **ask for the exact words**, **ask
what went wrong** (a faker's story is suspiciously smooth), and **run a live transfer test**
on a brand-new case in-chat.
- It keeps a persistent **`<memory>` tool** (per-lesson status + notes + caught bluffs),
hidden from the student, so it can circle back to weak spots.
- The **advancement rule is enforced in code** (`orchestrator.py`): a lesson can't advance
until it is `verified`, so a bluff can never silently move the course forward.

## Honesty note

Our *first* student never bluffed at all — too agreeable, exactly as the brief warns. We
moved the unreliability schedule out of the prompt and into the orchestrator (a private
per-turn directive), after which the student bluffed and was caught on lessons 3/6/9. The
rationale is candid about where the mentor still gets fooled: strip away the scripted
confession and it's really a *specificity* detector, not a lie detector — a consistent
confabulator would likely pass, which is why the live transfer test is the real backstop.
Binary file added mashinsonvv/mashinsonvv_submission.pdf
Binary file not shown.
4 changes: 4 additions & 0 deletions mashinsonvv/mentor_student/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
.venv*/
62 changes: 62 additions & 0 deletions mashinsonvv/mentor_student/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Mentor & Student Simulation — Negotiation

AI School task *"Build a Mentor That Knows You Learned."* Two AI agents run a
~10-lesson course **entirely through conversation**. The student sometimes
**fakes** having practised a skill; the mentor must catch the bluff and refuse to
advance without real evidence of **application** (not recall).

- **Subject:** Negotiation (BATNA → ZOPA → anchoring → interests → tactical
empathy → calibrated questions → logrolling → defending price → hardball →
closing).
- **Mentor:** Nadia Fontaine — veteran negotiation coach, "impossible to bluff".
- **Student:** Sam Reyes — freelance designer who chronically undercharges;
a believable learner who bluffs on lessons 3/6/9 and cuts a corner on 2/8.

## The core idea

The mentor trusts only words — no files, screenshots, or uploads. So it verifies
by conversation with three moves: **ask for specifics** (quote your exact words),
**ask what went wrong** (real practice has friction; a faker's story is smooth),
and **test transfer** (handle a brand-new case live). A memory tool lets it
remember weak spots and circle back.

## Layout

```
prompts/
mentor_system.md full mentor system prompt (verification doctrine + bluff protocol)
student_system.md full student system prompt (incl. the unreliability schedule)
course/
curriculum.json 10 lessons; each has a pre-written application Q, transfer test, red flags
chat_client.py minimal multi-turn DeepSeek client (stdlib urllib, zero pip deps)
memory.py the mentor's <memory> tool: per-lesson status + notes, parsed in code
orchestrator.py the two-agent loop; enforces "don't advance until verified" in CODE
run_course.py entry point -> output/transcript.md + output/state.json
build_pdf.py assembles the single 5-section submission PDF (needs reportlab)
submission/
tools.md section 3 (model + tools)
rationale.md section 5 (rationale & honest self-eval)
mentor_student.pdf the deliverable
output/
transcript.md full untrimmed ten-lesson dialogue
state.json per-turn log + final memory (statuses, bluff incidents)
```

## Run it

Reuses the project's DeepSeek key from the root `.env` (`DEEPSEEK_API_KEY`).

```bash
# from the agentic-orchestration-2026 project root
python -m mentor_student.run_course # runs the full course, writes output/
python -m mentor_student.build_pdf # assembles submission/mentor_student.pdf
```

The mentor runs at temperature 0.5 (disciplined), the student at 0.85 (human and
surprising). The advancement gate is enforced by `orchestrator.py`, so a bluff
can never silently move the course forward — the mentor is told to keep probing.

## Why this design

See `submission/rationale.md` for the full rationale and an honest account of
where the mentor still gets fooled.
1 change: 1 addition & 0 deletions mashinsonvv/mentor_student/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Mentor & Student simulation — AI School 'Build a Mentor That Knows You Learned'."""
172 changes: 172 additions & 0 deletions mashinsonvv/mentor_student/build_pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Assemble the single submission PDF from its five source sections.

Deliverable (per the brief): one PDF, five sections —
1. Mentor prompt prompts/mentor_system.md
2. Student prompt prompts/student_system.md
3. Tools used submission/tools.md
4. The dialogue output/transcript.md (full, untrimmed)
5. Rationale & self-eval submission/rationale.md

Usage: python -m mentor_student.build_pdf [--out submission/mentor_student.pdf]

Needs reportlab (see requirements.txt). A tiny Markdown subset is rendered:
headings, bold, inline code, code fences, block quotes, horizontal rules, lists.
"""
from __future__ import annotations

import argparse
import html
import re
from pathlib import Path

from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
HRFlowable, PageBreak, Paragraph, Preformatted, SimpleDocTemplate, Spacer,
)

ROOT = Path(__file__).resolve().parent


def _styles():
ss = getSampleStyleSheet()
styles = {
"title": ParagraphStyle("t", parent=ss["Title"], fontSize=24, leading=28,
spaceAfter=6, textColor="#12263f"),
"subtitle": ParagraphStyle("st", parent=ss["Normal"], fontSize=11, leading=15,
textColor="#5a6b7b", spaceAfter=4),
"h1": ParagraphStyle("h1", parent=ss["Heading1"], fontSize=18, leading=22,
textColor="#12263f", spaceBefore=10, spaceAfter=8),
"h2": ParagraphStyle("h2", parent=ss["Heading2"], fontSize=14, leading=18,
textColor="#1c3a5e", spaceBefore=12, spaceAfter=4),
"h3": ParagraphStyle("h3", parent=ss["Heading3"], fontSize=12, leading=15,
textColor="#1c3a5e", spaceBefore=8, spaceAfter=3),
"body": ParagraphStyle("b", parent=ss["Normal"], fontSize=10, leading=14,
alignment=TA_LEFT, spaceAfter=6),
"quote": ParagraphStyle("q", parent=ss["Normal"], fontSize=10, leading=14,
leftIndent=14, textColor="#44586b", spaceAfter=6,
borderColor="#d0a020", borderWidth=0, backColor="#faf6ea"),
"code": ParagraphStyle("c", parent=ss["Code"], fontSize=8.5, leading=11,
backColor="#f4f5f7", textColor="#22303c",
borderPadding=6, spaceAfter=8),
"bullet": ParagraphStyle("bl", parent=ss["Normal"], fontSize=10, leading=14,
leftIndent=14, bulletIndent=4, spaceAfter=3),
}
return styles


_BOLD = re.compile(r"\*\*(.+?)\*\*")
_CODE = re.compile(r"`([^`]+)`")


def _inline(text: str) -> str:
text = html.escape(text)
text = _BOLD.sub(r"<b>\1</b>", text)
text = _CODE.sub(r'<font face="Courier">\1</font>', text)
return text


def render_markdown(md: str, styles) -> list:
flow: list = []
lines = md.splitlines()
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()

# fenced code block
if stripped.startswith("```"):
buf = []
i += 1
while i < len(lines) and not lines[i].strip().startswith("```"):
buf.append(lines[i])
i += 1
i += 1 # skip closing fence
text = html.escape("\n".join(buf)) or " "
flow.append(Preformatted(text, styles["code"]))
continue

if not stripped:
i += 1
continue

if stripped in ("---", "***", "___"):
flow.append(Spacer(1, 4))
flow.append(HRFlowable(width="100%", thickness=0.6, color="#c9d3dd"))
flow.append(Spacer(1, 4))
elif stripped.startswith("### "):
flow.append(Paragraph(_inline(stripped[4:]), styles["h3"]))
elif stripped.startswith("## "):
flow.append(Paragraph(_inline(stripped[3:]), styles["h2"]))
elif stripped.startswith("# "):
flow.append(Paragraph(_inline(stripped[2:]), styles["h1"]))
elif stripped.startswith("> "):
flow.append(Paragraph(_inline(stripped[2:]), styles["quote"]))
elif re.match(r"^[-*] ", stripped):
flow.append(Paragraph(_inline(stripped[2:]), styles["bullet"], bulletText="•"))
elif re.match(r"^\d+\. ", stripped):
num = stripped.split(".", 1)[0]
flow.append(Paragraph(_inline(stripped.split(". ", 1)[1]), styles["bullet"],
bulletText=f"{num}."))
else:
flow.append(Paragraph(_inline(stripped), styles["body"]))
i += 1
return flow


def build(out_path: Path) -> Path:
styles = _styles()
doc = SimpleDocTemplate(
str(out_path), pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm, topMargin=1.8 * cm, bottomMargin=1.8 * cm,
title="Mentor & Student Simulation — Negotiation",
)
flow: list = []

# Title page
flow.append(Spacer(1, 4 * cm))
flow.append(Paragraph("Build a Mentor That Knows You Learned", styles["title"]))
flow.append(Paragraph("Mentor &amp; Student Simulation — subject: <b>Negotiation</b>", styles["subtitle"]))
flow.append(Paragraph("mashinsonvv · AI School", styles["subtitle"]))
flow.append(Spacer(1, 0.6 * cm))
flow.append(Paragraph(
"Two AI agents run a ten-lesson negotiation course entirely through conversation. "
"The student sometimes fakes having practised; the mentor must catch the bluff and "
"refuse to advance without real evidence of application.", styles["body"]))
flow.append(PageBreak())

sections = [
("1. Mentor prompt", ROOT / "prompts" / "mentor_system.md"),
("2. Student prompt", ROOT / "prompts" / "student_system.md"),
("3. Tools used", ROOT / "submission" / "tools.md"),
("4. The dialogue — full ten-lesson transcript", ROOT / "output" / "transcript.md"),
("5. Rationale & self-evaluation", ROOT / "submission" / "rationale.md"),
]
for title, path in sections:
flow.append(Paragraph(title, styles["h1"]))
flow.append(HRFlowable(width="100%", thickness=1, color="#d0a020"))
flow.append(Spacer(1, 6))
if path.exists():
flow.extend(render_markdown(path.read_text(encoding="utf-8"), styles))
else:
flow.append(Paragraph(f"<i>({path.name} not found — run the course first.)</i>", styles["body"]))
flow.append(PageBreak())

doc.build(flow)
return out_path


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=str(ROOT / "submission" / "mentor_student.pdf"))
args = ap.parse_args()
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
build(out)
print(f"Wrote {out}")


if __name__ == "__main__":
main()
91 changes: 91 additions & 0 deletions mashinsonvv/mentor_student/chat_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Minimal multi-turn chat client (DeepSeek / OpenAI-compatible).

The repo's existing `llm/client.py` only takes a single system + user string,
which is fine for one-shot agents but not for a running mentor<->student
conversation. This client accepts a full message list and keeps zero pip
dependencies (stdlib urllib only), reusing the same .env conventions:

DEEPSEEK_API_KEY (required)
DEEPSEEK_BASE_URL (optional, default https://api.deepseek.com)
DEEPSEEK_MODEL (optional, default deepseek-chat)

It never prints the key. On transient failures it retries with backoff.
"""
from __future__ import annotations

import json
import os
import time
import urllib.error
import urllib.request


class ChatError(Exception):
"""Raised on any LLM failure after retries are exhausted."""


def load_env(*paths: str) -> None:
"""Tiny .env loader (KEY=VALUE per line). Tries each path; no-op if absent."""
for path in paths:
if not path or not os.path.exists(path):
continue
for line in open(path, encoding="utf-8"):
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))


class ChatClient:
def __init__(self, model: str | None = None, base_url: str | None = None,
api_key: str | None = None, timeout: int = 90) -> None:
self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY", "")
self.base_url = (base_url or os.environ.get("DEEPSEEK_BASE_URL")
or "https://api.deepseek.com").rstrip("/")
self.model = model or os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")
self.timeout = timeout

@property
def available(self) -> bool:
return bool(self.api_key)

def chat(self, messages: list[dict], *, temperature: float = 0.7,
max_retries: int = 4) -> str:
"""Send a full message list, return the assistant's text content."""
if not self.api_key:
raise ChatError("DEEPSEEK_API_KEY not set — put it in the project .env")
payload = {
"model": self.model,
"messages": messages,
"temperature": temperature,
}
data = json.dumps(payload).encode("utf-8")
delay = 3
last_err: Exception | None = None
for attempt in range(1, max_retries + 1):
req = urllib.request.Request(
f"{self.base_url}/v1/chat/completions",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
return body["choices"][0]["message"]["content"]
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "ignore")[:200]
last_err = ChatError(f"HTTP {e.code}: {detail}")
# 4xx other than 429 won't fix itself — stop early.
if e.code < 500 and e.code != 429:
raise last_err from e
except Exception as e: # network, timeout, parse
last_err = ChatError(str(e))
if attempt < max_retries:
time.sleep(delay)
delay = min(delay * 2, 20)
raise last_err or ChatError("unknown chat failure")
Loading