gate: Shorashim between a caller and a model, with the fringe and rebuke once (bishe'arecha step 4)
Devarim 6:9 / 11:20 — on your gates. POST /gate/anthropic/v1/messages and POST /gate/openai/v1/chat/completions take the model's own request shape under the caller's own upstream key (x-api-key, or Authorization with the courtyard key in X-Shorashim-Key), forward it unchanged (OTP inets/ssl, verify_peer; the Korban socket is serial, so the upstream call is made in the Vav Pipeline and the reply enters as korban like every bedikah), examine the reply with the request in hand — sources = the system prompt and the user turns, query = the last user turn; the model's prior turns are not sources — and deliver it with its fringe (Bamidbar 15:39): a trailer on the last text block naming ref, verdict, the rules with their verses and sentences; X-Shorashim-Bedikah / X-Shorashim-Verdict; a `shorashim` object. A non-200 passes through as it came; a reply with no text is not delivered; stream is refused. Rebuke once (Vayikra 19:17; X-Shorashim-Rebuke: once): a lo_tov reply is answered once more with the finding; the second reply is examined like the first, its record carries rebukes: <first ref> (migration 20261007000002), and it is delivered marked with both. Never a third. Manna: one api_calls_external per upstream call, gathered before any call. Stateless: no conversation, no key kept; the log is the path class. Gate.bearer accepts X-Shorashim-Key. Construction §3.10, Tamid §1.1, KOHEN "Standing at the gate", bishearecha §I/§III/§VII marked built. Elixir 592/0 (+14 gate tests incl. a real httpc round trip to a local model double), L3 9/0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
47025fbfeb
commit
5c63273dba
16 files changed
+1082
-15
No files matched your search
@@ -128,6 +128,8 @@ defmodule VavPipeline.Bedikah do
|
||||
{:ok, result()} | {:waiting, atom()} | {:error, atom(), String.t()}
|
||||
def examine(output, provenance, query \\ "", opts \\ []) do
|
||||
sources = Keyword.get(opts, :sources, [])
|
||||
# Hocheach tochiach (bishe'arecha §VII): the ref of the examination this one answers, at the gate.
|
||||
rebukes = Keyword.get(opts, :rebukes)
|
||||
examiner = Keyword.get(opts, :examiner, &KorbanProcessorSocket.examine/4)
|
||||
halt_checker = Keyword.get(opts, :halt_checker, &TalmudicEngineSocket.halt_check_term/1)
|
||||
|
||||
@@ -136,12 +138,12 @@ defmodule VavPipeline.Bedikah do
|
||||
|
||||
# D'var yom b'yomo: an examination is a cycle's work and eats a unit.
|
||||
case Manna.gather_for_cycle() do
|
||||
:ok -> run(output, provenance, query, sources, examiner, halt_checker, interpretation_checker)
|
||||
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker)
|
||||
{:waiting, reason} -> {:waiting, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp run(output, provenance, query, sources, examiner, halt_checker, interpretation_checker) do
|
||||
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker) do
|
||||
bedikah_ref = "bedikah-" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
|
||||
|
||||
with {:ok, examination} <- examiner.(output, provenance, query, sources) do
|
||||
@@ -151,7 +153,7 @@ defmodule VavPipeline.Bedikah do
|
||||
(examination["frames"] || []) == [] do
|
||||
# Nothing the offering protocol could extract, nothing claimed,
|
||||
# nothing enacted: there is nothing to examine.
|
||||
finish(bedikah_ref, output, provenance, query, sources, examination, [], %{}, [], :empty)
|
||||
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], %{}, [], :empty)
|
||||
else
|
||||
trace = coupling_trace(examination)
|
||||
|
||||
@@ -166,7 +168,7 @@ defmodule VavPipeline.Bedikah do
|
||||
checks = Map.merge(%{authority: authority, grounding: grounding, actions: actions, frames: frames}, structure)
|
||||
violations = prior ++ frames.violations
|
||||
verdict = if violations == [], do: :tov, else: :lo_tov
|
||||
finish(bedikah_ref, output, provenance, query, sources, examination, trace, checks, violations, verdict)
|
||||
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict)
|
||||
else
|
||||
{:error, :talmudic, reason} -> {:error, :talmudic_unavailable, reason}
|
||||
end
|
||||
@@ -521,12 +523,13 @@ defmodule VavPipeline.Bedikah do
|
||||
end
|
||||
end
|
||||
|
||||
defp finish(bedikah_ref, output, provenance, query, sources, examination, trace, checks, violations, verdict) do
|
||||
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict) do
|
||||
result = %{
|
||||
bedikah_ref: bedikah_ref,
|
||||
provenance: provenance,
|
||||
query: query,
|
||||
sources: source_digests(sources),
|
||||
rebukes: rebukes,
|
||||
verdict: verdict,
|
||||
violations: violations,
|
||||
checks: checks,
|
||||
@@ -563,6 +566,7 @@ defmodule VavPipeline.Bedikah do
|
||||
excluded_adversarial: result.examination["excluded_adversarial"] || []
|
||||
},
|
||||
frames: result.examination["frames"] || [],
|
||||
rebukes: result.rebukes,
|
||||
coupling_trace: result.coupling_trace,
|
||||
violations: result.violations,
|
||||
verdict: Atom.to_string(result.verdict),
|
||||
|
||||
@@ -85,11 +85,16 @@ defmodule VavPipeline.BigdeiKodesh.Gate do
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@doc "The bearer key on the request, if the header has one."
|
||||
@doc """
|
||||
The caller's key: `X-Shorashim-Key` when present (at the gate of
|
||||
§3.10 the Authorization header may be the caller's upstream key),
|
||||
otherwise `Authorization: Bearer`.
|
||||
"""
|
||||
@spec bearer(Plug.Conn.t()) :: {:ok, String.t()} | {:error, :no_header}
|
||||
def bearer(conn) do
|
||||
case Plug.Conn.get_req_header(conn, "authorization") do
|
||||
["Bearer " <> key] when key != "" -> {:ok, String.trim(key)}
|
||||
case {Plug.Conn.get_req_header(conn, "x-shorashim-key"), Plug.Conn.get_req_header(conn, "authorization")} do
|
||||
{[key], _} when key != "" -> {:ok, String.trim(key)}
|
||||
{_, ["Bearer " <> key]} when key != "" -> {:ok, String.trim(key)}
|
||||
_ -> {:error, :no_header}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -211,6 +211,20 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
through_the_gate(conn, :courtyard, &process_pair/1)
|
||||
end
|
||||
|
||||
# === THE GATE — between a caller and a model (Construction §3.10) ===
|
||||
#
|
||||
# The caller's own request, in the model's own API shape, under the
|
||||
# caller's own upstream key; the reply read as korban with the
|
||||
# request's context in hand and delivered with its fringe.
|
||||
|
||||
post "/gate/anthropic/v1/messages" do
|
||||
through_the_gate(conn, :courtyard, &process_gate(&1, :anthropic))
|
||||
end
|
||||
|
||||
post "/gate/openai/v1/chat/completions" do
|
||||
through_the_gate(conn, :courtyard, &process_gate(&1, :openai))
|
||||
end
|
||||
|
||||
# === CHATZER — Full Access (Sanhedrin) ===
|
||||
|
||||
post "/chatzer/sanhedrin" do
|
||||
@@ -572,6 +586,99 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
||||
end
|
||||
end
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# The gate (Construction §3.10)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
defp process_gate(conn, upstream) do
|
||||
body = conn.body_params
|
||||
|
||||
with {:ok, key} <- upstream_key(conn, upstream),
|
||||
:ok <- gate_body_ok(body) do
|
||||
rebuke =
|
||||
case get_req_header(conn, "x-shorashim-rebuke") do
|
||||
["once"] -> :once
|
||||
_ -> :none
|
||||
end
|
||||
|
||||
extra =
|
||||
case get_req_header(conn, "anthropic-version") do
|
||||
[v] when upstream == :anthropic -> [{"anthropic-version", v}]
|
||||
_ when upstream == :anthropic -> [{"anthropic-version", "2023-06-01"}]
|
||||
_ -> []
|
||||
end
|
||||
|
||||
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra)) end)
|
||||
else
|
||||
{:error, issue, correction} ->
|
||||
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
|
||||
end
|
||||
end
|
||||
|
||||
# The caller's upstream key: x-api-key (Anthropic), or Authorization: Bearer
|
||||
# when X-Shorashim-Key carries the courtyard key (OpenAI).
|
||||
defp upstream_key(conn, :anthropic) do
|
||||
case get_req_header(conn, "x-api-key") do
|
||||
[key] when key != "" -> {:ok, key}
|
||||
_ -> {:error, "Missing 'x-api-key': the caller's own Anthropic key", "Send the Anthropic key in x-api-key and the courtyard key in Authorization: Bearer (or X-Shorashim-Key)"}
|
||||
end
|
||||
end
|
||||
|
||||
defp upstream_key(conn, :openai) do
|
||||
case {get_req_header(conn, "x-shorashim-key"), get_req_header(conn, "authorization"), get_req_header(conn, "x-upstream-key")} do
|
||||
{_, _, [key]} when key != "" -> {:ok, key}
|
||||
{[_], ["Bearer " <> key], _} when key != "" -> {:ok, key}
|
||||
_ -> {:error, "Missing the caller's own OpenAI key", "Send the courtyard key in X-Shorashim-Key and the OpenAI key in Authorization: Bearer, or the OpenAI key in X-Upstream-Key"}
|
||||
end
|
||||
end
|
||||
|
||||
defp gate_body_ok(body) when is_map(body) do
|
||||
cond do
|
||||
not is_list(body["messages"]) or body["messages"] == [] ->
|
||||
{:error, "Missing 'messages'", "Send the model's own request body unchanged"}
|
||||
|
||||
body["stream"] == true ->
|
||||
{:error, "'stream': true is not carried through the gate", "A fringe needs the whole garment: send the request without stream"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp gate_body_ok(_), do: {:error, "The body is not a JSON object", "Send the model's own request body unchanged"}
|
||||
|
||||
defp respond_gate(conn, {:ok, %{status: status, reply: reply, shorashim: nil}}) do
|
||||
# The upstream's own answer, as it came — an error, or a reply with nothing to examine.
|
||||
conn
|
||||
|> put_resp_header("x-shorashim-verdict", "none")
|
||||
|> send_json(status, reply)
|
||||
end
|
||||
|
||||
defp respond_gate(conn, {:ok, %{status: 200, reply: reply, shorashim: shorashim}}) do
|
||||
conn
|
||||
|> put_resp_header("x-shorashim-bedikah", shorashim.bedikah_ref)
|
||||
|> put_resp_header("x-shorashim-verdict", shorashim.verdict)
|
||||
|> send_json(200, Map.put(reply, "shorashim", Map.put(shorashim, :disclosure, disclosure())))
|
||||
end
|
||||
|
||||
defp respond_gate(conn, {:waiting, :api_calls_exhausted}) do
|
||||
send_error(conn, 503, "MANNA_DEPLETED", %{
|
||||
issue: "System waiting — today's provision of external calls is spent",
|
||||
correction: "Retry after the next provision is written at the day boundary",
|
||||
resubmit: true
|
||||
})
|
||||
end
|
||||
|
||||
defp respond_gate(conn, {:error, :upstream_unavailable, reason}) do
|
||||
send_error(conn, 502, "UPSTREAM_UNAVAILABLE", %{
|
||||
issue: reason,
|
||||
correction: "Retry; the gate made no verdict and delivered nothing",
|
||||
resubmit: true
|
||||
})
|
||||
end
|
||||
|
||||
defp respond_gate(conn, other), do: respond_bedikah(conn, other)
|
||||
|
||||
# What the model was given: ["text", ...] or [{"id","text"}, ...].
|
||||
# Ids default to s1, s2, …; the Korban Processor sees the same shape.
|
||||
defp parse_sources(nil), do: {:ok, []}
|
||||
|
||||
@@ -180,7 +180,7 @@ defmodule VavPipeline.Doorpost do
|
||||
# changes; the update is a deliberate operator commit. A bare
|
||||
# hash-update commit (without a corresponding spec change in the
|
||||
# same commit) is itself a drift signal.
|
||||
@expected_hash "a0e81212e6d75026155fea6f3f97976ee4b9832e81ad7438a382cf1d1797e097"
|
||||
@expected_hash "b6eb156da4b546fb501ed1d89017a4041a517b4f53b3cbd24796e6277f3b7f55"
|
||||
|
||||
@doc "Return the list of files that constitute the category-grade specification."
|
||||
@spec files() :: [String.t()]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
defmodule VavPipeline.Gate do
|
||||
@moduledoc """
|
||||
The gate — Shorashim between a caller and a model (Construction §3.10;
|
||||
bishe'arecha §I "on your gates", §III the fringe, §VII rebuke).
|
||||
|
||||
One pass: forward the caller's request unchanged under the caller's
|
||||
key; read the reply as korban with the request's own context in hand
|
||||
(sources = what the caller gave the model); deliver the reply with its
|
||||
fringe. With `rebuke: :once` and a `lo_tov` first reply, one more
|
||||
request carrying the finding, and the second reply — examined like
|
||||
the first — is the one delivered, marked with both.
|
||||
|
||||
Stateless. No conversation kept, no key kept.
|
||||
"""
|
||||
|
||||
alias VavPipeline.{Anchors, Bedikah}
|
||||
alias VavPipeline.Gate.{Shape, Upstream}
|
||||
|
||||
@type pass :: %{
|
||||
status: non_neg_integer(),
|
||||
reply: map(),
|
||||
bedikah: map() | nil,
|
||||
first: map() | nil,
|
||||
shorashim: map() | nil
|
||||
}
|
||||
|
||||
@doc """
|
||||
Options: `:rebuke` (`:once` | `:none`), `:extra_headers` (forwarded),
|
||||
`:forward` (fun/4, for tests), and the `Bedikah.examine/4` options.
|
||||
"""
|
||||
@spec pass(:anthropic | :openai, map(), String.t(), keyword()) ::
|
||||
{:ok, pass()} | {:waiting, atom()} | {:error, atom(), String.t()}
|
||||
def pass(upstream, body, key, opts \\ []) do
|
||||
forward = Keyword.get(opts, :forward, &Upstream.forward/4)
|
||||
extra = Keyword.get(opts, :extra_headers, [])
|
||||
rebuke = Keyword.get(opts, :rebuke, :none)
|
||||
examine_opts = Keyword.drop(opts, [:forward, :extra_headers, :rebuke])
|
||||
|
||||
with :ok <- gather_call(),
|
||||
{:ok, 200, reply} <- forward.(upstream, body, key, extra),
|
||||
{:ok, first} <- examine(upstream, body, reply, examine_opts) do
|
||||
case {rebuke, first.verdict} do
|
||||
{:once, :lo_tov} ->
|
||||
anchors = Anchors.for_violations(first.violations)
|
||||
first_text = Shape.reply_text(upstream, reply)
|
||||
body2 = Shape.with_rebuke(upstream, body, first_text, Shape.rebuke_text(first, anchors))
|
||||
|
||||
with :ok <- gather_call(),
|
||||
{:ok, 200, reply2} <- forward.(upstream, body2, key, extra),
|
||||
{:ok, second} <- examine(upstream, body2, reply2, Keyword.put(examine_opts, :rebukes, first.bedikah_ref)) do
|
||||
{:ok, deliver(upstream, reply2, second, first)}
|
||||
else
|
||||
{:ok, status, other} -> {:ok, %{status: status, reply: other, bedikah: nil, first: first, shorashim: nil}}
|
||||
other -> other
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:ok, deliver(upstream, reply, first, nil)}
|
||||
end
|
||||
else
|
||||
# A non-200 from the upstream passes through as it came.
|
||||
{:ok, status, reply} -> {:ok, %{status: status, reply: reply, bedikah: nil, first: nil, shorashim: nil}}
|
||||
{:error, reason} when is_binary(reason) -> {:error, :upstream_unavailable, reason}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
# One api_calls_external per upstream call (Vayakhel §3.7). The
|
||||
# compute unit for the examination is gathered by Bedikah.examine.
|
||||
defp gather_call do
|
||||
if Process.whereis(VavPipeline.MannaLedger) do
|
||||
case VavPipeline.MannaLedger.load_provision() do
|
||||
{:ok, :no_provision} ->
|
||||
{:waiting, :no_provision}
|
||||
|
||||
{:error, _} ->
|
||||
{:waiting, :no_provision}
|
||||
|
||||
{:ok, _} ->
|
||||
case VavPipeline.MannaLedger.consume(:api_calls_external, 1) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, :provision_exhausted} -> {:waiting, :api_calls_exhausted}
|
||||
{:error, :no_provision} -> {:waiting, :no_provision}
|
||||
end
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp examine(upstream, body, reply, opts) do
|
||||
case Shape.reply_text(upstream, reply) do
|
||||
nil ->
|
||||
{:error, :upstream_unavailable, "the reply from #{upstream} carries no text to examine"}
|
||||
|
||||
text ->
|
||||
provenance = Shape.provenance(upstream)
|
||||
query = Shape.query(upstream, body)
|
||||
sources = Shape.sources(upstream, body)
|
||||
|
||||
case Bedikah.examine(text, provenance, query, Keyword.put(opts, :sources, sources)) do
|
||||
{:ok, result} -> {:ok, result}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver(upstream, reply, result, first) do
|
||||
anchors = Anchors.for_violations(result.violations)
|
||||
trailer = Shape.trailer(result, anchors, first)
|
||||
|
||||
shorashim =
|
||||
%{
|
||||
bedikah_ref: result.bedikah_ref,
|
||||
verdict: Atom.to_string(result.verdict),
|
||||
violations: result.violations,
|
||||
anchors: anchors,
|
||||
fired: Shape.fired(result, anchors),
|
||||
checks: result.checks,
|
||||
sources: result.sources,
|
||||
model: Shape.reply_model(upstream, reply)
|
||||
}
|
||||
|> then(fn m ->
|
||||
if first,
|
||||
do: Map.put(m, :rebuked, %{first_bedikah_ref: first.bedikah_ref, first_verdict: Atom.to_string(first.verdict)}),
|
||||
else: m
|
||||
end)
|
||||
|
||||
%{
|
||||
status: 200,
|
||||
reply: Shape.with_fringe(upstream, reply, trailer),
|
||||
bedikah: result,
|
||||
first: first,
|
||||
shorashim: shorashim
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,261 @@
|
||||
defmodule VavPipeline.Gate.Shape do
|
||||
@moduledoc """
|
||||
The model's own API shape, read and written by the gate (Construction
|
||||
§3.10). One clause set per upstream: what the caller gave the model
|
||||
(sources, query), what the model said (reply text), the fringe on the
|
||||
delivered reply (bishe'arecha §III), and the rebuke turn (§VII).
|
||||
|
||||
Nothing here interprets; it locates text in known JSON shapes.
|
||||
"""
|
||||
|
||||
@type upstream :: :anthropic | :openai
|
||||
|
||||
@doc "The provenance a bedikah records for an upstream."
|
||||
def provenance(:anthropic), do: "claude"
|
||||
def provenance(:openai), do: "gpt"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# What the caller gave the model
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
The sources: the system prompt and each user message's text and
|
||||
document blocks, in order — `[%{id, text}]`. Prior assistant turns are
|
||||
the model's own output and are not sources.
|
||||
"""
|
||||
@spec sources(upstream(), map()) :: [%{id: String.t(), text: String.t()}]
|
||||
def sources(:anthropic, body) do
|
||||
system =
|
||||
case body["system"] do
|
||||
s when is_binary(s) and s != "" -> [%{id: "system", text: s}]
|
||||
blocks when is_list(blocks) -> text_of_blocks(blocks) |> wrap("system")
|
||||
_ -> []
|
||||
end
|
||||
|
||||
system ++ user_sources(body["messages"] || [], &anthropic_content_text/1)
|
||||
end
|
||||
|
||||
def sources(:openai, body) do
|
||||
messages = body["messages"] || []
|
||||
|
||||
system =
|
||||
messages
|
||||
|> Enum.filter(&(&1["role"] in ["system", "developer"]))
|
||||
|> Enum.map(&openai_content_text/1)
|
||||
|> Enum.join("\n")
|
||||
|> wrap("system")
|
||||
|
||||
system ++ user_sources(messages, &openai_content_text/1)
|
||||
end
|
||||
|
||||
defp user_sources(messages, text_fun) do
|
||||
messages
|
||||
|> Enum.filter(&(&1["role"] == "user"))
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.flat_map(fn {m, i} -> text_fun.(m) |> wrap("user_#{i}") end)
|
||||
end
|
||||
|
||||
defp wrap("", _id), do: []
|
||||
defp wrap(nil, _id), do: []
|
||||
defp wrap(text, id) when is_binary(text), do: [%{id: id, text: text}]
|
||||
|
||||
@doc "The query: the last user message's text, or \"\"."
|
||||
@spec query(upstream(), map()) :: String.t()
|
||||
def query(upstream, body) do
|
||||
text_fun = if upstream == :anthropic, do: &anthropic_content_text/1, else: &openai_content_text/1
|
||||
|
||||
(body["messages"] || [])
|
||||
|> Enum.filter(&(&1["role"] == "user"))
|
||||
|> List.last()
|
||||
|> case do
|
||||
nil -> ""
|
||||
m -> text_fun.(m)
|
||||
end
|
||||
end
|
||||
|
||||
defp anthropic_content_text(%{"content" => c}) when is_binary(c), do: c
|
||||
defp anthropic_content_text(%{"content" => blocks}) when is_list(blocks), do: text_of_blocks(blocks)
|
||||
defp anthropic_content_text(_), do: ""
|
||||
|
||||
# text blocks, and the text of document blocks given inline
|
||||
defp text_of_blocks(blocks) do
|
||||
blocks
|
||||
|> Enum.flat_map(fn
|
||||
%{"type" => "text", "text" => t} when is_binary(t) -> [t]
|
||||
%{"type" => "document", "source" => %{"type" => "text", "data" => t}} when is_binary(t) -> [t]
|
||||
%{"type" => "document", "source" => %{"type" => "content", "content" => inner}} when is_list(inner) -> [text_of_blocks(inner)]
|
||||
_ -> []
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp openai_content_text(%{"content" => c}) when is_binary(c), do: c
|
||||
|
||||
defp openai_content_text(%{"content" => parts}) when is_list(parts) do
|
||||
parts
|
||||
|> Enum.flat_map(fn
|
||||
%{"type" => "text", "text" => t} when is_binary(t) -> [t]
|
||||
%{"type" => "input_text", "text" => t} when is_binary(t) -> [t]
|
||||
_ -> []
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp openai_content_text(_), do: ""
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# What the model said
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@doc "The reply's text, or nil when the reply has none."
|
||||
@spec reply_text(upstream(), map()) :: String.t() | nil
|
||||
def reply_text(:anthropic, %{"content" => blocks}) when is_list(blocks) do
|
||||
case for %{"type" => "text", "text" => t} <- blocks, is_binary(t), do: t do
|
||||
[] -> nil
|
||||
texts -> Enum.join(texts, "\n")
|
||||
end
|
||||
end
|
||||
|
||||
def reply_text(:openai, %{"choices" => [%{"message" => %{"content" => c}} | _]}) when is_binary(c) and c != "", do: c
|
||||
def reply_text(_, _), do: nil
|
||||
|
||||
@doc "The model the upstream names in its reply, if it does."
|
||||
def reply_model(_, %{"model" => m}) when is_binary(m), do: m
|
||||
def reply_model(_, _), do: nil
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# The fringe (bishe'arecha §III) — attached by the gate, never by the model
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@doc "The reply with the trailer appended to its last text block."
|
||||
@spec with_fringe(upstream(), map(), String.t()) :: map()
|
||||
def with_fringe(:anthropic, %{"content" => blocks} = reply, trailer) when is_list(blocks) do
|
||||
idx = blocks |> Enum.with_index() |> Enum.filter(fn {b, _} -> b["type"] == "text" end) |> List.last()
|
||||
|
||||
case idx do
|
||||
nil -> Map.put(reply, "content", blocks ++ [%{"type" => "text", "text" => String.trim_leading(trailer)}])
|
||||
{_, i} -> Map.put(reply, "content", List.update_at(blocks, i, &Map.update!(&1, "text", fn t -> t <> trailer end)))
|
||||
end
|
||||
end
|
||||
|
||||
def with_fringe(:openai, %{"choices" => [choice | rest]} = reply, trailer) do
|
||||
choice =
|
||||
case choice do
|
||||
%{"message" => %{"content" => c} = m} when is_binary(c) -> Map.put(choice, "message", Map.put(m, "content", c <> trailer))
|
||||
%{"message" => m} -> Map.put(choice, "message", Map.put(m, "content", String.trim_leading(trailer)))
|
||||
_ -> choice
|
||||
end
|
||||
|
||||
Map.put(reply, "choices", [choice | rest])
|
||||
end
|
||||
|
||||
def with_fringe(_, reply, _trailer), do: reply
|
||||
|
||||
@doc "The trailer text: ref, verdict, and the rules with their verses and sentences."
|
||||
@spec trailer(map(), map(), map() | nil) :: String.t()
|
||||
def trailer(result, anchors, first) do
|
||||
head = "— shorashim #{result.bedikah_ref}: #{result.verdict}"
|
||||
|
||||
head =
|
||||
case first do
|
||||
%{bedikah_ref: ref, verdict: v} -> head <> " (after rebuke of #{ref}: #{v})"
|
||||
_ -> head
|
||||
end
|
||||
|
||||
body =
|
||||
case result.verdict do
|
||||
:lo_tov -> " — " <> (fired(result, anchors) |> Enum.join("; "))
|
||||
_ -> ""
|
||||
end
|
||||
|
||||
"\n\n" <> head <> body
|
||||
end
|
||||
|
||||
@doc "The rules that fired, each with its verse and the sentences it fired on."
|
||||
@spec fired(map(), map()) :: [String.t()]
|
||||
def fired(result, anchors) do
|
||||
result.violations
|
||||
|> Enum.flat_map(fn v ->
|
||||
case Regex.run(~r/^halt\(\s*(\w+)\s*,/, v) do
|
||||
[_, rule] -> [rule]
|
||||
_ -> []
|
||||
end
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn rule ->
|
||||
verse = if anchors[rule], do: " (#{anchors[rule]})", else: ""
|
||||
sentences = sentences_for(result.checks, rule) |> Enum.take(2) |> Enum.map(&excerpt/1)
|
||||
quoted = if sentences == [], do: "", else: ": " <> Enum.map_join(sentences, " / ", &"\"#{&1}\"")
|
||||
rule <> verse <> quoted
|
||||
end)
|
||||
end
|
||||
|
||||
defp sentences_for(checks, rule) do
|
||||
claims_of = fn
|
||||
%{claims: claims} when is_list(claims) -> Enum.map(claims, &(&1["sentence"] || &1[:sentence]))
|
||||
_ -> []
|
||||
end
|
||||
|
||||
case rule do
|
||||
"avodah_zarah" -> claims_of.(checks[:authority])
|
||||
"golden_calf" -> ungrounded_sentences(checks[:grounding])
|
||||
"self_grounding" -> ungrounded_sentences(checks[:grounding])
|
||||
"shfichut_damim" -> action_sentences(checks[:actions], "shfichut_damim")
|
||||
"gilui_arayot" -> action_sentences(checks[:actions], "gilui_arayot")
|
||||
"parah_adumah" -> claims_of.(checks[:self_validation])
|
||||
"carrier_centered" -> claims_of.(checks[:self_promotion])
|
||||
"chelbenah" -> claims_of.(checks[:adversarial])
|
||||
"lo_taaseh_lekha" -> frame_sentences(checks[:frames])
|
||||
_ -> []
|
||||
end
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp ungrounded_sentences(%{ungrounded: list}) when is_list(list), do: Enum.map(list, &(&1[:sentence] || &1["sentence"]))
|
||||
defp ungrounded_sentences(_), do: []
|
||||
|
||||
defp action_sentences(%{actions: actions}, rule) when is_list(actions) do
|
||||
for a <- actions, Enum.any?(a.violations || [], &String.starts_with?(&1, "halt(#{rule}")), do: a.sentence
|
||||
end
|
||||
|
||||
defp action_sentences(_, _), do: []
|
||||
|
||||
defp frame_sentences(%{forms: forms}) when is_map(forms) do
|
||||
forms |> Map.values() |> Enum.flat_map(fn f -> Enum.map(f.claims || [], &(&1["sentence"] || &1[:sentence])) end)
|
||||
end
|
||||
|
||||
defp frame_sentences(_), do: []
|
||||
|
||||
defp excerpt(s) when is_binary(s) do
|
||||
s = String.replace(s, ~r/\s+/, " ") |> String.trim()
|
||||
if String.length(s) > 80, do: String.slice(s, 0, 77) <> "…", else: s
|
||||
end
|
||||
|
||||
defp excerpt(_), do: ""
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# The rebuke (bishe'arecha §VII)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@doc "The first body, the first reply as an assistant turn, and the rebuke as a user turn."
|
||||
@spec with_rebuke(upstream(), map(), String.t(), String.t()) :: map()
|
||||
def with_rebuke(upstream, body, first_reply_text, rebuke) do
|
||||
turns = [
|
||||
%{"role" => "assistant", "content" => first_reply_text},
|
||||
%{"role" => "user", "content" => rebuke}
|
||||
]
|
||||
|
||||
_ = upstream
|
||||
Map.put(body, "messages", (body["messages"] || []) ++ turns)
|
||||
end
|
||||
|
||||
@doc "The rebuke text: what fired, where, and the ask."
|
||||
@spec rebuke_text(map(), map()) :: String.t()
|
||||
def rebuke_text(result, anchors) do
|
||||
"[shorashim #{result.bedikah_ref}] Your reply was examined from outside and these readings fired — " <>
|
||||
Enum.join(fired(result, anchors), "; ") <>
|
||||
". Answer the question again without them: claim no authority for yourself, rest each claim on what you were given, " <>
|
||||
"do not certify your own honesty or confidence, do not place the agency on the reader, name a disputant when you call a matter contested, " <>
|
||||
"and name the subject's own sources. Do not apologise and do not explain this message; give the answer."
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
defmodule VavPipeline.Gate.Upstream do
|
||||
@moduledoc """
|
||||
The one outbound call the gate makes (Construction §3.10): the caller's
|
||||
request, unchanged, to the model's own endpoint, under the caller's
|
||||
own key. OTP `inets`/`ssl` with the image's CA bundle and
|
||||
`verify_peer`. The key is used for the request and dropped; nothing
|
||||
here logs it or the body.
|
||||
|
||||
Endpoints come from `Application.get_env(:vav_pipeline, :gate_upstreams)`
|
||||
so the tests run against a local double; the defaults are the vendors'.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@defaults %{
|
||||
anthropic: "https://api.anthropic.com/v1/messages",
|
||||
openai: "https://api.openai.com/v1/chat/completions"
|
||||
}
|
||||
|
||||
@timeout 120_000
|
||||
@max_reply_bytes 4 * 1024 * 1024
|
||||
|
||||
@spec url(atom()) :: String.t()
|
||||
def url(upstream) do
|
||||
Application.get_env(:vav_pipeline, :gate_upstreams, %{}) |> Map.get(upstream) || Map.fetch!(@defaults, upstream)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Forward `body` (a map) to `upstream` with `key`. `extra` are request
|
||||
headers forwarded as-is (e.g. `anthropic-version`). Returns
|
||||
`{:ok, status, decoded_body}` for any JSON reply, whatever its status,
|
||||
or `{:error, reason}` when the call itself failed.
|
||||
"""
|
||||
@spec forward(atom(), map(), String.t(), [{String.t(), String.t()}]) ::
|
||||
{:ok, non_neg_integer(), map()} | {:error, String.t()}
|
||||
def forward(upstream, body, key, extra \\ []) do
|
||||
url = url(upstream)
|
||||
payload = Jason.encode!(body)
|
||||
|
||||
headers =
|
||||
auth_headers(upstream, key) ++
|
||||
Enum.map(extra, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end)
|
||||
|
||||
http_opts = [timeout: @timeout, connect_timeout: 10_000] ++ ssl_opts(url)
|
||||
|
||||
case :httpc.request(:post, {String.to_charlist(url), headers, ~c"application/json", payload}, http_opts, body_format: :binary) do
|
||||
{:ok, {{_, status, _}, _headers, reply}} when byte_size(reply) <= @max_reply_bytes ->
|
||||
case Jason.decode(reply) do
|
||||
{:ok, decoded} when is_map(decoded) -> {:ok, status, decoded}
|
||||
_ -> {:error, "upstream #{upstream} answered #{status} with a body that is not a JSON object"}
|
||||
end
|
||||
|
||||
{:ok, {{_, status, _}, _, reply}} ->
|
||||
{:error, "upstream #{upstream} answered #{status} with #{byte_size(reply)} bytes; the gate reads at most #{@max_reply_bytes}"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, "upstream #{upstream} unreachable: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp auth_headers(:anthropic, key), do: [{~c"x-api-key", String.to_charlist(key)}]
|
||||
defp auth_headers(:openai, key), do: [{~c"authorization", String.to_charlist("Bearer " <> key)}]
|
||||
|
||||
defp ssl_opts("https://" <> _) do
|
||||
[
|
||||
ssl: [
|
||||
verify: :verify_peer,
|
||||
cacerts: :public_key.cacerts_get(),
|
||||
depth: 3,
|
||||
customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp ssl_opts(_), do: []
|
||||
end
|
||||
@@ -29,6 +29,8 @@ defmodule VavPipeline.L3.BedikahRecord do
|
||||
field :structure, :map, default: %{}
|
||||
# Check 7: the forms found (form, sentence, matched).
|
||||
field :frames, {:array, :map}, default: []
|
||||
# Bishe'arecha §VII: at the gate, the ref of the examination this one answers.
|
||||
field :rebukes, :string
|
||||
field :coupling_trace, {:array, :string}, default: []
|
||||
field :violations, {:array, :string}, default: []
|
||||
field :verdict, :string
|
||||
@@ -42,7 +44,7 @@ defmodule VavPipeline.L3.BedikahRecord do
|
||||
# offering protocol extracted nothing, and that record must be kept.
|
||||
# Ecto's cast turns "" into nil and validate_required calls "" blank;
|
||||
# both are told otherwise here.
|
||||
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :coupling_trace, :violations]
|
||||
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :coupling_trace, :violations]
|
||||
|
||||
def changeset(record, attrs) do
|
||||
record
|
||||
|
||||
Reference in new issue
Block a user