visproj commited on
Commit
4792e6e
·
verified ·
1 Parent(s): dcda9d3

fix: hint guardrails, coached runs stay off the dataset, journal accounting fixes

Browse files
Files changed (8) hide show
  1. README.md +5 -3
  2. app.py +11 -2
  3. lantern/agent.py +11 -4
  4. lantern/bench.py +14 -8
  5. lantern/export.py +75 -72
  6. lantern/guard.py +78 -0
  7. lantern/journal.py +13 -7
  8. 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, Beam reads its notes and recent log and writes one to three
193
- lessons for a new layout of the same level. These go into the journal.
 
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. `ChatClient` takes any OpenAI-compatible endpoint, so other
 
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
- curate=True, lesson_records=journal.records(self.task_id))
 
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
- if config.reflect and outcome not in ("stopped",):
 
 
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
- revised = json.loads(text[start:end + 1]).get("journal", [])
334
  except (json.JSONDecodeError, ValueError):
335
- return [r["text"] for r in records] # keep the journal as it was rather than lose it
336
- return [str(x).strip()[:300] for x in revised if str(x).strip()]
 
 
 
 
 
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[str] | 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,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
- lessons: list[str] = list(initial_lessons or []) # non-empty when resuming a series
39
- records = [journal.new_record(t) for t in lessons]
 
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) -> tuple[set[int], list[str]]:
 
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
- lessons: list[str] = []
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
- lessons = merge_lessons(start.get("lessons", []), end.get("new_lessons", []), JOURNAL_LIMIT)
134
- return {r["seed"] for r in done}, lessons
 
 
 
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 .store import EpisodeStore
18
-
19
-
20
- def tool_calls_for(executed: list[dict]) -> list[dict]:
21
- """Rebuild OpenAI-style tool calls from the agent's executed-tool records."""
22
- calls = []
23
- for i, e in enumerate(executed):
24
- if e["tool"] == "act":
25
- args = {"actions": e["planned"]}
26
- elif e["tool"] == "write_notes":
27
- args = {"notes": e["notes"]}
28
- else:
29
- continue
30
- calls.append({"id": f"call_{i}", "type": "function",
31
- "function": {"name": e["tool"], "arguments": json.dumps(args)}})
32
- return calls
33
-
34
-
35
- def episode_rows(record: dict) -> list[dict]:
36
- """SFT rows for one episode, or [] if it was recorded with a different prompt version."""
37
- if record.get("coached"):
38
- return [] # a person helped; the student should learn Beam's own play
39
- start = next((e for e in record["events"] if e["type"] == "start"), None)
40
- if not start or start.get("prompt_version") != PROMPT_VERSION:
41
- return [] # older recordings used a different prompt; never mix them in
42
- rows = []
43
- for e in record["events"]:
44
- if e["type"] != "turn" or not e["executed"]:
45
- continue
46
- rows.append({
47
- "messages": [
48
- {"role": "system", "content": start["system"]},
49
- {"role": "user", "content": e["prompt"]},
50
- {"role": "assistant", "content": "", "tool_calls": tool_calls_for(e["executed"])},
51
- ],
52
- "tools": start["tools"],
53
- "task": record["task"], "seed": record.get("seed"), "episode": record["id"],
54
- })
55
- return rows
56
-
57
-
58
- def export(root: Path, out: Path, outcomes: tuple[str, ...] = ("success",)) -> int:
59
- """Write SFT rows from every stored episode with a matching outcome; returns the row count."""
60
- n = 0
61
- with out.open("w", encoding="utf-8") as f:
62
- for record in EpisodeStore(root).episodes():
63
- if record.get("outcome") not in outcomes:
64
- continue
65
- for row in episode_rows(record):
66
- f.write(json.dumps(row) + "\n")
67
- n += 1
68
- return n
69
-
70
-
71
- if __name__ == "__main__":
72
- print(export(Path(sys.argv[1]), Path(sys.argv[2])), "rows")
 
 
 
 
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 re.search(rf"\b{re.escape(word)}\b", lowered) and action not in legal_actions:
 
 
 
 
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
- self._upload([(path, rel.as_posix())], f"Add episode {record['id']} ({record['task']})", wait)
 
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: