Skip to content

Commit a8c0f41

Browse files
committed
add module 10 files
1 parent 2fad8e6 commit a8c0f41

167 files changed

Lines changed: 22092 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
OPENAI_API_KEY=sk-...
2+
3+
# Langfuse tracing. Self-hosted (local docker compose) is the default; for
4+
# Langfuse Cloud set LANGFUSE_HOST to https://us.cloud.langfuse.com (or the EU host).
5+
LANGFUSE_PUBLIC_KEY=pk-lf-...
6+
LANGFUSE_SECRET_KEY=sk-lf-...
7+
LANGFUSE_HOST=http://localhost:3000

module-10-context/demos/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.venv
2+
__pycache__/
3+
*.pyc
4+
*.pyo
5+
.env
6+
state.db
7+
realthor.db
8+
uv.lock
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Local Langfuse for the isolation demo
2+
3+
`demo_02.py --trace` ships each run to Langfuse, attributed to the caller's **user** and
4+
**session**. Run Langfuse locally with its official Docker stack, pinned to a release:
5+
6+
```bash
7+
git clone --branch v3.130.0 https://github.com/langfuse/langfuse.git
8+
cd langfuse
9+
docker compose up -d # UI on http://localhost:3000 after ~2-3 min
10+
```
11+
12+
Then:
13+
14+
1. Open `http://localhost:3000`, create an account (local, not the cloud), create a project.
15+
2. Copy the project's **public** and **secret** keys into this demo's `.env`:
16+
17+
```
18+
LANGFUSE_PUBLIC_KEY=pk-lf-...
19+
LANGFUSE_SECRET_KEY=sk-lf-...
20+
LANGFUSE_HOST=http://localhost:3000
21+
```
22+
23+
3. Run the traced demo and open the runs in the UI:
24+
25+
```bash
26+
uv run python demo_02.py --case login-sessions --trace
27+
```
28+
29+
This is a **separate** Docker stack from any RealThor container — run it from the cloned
30+
`langfuse/` directory with its own `docker compose`. Pin the release tag; the self-hosting
31+
stack evolves. Current quickstart: https://langfuse.com/self-hosting/deployment/docker-compose
32+
33+
The backend is chosen entirely by env vars, so switching to Langfuse Cloud is just a `.env`
34+
change: set `LANGFUSE_HOST=https://us.cloud.langfuse.com` (or the EU host) and use the keys
35+
from your cloud project.
36+
37+
## What to look at once it's open
38+
39+
Go to **Tracing → Traces**. Each run carries the caller's identity:
40+
41+
- **Filter by User** — pick `analyst_001` and you see only that analyst's runs. A different
42+
user's runs are a separate, attributed group. The isolation the checkpointer enforces on
43+
state is now visible in the traces.
44+
- **Filter by Session** — one session groups a user's turns together, so you can follow a
45+
conversation across runs (and come back to it later).
46+
- Open a trace and read the span tree: **spans** are the `get_demand_score` tool calls
47+
(click one for its arguments and returned row); **generations** are the LLM calls with
48+
model, tokens, cost, and latency.
49+
50+
Attribution is set in `run_turn` via `config["metadata"]`: `langfuse_user_id`,
51+
`langfuse_session_id`, and `langfuse_tags`.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
agent:
2+
name: RealThor
3+
version: "0.10.0-demo"
4+
5+
model:
6+
name: gpt-4o-mini
7+
temperature: 0.0
8+
9+
tracing:
10+
enabled: true
11+
12+
paths:
13+
prompt: prompts/system_prompt.md
14+
demand_data: data/demand.csv
15+
users_data: data/users.csv
16+
app_db: realthor.db # application state — users + sessions
17+
state_db: state.db # LangGraph checkpointer — agent state, keyed by thread_id
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
region,property_type,bedrooms,demand_score,supply_score
2+
west,apartment,2,0.82,0.41
3+
west,studio,1,0.69,0.55
4+
north,apartment,2,0.54,0.80
5+
north,house,3,0.37,0.91
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
user_id,name,role,region_access,active
2+
analyst_001,Maria Santos,analyst,all,True
3+
broker_west_001,Emma Rossi,broker,west,True
4+
broker_north_001,Yuki Tanaka,broker,north,True

