1107 lines
46 KiB
Python
1107 lines
46 KiB
Python
"""Run a sequential Qwen Ink generation prototype.
|
|
|
|
Pass 1: generate Maer choices from Maer-local awareness only.
|
|
Pass 2: generate participant appraisal/consolidation for the selected
|
|
observable event.
|
|
Pass 3: generate the next actor's action from updated local state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from project_local_context import build_projected_context
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_ENDPOINT = "http://192.168.1.84:11434/api/chat"
|
|
DEFAULT_FIXTURE = ROOT / "examples" / "agent-state.cold-wake-story-lab.json"
|
|
DEFAULT_ANNOTATION = ROOT / "examples" / "ink" / "cold-wake-sanctuary-intake.training.json"
|
|
DEFAULT_OUT_DIR = ROOT / "experiments" / "ink"
|
|
VALID_ACTION_TYPES = {
|
|
"speak",
|
|
"silence",
|
|
"move",
|
|
"gesture",
|
|
"touch_object",
|
|
"block_object",
|
|
"use_object",
|
|
"show_object",
|
|
"withhold_object",
|
|
"transfer_object",
|
|
"spend_resource",
|
|
"attack",
|
|
"wait",
|
|
"mixed",
|
|
}
|
|
VALID_RELATIONSHIP_IDS = {"rel-sella-maer", "rel-maer-sella"}
|
|
VALID_RELATIONSHIP_DELTA_PATHS = {
|
|
"trust",
|
|
"respect",
|
|
"resentment",
|
|
"dependence",
|
|
"fear",
|
|
"fascination",
|
|
"obligation",
|
|
"envy",
|
|
"moral_disgust",
|
|
"perceived_status_gap",
|
|
"expectation_of_care",
|
|
"expectation_of_betrayal",
|
|
}
|
|
VALID_SELLA_STATE_DELTA_PATHS = {
|
|
"canonical_state.behavioral_dimensions.control_pressure.current_activation",
|
|
"canonical_state.behavioral_dimensions.suspicion.current_activation",
|
|
"canonical_state.behavioral_dimensions.anxiety.current_activation",
|
|
"canonical_state.behavioral_dimensions.withdrawal.current_activation",
|
|
"canonical_state.behavioral_dimensions.attachment_seeking.current_activation",
|
|
"canonical_state.behavioral_dimensions.interpersonal_warmth.current_activation",
|
|
"canonical_state.situational_state.scarcity_pressure.current_activation",
|
|
"canonical_state.situational_state.exhaustion.current_activation",
|
|
"canonical_state.situational_state.overstimulation.current_activation",
|
|
"canonical_state.presentation_strategy.competence_theater.current_activation",
|
|
"canonical_state.presentation_strategy.moral_theater.current_activation",
|
|
}
|
|
|
|
|
|
def string_array_schema(description: str) -> dict[str, Any]:
|
|
return {"type": "array", "description": description, "items": {"type": "string"}}
|
|
|
|
|
|
def action_type_schema() -> dict[str, Any]:
|
|
return {"type": "string", "enum": sorted(VALID_ACTION_TYPES)}
|
|
|
|
|
|
def maer_choices_tool() -> dict[str, Any]:
|
|
choice_schema = {
|
|
"type": "object",
|
|
"required": [
|
|
"branch_id",
|
|
"ink_path",
|
|
"choice_text",
|
|
"action_type",
|
|
"actor_intent_private",
|
|
"observable_action",
|
|
"spoken_text",
|
|
"state_basis",
|
|
"expected_consequence_surfaces",
|
|
"training_hooks",
|
|
],
|
|
"properties": {
|
|
"branch_id": {"type": "string"},
|
|
"ink_path": {"type": "string"},
|
|
"choice_text": {"type": "string"},
|
|
"action_type": action_type_schema(),
|
|
"actor_intent_private": {"type": "string"},
|
|
"observable_action": {"type": "string"},
|
|
"spoken_text": {"type": "string"},
|
|
"state_basis": {"type": "string"},
|
|
"expected_consequence_surfaces": string_array_schema("Possible effects if this choice is selected."),
|
|
"training_hooks": string_array_schema("Semantic labels, not branch ids."),
|
|
},
|
|
}
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "record_maer_choices",
|
|
"description": "Record four Maer-local candidate choices for the current scene.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"required": ["choices"],
|
|
"properties": {
|
|
"choices": {
|
|
"type": "array",
|
|
"minItems": 4,
|
|
"maxItems": 4,
|
|
"items": choice_schema,
|
|
}
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def sella_appraisal_tool() -> dict[str, Any]:
|
|
delta_schema = {
|
|
"type": "object",
|
|
"required": ["path", "delta", "rationale"],
|
|
"properties": {
|
|
"path": {"type": "string", "enum": sorted(VALID_SELLA_STATE_DELTA_PATHS)},
|
|
"delta": {"type": "number"},
|
|
"rationale": {"type": "string"},
|
|
},
|
|
}
|
|
relationship_delta_schema = {
|
|
"type": "object",
|
|
"required": ["relationship_id", "path", "delta", "rationale"],
|
|
"properties": {
|
|
"relationship_id": {"type": "string", "enum": ["rel-sella-maer"]},
|
|
"path": {"type": "string", "enum": sorted(VALID_RELATIONSHIP_DELTA_PATHS)},
|
|
"delta": {"type": "number"},
|
|
"rationale": {"type": "string"},
|
|
},
|
|
}
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "record_sella_appraisal",
|
|
"description": "Record Sella's participant appraisal and proposed state consolidation for the observed Maer event.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"required": [
|
|
"responder_agent_id",
|
|
"private_interpretation",
|
|
"attributed_motive",
|
|
"misread_risk",
|
|
"immediate_state_deltas",
|
|
"relationship_deltas",
|
|
"belief_updates",
|
|
"response_constraints",
|
|
],
|
|
"properties": {
|
|
"responder_agent_id": {"type": "string", "enum": ["sella_ren"]},
|
|
"private_interpretation": {"type": "string"},
|
|
"attributed_motive": {"type": "string"},
|
|
"misread_risk": {"type": "string"},
|
|
"immediate_state_deltas": {"type": "array", "items": delta_schema},
|
|
"relationship_deltas": {"type": "array", "items": relationship_delta_schema},
|
|
"belief_updates": string_array_schema("Belief changes or reinforced beliefs caused by the event."),
|
|
"response_constraints": string_array_schema("Constraints that should shape the next action."),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def sella_next_action_tool() -> dict[str, Any]:
|
|
response_schema = {
|
|
"type": "object",
|
|
"required": [
|
|
"responder_agent_id",
|
|
"action_type",
|
|
"observable_response",
|
|
"spoken_text",
|
|
"sella_private_interpretation",
|
|
"sella_attributed_motive",
|
|
"misread_risk",
|
|
"state_basis",
|
|
"reviewed_consequences",
|
|
"training_hooks",
|
|
],
|
|
"properties": {
|
|
"responder_agent_id": {"type": "string", "enum": ["sella_ren"]},
|
|
"action_type": action_type_schema(),
|
|
"observable_response": {"type": "string"},
|
|
"spoken_text": {"type": "string"},
|
|
"sella_private_interpretation": {"type": "string"},
|
|
"sella_attributed_motive": {"type": "string"},
|
|
"misread_risk": {"type": "string"},
|
|
"state_basis": {"type": "string"},
|
|
"reviewed_consequences": {"type": "string"},
|
|
"training_hooks": string_array_schema("Semantic labels for training."),
|
|
},
|
|
}
|
|
return {
|
|
"type": "function",
|
|
"function": {
|
|
"name": "record_sella_next_action",
|
|
"description": "Record Sella's next action from updated local state.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"required": ["response"],
|
|
"properties": {"response": response_schema},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def load_json(path: Path) -> Any:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_text(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
|
|
|
|
def write_json(path: Path, payload: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def find_agent(fixture: dict[str, Any], agent_id: str) -> dict[str, Any]:
|
|
return next(agent for agent in fixture["agents"] if agent["agent_id"] == agent_id)
|
|
|
|
|
|
def find_scene(fixture: dict[str, Any], scene_id: str) -> dict[str, Any]:
|
|
return next(scene for scene in fixture["scenes"] if scene["scene_id"] == scene_id)
|
|
|
|
|
|
def find_pack(scene: dict[str, Any], speaker_id: str) -> dict[str, Any]:
|
|
return next(pack for pack in scene["dialogue_context_packs"] if pack["speaker_agent_id"] == speaker_id)
|
|
|
|
|
|
def find_relationship(fixture: dict[str, Any], source_id: str, target_id: str) -> dict[str, Any]:
|
|
return next(rel for rel in fixture["relationships"] if rel["source_id"] == source_id and rel["target_id"] == target_id)
|
|
|
|
|
|
def memory_summaries(agent: dict[str, Any], memory_ids: list[str]) -> list[str]:
|
|
summaries: list[str] = []
|
|
for group in agent["memories"].values():
|
|
for memory in group:
|
|
if memory["memory_id"] in memory_ids:
|
|
summaries.append(f"{memory['memory_id']}: {memory['summary']}")
|
|
return summaries
|
|
|
|
|
|
def goal_summaries(agent: dict[str, Any], goal_ids: list[str]) -> list[str]:
|
|
return [
|
|
f"{goal['goal_id']}: {goal['description']} Priority {goal['priority']}. Stake: {goal['emotional_stake']}"
|
|
for goal in agent["goals"]
|
|
if goal["goal_id"] in goal_ids
|
|
]
|
|
|
|
|
|
def post_json(endpoint: str, payload: dict[str, Any], timeout: int) -> dict[str, Any]:
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
endpoint,
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
def extract_json_object(text: str) -> Any | None:
|
|
stripped = text.strip()
|
|
if stripped.startswith("{"):
|
|
return json.loads(stripped)
|
|
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", stripped, re.DOTALL)
|
|
if fenced:
|
|
return json.loads(fenced.group(1))
|
|
start = stripped.find("{")
|
|
end = stripped.rfind("}")
|
|
if start != -1 and end != -1 and end > start:
|
|
return json.loads(stripped[start : end + 1])
|
|
return None
|
|
|
|
|
|
def extract_tool_arguments(raw: dict[str, Any], expected_tool_name: str) -> tuple[Any | None, list[str]]:
|
|
message = raw.get("message", {})
|
|
tool_calls = message.get("tool_calls") or []
|
|
notes: list[str] = []
|
|
if not tool_calls:
|
|
return None, ["model returned no tool_calls"]
|
|
first_call = tool_calls[0]
|
|
function = first_call.get("function", {})
|
|
name = function.get("name")
|
|
if name != expected_tool_name:
|
|
notes.append(f"expected tool {expected_tool_name}, got {name}")
|
|
arguments = function.get("arguments")
|
|
if isinstance(arguments, str):
|
|
try:
|
|
arguments = json.loads(arguments)
|
|
except json.JSONDecodeError as exc:
|
|
return None, notes + [f"tool arguments parse error: {exc}"]
|
|
if not isinstance(arguments, dict):
|
|
return None, notes + ["tool arguments were not an object"]
|
|
arguments, repair_notes = repair_tool_arguments(arguments)
|
|
notes.extend(repair_notes)
|
|
return arguments, notes
|
|
|
|
|
|
def repair_tool_arguments(arguments: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
repaired = dict(arguments)
|
|
notes: list[str] = []
|
|
for key in ["choices", "response", "appraisal"]:
|
|
value = repaired.get(key)
|
|
if isinstance(value, str):
|
|
parsed = parse_jsonish_tool_string(value)
|
|
if parsed is not None:
|
|
repaired[key] = parsed
|
|
notes.append(f"repaired stringified tool argument: {key}")
|
|
else:
|
|
notes.append(f"could not repair stringified tool argument: {key}")
|
|
return repaired, notes
|
|
|
|
|
|
def parse_jsonish_tool_string(value: str) -> Any | None:
|
|
candidate = value.strip()
|
|
candidates = [candidate]
|
|
if candidate.startswith("[") and not candidate.endswith("]"):
|
|
candidates.append(candidate + "]")
|
|
if candidate.startswith("{") and not candidate.endswith("}"):
|
|
candidates.append(candidate + "}")
|
|
if candidate.endswith(","):
|
|
candidates.append(candidate[:-1])
|
|
for item in candidates:
|
|
try:
|
|
return json.loads(item)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def repair_nested_list_fields(payload: Any, fields: list[str]) -> tuple[Any, list[str]]:
|
|
notes: list[str] = []
|
|
if not isinstance(payload, dict):
|
|
return payload, notes
|
|
repaired = dict(payload)
|
|
for field in fields:
|
|
value = repaired.get(field)
|
|
if not isinstance(value, str):
|
|
continue
|
|
candidate = value.strip()
|
|
if candidate.endswith(","):
|
|
candidate = candidate[:-1]
|
|
parsed = parse_jsonish_tool_string(candidate)
|
|
if parsed is None:
|
|
notes.append(f"could not repair stringified nested list field: {field}")
|
|
continue
|
|
if isinstance(parsed, list):
|
|
repaired[field] = parsed
|
|
notes.append(f"repaired stringified nested list field: {field}")
|
|
return repaired, notes
|
|
|
|
|
|
def normalize_appraisal_payload(parsed: Any) -> tuple[Any, list[str]]:
|
|
notes: list[str] = []
|
|
if not isinstance(parsed, dict):
|
|
return parsed, notes
|
|
if isinstance(parsed.get("appraisal"), dict):
|
|
appraisal = parsed["appraisal"]
|
|
elif "responder_agent_id" in parsed:
|
|
appraisal = parsed
|
|
else:
|
|
return parsed, notes
|
|
appraisal, nested_repair_notes = repair_nested_list_fields(
|
|
appraisal,
|
|
["belief_updates", "response_constraints"],
|
|
)
|
|
notes.extend(nested_repair_notes)
|
|
return {"appraisal": appraisal}, notes
|
|
|
|
|
|
def fatal_notes(notes: list[str]) -> list[str]:
|
|
return [note for note in notes if not note.startswith("repaired ")]
|
|
|
|
|
|
def repair_notes(notes: list[str]) -> list[str]:
|
|
return [note for note in notes if note.startswith("repaired ")]
|
|
|
|
|
|
def request_qwen_tool(
|
|
endpoint: str,
|
|
model: str,
|
|
prompt: str,
|
|
tool: dict[str, Any],
|
|
expected_tool_name: str,
|
|
temperature: float,
|
|
top_p: float,
|
|
num_ctx: int,
|
|
think: bool,
|
|
timeout: int,
|
|
) -> tuple[dict[str, Any], str, Any, list[str]]:
|
|
request_payload = {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"tools": [tool],
|
|
"stream": False,
|
|
"think": think,
|
|
"keep_alive": "10m",
|
|
"options": {"num_ctx": num_ctx, "temperature": temperature, "top_p": top_p},
|
|
}
|
|
try:
|
|
raw = post_json(endpoint, request_payload, timeout)
|
|
except Exception as exc:
|
|
raw = {"model": model, "error": repr(exc)}
|
|
response_text = json.dumps(
|
|
{
|
|
"content": "",
|
|
"thinking": None,
|
|
"tool_calls": [],
|
|
"error": repr(exc),
|
|
},
|
|
indent=2,
|
|
ensure_ascii=False,
|
|
)
|
|
return raw, response_text, None, [f"request failed for {expected_tool_name}: {exc!r}"]
|
|
message = raw.get("message", {})
|
|
response_text = json.dumps(
|
|
{
|
|
"content": message.get("content", ""),
|
|
"thinking": message.get("thinking"),
|
|
"tool_calls": message.get("tool_calls", []),
|
|
},
|
|
indent=2,
|
|
ensure_ascii=False,
|
|
)
|
|
parsed, notes = extract_tool_arguments(raw, expected_tool_name)
|
|
return raw, response_text, parsed, notes
|
|
|
|
|
|
def observable_action_payload(actor_id: str, selected_choice: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"actor_id": actor_id,
|
|
"action_type": selected_choice["action_type"],
|
|
"observable_action": selected_choice["observable_action"],
|
|
"spoken_text": selected_choice.get("spoken_text", ""),
|
|
}
|
|
|
|
|
|
def build_maer_prompt(fixture: dict[str, Any], annotation: dict[str, Any]) -> str:
|
|
scene_id = annotation["scene_id"]
|
|
actor_id = annotation["protagonist_agent_id"]
|
|
projected = build_projected_context(fixture, annotation, actor_id, "choice_generation")
|
|
|
|
payload = {
|
|
"task": "Generate candidate player choices for Maer only. Do not write Sella's response.",
|
|
"output_contract": {
|
|
"format": "Call the provided tool exactly once. Do not write plain assistant content.",
|
|
"root_keys": ["choices"],
|
|
"choice_count": 4,
|
|
"valid_action_types": sorted(VALID_ACTION_TYPES),
|
|
"choice_fields": [
|
|
"branch_id",
|
|
"ink_path",
|
|
"choice_text",
|
|
"action_type",
|
|
"actor_intent_private",
|
|
"observable_action",
|
|
"spoken_text",
|
|
"state_basis",
|
|
"expected_consequence_surfaces",
|
|
"training_hooks",
|
|
],
|
|
},
|
|
"hard_rules": [
|
|
"Use only Maer's local awareness and scene-observable facts.",
|
|
"Do not stringify nested arrays or objects inside tool arguments.",
|
|
"Do not include Sella's private state, private motives, or future response.",
|
|
"Do not resolve whether the corrupted packet contains a person.",
|
|
"Maer is a cetacean Navigator: do not describe him standing, walking, stepping, leaning, using hands, using pockets, or blocking a doorway.",
|
|
"Maer's physical actions should use shared clinic systems, mixed wet/dry work surfaces, displays, acoustic or translation systems, pressure fields, haptic waterflow, neural interfaces, or delegated crew action.",
|
|
"Do not describe ledger backing as a property of the packet. The packet can be shown, withheld, summarized, scanned, or rendered through clinic systems; route-ledger backing is a separate resource offer.",
|
|
"Include at least two non-speech choices.",
|
|
f"Use action_type values only from this exact enum: {', '.join(sorted(VALID_ACTION_TYPES))}.",
|
|
"The selected branch must be reducible to observable action for Sella.",
|
|
"Use semantic training hooks, not branch ids.",
|
|
],
|
|
"projected_local_context": projected["context"]["prompt_text"],
|
|
"projector_audit": projected["audit"],
|
|
}
|
|
return (
|
|
"You are Ghostlight's Maer-local choice generator. Call record_maer_choices exactly once.\n"
|
|
"Do not write Sella's response or private interpretation.\n\n"
|
|
+ json.dumps(payload, indent=2, ensure_ascii=False)
|
|
)
|
|
|
|
|
|
def sella_local_payload(fixture: dict[str, Any], annotation: dict[str, Any]) -> dict[str, Any]:
|
|
responder_id = annotation["npc_agent_ids"][0]
|
|
projected = build_projected_context(fixture, annotation, responder_id, "response_generation")
|
|
return projected["context"]["prompt_text"]
|
|
|
|
|
|
def build_sella_appraisal_prompt(fixture: dict[str, Any], annotation: dict[str, Any], selected_choice: dict[str, Any]) -> str:
|
|
scene_id = annotation["scene_id"]
|
|
responder_id = annotation["npc_agent_ids"][0]
|
|
actor_id = annotation["protagonist_agent_id"]
|
|
scene = find_scene(fixture, scene_id)
|
|
event_context = observable_action_payload(actor_id, selected_choice)
|
|
projected = build_projected_context(fixture, annotation, responder_id, "event_appraisal", event_context)
|
|
payload = {
|
|
"task": "Generate Sella's participant appraisal/consolidation for one observable Maer event.",
|
|
"output_contract": {
|
|
"format": "Call the provided tool exactly once. Do not write plain assistant content.",
|
|
"root_keys": ["appraisal"],
|
|
"appraisal_fields": [
|
|
"responder_agent_id",
|
|
"private_interpretation",
|
|
"attributed_motive",
|
|
"misread_risk",
|
|
"immediate_state_deltas",
|
|
"relationship_deltas",
|
|
"belief_updates",
|
|
"response_constraints",
|
|
],
|
|
},
|
|
"hard_rules": [
|
|
"Use only Sella's local awareness plus the observable Maer action.",
|
|
"Do not stringify nested arrays or objects inside tool arguments.",
|
|
"Do not read Maer's private intent. Treat actor intent as unavailable.",
|
|
"Sella may correctly read, partially read, or misread Maer's action.",
|
|
"Do not resolve whether the corrupted packet contains a person.",
|
|
"This pass proposes reviewed state changes for Sella caused by the event. Do not write Sella's next action.",
|
|
"Keep deltas small and bounded between -0.20 and 0.20.",
|
|
"State delta paths must be chosen from the tool schema enum; do not invent abstract resource paths like sanctuary_capacity.",
|
|
"Relationship deltas must use relationship_id rel-sella-maer and a valid stance axis from the tool schema enum.",
|
|
],
|
|
"scene_observable_context": {
|
|
"scene_id": scene_id,
|
|
"location": scene["location"],
|
|
"public_stakes": scene["public_stakes"],
|
|
},
|
|
"observable_maer_action": event_context,
|
|
"projected_local_context": projected["context"]["prompt_text"],
|
|
"projector_audit": projected["audit"],
|
|
}
|
|
return (
|
|
"You are Ghostlight's Sella-local appraisal generator. Call record_sella_appraisal exactly once.\n"
|
|
"Do not use Maer's private intent. Do not write her next action.\n\n"
|
|
+ json.dumps(payload, indent=2, ensure_ascii=False)
|
|
)
|
|
|
|
|
|
def build_sella_prompt(
|
|
fixture: dict[str, Any],
|
|
annotation: dict[str, Any],
|
|
selected_choice: dict[str, Any],
|
|
appraisal: dict[str, Any] | None,
|
|
) -> str:
|
|
scene_id = annotation["scene_id"]
|
|
responder_id = annotation["npc_agent_ids"][0]
|
|
actor_id = annotation["protagonist_agent_id"]
|
|
scene = find_scene(fixture, scene_id)
|
|
event_context = observable_action_payload(actor_id, selected_choice)
|
|
projected = build_projected_context(fixture, annotation, responder_id, "next_action_generation", event_context)
|
|
payload = {
|
|
"task": "Generate Sella's next action from Sella-local awareness after applying reviewed event appraisal.",
|
|
"output_contract": {
|
|
"format": "Call the provided tool exactly once. Do not write plain assistant content.",
|
|
"root_keys": ["response"],
|
|
"valid_action_types": sorted(VALID_ACTION_TYPES),
|
|
"response_fields": [
|
|
"responder_agent_id",
|
|
"action_type",
|
|
"observable_response",
|
|
"spoken_text",
|
|
"sella_private_interpretation",
|
|
"sella_attributed_motive",
|
|
"misread_risk",
|
|
"state_basis",
|
|
"reviewed_consequences",
|
|
"training_hooks",
|
|
],
|
|
},
|
|
"hard_rules": [
|
|
"Use only Sella's local awareness, the observable Maer event, and reviewed Sella state changes from that event.",
|
|
"Do not stringify nested arrays or objects inside tool arguments.",
|
|
"Do not read Maer's private intent. Treat actor intent as unavailable.",
|
|
"Sella may correctly read, partially read, or misread Maer's action.",
|
|
"Do not resolve whether the corrupted packet contains a person.",
|
|
"Do not accept without cost, condition, authority defense, or resentment risk.",
|
|
f"Use action_type values only from this exact enum: {', '.join(sorted(VALID_ACTION_TYPES))}.",
|
|
"Use semantic training hooks, not branch ids.",
|
|
],
|
|
"scene_observable_context": {
|
|
"scene_id": scene_id,
|
|
"location": scene["location"],
|
|
"public_stakes": scene["public_stakes"],
|
|
},
|
|
"observable_maer_action": event_context,
|
|
"sella_immediate_appraisal_context": appraisal_context_for_prompt(appraisal),
|
|
"projected_local_context": projected["context"]["prompt_text"],
|
|
"projector_audit": projected["audit"],
|
|
}
|
|
return (
|
|
"You are Ghostlight's Sella-local next-action generator. Call record_sella_next_action exactly once.\n"
|
|
"Use reviewed event appraisal as the changed state she is acting from.\n\n"
|
|
+ json.dumps(payload, indent=2, ensure_ascii=False)
|
|
)
|
|
|
|
|
|
def appraisal_context_for_prompt(appraisal: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if not isinstance(appraisal, dict):
|
|
return None
|
|
state_change_summary = []
|
|
for delta in appraisal.get("immediate_state_deltas", []):
|
|
if isinstance(delta, dict) and delta.get("rationale"):
|
|
state_change_summary.append(delta["rationale"])
|
|
for delta in appraisal.get("relationship_deltas", []):
|
|
if isinstance(delta, dict) and delta.get("rationale"):
|
|
state_change_summary.append(delta["rationale"])
|
|
return {
|
|
"private_interpretation": appraisal.get("private_interpretation", ""),
|
|
"attributed_motive": appraisal.get("attributed_motive", ""),
|
|
"misread_risk": appraisal.get("misread_risk", ""),
|
|
"belief_updates": appraisal.get("belief_updates", []),
|
|
"response_constraints": appraisal.get("response_constraints", []),
|
|
"state_change_summary": state_change_summary,
|
|
}
|
|
|
|
|
|
def validate_choice_payload(parsed: Any) -> list[str]:
|
|
notes: list[str] = []
|
|
if not isinstance(parsed, dict):
|
|
return ["choice response was not an object"]
|
|
choices = parsed.get("choices")
|
|
if not isinstance(choices, list):
|
|
return ["choice response missing choices array"]
|
|
if len(choices) != 4:
|
|
notes.append(f"expected 4 choices, got {len(choices)}")
|
|
required = ["branch_id", "ink_path", "choice_text", "action_type", "actor_intent_private", "observable_action", "state_basis", "expected_consequence_surfaces", "training_hooks"]
|
|
non_speech = 0
|
|
for index, choice in enumerate(choices):
|
|
if not isinstance(choice, dict):
|
|
notes.append(f"choices[{index}] was not an object")
|
|
continue
|
|
missing = [key for key in required if key not in choice]
|
|
if missing:
|
|
notes.append(f"choices[{index}] missing keys: {', '.join(missing)}")
|
|
if choice.get("action_type") not in VALID_ACTION_TYPES:
|
|
notes.append(f"choices[{index}] invalid action_type")
|
|
for list_key in ["expected_consequence_surfaces", "training_hooks"]:
|
|
if not isinstance(choice.get(list_key), list):
|
|
notes.append(f"choices[{index}] {list_key} must be an array")
|
|
notes.extend(validate_maer_embodiment(choice, f"choices[{index}]"))
|
|
notes.extend(validate_cold_wake_object_semantics(choice, f"choices[{index}]"))
|
|
if choice.get("action_type") != "speak":
|
|
non_speech += 1
|
|
if non_speech < 2:
|
|
notes.append("choices had fewer than two non-speech actions")
|
|
return notes
|
|
|
|
|
|
def validate_maer_embodiment(choice: dict[str, Any], path: str) -> list[str]:
|
|
text = " ".join(str(choice.get(key, "")) for key in ["choice_text", "observable_action", "spoken_text", "state_basis"])
|
|
banned = [
|
|
"hand",
|
|
"hands",
|
|
"pocket",
|
|
"pockets",
|
|
"stand",
|
|
"stands",
|
|
"standing",
|
|
"walk",
|
|
"walks",
|
|
"walking",
|
|
"step",
|
|
"steps",
|
|
"stepping",
|
|
"lean",
|
|
"leans",
|
|
"leaning",
|
|
"doorway",
|
|
"doorframe",
|
|
]
|
|
hits = [word for word in banned if re.search(rf"\b{re.escape(word)}\b", text, re.IGNORECASE)]
|
|
if hits:
|
|
return [f"{path} violates Maer cetacean embodiment constraints: {', '.join(sorted(set(hits)))}"]
|
|
return []
|
|
|
|
|
|
def validate_cold_wake_object_semantics(choice: dict[str, Any], path: str) -> list[str]:
|
|
text = " ".join(str(choice.get(key, "")) for key in ["choice_text", "observable_action", "spoken_text", "state_basis"])
|
|
notes: list[str] = []
|
|
if re.search(r"\bpacket'?s ledger backing\b", text, re.IGNORECASE):
|
|
notes.append(f"{path} conflates packet object custody with route-ledger backing")
|
|
if re.search(r"\bpacket has weight\b|\bpacket's weight\b|\btangible object with potential value\b", text, re.IGNORECASE):
|
|
notes.append(f"{path} treats the ambiguous packet as weighted evidence beyond source constraints")
|
|
if choice.get("action_type") == "show_object" and re.search(r"\bledger backing\b", text, re.IGNORECASE):
|
|
notes.append(f"{path} uses show_object for ledger backing; use spend_resource for backing or show_object for the packet itself")
|
|
return notes
|
|
|
|
|
|
def validate_appraisal_payload(parsed: Any) -> list[str]:
|
|
if not isinstance(parsed, dict):
|
|
return ["appraisal was not an object"]
|
|
appraisal = parsed.get("appraisal")
|
|
if not isinstance(appraisal, dict):
|
|
return ["appraisal missing appraisal object"]
|
|
required = [
|
|
"responder_agent_id",
|
|
"private_interpretation",
|
|
"attributed_motive",
|
|
"misread_risk",
|
|
"immediate_state_deltas",
|
|
"relationship_deltas",
|
|
"response_constraints",
|
|
]
|
|
missing = [key for key in required if key not in appraisal]
|
|
notes = [f"appraisal missing keys: {', '.join(missing)}"] if missing else []
|
|
for list_key in ["immediate_state_deltas", "relationship_deltas", "response_constraints"]:
|
|
if list_key in appraisal and not isinstance(appraisal[list_key], list):
|
|
notes.append(f"appraisal.{list_key} must be an array")
|
|
for index, delta in enumerate(appraisal.get("immediate_state_deltas", [])):
|
|
if not isinstance(delta, dict):
|
|
notes.append(f"appraisal.immediate_state_deltas[{index}] must be an object")
|
|
continue
|
|
path = str(delta.get("path", ""))
|
|
if path not in VALID_SELLA_STATE_DELTA_PATHS:
|
|
notes.append(f"appraisal.immediate_state_deltas[{index}].path is not an allowed Sella state path: {path}")
|
|
for index, delta in enumerate(appraisal.get("relationship_deltas", [])):
|
|
if not isinstance(delta, dict):
|
|
notes.append(f"appraisal.relationship_deltas[{index}] must be an object")
|
|
continue
|
|
relationship_id = str(delta.get("relationship_id", ""))
|
|
path = str(delta.get("path", ""))
|
|
if relationship_id not in VALID_RELATIONSHIP_IDS:
|
|
notes.append(f"appraisal.relationship_deltas[{index}].relationship_id must be a relationship id, got {relationship_id}")
|
|
if path not in VALID_RELATIONSHIP_DELTA_PATHS:
|
|
notes.append(f"appraisal.relationship_deltas[{index}].path is not a relationship stance axis: {path}")
|
|
return notes
|
|
|
|
|
|
def validate_response_payload(parsed: Any) -> list[str]:
|
|
if not isinstance(parsed, dict):
|
|
return ["response was not an object"]
|
|
response = parsed.get("response")
|
|
if not isinstance(response, dict):
|
|
return ["response missing response object"]
|
|
required = ["responder_agent_id", "action_type", "observable_response", "sella_private_interpretation", "misread_risk", "state_basis", "reviewed_consequences", "training_hooks"]
|
|
missing = [key for key in required if key not in response]
|
|
notes = [f"response missing keys: {', '.join(missing)}"] if missing else []
|
|
if response.get("action_type") not in VALID_ACTION_TYPES:
|
|
notes.append("response invalid action_type")
|
|
if "training_hooks" in response and not isinstance(response["training_hooks"], list):
|
|
notes.append("response.training_hooks must be an array")
|
|
notes.extend(validate_response_prompt_leakage(response))
|
|
return notes
|
|
|
|
|
|
def validate_response_prompt_leakage(response: dict[str, Any]) -> list[str]:
|
|
text = " ".join(
|
|
str(response.get(key, ""))
|
|
for key in [
|
|
"observable_response",
|
|
"spoken_text",
|
|
"reviewed_consequences",
|
|
"sella_private_interpretation",
|
|
"state_basis",
|
|
]
|
|
)
|
|
leaked_patterns = [
|
|
r"\bDo not resolve\b",
|
|
r"\bDo not accept\b",
|
|
r"\bDo not let\b",
|
|
r"\bmust not resolve\b",
|
|
r"\bmust not make\b",
|
|
r"\bmust frame the scene\b",
|
|
r"\bmust speak warmly\b",
|
|
r"\bmust treat care\b",
|
|
r"\bSpeak warmly\b",
|
|
r"\bDemand ledger backing\b",
|
|
r"\btreat it as unresolved uncertainty\b",
|
|
r"\bKeep sanctuary intake materially possible\b",
|
|
r"\bSpeak the personhood\b",
|
|
]
|
|
hits = [pattern for pattern in leaked_patterns if re.search(pattern, text, re.IGNORECASE)]
|
|
if hits:
|
|
return [f"response appears to leak prompt constraints into prose: {', '.join(hits)}"]
|
|
return []
|
|
|
|
|
|
def choose_selected_choice(parsed: Any, preferred_action: str) -> dict[str, Any]:
|
|
choices = parsed.get("choices", []) if isinstance(parsed, dict) else []
|
|
for choice in choices:
|
|
if isinstance(choice, dict) and choice.get("action_type") == preferred_action:
|
|
return choice
|
|
for choice in choices:
|
|
if isinstance(choice, dict):
|
|
return choice
|
|
raise SystemExit("No choices available to select")
|
|
|
|
|
|
def write_sequential_artifacts(args: argparse.Namespace, capture: dict[str, Any], prompts: dict[str, str], responses: dict[str, str]) -> Path:
|
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
base = args.capture_id.replace(".", "-")
|
|
capture_path = args.out_dir / f"{base}.capture.json"
|
|
write_json(capture_path, capture)
|
|
for name, text in prompts.items():
|
|
write_text(args.out_dir / f"{base}.{name}.prompt.md", text + "\n")
|
|
for name, text in responses.items():
|
|
write_text(args.out_dir / f"{base}.{name}.response.md", text + "\n")
|
|
return capture_path
|
|
|
|
|
|
def skipped_pass(local_agent_id: str, reason: str) -> dict[str, Any]:
|
|
return {
|
|
"local_agent_id": local_agent_id,
|
|
"prompt_text": f"Skipped: {reason}",
|
|
"response_text": f"Skipped: {reason}",
|
|
"parsed_response": None,
|
|
"validation_notes": [reason],
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Run a sequential local-awareness Qwen Ink prototype.")
|
|
parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE)
|
|
parser.add_argument("--annotation", type=Path, default=DEFAULT_ANNOTATION)
|
|
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR)
|
|
parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
|
|
parser.add_argument("--model", default="qwen3.5:9b")
|
|
parser.add_argument("--temperature", type=float, default=0.55)
|
|
parser.add_argument("--top-p", type=float, default=0.9)
|
|
parser.add_argument("--num-ctx", type=int, default=8192)
|
|
parser.add_argument("--request-timeout", type=int, default=600)
|
|
parser.add_argument("--think", action=argparse.BooleanOptionalAction, default=False)
|
|
parser.add_argument("--capture-id", default="cold-wake-sanctuary-intake.qwen-sequential.v1")
|
|
parser.add_argument("--preferred-action", default="show_object")
|
|
args = parser.parse_args()
|
|
|
|
fixture = load_json(args.fixture)
|
|
annotation = load_json(args.annotation)
|
|
maer_prompt = build_maer_prompt(fixture, annotation)
|
|
maer_raw, maer_response_text, maer_parsed, maer_notes = request_qwen_tool(
|
|
args.endpoint,
|
|
args.model,
|
|
maer_prompt,
|
|
maer_choices_tool(),
|
|
"record_maer_choices",
|
|
args.temperature,
|
|
args.top_p,
|
|
args.num_ctx,
|
|
args.think,
|
|
args.request_timeout,
|
|
)
|
|
maer_notes.extend(validate_choice_payload(maer_parsed))
|
|
try:
|
|
selected_choice = choose_selected_choice(maer_parsed, args.preferred_action)
|
|
except SystemExit as exc:
|
|
selected_choice = {
|
|
"branch_id": "no-selection",
|
|
"ink_path": "no_selection",
|
|
"choice_text": "No selectable Maer choice was produced.",
|
|
"action_type": "no_valid_action",
|
|
"observable_action": "No observable action was selected because Maer choice generation failed.",
|
|
"spoken_text": "",
|
|
"expected_consequence_surfaces": [],
|
|
"training_hooks": [],
|
|
}
|
|
maer_notes.append(str(exc))
|
|
all_notes = fatal_notes(maer_notes)
|
|
capture = {
|
|
"schema_version": "ghostlight.qwen_ink_sequential_capture.v0",
|
|
"capture_id": args.capture_id,
|
|
"source_fixture_ref": str(args.fixture.relative_to(ROOT)).replace("\\", "/"),
|
|
"source_annotation_ref": str(args.annotation.relative_to(ROOT)).replace("\\", "/"),
|
|
"model": maer_raw.get("model", args.model),
|
|
"endpoint": args.endpoint,
|
|
"request_options": {
|
|
"think": args.think,
|
|
"api": "ollama_chat_tools",
|
|
"num_ctx": args.num_ctx,
|
|
"request_timeout": args.request_timeout,
|
|
"temperature": args.temperature,
|
|
"top_p": args.top_p,
|
|
},
|
|
"passes": {
|
|
"maer_choice_generation": {
|
|
"local_agent_id": "maer_tidecall",
|
|
"prompt_text": maer_prompt,
|
|
"response_text": maer_response_text,
|
|
"parsed_response": maer_parsed,
|
|
"validation_notes": maer_notes,
|
|
},
|
|
"selected_observable_action": selected_choice,
|
|
"sella_response_generation": skipped_pass("sella_ren", "skipped because no selectable Maer choice was produced"),
|
|
},
|
|
"review": {
|
|
"status": "useful_needs_revision",
|
|
"strengths": [
|
|
"Captured a failed projector-routed Qwen call instead of exiting without a receipt.",
|
|
],
|
|
"failure_notes": all_notes,
|
|
"repair_notes": repair_notes(maer_notes),
|
|
"accepted_into_ink_ref": None,
|
|
"pipeline_lessons": [
|
|
"Sequential generation failures should still produce reviewed receipts.",
|
|
"Maer choice generation needs a retry or stricter tool-call fallback before later passes run.",
|
|
],
|
|
},
|
|
}
|
|
capture_path = write_sequential_artifacts(
|
|
args,
|
|
capture,
|
|
{"maer": maer_prompt},
|
|
{"maer": maer_response_text},
|
|
)
|
|
print(f"wrote {capture_path.relative_to(ROOT)}")
|
|
print(json.dumps({"selected_observable_action": selected_choice, "failure_notes": all_notes}, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
sella_appraisal_prompt = build_sella_appraisal_prompt(fixture, annotation, selected_choice)
|
|
sella_appraisal_raw, sella_appraisal_response_text, sella_appraisal_parsed, sella_appraisal_notes = request_qwen_tool(
|
|
args.endpoint,
|
|
args.model,
|
|
sella_appraisal_prompt,
|
|
sella_appraisal_tool(),
|
|
"record_sella_appraisal",
|
|
args.temperature,
|
|
args.top_p,
|
|
args.num_ctx,
|
|
args.think,
|
|
args.request_timeout,
|
|
)
|
|
sella_appraisal_parsed, appraisal_repair_notes = normalize_appraisal_payload(sella_appraisal_parsed)
|
|
sella_appraisal_notes.extend(appraisal_repair_notes)
|
|
sella_appraisal_notes.extend(validate_appraisal_payload(sella_appraisal_parsed))
|
|
appraisal = sella_appraisal_parsed.get("appraisal") if isinstance(sella_appraisal_parsed, dict) else None
|
|
|
|
appraisal_fatal_notes = fatal_notes(sella_appraisal_notes)
|
|
if appraisal_fatal_notes:
|
|
capture = {
|
|
"schema_version": "ghostlight.qwen_ink_sequential_capture.v0",
|
|
"capture_id": args.capture_id,
|
|
"source_fixture_ref": str(args.fixture.relative_to(ROOT)).replace("\\", "/"),
|
|
"source_annotation_ref": str(args.annotation.relative_to(ROOT)).replace("\\", "/"),
|
|
"model": maer_raw.get("model", args.model),
|
|
"endpoint": args.endpoint,
|
|
"request_options": {
|
|
"think": args.think,
|
|
"api": "ollama_chat_tools",
|
|
"num_ctx": args.num_ctx,
|
|
"request_timeout": args.request_timeout,
|
|
"temperature": args.temperature,
|
|
"top_p": args.top_p,
|
|
},
|
|
"passes": {
|
|
"maer_choice_generation": {
|
|
"local_agent_id": "maer_tidecall",
|
|
"prompt_text": maer_prompt,
|
|
"response_text": maer_response_text,
|
|
"parsed_response": maer_parsed,
|
|
"validation_notes": maer_notes,
|
|
},
|
|
"selected_observable_action": selected_choice,
|
|
"sella_immediate_appraisal": {
|
|
"local_agent_id": "sella_ren",
|
|
"prompt_text": sella_appraisal_prompt,
|
|
"response_text": sella_appraisal_response_text,
|
|
"parsed_response": sella_appraisal_parsed,
|
|
"validation_notes": sella_appraisal_notes,
|
|
},
|
|
"sella_response_generation": skipped_pass("sella_ren", "skipped because Sella appraisal failed validation or request"),
|
|
},
|
|
"review": {
|
|
"status": "useful_needs_revision",
|
|
"strengths": [
|
|
"Generated Maer-local choices before the later-pass failure.",
|
|
"Captured the failed Sella appraisal pass instead of losing the run.",
|
|
],
|
|
"failure_notes": fatal_notes(maer_notes + sella_appraisal_notes),
|
|
"repair_notes": repair_notes(maer_notes + sella_appraisal_notes),
|
|
"accepted_into_ink_ref": None,
|
|
"pipeline_lessons": [
|
|
"Later-pass transport or tool failures must still write reviewed receipts.",
|
|
"Critical embodiment constraints need post-generation validation; projected prose alone is not enough.",
|
|
],
|
|
},
|
|
}
|
|
capture_path = write_sequential_artifacts(
|
|
args,
|
|
capture,
|
|
{"maer": maer_prompt, "sella-appraisal": sella_appraisal_prompt},
|
|
{"maer": maer_response_text, "sella-appraisal": sella_appraisal_response_text},
|
|
)
|
|
print(f"wrote {capture_path.relative_to(ROOT)}")
|
|
print(json.dumps({"selected_observable_action": selected_choice, "failure_notes": capture["review"]["failure_notes"]}, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
sella_prompt = build_sella_prompt(fixture, annotation, selected_choice, appraisal)
|
|
sella_raw, sella_response_text, sella_parsed, sella_notes = request_qwen_tool(
|
|
args.endpoint,
|
|
args.model,
|
|
sella_prompt,
|
|
sella_next_action_tool(),
|
|
"record_sella_next_action",
|
|
args.temperature,
|
|
args.top_p,
|
|
args.num_ctx,
|
|
args.think,
|
|
args.request_timeout,
|
|
)
|
|
sella_notes.extend(validate_response_payload(sella_parsed))
|
|
|
|
repair_note_list = repair_notes(maer_notes + sella_appraisal_notes + sella_notes)
|
|
all_notes = fatal_notes(maer_notes + sella_appraisal_notes + sella_notes)
|
|
capture = {
|
|
"schema_version": "ghostlight.qwen_ink_sequential_capture.v0",
|
|
"capture_id": args.capture_id,
|
|
"source_fixture_ref": str(args.fixture.relative_to(ROOT)).replace("\\", "/"),
|
|
"source_annotation_ref": str(args.annotation.relative_to(ROOT)).replace("\\", "/"),
|
|
"model": maer_raw.get("model", args.model),
|
|
"endpoint": args.endpoint,
|
|
"request_options": {
|
|
"think": args.think,
|
|
"api": "ollama_chat_tools",
|
|
"num_ctx": args.num_ctx,
|
|
"request_timeout": args.request_timeout,
|
|
"temperature": args.temperature,
|
|
"top_p": args.top_p,
|
|
},
|
|
"passes": {
|
|
"maer_choice_generation": {
|
|
"local_agent_id": "maer_tidecall",
|
|
"prompt_text": maer_prompt,
|
|
"response_text": maer_response_text,
|
|
"parsed_response": maer_parsed,
|
|
"validation_notes": maer_notes,
|
|
},
|
|
"selected_observable_action": selected_choice,
|
|
"sella_immediate_appraisal": {
|
|
"local_agent_id": "sella_ren",
|
|
"prompt_text": sella_appraisal_prompt,
|
|
"response_text": sella_appraisal_response_text,
|
|
"parsed_response": sella_appraisal_parsed,
|
|
"validation_notes": sella_appraisal_notes,
|
|
},
|
|
"sella_response_generation": {
|
|
"local_agent_id": "sella_ren",
|
|
"prompt_text": sella_prompt,
|
|
"response_text": sella_response_text,
|
|
"parsed_response": sella_parsed,
|
|
"validation_notes": sella_notes,
|
|
},
|
|
},
|
|
"review": {
|
|
"status": "accepted_as_draft" if not all_notes and not repair_note_list else "useful_needs_revision",
|
|
"strengths": [
|
|
"Separated Maer choice generation from Sella next-action generation.",
|
|
"Inserted participant appraisal/consolidation before next-actor action generation.",
|
|
"Sella prompt received observable Maer event and reviewed Sella state changes, not Maer's private intent.",
|
|
],
|
|
"failure_notes": all_notes,
|
|
"repair_notes": repair_note_list,
|
|
"accepted_into_ink_ref": None,
|
|
"pipeline_lessons": [
|
|
"Sequential generation needs event appraisal/consolidation before next actor selection.",
|
|
"Action type selection should be tool-shaped or schema-constrained, not left to prose compliance.",
|
|
"Character-local prompts should consume projected operating context, not raw canonical state variables.",
|
|
"Next-action prompts should receive appraisal context as prose constraints, not raw numeric state deltas.",
|
|
"Critical embodiment constraints need post-generation validation; projected prose alone is not enough.",
|
|
"Next pass should materialize the sequential capture into Ink and mutation training data.",
|
|
],
|
|
},
|
|
}
|
|
|
|
capture_path = write_sequential_artifacts(
|
|
args,
|
|
capture,
|
|
{
|
|
"maer": maer_prompt,
|
|
"sella-appraisal": sella_appraisal_prompt,
|
|
"sella": sella_prompt,
|
|
},
|
|
{
|
|
"maer": maer_response_text,
|
|
"sella-appraisal": sella_appraisal_response_text,
|
|
"sella": sella_response_text,
|
|
},
|
|
)
|
|
print(f"wrote {capture_path.relative_to(ROOT)}")
|
|
print(json.dumps({"selected_observable_action": selected_choice, "sella_appraisal": sella_appraisal_parsed, "sella_response": sella_parsed}, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|