diff --git a/KOHEN.md b/KOHEN.md index a89f0ca1..3eb1a680 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -405,6 +405,11 @@ curl http:///kodesh/health | jq '.checks' # Architecture Step 22 it is answered with 422 TEIKU_NO_ROOTS and does # NOT write L3 — by design (BUILD_KORBAN.md:123). The L3 count below is # expected to rise by the number of Hebrew questions, not by ten. +# +# Tablet 30 (Day 7, 2026-10-04): the verse in the answer must be the +# verse in the question. Expect sources[0].ref = Bereishit 1:1, 1:1, +# 1:3, 1:5, 1:1 in that order, and processing.scope.resolved_by = +# "text_match". A "default" there means the question was not heard. for q in "בראשית ברא אלהים" "את השמים ואת הארץ" "ויאמר אלהים יהי אור" "ויהי ערב ויהי בקר" "ברא אלהים"; do curl -s -X POST http:///peah/ask \ -H 'Content-Type: application/json' \ diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 2e0a447c..6f21b29c 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -41,7 +41,9 @@ defmodule VavPipeline.Application do {VavPipeline.UrimVTumim, []}, {VavPipeline.ShabbatTimer, []}, {VavPipeline.VoiceMonitor, []}, - {VavPipeline.MachtzitHaShekel, []} + {VavPipeline.MachtzitHaShekel, []}, + # Tablet 30 — the index that lets a question name its own verse + {VavPipeline.L1Index, []} ] |> maybe_add_http_server() |> maybe_add_repo() diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 443504f4..6a7f7e74 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -314,16 +314,34 @@ defmodule VavPipeline.BigdeiKodesh.Router do }) :available -> - # Pe'ah uses minchah protocol — simplest offering - scope = extract_scope_from_question(question) + # Pe'ah uses minchah protocol — simplest offering. + # The verse is heard from the question (tablet 30), not assumed. + case VavPipeline.ScopeResolver.resolve(question) do + {:error, {:invalid_reference, ref}} -> + send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{ + issue: "INVALID_SCOPE: #{ref} is not a verse in L1", + correction: + "Name a verse that exists — chapter and verse within the five books " <> + "(for example \"bereishit 1:1\" or \"devarim 6:4\").", + resubmit: true + }) - input = %{ - source: "peah", - verse_scope: scope, - content: question - } + {:ok, scope, resolved_by} -> + process_peah(conn, question, scope, resolved_by) + end + end + end - case VavPipeline.process(input) do + defp process_peah(conn, question, scope, resolved_by) do + input = %{ + source: "peah", + verse_scope: scope, + content: question + } + + scope_report = scope_report(scope, resolved_by) + + case VavPipeline.process(input) do {:ok, data} -> send_json(conn, 200, %{ answer: format_answer(data), @@ -331,7 +349,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do processing: %{ roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(), deliberation: "none", - cycle_phase: "voker" + cycle_phase: "voker", + scope: scope_report }, disclosure: disclosure() }) @@ -343,7 +362,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do processing: %{ roots_consulted: Map.get(data, :roots_activated, []) |> format_roots(), deliberation: "single-judge", - cycle_phase: "voker" + cycle_phase: "voker", + scope: scope_report }, note: "This question produced an unresolved result — teiku.", disclosure: disclosure() @@ -379,7 +399,6 @@ defmodule VavPipeline.BigdeiKodesh.Router do correction: "Contact system operators", resubmit: false }) - end end end @@ -808,10 +827,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp format_since(nil), do: "all_time" defp format_since(%DateTime{} = dt), do: DateTime.to_iso8601(dt) - defp extract_scope_from_question(_question) do - # Default scope for pe'ah — Bereishit 1:1 - # In production, this would parse the question for verse references - %{book: "bereishit", chapter: 1, verse: 1} + # How the verse was chosen, said out loud in the response. A default + # scope is a confession, not a grounding (tablet 30). + defp scope_report(scope, :default) do + %{ + verse: scope_verse(scope), + resolved_by: "default", + note: + "The question named no verse and quoted no Torah the index could find, " <> + "so the default scope was used. Roots are read against it, not against " <> + "the question's source." + } + end + + defp scope_report(scope, resolved_by) do + %{verse: scope_verse(scope), resolved_by: to_string(resolved_by)} end defp parse_scope(nil), do: %{book: "bereishit", chapter: 1, verse: 1} diff --git a/vav_pipeline/lib/vav_pipeline/l1_index.ex b/vav_pipeline/lib/vav_pipeline/l1_index.ex new file mode 100644 index 00000000..f4460c6c --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/l1_index.ex @@ -0,0 +1,172 @@ +defmodule VavPipeline.L1Index do + @moduledoc """ + Inverted index over the edut: consonantal word → the verses that carry it. + + *V'darashta v'chakarta v'sha'alta heitev* — you shall inquire, search and + ask diligently (Devarim 13:15). A question that quotes Torah names its + own verse; this index is how the pipeline hears which one. + + Built once from `l1/torah///.json` right after the + process starts (`handle_continue`), so the first pe'ah request does not + pay for it. Read-only after that — L1 is engraved and so is the index. + Callers block until the build finishes; no call ever sees a half-built + index. + + Verse ids are `{book_order, chapter, verse}` so that `Enum.sort/1` is + Torah order. + """ + + use GenServer + require Logger + + alias VavPipeline.L1Reader + + @books ~w(bereishit shemot vayikra bamidbar devarim) + @book_order @books |> Enum.with_index() |> Map.new() + + @type verse_id :: {non_neg_integer(), pos_integer(), pos_integer()} + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__)) + end + + @doc "Canonical book order, as the index sorts it." + @spec books() :: [String.t()] + def books, do: @books + + @doc "The verses that contain this consonantal word. Empty list if none." + @spec lookup(String.t(), GenServer.server()) :: [verse_id()] + def lookup(consonants, server \\ __MODULE__) do + GenServer.call(server, {:lookup, consonants}, 60_000) + end + + @doc "`{verses_indexed, words_indexed}` — for health and for tests." + @spec stats(GenServer.server()) :: {non_neg_integer(), non_neg_integer()} + def stats(server \\ __MODULE__), do: GenServer.call(server, :stats, 60_000) + + @doc "Turn an index verse id back into a scope map." + @spec to_scope(verse_id()) :: %{book: String.t(), chapter: pos_integer(), verse: pos_integer()} + def to_scope({order, chapter, verse}) do + %{book: Enum.at(@books, order), chapter: chapter, verse: verse} + end + + # ------------------------------------------------------------ + # GenServer + # ------------------------------------------------------------ + + @impl true + def init(opts) do + root = Keyword.get(opts, :l1_root, L1Reader.l1_root()) + {:ok, %{root: root, words: %{}, verse_count: 0}, {:continue, :build}} + end + + @impl true + def handle_continue(:build, state) do + {time_us, built} = :timer.tc(fn -> build(state.root) end) + + case built do + {:ok, words, verse_count} -> + Logger.info( + "L1Index: #{verse_count} verses, #{map_size(words)} distinct words, " <> + "#{div(time_us, 1000)}ms" + ) + + {:noreply, %{state | words: words, verse_count: verse_count}} + + {:error, reason} -> + # No L1 → no index. Resolution falls back to the default scope and + # says so; the health check already reports l1_hash separately. + Logger.warning("L1Index: not built — #{reason}") + {:noreply, state} + end + end + + @impl true + def handle_call({:lookup, consonants}, _from, state) do + {:reply, Map.get(state.words, consonants, []), state} + end + + def handle_call(:stats, _from, state) do + {:reply, {state.verse_count, map_size(state.words)}, state} + end + + # ------------------------------------------------------------ + # Build + # ------------------------------------------------------------ + + defp build(root) do + torah = Path.join(root, "torah") + + if File.dir?(torah) do + {words, count} = + Enum.reduce(@books, {%{}, 0}, fn book, acc -> + index_book(Path.join(torah, book), @book_order[book], acc) + end) + + if count == 0, do: {:error, "no verses under #{torah}"}, else: {:ok, words, count} + else + {:error, "#{torah} is not a directory"} + end + end + + defp index_book(book_dir, order, acc) do + book_dir + |> list_numeric() + |> Enum.reduce(acc, fn chapter, acc -> + chapter_dir = Path.join(book_dir, to_string(chapter)) + + chapter_dir + |> list_numeric(".json") + |> Enum.reduce(acc, fn verse, {words, count} -> + case read_consonants(Path.join(chapter_dir, "#{verse}.json")) do + {:ok, consonant_words} -> + id = {order, chapter, verse} + + words = + consonant_words + |> Enum.uniq() + |> Enum.reduce(words, fn w, words -> Map.update(words, w, [id], &[id | &1]) end) + + {words, count + 1} + + :error -> + {words, count} + end + end) + end) + end + + # Directory entries that are integers (optionally with a suffix), ascending. + defp list_numeric(dir, suffix \\ "") do + case File.ls(dir) do + {:ok, entries} -> + entries + |> Enum.flat_map(fn e -> + case Integer.parse(String.replace_suffix(e, suffix, "")) do + {n, ""} -> [n] + _ -> [] + end + end) + |> Enum.sort() + + {:error, _} -> + [] + end + end + + defp read_consonants(path) do + with {:ok, contents} <- File.read(path), + {:ok, %{"words" => words}} when is_list(words) <- Jason.decode(contents) do + {:ok, Enum.flat_map(words, &word_consonants/1)} + else + _ -> :error + end + end + + # A word joined by maqaf is indexed as each half, so that a question + # written with spaces still finds it. + defp word_consonants(%{"consonants" => c}) when is_binary(c), + do: String.split(c, "־", trim: true) + + defp word_consonants(_), do: [] +end diff --git a/vav_pipeline/lib/vav_pipeline/scope_resolver.ex b/vav_pipeline/lib/vav_pipeline/scope_resolver.ex new file mode 100644 index 00000000..438c7f08 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/scope_resolver.ex @@ -0,0 +1,182 @@ +defmodule VavPipeline.ScopeResolver do + @moduledoc """ + Hear which verse a question is standing in. + + Broken tablet 30 (2026-10-04): for seven months `/peah/ask` answered every + question against Bereishit 1:1 — `extract_scope_from_question/1` ignored + its argument. The grounding check passed by construction. *Lo tov.* + + Three ways a question names its verse, tried in order: + + 1. **An explicit reference** — `bereishit 1:3`, `Genesis 1:3`, `בראשית 1:3`. + If it names a verse L1 does not have, that is a shogeg error: + `{:error, {:invalid_reference, "bereishit 99:1"}}`, corrected without + penalty by the router. + 2. **Quoted Torah** — the question's Hebrew words are looked up in + `VavPipeline.L1Index`. The verse carrying the most of them wins; ties go + to the verse where they stand together in order, then to Torah order. + Deterministic, and the choice is disclosed in the response. + 3. **Nothing** — no reference, no Hebrew the Torah contains. The default + scope (Bereishit 1:1) is used and the response says `resolved_by: + "default"`, so a grounded-by-construction answer can no longer pass as + a grounded one. + """ + + alias VavPipeline.{L1Index, L1Reader} + + @default %{book: "bereishit", chapter: 1, verse: 1} + + @book_names %{ + "bereishit" => "bereishit", "genesis" => "bereishit", "בראשית" => "bereishit", + "shemot" => "shemot", "exodus" => "shemot", "שמות" => "shemot", + "vayikra" => "vayikra", "leviticus" => "vayikra", "ויקרא" => "vayikra", + "bamidbar" => "bamidbar", "numbers" => "bamidbar", "במדבר" => "bamidbar", + "devarim" => "devarim", "deuteronomy" => "devarim", "דברים" => "devarim" + } + + @reference ~r/(?#{Enum.join(Map.keys(@book_names), "|")})\s+(?\d{1,3})\s*[:.]\s*(?\d{1,3})/iu + + # Nikud, te'amim and the other combining marks of the Hebrew block. + @marks ~r/[\x{0591}-\x{05C7}]/u + @hebrew_word ~r/[\x{05D0}-\x{05EA}]+/u + @maqaf "־" + + # How many tied candidates we are willing to open from disk to break a tie. + @tie_limit 25 + + @type scope :: %{book: String.t(), chapter: pos_integer(), verse: pos_integer()} + @type resolution :: :verse_reference | :text_match | :default + + @doc """ + `{:ok, scope, resolution}` or `{:error, {:invalid_reference, text}}`. + """ + @spec resolve(String.t()) :: {:ok, scope(), resolution()} | {:error, {:invalid_reference, String.t()}} + def resolve(question) when is_binary(question) do + case explicit_reference(question) do + {:ok, scope} -> + {:ok, scope, :verse_reference} + + {:error, _} = error -> + error + + :none -> + case text_match(question) do + {:ok, scope} -> {:ok, scope, :text_match} + :none -> {:ok, @default, :default} + end + end + end + + @doc "The scope used when a question names none." + @spec default() :: scope() + def default, do: @default + + @doc """ + The question's Hebrew words as bare consonants, in order — what L1 stores + under `consonants`. Marks stripped, maqaf split, everything else dropped. + """ + @spec hebrew_tokens(String.t()) :: [String.t()] + def hebrew_tokens(question) do + # Maqaf first: it sits inside the combining-mark range and would vanish. + question + |> String.replace(@maqaf, " ") + |> String.replace(@marks, "") + |> then(&Regex.scan(@hebrew_word, &1)) + |> List.flatten() + end + + # ------------------------------------------------------------ + # 1. Explicit reference + # ------------------------------------------------------------ + + defp explicit_reference(question) do + case Regex.named_captures(@reference, question) do + nil -> + :none + + %{"book" => book, "chapter" => chapter, "verse" => verse} -> + scope = %{ + book: @book_names[String.downcase(book)], + chapter: String.to_integer(chapter), + verse: String.to_integer(verse) + } + + case L1Reader.read_verse(scope.book, scope.chapter, scope.verse) do + {:ok, _} -> {:ok, scope} + {:error, _} -> {:error, {:invalid_reference, "#{scope.book} #{chapter}:#{verse}"}} + end + end + end + + # ------------------------------------------------------------ + # 2. Quoted Torah + # ------------------------------------------------------------ + + defp text_match(question) do + tokens = question |> hebrew_tokens() |> Enum.uniq() + + if tokens == [] or not index_up?() do + :none + else + tokens + |> Enum.flat_map(fn t -> t |> L1Index.lookup() |> Enum.map(&{&1, t}) end) + |> Enum.group_by(fn {id, _} -> id end, fn {_, t} -> t end) + |> Enum.map(fn {id, found} -> {id, length(Enum.uniq(found))} end) + |> best_by_count() + |> break_ties(tokens) + end + end + + defp best_by_count([]), do: [] + + defp best_by_count(scored) do + max = scored |> Enum.map(&elem(&1, 1)) |> Enum.max() + scored |> Enum.filter(fn {_, n} -> n == max end) |> Enum.map(&elem(&1, 0)) |> Enum.sort() + end + + defp break_ties([], _tokens), do: :none + defp break_ties([only], _tokens), do: {:ok, L1Index.to_scope(only)} + + defp break_ties(tied, tokens) do + # Longest run of the question's words standing together, in order, + # wins; `Enum.max_by` keeps the first (earliest in Torah) on a tie. + best = + tied + |> Enum.take(@tie_limit) + |> Enum.max_by(fn id -> id |> verse_consonants() |> longest_run(tokens) end) + + {:ok, L1Index.to_scope(best)} + end + + defp verse_consonants(id) do + %{book: b, chapter: c, verse: v} = L1Index.to_scope(id) + + case L1Reader.read_verse(b, c, v) do + {:ok, %{words: words}} -> + Enum.flat_map(words, fn w -> String.split(w.consonants || "", @maqaf, trim: true) end) + + _ -> + [] + end + end + + # Longest contiguous stretch of `verse_words` that is also a contiguous, + # in-order stretch of `tokens`. + defp longest_run(verse_words, tokens) do + for i <- 0..max(length(verse_words) - 1, 0), + j <- 0..max(length(tokens) - 1, 0), + reduce: 0 do + acc -> max(acc, run_from(Enum.drop(verse_words, i), Enum.drop(tokens, j))) + end + end + + defp run_from([w | vs], [w | ts]), do: 1 + run_from(vs, ts) + defp run_from(_, _), do: 0 + + defp index_up? do + case Process.whereis(L1Index) do + nil -> false + _pid -> match?({n, _} when n > 0, L1Index.stats()) + end + end +end diff --git a/vav_pipeline/test/tablet30_scope_test.exs b/vav_pipeline/test/tablet30_scope_test.exs new file mode 100644 index 00000000..4c2933f9 --- /dev/null +++ b/vav_pipeline/test/tablet30_scope_test.exs @@ -0,0 +1,219 @@ +defmodule VavPipeline.Tablet30ScopeTest do + @moduledoc """ + Tablet 30 (kohen Day 7, 2026-10-04) — every Hebrew question was answered + against Bereishit 1:1. `ויאמר אלהים יהי אור` (1:3) and `ויהי ערב ויהי בקר` + (1:5) came back with `sources: [Bereishit 1:1]` and roots marked "not in + this verse". `extract_scope_from_question/1` ignored its argument. + + These tests send the Day-7 offerings through the real router and demand + that the verse in the answer is the verse in the question. + """ + + use ExUnit.Case, async: false + + import Plug.Test + import Plug.Conn + + alias VavPipeline.{HaltMonitor, L1Index, MannaLedger, ScopeResolver} + alias VavPipeline.BigdeiKodesh.Router + + @opts Router.init([]) + + setup_all do + # The index builds in handle_continue; the first call waits for it. + {verses, words} = L1Index.stats() + assert verses == 5853, "L1Index built #{verses} verses, expected the WLC 5853" + assert words > 10_000 + :ok + end + + # ============================================================ + # ScopeResolver — the three ways a question names its verse + # ============================================================ + describe "hebrew_tokens/1" do + test "strips nikud and te'amim, splits maqaf, drops everything else" do + assert ScopeResolver.hebrew_tokens("וַֽיְהִי־אֽוֹר ׃") == ["ויהי", "אור"] + assert ScopeResolver.hebrew_tokens("What does בְּרֵאשִׁ֖ית mean?") == ["בראשית"] + assert ScopeResolver.hebrew_tokens("Test query 3") == [] + end + end + + describe "resolve/1 — quoted Torah" do + test "the Day-7 offerings land on their own verses" do + assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :text_match} = + ScopeResolver.resolve("בראשית ברא אלהים") + + assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :text_match} = + ScopeResolver.resolve("את השמים ואת הארץ") + + assert {:ok, %{book: "bereishit", chapter: 1, verse: 3}, :text_match} = + ScopeResolver.resolve("ויאמר אלהים יהי אור") + + assert {:ok, %{book: "bereishit", chapter: 1, verse: 5}, :text_match} = + ScopeResolver.resolve("ויהי ערב ויהי בקר") + + assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :text_match} = + ScopeResolver.resolve("ברא אלהים") + end + + test "pointed text resolves the same as bare consonants" do + assert {:ok, %{book: "bereishit", chapter: 1, verse: 3}, :text_match} = + ScopeResolver.resolve("וַיֹּ֥אמֶר אֱלֹהִ֖ים יְהִ֣י א֑וֹר") + end + + test "reaches past Bereishit — the Shema" do + assert {:ok, %{book: "devarim", chapter: 6, verse: 4}, :text_match} = + ScopeResolver.resolve("שמע ישראל יהוה אלהינו יהוה אחד") + end + + test "a phrase found in several verses goes to the earliest in Torah order" do + # שמע ישראל opens Devarim 5:1 before it opens 6:4. + assert {:ok, %{book: "devarim", chapter: 5, verse: 1}, :text_match} = + ScopeResolver.resolve("שמע ישראל") + end + + test "Hebrew the Torah does not contain falls to the default, and says so" do + assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :default} = + ScopeResolver.resolve("מחשב") + end + end + + describe "resolve/1 — explicit reference" do + test "transliterated, English and Hebrew book names, colon or dot" do + assert {:ok, %{book: "bereishit", chapter: 1, verse: 3}, :verse_reference} = + ScopeResolver.resolve("What happens in bereishit 1:3?") + + assert {:ok, %{book: "shemot", chapter: 3, verse: 14}, :verse_reference} = + ScopeResolver.resolve("Exodus 3.14") + + assert {:ok, %{book: "devarim", chapter: 6, verse: 4}, :verse_reference} = + ScopeResolver.resolve("דברים 6:4") + end + + test "a reference outranks quoted text" do + assert {:ok, %{book: "devarim", chapter: 6, verse: 4}, :verse_reference} = + ScopeResolver.resolve("devarim 6:4 — בראשית ברא אלהים") + end + + test "a verse L1 does not have is a shogeg error, not a silent default" do + assert {:error, {:invalid_reference, "devarim 99:1"}} = + ScopeResolver.resolve("devarim 99:1") + end + end + + describe "resolve/1 — nothing to go on" do + test "English defaults, and the resolution is :default" do + assert {:ok, %{book: "bereishit", chapter: 1, verse: 1}, :default} = + ScopeResolver.resolve("What is bereishit?") + end + end + + # ============================================================ + # Router — the verse in the answer is the verse in the question + # ============================================================ + describe "POST /peah/ask" do + setup do + # halt_monitor_test leaves the monitor halted on some seeds; a halted + # vessel answers 503 before the resolver is reached. Clear it, as + # liveness_test does, so this file measures scope and nothing else. + if HaltMonitor.halted?(), do: HaltMonitor.resolve("tablet30 test setup") + write_provision(50) + :ok + end + + test "Day-7 Hebrew offerings are sourced from their own verses" do + for {q, ref} <- [ + {"ויאמר אלהים יהי אור", "Bereishit 1:3"}, + {"ויהי ערב ויהי בקר", "Bereishit 1:5"}, + {"בראשית ברא אלהים", "Bereishit 1:1"} + ] do + conn = post_ask(q) + body = Jason.decode!(conn.resp_body) + + assert conn.status == 200, "#{q}: #{conn.status} #{conn.resp_body}" + assert [%{"ref" => ^ref} | _] = body["sources"], "#{q} sourced from #{inspect(body["sources"])}" + assert body["processing"]["scope"]["resolved_by"] == "text_match" + assert body["processing"]["scope"]["verse"]["ref"] == ref + refute Map.has_key?(body["processing"]["scope"], "note") + end + end + + test "an explicit reference is honoured — the error text promised it" do + conn = post_ask("bereishit 1:3") + body = Jason.decode!(conn.resp_body) + + assert conn.status == 200 + assert [%{"ref" => "Bereishit 1:3"}] = body["sources"] + assert body["processing"]["scope"]["resolved_by"] == "verse_reference" + end + + test "a verse that does not exist is a 422 chatat, with the correction" do + conn = post_ask("bereishit 99:1") + body = Jason.decode!(conn.resp_body) + + assert conn.status == 422 + assert body["error"]["code"] == "KIYOR_VALIDATION_FAILED" + assert body["error"]["guidance"]["issue"] =~ "INVALID_SCOPE" + assert body["error"]["guidance"]["resubmit"] == true + end + + test "a question with no verse in it confesses the default scope" do + # In the test VM there is no Haskell Port, so the L1 fallback grounds + # English against the default verse and answers 200 (day6_serving_test + # holds the real-engine 422 shut). Whatever the status, the scope + # report must say the verse was assumed, not heard. + conn = post_ask("What is bereishit?") + body = Jason.decode!(conn.resp_body) + + scope = + case conn.status do + 200 -> body["processing"]["scope"] + 422 -> %{"resolved_by" => "default", "note" => "n/a", "verse" => body["error"]["guidance"]["scope_verse"]} + end + + assert scope["resolved_by"] == "default" + assert scope["verse"]["ref"] == "Bereishit 1:1" + assert is_binary(scope["note"]) + end + end + + # ------------------------------------------------------------ + # Helpers (shape from day6_serving_test.exs) + # ------------------------------------------------------------ + + defp write_provision(compute_units) do + MannaLedger.reset() + on_exit(fn -> MannaLedger.reset() end) + today = Date.utc_today() |> Date.to_iso8601() + expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601() + + provision = %{ + "date" => today, + "provision" => %{ + "compute_units" => compute_units, + "memory_mb" => 512, + "api_calls_external" => 5, + "storage_delta_mb" => 2, + "sanhedrin_panels_max" => 2 + }, + "type" => "regular", + "expires_at" => expires, + "written_by" => "tablet30_test", + "written_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + path = VavPipeline.Manna.provision_file() + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(provision)) + # Leave no provision behind: the Shema tests run at a fixed 2026-02-25 + # and read a dated file as MANNA_STALE. + on_exit(fn -> File.rm(path) end) + path + end + + defp post_ask(question) do + conn(:post, "/peah/ask", Jason.encode!(%{question: question})) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + end +end