module-10-context/demos/demo_01.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Demo 1 — the *before*: a flag is not a login, a run is not a session.
2+
3+
Reproduces the two failures RealThor has before this module:
4+
* impersonation — identity is a bare ``--user`` claim, no password;
5+
* amnesia — every run gets a fresh thread, so nothing is remembered, and the
6+
naive fix (share a thread) leaks one caller's context into another's.
7+
8+
Run: uv run python demo_01.py --case all
9+
uv run python demo_01.py --case impersonation # no model call
10+
uv run python demo_01.py --case amnesia
11+
"""
12+
13+
import click
14+
from dotenv import load_dotenv
15+
16+
from realthor.agent import build_agent, build_checkpointer, ensure_db, run_turn_raw
17+
from realthor.config import load_config, resolve_paths
18+
from realthor.context import UserContext, resolve_user
19+
20+
load_dotenv()
21+
CONFIG = "config.yaml"
22+
23+
24+
def _rule(title):
25+
click.echo("\n" + "=" * 60)
26+
click.echo(title)
27+
click.echo("=" * 60)
28+
29+
30+
def case_impersonation(cfg):
31+
_rule("BEFORE — impersonation: a flag is not a login")
32+
db = cfg["paths"]["app_db"]
33+
34+
# The old CLI trusted --user with no password. Anyone can pick any id.
35+
ctx = resolve_user("analyst_001", db)
36+
click.echo(f"$ realthor chat --user analyst_001 (no password asked)")
37+
click.echo(f" → became {ctx.user_id}: role={ctx.role}, region_access={ctx.region_access}")
38+
39+
# Worse, a flag can carry anything the caller invents — nothing verifies it.
40+
forged = UserContext(user_id="ceo_root", role="admin", region_access="all")
41+
click.echo(f"$ realthor chat --user ceo_root (also no password)")
42+
click.echo(f" → claimed {forged.user_id}: role={forged.role}, region_access={forged.region_access}")
43+
click.echo("\nNo credential was ever checked. Identity is unproven.")
44+
45+
46+
def case_amnesia(cfg):
47+
_rule("BEFORE — amnesia: every run is a throwaway thread")
48+
agent = build_agent(cfg, checkpointer=build_checkpointer(cfg))
49+
analyst = resolve_user("analyst_001", cfg["paths"]["app_db"])
50+
51+
# Two turns, each on a FRESH random thread — the second forgets the first.
52+
import uuid
53+
click.echo("\nTurn 1 (thread A):")
54+
click.echo(" user> For my notes: my client's acquisition budget is 2.4 million euros.")
55+
r1 = run_turn_raw(agent, analyst, str(uuid.uuid4()), "For my notes: my client's acquisition budget is 2.4 million euros.")
56+
click.echo(f" realthor> {r1}")
57+
click.echo("\nTurn 2 (thread B — a new run):")
58+
click.echo(" user> What budget did we just discuss?")
59+
r2 = run_turn_raw(agent, analyst, str(uuid.uuid4()), "What budget did we just discuss?")
60+
click.echo(f" realthor> {r2}")
61+
click.echo("\n→ No memory across runs. There is no session.")
62+
63+
# Naive fix: share one hardcoded thread so it 'remembers'. Now it leaks.
64+
_rule("BEFORE — the naive fix leaks: one shared thread, two users")
65+
broker = resolve_user("broker_north_001", cfg["paths"]["app_db"])
66+
shared = "shared-thread"
67+
a_msg = "For my notes: my client's acquisition budget is 2.4 million euros."
68+
click.echo("\nUser A = analyst_001 (thread 'shared-thread'):")
69+
click.echo(f" user> {a_msg}")
70+
a_reply = run_turn_raw(agent, analyst, shared, a_msg)
71+
click.echo(f" realthor> {a_reply}")
72+
click.echo("\nUser B = broker_north_001 (SAME thread 'shared-thread'):")
73+
click.echo(" user> What budget did we discuss earlier?")
74+
leak = run_turn_raw(agent, broker, shared, "What budget did we discuss earlier?")
75+
click.echo(f" realthor> {leak}")
76+
click.echo("\n→ User B just read User A's private note. State is shared because nothing keys it to identity.")
77+
78+
79+
@click.command()
80+
@click.option("--case", "case", default="all",
81+
type=click.Choice(["all", "impersonation", "amnesia"]))
82+
def main(case):
83+
cfg = resolve_paths(load_config(CONFIG), CONFIG)
84+
ensure_db(cfg)
85+
if case in ("all", "impersonation"):
86+
case_impersonation(cfg)
87+
if case in ("all", "amnesia"):
88+
case_amnesia(cfg)
89+
90+
91+
if __name__ == "__main__":
92+
main()

module-10-context/demos/demo_02.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Demo 2 — the *after*: log in, and your sessions are yours.
2+
3+
Same scenarios as demo_01, now correct:
4+
* login — a password proves identity before any context is resolved;
5+
* sessions — owned, resumable conversations with human-readable titles;
6+
* isolation — a caller can only see and resume their own sessions, and state
7+
is keyed by ``thread_id_for(user, session)`` so nothing leaks.
8+
9+
Run: uv run python demo_02.py --case all
10+
uv run python demo_02.py --case login-sessions --trace # attribute in Langfuse
11+
uv run python demo_02.py --case isolation # ownership; no model call
12+
"""
13+
14+
import click
15+
from dotenv import load_dotenv
16+
17+
from realthor import auth, sessions
18+
from realthor.agent import build_agent, build_checkpointer, build_tracer, ensure_db, run_turn
19+
from realthor.config import load_config, resolve_paths
20+
21+
load_dotenv()
22+
CONFIG = "config.yaml"
23+
24+
25+
def _rule(title):
26+
click.echo("\n" + "=" * 60)
27+
click.echo(title)
28+
click.echo("=" * 60)
29+
30+
31+
def case_login_sessions(cfg, trace):
32+
_rule("AFTER — login + an owned, resumable session")
33+
db = cfg["paths"]["app_db"]
34+
agent = build_agent(cfg, checkpointer=build_checkpointer(cfg))
35+
tracer = build_tracer(cfg) if trace else None
36+
37+
# 1) Prove identity with a password (not a bare flag).
38+
click.echo("\nLogin: analyst_001 / <password>")
39+
ctx = auth.authenticate(db, "analyst_001", auth.DEV_PASSWORD)
40+
bad = auth.authenticate(db, "analyst_001", "wrong-password")
41+
click.echo(f" correct password → {ctx.user_id} ({ctx.role})")
42+
click.echo(f" wrong password → {bad} (denied)")
43+
44+
# 2) Start a session; its title comes from the first message.
45+
first = "What's the demand for a 2-bedroom apartment in the west region?"
46+
title = sessions.generate_title(first)
47+
sid = sessions.create_session(db, ctx.user_id, title)
48+
click.echo(f'\nStarted session "{title}"')
49+
click.echo(f" user> {first}")
50+
r1 = run_turn(agent, ctx, sid, first, tracer=tracer)
51+
sessions.touch_session(db, sid)
52+
click.echo(f" realthor> {r1}")
53+
54+
# 3) List and resume it — same user+session → same thread → it remembers.
55+
click.echo("\nYour sessions:")
56+
for s in sessions.list_sessions(db, ctx.user_id):
57+
click.echo(f" · {s['title']} ({s['message_count']} msgs)")
58+
resumed = sessions.get_session(db, sid, ctx.user_id)
59+
click.echo(f'\nResume "{resumed["title"]}":')
60+
click.echo(" user> Remind me which segment we were looking at.")
61+
r2 = run_turn(agent, ctx, sid, "Remind me which segment we were looking at.", tracer=tracer)
62+
sessions.touch_session(db, sid)
63+
click.echo(f" realthor> {r2}")
64+
click.echo("\n→ Persisted, because the thread is keyed to (this user, this session).")
65+
if tracer:
66+
click.echo("(traced — filter Langfuse by user analyst_001 / this session)")
67+
68+
69+
def case_isolation(cfg):
70+
_rule("AFTER — isolation: sessions belong to their owner")
71+
db = cfg["paths"]["app_db"]
72+
analyst = auth.authenticate(db, "analyst_001", auth.DEV_PASSWORD)
73+
broker = auth.authenticate(db, "broker_north_001", auth.DEV_PASSWORD)
74+
75+
# analyst_001 owns a session; broker_north_001 must not see or resume it.
76+
sid = sessions.create_session(db, analyst.user_id, "Analyst private notes")
77+
click.echo(f"\nanalyst_001 created session {sid[:8]}… ('Analyst private notes')")
78+
79+
click.echo("\nbroker_north_001 lists their sessions:")
80+
theirs = sessions.list_sessions(db, broker.user_id)
81+
click.echo(f" → {[s['title'] for s in theirs]} (analyst's is not here)")
82+
83+
click.echo("\nbroker_north_001 tries to resume the analyst's session by id:")
84+
stolen = sessions.get_session(db, sid, broker.user_id)
85+
click.echo(f" → get_session(...) = {stolen} (ownership check denies it)")
86+
87+
# One user, two sessions → different threads → no cross-talk.
88+
from realthor.context import thread_id_for
89+
s_west = sessions.create_session(db, analyst.user_id, "West focus")
90+
s_north = sessions.create_session(db, analyst.user_id, "North focus")
91+
click.echo("\nOne analyst, two sessions map to two isolated threads:")
92+
click.echo(f" West → {thread_id_for(analyst.user_id, s_west)}")
93+
click.echo(f" North → {thread_id_for(analyst.user_id, s_north)}")
94+
click.echo("\n→ Isolation is identity-scoped state, not a smarter prompt.")
95+
96+
97+
@click.command()
98+
@click.option("--case", "case", default="all",
99+
type=click.Choice(["all", "login-sessions", "isolation"]))
100+
@click.option("--trace/--no-trace", default=False, help="Attribute the run to user/session in Langfuse.")
101+
def main(case, trace):
102+
cfg = resolve_paths(load_config(CONFIG), CONFIG)
103+
ensure_db(cfg)
104+
if case in ("all", "login-sessions"):
105+
case_login_sessions(cfg, trace)
106+
if case in ("all", "isolation"):
107+
case_isolation(cfg)
108+
109+
110+
if __name__ == "__main__":
111+
main()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
You are RealThor, a listing supply intelligence assistant for Ciudaty PropTech.
2+
3+
Answer questions about market demand and supply for a region, property type, and
4+
bedroom count. Always call the `get_demand_score` tool for the requested segment
5+
— do not guess. Keep answers short and concrete.
6+
7+
The tool enforces the caller's region access. Only if it returns a `blocked`
8+
status with `region_out_of_scope` should you tell the caller that segment is
9+
outside their access; otherwise report the demand and supply scores it returns.
10+
Remember what the caller told you earlier in this conversation.

0 commit comments

Comments
 (0)