fix: hint guardrails, coached runs stay off the dataset, journal accounting fixes
Browse files- README.md +5 -3
- app.py +11 -2
- lantern/agent.py +11 -4
- lantern/bench.py +14 -8
- lantern/export.py +75 -72
- lantern/guard.py +78 -0
- lantern/journal.py +13 -7
- lantern/store.py +7 -3
README.md
CHANGED
|
@@ -189,8 +189,9 @@ Beam is the agent, and each Beam call is one step in a loop:
|
|
| 189 |
2. **Plan and act.** Beam calls `act` with 1 to 8 moves. If something surprising happens partway
|
| 190 |
(a new game message, lost HP, the run ending), the rest of the plan is dropped and Beam sees the
|
| 191 |
new state before deciding again. It can call `write_notes` to keep working memory for the run.
|
| 192 |
-
3. **Reflect.** When the run ends,
|
| 193 |
-
|
|
|
|
| 194 |
|
| 195 |
Beam's reasoning comes back separately from its tool calls, so the page shows what it thought next
|
| 196 |
to what it did. Beam is also the teacher: every call in a winning episode becomes a training
|
|
@@ -270,7 +271,8 @@ modal run scripts/modal_train.py::eval_entry --model Qwen/Qwen3-1.7B --tag base-
|
|
| 270 |
```
|
| 271 |
|
| 272 |
The benchmark enforces a token cap across all parallel containers and can resume a stopped run
|
| 273 |
-
without replaying finished seeds. `
|
|
|
|
| 274 |
models can be run through the same harness.
|
| 275 |
|
| 276 |
## Limitations
|
|
|
|
| 189 |
2. **Plan and act.** Beam calls `act` with 1 to 8 moves. If something surprising happens partway
|
| 190 |
(a new game message, lost HP, the run ending), the rest of the plan is dropped and Beam sees the
|
| 191 |
new state before deciding again. It can call `write_notes` to keep working memory for the run.
|
| 192 |
+
3. **Reflect.** When the run ends, every lesson Beam read is credited with the win or loss. Beam
|
| 193 |
+
then sees the journal with those records, plus its notes and recent log, and rewrites it for a
|
| 194 |
+
new layout of the same level (see Memory and self-improvement).
|
| 195 |
|
| 196 |
Beam's reasoning comes back separately from its tool calls, so the page shows what it thought next
|
| 197 |
to what it did. Beam is also the teacher: every call in a winning episode becomes a training
|
|
|
|
| 271 |
```
|
| 272 |
|
| 273 |
The benchmark enforces a token cap across all parallel containers and can resume a stopped run
|
| 274 |
+
without replaying finished seeds. `python scripts/compare_journal.py` rebuilds the memory
|
| 275 |
+
comparison table in Results from the dataset. `ChatClient` takes any OpenAI-compatible endpoint, so other
|
| 276 |
models can be run through the same harness.
|
| 277 |
|
| 278 |
## Limitations
|
app.py
CHANGED
|
@@ -27,6 +27,7 @@ from pydantic import BaseModel, Field
|
|
| 27 |
|
| 28 |
from lantern.agent import EpisodeConfig, run_episode
|
| 29 |
from lantern.coach import Hint, HintQueue
|
|
|
|
| 30 |
from lantern.env import TASKS, DungeonEnv
|
| 31 |
from lantern.journal import Journal
|
| 32 |
from lantern.llm import ChatClient, OutOfCredits
|
|
@@ -104,8 +105,11 @@ class Run:
|
|
| 104 |
env = DungeonEnv(TASKS[self.task_id], seed=self.seed)
|
| 105 |
lessons = journal.lessons(self.task_id)
|
| 106 |
# Journal v2: Beam curates the journal from each lesson's win/loss record.
|
|
|
|
|
|
|
| 107 |
config = EpisodeConfig(use_lessons=self.use_lessons, max_llm_calls=MAX_CALLS_PER_RUN,
|
| 108 |
-
|
|
|
|
| 109 |
for event in run_episode(env, ChatClient(), lessons, config, self.stop, self.hints):
|
| 110 |
if self.stop.is_set():
|
| 111 |
break
|
|
@@ -140,11 +144,13 @@ class Run:
|
|
| 140 |
"""Mark done and save; callers hold the lock."""
|
| 141 |
self.done = True
|
| 142 |
end = next((e for e in reversed(self.events) if e["type"] == "end"), {})
|
|
|
|
|
|
|
| 143 |
store.save({"id": self.id, "task": self.task_id, "seed": self.seed, "source": "space",
|
| 144 |
"use_lessons": self.use_lessons, "finished_at": time.time(),
|
| 145 |
"outcome": end.get("outcome", "error"), "steps": end.get("steps", 0),
|
| 146 |
"coached": self.coached,
|
| 147 |
-
"events": list(self.events)})
|
| 148 |
|
| 149 |
|
| 150 |
runs: dict[str, Run] = {}
|
|
@@ -301,6 +307,9 @@ def send_hint(run_id: str, req: HintRequest, request: Request) -> dict:
|
|
| 301 |
text = clean_hint(req.text)
|
| 302 |
if not text:
|
| 303 |
raise HTTPException(400, "Type a hint first.")
|
|
|
|
|
|
|
|
|
|
| 304 |
run.add_hint(Hint(text, req.seen_step, req.seen_x, req.seen_y))
|
| 305 |
return {"queued": text}
|
| 306 |
|
|
|
|
| 27 |
|
| 28 |
from lantern.agent import EpisodeConfig, run_episode
|
| 29 |
from lantern.coach import Hint, HintQueue
|
| 30 |
+
from lantern.guard import check_hint
|
| 31 |
from lantern.env import TASKS, DungeonEnv
|
| 32 |
from lantern.journal import Journal
|
| 33 |
from lantern.llm import ChatClient, OutOfCredits
|
|
|
|
| 105 |
env = DungeonEnv(TASKS[self.task_id], seed=self.seed)
|
| 106 |
lessons = journal.lessons(self.task_id)
|
| 107 |
# Journal v2: Beam curates the journal from each lesson's win/loss record.
|
| 108 |
+
# With "Use journal" off Beam never sees the lessons, so the run must neither credit
|
| 109 |
+
# them with its outcome nor rewrite the journal (same as the benchmark's "off").
|
| 110 |
config = EpisodeConfig(use_lessons=self.use_lessons, max_llm_calls=MAX_CALLS_PER_RUN,
|
| 111 |
+
reflect=self.use_lessons, curate=True,
|
| 112 |
+
lesson_records=journal.records(self.task_id) if self.use_lessons else None)
|
| 113 |
for event in run_episode(env, ChatClient(), lessons, config, self.stop, self.hints):
|
| 114 |
if self.stop.is_set():
|
| 115 |
break
|
|
|
|
| 144 |
"""Mark done and save; callers hold the lock."""
|
| 145 |
self.done = True
|
| 146 |
end = next((e for e in reversed(self.events) if e["type"] == "end"), {})
|
| 147 |
+
# Coached runs hold text typed by visitors (also quoted in Beam's reasoning), so they
|
| 148 |
+
# stay on the Space and never go to the public dataset.
|
| 149 |
store.save({"id": self.id, "task": self.task_id, "seed": self.seed, "source": "space",
|
| 150 |
"use_lessons": self.use_lessons, "finished_at": time.time(),
|
| 151 |
"outcome": end.get("outcome", "error"), "steps": end.get("steps", 0),
|
| 152 |
"coached": self.coached,
|
| 153 |
+
"events": list(self.events)}, upload=not self.coached)
|
| 154 |
|
| 155 |
|
| 156 |
runs: dict[str, Run] = {}
|
|
|
|
| 307 |
text = clean_hint(req.text)
|
| 308 |
if not text:
|
| 309 |
raise HTTPException(400, "Type a hint first.")
|
| 310 |
+
problem = check_hint(text) # PII, health information, profanity; the text itself is never logged
|
| 311 |
+
if problem:
|
| 312 |
+
raise HTTPException(400, f"That hint looks like it contains {problem}. Please keep hints about the game.")
|
| 313 |
run.add_hint(Hint(text, req.seen_step, req.seen_x, req.seen_y))
|
| 314 |
return {"queued": text}
|
| 315 |
|
lantern/agent.py
CHANGED
|
@@ -276,7 +276,9 @@ def run_episode(env: Env, client: ChatClient, lessons: list[str] | None = None,
|
|
| 276 |
|
| 277 |
new_lessons: list[str] = []
|
| 278 |
records: list[dict] | None = None
|
| 279 |
-
|
|
|
|
|
|
|
| 280 |
if config.curate:
|
| 281 |
credited = journal.credit(config.lesson_records or [], outcome == "success")
|
| 282 |
revised = curate_journal(client, outcome, steps, usage["calls"], config.max_llm_calls,
|
|
@@ -330,10 +332,15 @@ def curate_journal(client: ChatClient, outcome: str, steps: int, calls: int, max
|
|
| 330 |
text = (reply.content or "").strip()
|
| 331 |
start, end = text.find("{"), text.rfind("}")
|
| 332 |
try:
|
| 333 |
-
|
| 334 |
except (json.JSONDecodeError, ValueError):
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
|
| 339 |
def reflect(client: ChatClient, outcome: str, steps: int, notes: str, log: list[str],
|
|
|
|
| 276 |
|
| 277 |
new_lessons: list[str] = []
|
| 278 |
records: list[dict] | None = None
|
| 279 |
+
# A coached run's lessons never enter the shared journal (a viewer's text could ride along),
|
| 280 |
+
# so don't pay for a reflection that would be thrown away.
|
| 281 |
+
if config.reflect and outcome not in ("stopped",) and not advice_given:
|
| 282 |
if config.curate:
|
| 283 |
credited = journal.credit(config.lesson_records or [], outcome == "success")
|
| 284 |
revised = curate_journal(client, outcome, steps, usage["calls"], config.max_llm_calls,
|
|
|
|
| 332 |
text = (reply.content or "").strip()
|
| 333 |
start, end = text.find("{"), text.rfind("}")
|
| 334 |
try:
|
| 335 |
+
parsed = json.loads(text[start:end + 1])
|
| 336 |
except (json.JSONDecodeError, ValueError):
|
| 337 |
+
parsed = None
|
| 338 |
+
revised = parsed.get("journal") if isinstance(parsed, dict) else None
|
| 339 |
+
if not isinstance(revised, list):
|
| 340 |
+
# Unreadable, the wrong shape, or a missing key: keep the journal as it was rather than
|
| 341 |
+
# erase it (or split a bare string into letters).
|
| 342 |
+
return [r["text"] for r in records]
|
| 343 |
+
return [str(x).strip()[:300] for x in revised if isinstance(x, str) and x.strip()]
|
| 344 |
|
| 345 |
|
| 346 |
def reflect(client: ChatClient, outcome: str, steps: int, notes: str, log: list[str],
|
lantern/bench.py
CHANGED
|
@@ -26,7 +26,7 @@ def bench_seeds(n: int) -> list[int]:
|
|
| 26 |
def play_series(task_id: str, seeds: list[int], use_lessons: bool, tag: str,
|
| 27 |
client: ChatClient, store: EpisodeStore, source: str = "bench",
|
| 28 |
stop: threading.Event | None = None,
|
| 29 |
-
initial_lessons: list
|
| 30 |
"""Play `seeds` in order on one level and save every episode; returns per-episode summaries.
|
| 31 |
|
| 32 |
Each series starts with an empty journal; with `use_lessons`, lessons written after
|
|
@@ -35,8 +35,9 @@ def play_series(task_id: str, seeds: list[int], use_lessons: bool, tag: str,
|
|
| 35 |
With `curated` (journal v2), lessons carry win/loss records and Beam rewrites the whole
|
| 36 |
journal after each run instead of appending to it.
|
| 37 |
"""
|
| 38 |
-
|
| 39 |
-
records = [journal.new_record(
|
|
|
|
| 40 |
summaries = []
|
| 41 |
for seed in seeds:
|
| 42 |
if stop is not None and stop.is_set():
|
|
@@ -117,21 +118,26 @@ class BudgetedClient:
|
|
| 117 |
return reply
|
| 118 |
|
| 119 |
|
| 120 |
-
def resume_state(records: list[dict], task_id: str, use_lessons: bool
|
|
|
|
| 121 |
"""Seeds already finished by a series, and the journal it had built, from saved episodes.
|
| 122 |
|
| 123 |
Episodes cut off by the spending cap or by infrastructure errors count as unfinished
|
| 124 |
-
and are replayed.
|
|
|
|
| 125 |
"""
|
| 126 |
done = sorted((r for r in records if r["task"] == task_id and r["use_lessons"] == use_lessons
|
| 127 |
and r["outcome"] not in NOT_RESULTS), key=lambda r: r["seed"])
|
| 128 |
-
|
| 129 |
if use_lessons and done:
|
| 130 |
last = done[-1]
|
| 131 |
start = next((e for e in last["events"] if e["type"] == "start"), {})
|
| 132 |
end = next((e for e in last["events"] if e["type"] == "end"), {})
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
def summary_table(results: list[dict], task_ids: list[str], conditions: list[str]) -> str:
|
|
|
|
| 26 |
def play_series(task_id: str, seeds: list[int], use_lessons: bool, tag: str,
|
| 27 |
client: ChatClient, store: EpisodeStore, source: str = "bench",
|
| 28 |
stop: threading.Event | None = None,
|
| 29 |
+
initial_lessons: list | None = None, curated: bool = False) -> list[dict]:
|
| 30 |
"""Play `seeds` in order on one level and save every episode; returns per-episode summaries.
|
| 31 |
|
| 32 |
Each series starts with an empty journal; with `use_lessons`, lessons written after
|
|
|
|
| 35 |
With `curated` (journal v2), lessons carry win/loss records and Beam rewrites the whole
|
| 36 |
journal after each run instead of appending to it.
|
| 37 |
"""
|
| 38 |
+
# Non-empty when resuming: lesson strings (v1) or records with win/loss counts (v2).
|
| 39 |
+
records = [r if isinstance(r, dict) else journal.new_record(r) for r in (initial_lessons or [])]
|
| 40 |
+
lessons = [r["text"] for r in records]
|
| 41 |
summaries = []
|
| 42 |
for seed in seeds:
|
| 43 |
if stop is not None and stop.is_set():
|
|
|
|
| 118 |
return reply
|
| 119 |
|
| 120 |
|
| 121 |
+
def resume_state(records: list[dict], task_id: str, use_lessons: bool,
|
| 122 |
+
curated: bool = False) -> tuple[set[int], list]:
|
| 123 |
"""Seeds already finished by a series, and the journal it had built, from saved episodes.
|
| 124 |
|
| 125 |
Episodes cut off by the spending cap or by infrastructure errors count as unfinished
|
| 126 |
+
and are replayed. A v1 journal comes back as lesson strings rebuilt with v1 rules; a
|
| 127 |
+
curated (v2) journal comes back as the last episode's records, win/loss counts intact.
|
| 128 |
"""
|
| 129 |
done = sorted((r for r in records if r["task"] == task_id and r["use_lessons"] == use_lessons
|
| 130 |
and r["outcome"] not in NOT_RESULTS), key=lambda r: r["seed"])
|
| 131 |
+
saved: list = []
|
| 132 |
if use_lessons and done:
|
| 133 |
last = done[-1]
|
| 134 |
start = next((e for e in last["events"] if e["type"] == "start"), {})
|
| 135 |
end = next((e for e in last["events"] if e["type"] == "end"), {})
|
| 136 |
+
if curated:
|
| 137 |
+
saved = end.get("journal_records") or [journal.new_record(t) for t in start.get("lessons", [])]
|
| 138 |
+
else:
|
| 139 |
+
saved = merge_lessons(start.get("lessons", []), end.get("new_lessons", []), JOURNAL_LIMIT)
|
| 140 |
+
return {r["seed"] for r in done}, saved
|
| 141 |
|
| 142 |
|
| 143 |
def summary_table(results: list[dict], task_ids: list[str], conditions: list[str]) -> str:
|
lantern/export.py
CHANGED
|
@@ -1,72 +1,75 @@
|
|
| 1 |
-
"""Turn recorded episodes into supervised fine-tuning rows for a student model.
|
| 2 |
-
|
| 3 |
-
Each Beam call becomes one chat example: the system prompt and observation Beam saw, and
|
| 4 |
-
the tool calls it made. Only calls that executed something are kept, and by default only
|
| 5 |
-
episodes Beam won, so the student imitates successful play.
|
| 6 |
-
|
| 7 |
-
python -m lantern.export data/ train.jsonl
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import json
|
| 13 |
-
import sys
|
| 14 |
-
from pathlib import Path
|
| 15 |
-
|
| 16 |
-
from .agent import PROMPT_VERSION
|
| 17 |
-
from .
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
calls
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
if
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn recorded episodes into supervised fine-tuning rows for a student model.
|
| 2 |
+
|
| 3 |
+
Each Beam call becomes one chat example: the system prompt and observation Beam saw, and
|
| 4 |
+
the tool calls it made. Only calls that executed something are kept, and by default only
|
| 5 |
+
episodes Beam won, so the student imitates successful play.
|
| 6 |
+
|
| 7 |
+
python -m lantern.export data/ train.jsonl
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from .agent import PROMPT_VERSION
|
| 17 |
+
from .llm import BEAM_MODEL
|
| 18 |
+
from .store import EpisodeStore
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def tool_calls_for(executed: list[dict]) -> list[dict]:
|
| 22 |
+
"""Rebuild OpenAI-style tool calls from the agent's executed-tool records."""
|
| 23 |
+
calls = []
|
| 24 |
+
for i, e in enumerate(executed):
|
| 25 |
+
if e["tool"] == "act":
|
| 26 |
+
args = {"actions": e["planned"]}
|
| 27 |
+
elif e["tool"] == "write_notes":
|
| 28 |
+
args = {"notes": e["notes"]}
|
| 29 |
+
else:
|
| 30 |
+
continue
|
| 31 |
+
calls.append({"id": f"call_{i}", "type": "function",
|
| 32 |
+
"function": {"name": e["tool"], "arguments": json.dumps(args)}})
|
| 33 |
+
return calls
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def episode_rows(record: dict) -> list[dict]:
|
| 37 |
+
"""SFT rows for one episode, or [] if it was recorded with a different prompt version."""
|
| 38 |
+
if record.get("coached"):
|
| 39 |
+
return [] # a person helped; the student should learn Beam's own play
|
| 40 |
+
start = next((e for e in record["events"] if e["type"] == "start"), None)
|
| 41 |
+
if not start or start.get("prompt_version") != PROMPT_VERSION:
|
| 42 |
+
return [] # older recordings used a different prompt; never mix them in
|
| 43 |
+
if start.get("model") != BEAM_MODEL:
|
| 44 |
+
return [] # only the teacher's moves; never the student's or the untrained model's
|
| 45 |
+
rows = []
|
| 46 |
+
for e in record["events"]:
|
| 47 |
+
if e["type"] != "turn" or not e["executed"]:
|
| 48 |
+
continue
|
| 49 |
+
rows.append({
|
| 50 |
+
"messages": [
|
| 51 |
+
{"role": "system", "content": start["system"]},
|
| 52 |
+
{"role": "user", "content": e["prompt"]},
|
| 53 |
+
{"role": "assistant", "content": "", "tool_calls": tool_calls_for(e["executed"])},
|
| 54 |
+
],
|
| 55 |
+
"tools": start["tools"],
|
| 56 |
+
"task": record["task"], "seed": record.get("seed"), "episode": record["id"],
|
| 57 |
+
})
|
| 58 |
+
return rows
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def export(root: Path, out: Path, outcomes: tuple[str, ...] = ("success",)) -> int:
|
| 62 |
+
"""Write SFT rows from every stored episode with a matching outcome; returns the row count."""
|
| 63 |
+
n = 0
|
| 64 |
+
with out.open("w", encoding="utf-8") as f:
|
| 65 |
+
for record in EpisodeStore(root).episodes():
|
| 66 |
+
if record.get("outcome") not in outcomes:
|
| 67 |
+
continue
|
| 68 |
+
for row in episode_rows(record):
|
| 69 |
+
f.write(json.dumps(row) + "\n")
|
| 70 |
+
n += 1
|
| 71 |
+
return n
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
print(export(Path(sys.argv[1]), Path(sys.argv[2])), "rows")
|
lantern/guard.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Guardrails for the only free text strangers can send: coaching hints.
|
| 2 |
+
|
| 3 |
+
Hints are shown to Beam and can be quoted in its reasoning, so they're checked for personal
|
| 4 |
+
data (PII), health information (PHI) and profanity before they're accepted. Pattern checks
|
| 5 |
+
catch the common cases, not everything; coached runs are also never uploaded to the public
|
| 6 |
+
dataset, so anything that slips through stays on the Space.
|
| 7 |
+
|
| 8 |
+
Nothing here logs hint text: the caller only gets back a reason category.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
_PII = [
|
| 16 |
+
("an email address", re.compile(r"[\w.+-]+@[\w-]+\.[a-z]{2,}", re.I)),
|
| 17 |
+
("a web address", re.compile(r"(https?://|www\.)\S+", re.I)),
|
| 18 |
+
("a Social Security number", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
|
| 19 |
+
("a phone number", re.compile(r"(?:\+?\d{1,3}[\s.-]?)?\(?\b\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b")),
|
| 20 |
+
("an IP address", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
| 21 |
+
("a date", re.compile(r"\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b")),
|
| 22 |
+
]
|
| 23 |
+
_CARD = re.compile(r"\b(?:\d[ -]?){13,19}\b")
|
| 24 |
+
# Game hints need small coordinates, never ID-like numbers: six digits in a row, or ten or more
|
| 25 |
+
# split by spaces/dashes (e.g. a card number with a typo that fails the checksum).
|
| 26 |
+
_LONG_NUMBER = re.compile(r"\d{6,}|\b(?:\d[ -]?){10,}\b")
|
| 27 |
+
|
| 28 |
+
# Health-information terms that have no place in dungeon advice.
|
| 29 |
+
_PHI = re.compile(r"\b(diagnos\w*|prescri\w*|medical record|mrn|patient|date of birth|dob|"
|
| 30 |
+
r"health insurance|insurance (?:id|number)|medicaid|medicare|social security)\b", re.I)
|
| 31 |
+
|
| 32 |
+
# General profanity, matched on whole words or word starts (so "fucking" matches but innocent
|
| 33 |
+
# words that merely contain these letters don't). Slurs are deliberately not spelled out here.
|
| 34 |
+
_PROFANE_WORDS = {"fuck", "shit", "cunt", "bitch", "bastard", "dick", "cock", "pussy", "asshole",
|
| 35 |
+
"motherfucker", "bullshit", "wanker", "twat", "prick", "slut", "whore"}
|
| 36 |
+
_PROFANE_STEMS = ("fuck", "shit", "motherf", "bullsh")
|
| 37 |
+
_LEET = str.maketrans({"0": "o", "1": "i", "3": "e", "4": "a", "5": "s", "7": "t", "@": "a",
|
| 38 |
+
"$": "s", "!": "i", "*": "u"})
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _luhn(digits: str) -> bool:
|
| 42 |
+
total, double = 0, False
|
| 43 |
+
for d in reversed(digits):
|
| 44 |
+
n = int(d) * (2 if double else 1)
|
| 45 |
+
total += n - 9 if n > 9 else n
|
| 46 |
+
double = not double
|
| 47 |
+
return total % 10 == 0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _profane(text: str) -> bool:
|
| 51 |
+
lowered = text.lower()
|
| 52 |
+
# Undo spaced-out letters ("f u c k") and common symbol swaps before matching.
|
| 53 |
+
joined = re.sub(r"\b(\w)[\s.\-_]+(?=\w\b)", r"\1", lowered)
|
| 54 |
+
for variant in (lowered, joined):
|
| 55 |
+
words = re.findall(r"[\w@$!*]+", variant.translate(_LEET))
|
| 56 |
+
for word in words:
|
| 57 |
+
word = re.sub(r"(.)\1{2,}", r"\1", word) # "fuuuck" -> "fuck"
|
| 58 |
+
if word in _PROFANE_WORDS or word.startswith(_PROFANE_STEMS):
|
| 59 |
+
return True
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def check_hint(text: str) -> str | None:
|
| 64 |
+
"""Why a hint can't be accepted, as a short phrase for the viewer; None if it's fine."""
|
| 65 |
+
for label, pattern in _PII:
|
| 66 |
+
if pattern.search(text):
|
| 67 |
+
return f"personal information ({label})"
|
| 68 |
+
for match in _CARD.finditer(text):
|
| 69 |
+
digits = re.sub(r"\D", "", match.group())
|
| 70 |
+
if 13 <= len(digits) <= 19 and _luhn(digits):
|
| 71 |
+
return "personal information (a card number)"
|
| 72 |
+
if _LONG_NUMBER.search(text):
|
| 73 |
+
return "personal information (a long ID-like number)"
|
| 74 |
+
if _PHI.search(text):
|
| 75 |
+
return "health information"
|
| 76 |
+
if _profane(text):
|
| 77 |
+
return "language we keep out of the demo"
|
| 78 |
+
return None
|
lantern/journal.py
CHANGED
|
@@ -51,12 +51,6 @@ class Journal:
|
|
| 51 |
self._save()
|
| 52 |
return self.lessons(task_id)
|
| 53 |
|
| 54 |
-
def add(self, task_id: str, new: list[str]) -> list[str]:
|
| 55 |
-
"""Merge new lessons in the v1 way (append, newest wins), keeping existing records."""
|
| 56 |
-
known = {r["text"]: r for r in self._data.get(task_id, [])}
|
| 57 |
-
merged = merge_lessons(self.lessons(task_id), new, self.limit)
|
| 58 |
-
return self.set_records(task_id, [known.get(t, new_record(t)) for t in merged])
|
| 59 |
-
|
| 60 |
def _save(self) -> None:
|
| 61 |
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 62 |
self.path.write_text(json.dumps(self._data, indent=2), "utf-8")
|
|
@@ -74,6 +68,14 @@ ACTION_WORDS = {
|
|
| 74 |
"throw": "throw", "wield": "wield", "pick up": "pickup", "pickup": "pickup", "apply": "apply",
|
| 75 |
"eat": "eat", "descend": "descend",
|
| 76 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
def new_record(text: str) -> dict:
|
|
@@ -90,7 +92,11 @@ def unavailable_action(text: str, legal_actions: list[str]) -> str | None:
|
|
| 90 |
"""The first action word in `text` that this level can't do, if any."""
|
| 91 |
lowered = text.lower()
|
| 92 |
for word, action in ACTION_WORDS.items():
|
| 93 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
return word
|
| 95 |
return None
|
| 96 |
|
|
|
|
| 51 |
self._save()
|
| 52 |
return self.lessons(task_id)
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
def _save(self) -> None:
|
| 55 |
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 56 |
self.path.write_text(json.dumps(self._data, indent=2), "utf-8")
|
|
|
|
| 68 |
"throw": "throw", "wield": "wield", "pick up": "pickup", "pickup": "pickup", "apply": "apply",
|
| 69 |
"eat": "eat", "descend": "descend",
|
| 70 |
}
|
| 71 |
+
# "Search" is also everyday English ("search each corridor branch" just means explore), so it
|
| 72 |
+
# only counts as the game command when phrased like one.
|
| 73 |
+
COMMAND_PHRASES = {
|
| 74 |
+
"search": re.compile(r"\b(?:use|repeat|press)\s+(?:the\s+)?search\b"
|
| 75 |
+
r"|\bsearch(?:es|ing)?\s+(?:for\s+)?(?:secret|hidden)\b"
|
| 76 |
+
r"|\bsearch(?:es|ing)?\s+(?:the\s+|each\s+|every\s+)?(?:wall|walls|dead[- ]end)s?\b"
|
| 77 |
+
r"|\bsearch(?:es|ing)?\s+(?:\w+\s+){0,3}(?:times|again|repeatedly|turns)\b"),
|
| 78 |
+
}
|
| 79 |
|
| 80 |
|
| 81 |
def new_record(text: str) -> dict:
|
|
|
|
| 92 |
"""The first action word in `text` that this level can't do, if any."""
|
| 93 |
lowered = text.lower()
|
| 94 |
for word, action in ACTION_WORDS.items():
|
| 95 |
+
if action in legal_actions:
|
| 96 |
+
continue
|
| 97 |
+
phrase = COMMAND_PHRASES.get(word)
|
| 98 |
+
hit = phrase.search(lowered) if phrase else re.search(rf"\b{re.escape(word)}\b", lowered)
|
| 99 |
+
if hit:
|
| 100 |
return word
|
| 101 |
return None
|
| 102 |
|
lantern/store.py
CHANGED
|
@@ -41,13 +41,17 @@ class EpisodeStore:
|
|
| 41 |
except Exception as exc: # the app still works from local files
|
| 42 |
log.warning("Could not pull episodes from %s: %s", self.repo_id, exc)
|
| 43 |
|
| 44 |
-
def save(self, record: dict, wait: bool = False) -> Path:
|
| 45 |
-
"""Write one episode record locally, then upload it (in the background unless `wait`).
|
|
|
|
|
|
|
|
|
|
| 46 |
rel = Path("episodes") / record["task"] / f"{record['id']}.json"
|
| 47 |
path = self.root / rel
|
| 48 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 49 |
path.write_text(json.dumps(record), "utf-8")
|
| 50 |
-
|
|
|
|
| 51 |
return path
|
| 52 |
|
| 53 |
def push_journal(self) -> None:
|
|
|
|
| 41 |
except Exception as exc: # the app still works from local files
|
| 42 |
log.warning("Could not pull episodes from %s: %s", self.repo_id, exc)
|
| 43 |
|
| 44 |
+
def save(self, record: dict, wait: bool = False, upload: bool = True) -> Path:
|
| 45 |
+
"""Write one episode record locally, then upload it (in the background unless `wait`).
|
| 46 |
+
|
| 47 |
+
With `upload=False` the record stays local (e.g. runs containing visitors' text).
|
| 48 |
+
"""
|
| 49 |
rel = Path("episodes") / record["task"] / f"{record['id']}.json"
|
| 50 |
path = self.root / rel
|
| 51 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
path.write_text(json.dumps(record), "utf-8")
|
| 53 |
+
if upload:
|
| 54 |
+
self._upload([(path, rel.as_posix())], f"Add episode {record['id']} ({record['task']})", wait)
|
| 55 |
return path
|
| 56 |
|
| 57 |
def push_journal(self) -> None:
|