mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-02 19:58:46 +00:00
Show host facts on owner quiz cards and name the Project on chat.quiz
A root's owner card now carries one host-written sentence under the question: the asking task, how its run started (read from the typed run_origin provenance of the task record: the owner's message and its time, a scheduled follow-up of task X, background consciousness, a promotion, a schedule, or origin unknown with the recorded marker), and when the owner last wrote in the card's chat, read from the chat log tail. It is computed from host records only, never from the question text, and an unrecorded fact is written as unknown. The sentence is stored in the owner_quiz block (record_asked host_facts), forwarded by the send_quiz event handler, and carried by the live quiz frame, the chat.quiz host event, the chat row, history replay (with the block as the source for rows logged without it), the Main question pointer, the activity census and the browser mirror. The web card renders it as a muted plain-text line (.chat-quiz-host-facts), absent when empty. The chat.quiz event also carries the Project name for a Project card; the browser wire does not. QuizOutbound and ChatOutbound gain the optional host_facts field in both language mirrors; the frozen-contract row and DESIGN.md describe it. No gateway version change.
This commit is contained in:
parent
55ad78213a
commit
2f977d78df
16 changed files with 386 additions and 14 deletions
|
|
@ -645,7 +645,11 @@ answer keep both forms readable. Anatomy, top to bottom:
|
|||
contain paragraphs, lists, checklists, tables and code; those blocks keep
|
||||
the shared rich-content gutter, rhythm and bounded code scrolling. The card
|
||||
does not infer a title from the first line or rewrite authored Markdown to
|
||||
make it fit.
|
||||
make it fit, and the question has no quiz-specific length cap. Directly under
|
||||
it, a muted plain-text host line (`.chat-quiz-host-facts`, `--type-meta`,
|
||||
`--text-meta`) states what only the host knows: the asking task, how its run
|
||||
started and when the owner last wrote in this chat, with unknown facts said
|
||||
as unknown; the line is absent when the card carries no `host_facts`.
|
||||
3. **Stake** — optional one-liner (`At stake: …`), `--type-meta`, `--text-meta`.
|
||||
4. **Options** — real owner actions: buttons with `--text-primary` labels,
|
||||
legible at rest; an optional per-option detail steps down to meta ink.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ This chapter owns the ABI promise: which typed shapes and their parsing, normali
|
|||
| `ChatOutbound.initiator` — additive origin label of a self-initiated turn (`"consciousness"` on every frame and chat/progress/summary row of a consciousness wake-up and of the roots it starts; absent on an owner's turn); stamped by the turn's own event queue and the agent's frame meta, persisted by `log_chat`/the authored summary row/the task result, replayed by history on each row | `ouroboros/gateway/contracts.py`, `supervisor/log_addressing.py`, `ouroboros/subagent_messages.py`, `supervisor/message_bus.py`, `ouroboros/gateway/history.py`, `web/modules/api_types.js` | `tests/test_consciousness_initiator_label.py`, `tests/test_consciousness_wake_lane.py`, `web/tests/consciousness_label.test.js` |
|
||||
| `project_thread` stamp on all seven outbound frame types, stamped at the message-bus broadcast choke; a stamped frame is never adopted by Main (`chat_activity.mainThreadAccepts`) | `supervisor/message_bus.py`, `ouroboros/projects_registry.py` | `tests/test_message_bus.py`, `web/tests/chat_thread_routing.test.js` |
|
||||
| Media/link envelopes — media `task_id`/`size_bytes`/`download_url`; `LinkAction {label,url}` with at most twelve absolute HTTP(S) actions; `links` in `WS_MESSAGE_TYPES`; `chat.links` host topic | `ouroboros/gateway/contracts.py`, `ouroboros/tools/core.py`, `ouroboros/event_bus.py` | `tests/test_contracts.py` |
|
||||
| Owner quiz ABI — `QuizOption {label, detail?, recommended?}` (the asker marks its recommendation on that option; the web card badges it, Telegram stars its button, the durable block keeps `recommended_index`), `QuizOutbound` (quiz_id, question, options, stake, `assumption` (required for optional clarification), additive `wait_for_answer` for a live pooled or ordinary-conversation root that must wait, lifecycle state open/answered/expired_terminal/superseded), separate `QuizStateOutbound` discriminator, `chat.quiz` host topic; the producer is the one escalation verb `escalate(question, options, stake, assumption, wait_for_answer=False, max_wait_minutes=None)` (the bound applies to a required wait only, never past the task's own deadline: the wait resumes with a system notice and the card stays open; named on an optional question it takes the omitted path and the asker's receipt says so) — a ROOT asks the owner, a SUBAGENT delivers a typed frame to its nearest LIVE ancestor, which answers via `forward_to_worker` or escalates verbatim, so the owner sees only what no ancestor answered; answers arrive through the ONE ingress `POST /api/decisions` (family ids `quiz:{task_id}:{quiz_id}`, `routing:{client_message_id}:{routing_token}`; `interaction:` reserved), request-id idempotent, first answer wins, validated against the STORED options; `option_index` is optional for the quiz family alone — a comment-only answer writes NO `answered_index`, because a stored 0 would replay as "chose the first option"; injected as the typed `KIND_QUIZ_ANSWER` mailbox control and broadcast as `quiz_state` (carrying the recorded `comment` when the owner answered in their own words, so the live card shows `Owner's answer:` exactly as replay does); expiry is structural only (the task-done seam flips open quizzes to `expired_terminal`, and the SAME reconcile closes the paired `owner_wait` so a terminal task never projects `quiz=expired_terminal` beside `owner_wait=waiting`; `owner_wait.set_owner_wait`'s refusal to continue waiting on a terminal result is preserved, not caught); a LATE answer to such an expired card is nevertheless ACCEPTED at the same ingress (the projection records `answered_after_terminal`) and, because no mailbox will ever be drained, is delivered as the owner's OWN message into the card's chat through the named ingress `supervisor.message_bus.accept_local_message`, idempotent on `client_message_id = quiz_late_answer:<task>:<quiz>`, its provenance in the message's own `late_answer` metadata rather than a substituted `client_surface`; the 2xx says `forwarded` so no surface claims a delivery that did not happen, and 409 is left for what is genuinely settled (an already answered card, a non-root addressee). History replay merges the projection state | `ouroboros/gateway/contracts.py`, `ouroboros/gateway/task_decision.py`, `ouroboros/owner_quiz.py`, `ouroboros/tools/core.py` | `tests/test_gateway_parity.py`, `tests/test_quiz_display.py`, `tests/test_quiz_answer.py`, `web/tests/chat_decision.test.js` |
|
||||
| Owner quiz ABI — `QuizOption {label, detail?, recommended?}` (the asker marks its recommendation on that option; the web card badges it, Telegram stars its button, the durable block keeps `recommended_index`), `QuizOutbound` (quiz_id, question, options, stake, `assumption` (required for optional clarification), additive `wait_for_answer` for a live pooled or ordinary-conversation root that must wait, lifecycle state open/answered/expired_terminal/superseded, optional host-written `host_facts` sentence, also on history rows and Main pointers), separate `QuizStateOutbound` discriminator, `chat.quiz` host topic (+ optional event-only `project_name`); the producer is the one escalation verb `escalate(question, options, stake, assumption, wait_for_answer=False, max_wait_minutes=None)` (the bound applies to a required wait only, never past the task's own deadline: the wait resumes with a system notice and the card stays open; named on an optional question it takes the omitted path and the asker's receipt says so) — a ROOT asks the owner, a SUBAGENT delivers a typed frame to its nearest LIVE ancestor, which answers via `forward_to_worker` or escalates verbatim, so the owner sees only what no ancestor answered; answers arrive through the ONE ingress `POST /api/decisions` (family ids `quiz:{task_id}:{quiz_id}`, `routing:{client_message_id}:{routing_token}`; `interaction:` reserved), request-id idempotent, first answer wins, validated against the STORED options; `option_index` is optional for the quiz family alone — a comment-only answer writes NO `answered_index`, because a stored 0 would replay as "chose the first option"; injected as the typed `KIND_QUIZ_ANSWER` mailbox control and broadcast as `quiz_state` (carrying the recorded `comment` when the owner answered in their own words, so the live card shows `Owner's answer:` exactly as replay does); expiry is structural only (the task-done seam flips open quizzes to `expired_terminal`, and the SAME reconcile closes the paired `owner_wait` so a terminal task never projects `quiz=expired_terminal` beside `owner_wait=waiting`; `owner_wait.set_owner_wait`'s refusal to continue waiting on a terminal result is preserved, not caught); a LATE answer to such an expired card is nevertheless ACCEPTED at the same ingress (the projection records `answered_after_terminal`) and, because no mailbox will ever be drained, is delivered as the owner's OWN message into the card's chat through the named ingress `supervisor.message_bus.accept_local_message`, idempotent on `client_message_id = quiz_late_answer:<task>:<quiz>`, its provenance in the message's own `late_answer` metadata rather than a substituted `client_surface`; the 2xx says `forwarded` so no surface claims a delivery that did not happen, and 409 is left for what is genuinely settled (an already answered card, a non-root addressee). History replay merges the projection state | `ouroboros/gateway/contracts.py`, `ouroboros/gateway/task_decision.py`, `ouroboros/owner_quiz.py`, `ouroboros/tools/core.py` | `tests/test_gateway_parity.py`, `tests/test_quiz_display.py`, `tests/test_quiz_answer.py`, `web/tests/chat_decision.test.js` |
|
||||
| Managed update ABI — preflight, `UpdateMergePlan`, pinned apply, process-local `update_progress`, `update_progress_changed` invalidation and boot-only `update_status_ready` | `ouroboros/gateway/contracts.py` | `tests/test_update_apply_routing.py` |
|
||||
| `ChatOutbound.review_projection` — bounded actor findings via `utils.truncate_review_artifact`, at most `MAX_PROJECTED_ACTOR_FINDINGS` rows (`review_execution_projection.py`) | `ouroboros/gateway/contracts.py` | `tests/test_review_substrate_v2.py`, `web/tests/review_truth.test.js` |
|
||||
| Skill preflight statuses — `preflight_failed` is fresh-only; a stale failure surfaces as `preflight_failed_stale`; absence means the caller could not know | `ouroboros/skill_review_status.py` | `tests/test_skill_preflight_repair.py`, `web/tests/skill_preflight_repair.test.js` |
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class ChatOutbound(TypedDict):
|
|||
recommended_index: NotRequired[int]
|
||||
answered_index: NotRequired[int]
|
||||
comment: NotRequired[str]
|
||||
host_facts: NotRequired[str] # the mirrored card's host sentence (QuizOutbound.host_facts)
|
||||
task_incident: NotRequired[str]
|
||||
# A cancellation fault names the PHYSICAL task it could not settle when it differs from the logical task id.
|
||||
cancel_physical_task_id: NotRequired[str]
|
||||
|
|
@ -387,8 +388,7 @@ class QuizOutbound(TypedDict):
|
|||
Optional questions continue under ``assumption``; ``wait_for_answer`` marks
|
||||
required waiting without an implied answer. ``state`` carries lifecycle.
|
||||
Replay merges the stored ``answered_index`` and verbatim ``comment``; a
|
||||
comment without an index is the owner's whole answer, not an option choice.
|
||||
"""
|
||||
comment without an index is the owner's whole answer, not an option choice."""
|
||||
|
||||
type: Literal["quiz"]
|
||||
role: Literal["assistant"]
|
||||
|
|
@ -402,6 +402,7 @@ class QuizOutbound(TypedDict):
|
|||
ts: str
|
||||
answered_index: NotRequired[int]
|
||||
comment: NotRequired[str]
|
||||
host_facts: NotRequired[str] # host-written, never the model's: asking task, run start, owner's last message
|
||||
chat_id: NotRequired[int]
|
||||
task_id: NotRequired[str]
|
||||
project_thread: NotRequired[bool]
|
||||
|
|
|
|||
|
|
@ -881,6 +881,8 @@ def _collect_chat_rows(
|
|||
for key in ("answered_index", "comment", "wait_ended_at"): # the answer, the closed bound
|
||||
if key in _live:
|
||||
quiz[key] = _live[key]
|
||||
if _live.get("host_facts") and not quiz.get("host_facts"):
|
||||
quiz["host_facts"] = str(_live["host_facts"]) # the ask-time sentence the block holds
|
||||
if "wait_for_answer" not in _live:
|
||||
quiz.pop("wait_for_answer", None) # the bound closed: the card no longer waits
|
||||
if quiz.get("wait_for_answer") or quiz.get("wait_ended_at"):
|
||||
|
|
|
|||
|
|
@ -322,7 +322,8 @@ def _task_activity_facts(drive_root: Any, task_id: str) -> dict:
|
|||
# (project_dialogue.project_question_pointer): display fields ride along.
|
||||
"quiz": {key: quiz[key] for key in ("quiz_id", "state", "asked_at", "wait_for_answer", "question",
|
||||
"options", "option_details", "stake", "assumption",
|
||||
"recommended_index", "answered_index", "comment", "wait_ended_at")
|
||||
"recommended_index", "answered_index", "comment", "wait_ended_at",
|
||||
"host_facts")
|
||||
if isinstance(quiz, dict) and key in quiz}}
|
||||
if len(_FINALIZING_MEMO) >= _FINALIZING_MEMO_MAX:
|
||||
_FINALIZING_MEMO.clear()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ like the hurry projection):
|
|||
"assumption", "state": open|answered|expired_terminal,
|
||||
"asked_at", "answered_at"?, "answered_index"?, "request_id"?,
|
||||
"comment"?, "reconciled_at"?, "chat_id"?, "max_wait_minutes"?,
|
||||
"answered_after_terminal"?,
|
||||
"answered_after_terminal"?, "host_facts"?,
|
||||
}, ...
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +121,7 @@ def record_asked(
|
|||
recommended_index: Optional[int] = None,
|
||||
chat_id: Optional[int] = None,
|
||||
max_wait_minutes: Optional[int] = None,
|
||||
host_facts: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Worker-side projection write at ask time.
|
||||
|
||||
|
|
@ -132,7 +133,10 @@ def record_asked(
|
|||
real hidden partition, never "no chat"): a late answer arriving after the
|
||||
task is gone is delivered there as an ordinary owner message instead of
|
||||
into a mailbox nobody drains. ``max_wait_minutes`` records the bound a
|
||||
waiting asker chose, so replay can say what the task waited for."""
|
||||
waiting asker chose, so replay can say what the task waited for.
|
||||
``host_facts`` is the host-written sentence the card shows under the
|
||||
question (asking task, how its run started, the owner's last message in
|
||||
the chat); stored only when non-empty."""
|
||||
if option_details is not None and (
|
||||
not isinstance(option_details, list) or len(option_details) != len(options)
|
||||
or not all(isinstance(value, str) for value in option_details)
|
||||
|
|
@ -150,6 +154,7 @@ def record_asked(
|
|||
**({"chat_id": int(chat_id)} if isinstance(chat_id, int) and not isinstance(chat_id, bool) else {}),
|
||||
**({"max_wait_minutes": int(max_wait_minutes)}
|
||||
if isinstance(max_wait_minutes, int) and not isinstance(max_wait_minutes, bool) else {}),
|
||||
**({"host_facts": str(host_facts)} if str(host_facts or "") else {}),
|
||||
}
|
||||
|
||||
refused: Dict[str, str] = {}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any,
|
|||
question = str(quiz.get("question") or row.get("text") or block.get("question") or "")
|
||||
assumption = str(quiz.get("assumption") or block.get("assumption") or "")
|
||||
stake = str(quiz.get("stake") or block.get("stake") or "")
|
||||
host_facts = str(quiz.get("host_facts") or block.get("host_facts") or "")
|
||||
recommended = block.get("recommended_index")
|
||||
if not isinstance(recommended, int) or isinstance(recommended, bool):
|
||||
recommended = next((i for i, option in enumerate(options)
|
||||
|
|
@ -151,6 +152,7 @@ def project_question_pointer(row: Dict[str, Any], block: Any, project: Any,
|
|||
**({"option_details": details} if details else {}),
|
||||
**({"stake": stake} if stake else {}),
|
||||
**({"assumption": assumption} if assumption else {}),
|
||||
**({"host_facts": host_facts} if host_facts else {}),
|
||||
**({"recommended_index": recommended} if recommended is not None else {}),
|
||||
**facts,
|
||||
**({"source_status": "unavailable"} if not known else {}),
|
||||
|
|
|
|||
|
|
@ -436,6 +436,71 @@ def _send_links(
|
|||
return "OK: link buttons queued for delivery to owner."
|
||||
|
||||
|
||||
def _quiz_host_facts(ctx: ToolContext, canonical_root: pathlib.Path, task_id: str, chat_id: int) -> str:
|
||||
"""The host's one-sentence account under a root's owner card: which task asks,
|
||||
how its run started, and when the owner last wrote in this chat. Read from
|
||||
typed records only (the task record's ``run_origin`` provenance and the chat
|
||||
log tail), never from the question text; an unrecorded fact says unknown."""
|
||||
import datetime
|
||||
|
||||
from ouroboros.consciousness_authority import CONSCIOUSNESS_INITIATOR
|
||||
from ouroboros.dialogue_provenance import run_origin
|
||||
from ouroboros.task_results import load_task_result
|
||||
from ouroboros.tools.followup import FOLLOWUP_SOURCE
|
||||
from ouroboros.utils import iter_jsonl_objects
|
||||
|
||||
def moment(value: Any) -> Optional[datetime.datetime]:
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(str(value or ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=datetime.timezone.utc)).astimezone(
|
||||
datetime.timezone.utc)
|
||||
|
||||
def shown(when: datetime.datetime) -> str:
|
||||
return when.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
meta = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
|
||||
try:
|
||||
record = load_task_result(canonical_root, task_id) or {}
|
||||
except Exception:
|
||||
record = {}
|
||||
# The live metadata (which carries the owner door's stamp) laid over the
|
||||
# persisted record's own, as the post-task synthesis reads the same origin.
|
||||
metadata = {**(record.get("metadata") if isinstance(record.get("metadata"), dict) else {}), **meta}
|
||||
origin = run_origin({**record, "metadata": metadata})
|
||||
ref = metadata.get("origin_message_ref") or record.get("origin_message_ref")
|
||||
origin_task = str(origin.get("origin_task_id") or "")
|
||||
if origin.get("owner_ingress"):
|
||||
sent = moment(ref.get("ts")) if isinstance(ref, dict) else None
|
||||
started = "started by your message" + (f" of {shown(sent)}" if sent else "")
|
||||
elif origin.get("source") == FOLLOWUP_SOURCE and origin_task:
|
||||
started = f"started as a scheduled follow-up of task {origin_task}"
|
||||
elif origin.get("initiator") == CONSCIOUSNESS_INITIATOR:
|
||||
started = "started by background consciousness"
|
||||
elif origin.get("source") == "promote_chat_to_task":
|
||||
started = "started by promotion" + (f" from task {origin_task}" if origin_task else "")
|
||||
elif origin.get("schedule_id"):
|
||||
started = f"started by schedule {origin['schedule_id']}"
|
||||
elif origin_task:
|
||||
started = f"started from task {origin_task}"
|
||||
else:
|
||||
started = "origin unknown" + (f" (recorded source: {origin['source']})" if origin.get("source") else "")
|
||||
last = None
|
||||
try:
|
||||
for entry in iter_jsonl_objects(canonical_root / "logs" / "chat.jsonl", tail_bytes=512_000):
|
||||
if entry.get("direction") == "in" and str(entry.get("chat_id")) == str(chat_id):
|
||||
last = moment(entry.get("ts")) or last
|
||||
except Exception:
|
||||
last = None
|
||||
if last is None:
|
||||
seen = "your last message in this chat: unknown"
|
||||
else:
|
||||
minutes = max(0, int((datetime.datetime.now(datetime.timezone.utc) - last).total_seconds() // 60))
|
||||
seen = f"your last message in this chat: {shown(last)} ({minutes} minutes before this question)"
|
||||
return f"Asked by task {task_id}, {started}; {seen}."
|
||||
|
||||
|
||||
def _escalate(
|
||||
ctx: ToolContext,
|
||||
question: str,
|
||||
|
|
@ -581,6 +646,7 @@ def _escalate(
|
|||
card_chat_id = int(getattr(ctx, "current_chat_id", None) or 0)
|
||||
except (TypeError, ValueError):
|
||||
card_chat_id = 0
|
||||
host_facts = _quiz_host_facts(ctx, canonical_root, task_id, card_chat_id)
|
||||
asked = record_asked(
|
||||
canonical_root, task_id,
|
||||
quiz_id=quiz_id, question=payload["question"],
|
||||
|
|
@ -590,6 +656,7 @@ def _escalate(
|
|||
stake=payload["stake"], assumption=payload["assumption"],
|
||||
wait_for_answer=wait_for_answer, chat_id=card_chat_id,
|
||||
max_wait_minutes=payload.get("max_wait_minutes"),
|
||||
host_facts=host_facts,
|
||||
)
|
||||
if asked.get("refused"):
|
||||
if wait_for_answer:
|
||||
|
|
@ -610,6 +677,7 @@ def _escalate(
|
|||
"assumption": payload["assumption"],
|
||||
"state": "open",
|
||||
"task_id": task_id,
|
||||
"host_facts": host_facts,
|
||||
**({"wait_for_answer": True} if wait_for_answer else {}),
|
||||
})
|
||||
delivered = "delivered to the owner" if mode == "live" else "queued for the owner"
|
||||
|
|
|
|||
|
|
@ -457,6 +457,7 @@ def _handle_send_quiz(evt: Dict[str, Any], ctx: Any) -> None:
|
|||
assumption=str(evt.get("assumption") or ""),
|
||||
state=str(evt.get("state") or "open"),
|
||||
task_id=str(evt.get("task_id") or ""),
|
||||
host_facts=str(evt.get("host_facts") or ""),
|
||||
**({"wait_for_answer": True} if evt.get("wait_for_answer") is True else {}),
|
||||
)
|
||||
if not ok:
|
||||
|
|
|
|||
|
|
@ -957,8 +957,12 @@ class LocalChatBridge:
|
|||
state: str = "open",
|
||||
task_id: str = "",
|
||||
wait_for_answer: bool = False,
|
||||
host_facts: str = "",
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send an owner quiz card to the UI and host event subscribers."""
|
||||
"""Send an owner quiz card to the UI and host event subscribers.
|
||||
|
||||
``host_facts`` (the host's sentence under the question) rides the frame, the
|
||||
event and the chat row when non-empty; ``project_name`` rides the EVENT only."""
|
||||
if is_a2a_chat_id(chat_id):
|
||||
return True, "ok"
|
||||
qid = str(quiz_id or "").strip()
|
||||
|
|
@ -988,7 +992,18 @@ class LocalChatBridge:
|
|||
"chat_id": int(chat_id or 0),
|
||||
"task_id": str(task_id or ""),
|
||||
}
|
||||
if host_facts:
|
||||
msg["host_facts"] = str(host_facts) # the envelope literal keeps constant keys (contract scan)
|
||||
stamp_project_thread(DATA_DIR, msg)
|
||||
project = None
|
||||
if msg.get("project_thread"):
|
||||
try:
|
||||
from ouroboros.projects_registry import list_reserved_projects
|
||||
|
||||
project = next((row for row in list_reserved_projects(DATA_DIR)
|
||||
if row.get("chat_id") == int(chat_id)), None)
|
||||
except Exception:
|
||||
log.debug("Quiz project lookup failed", exc_info=True)
|
||||
if self._broadcast_fn:
|
||||
self._broadcast_fn(msg)
|
||||
quiz_transport = dict(self._chat_transports.get(int(chat_id or 0), {}) or {})
|
||||
|
|
@ -1004,6 +1019,8 @@ class LocalChatBridge:
|
|||
"assumption": payload["assumption"],
|
||||
"state": str(state or "open"),
|
||||
"ts": ts,
|
||||
**({"host_facts": str(host_facts)} if host_facts else {}),
|
||||
**({"project_name": str(project["name"])} if project and project.get("name") else {}),
|
||||
})
|
||||
try:
|
||||
owner_id = int(load_state().get("owner_id") or 0)
|
||||
|
|
@ -1019,6 +1036,7 @@ class LocalChatBridge:
|
|||
"stake": payload["stake"],
|
||||
"assumption": payload["assumption"],
|
||||
"state": str(state or "open"),
|
||||
**({"host_facts": str(host_facts)} if host_facts else {}),
|
||||
},
|
||||
)
|
||||
_advance_project_visible_revision(chat_id)
|
||||
|
|
@ -1026,10 +1044,7 @@ class LocalChatBridge:
|
|||
try:
|
||||
from ouroboros.owner_quiz import quiz_states
|
||||
from ouroboros.project_dialogue import project_question_pointer
|
||||
from ouroboros.projects_registry import list_reserved_projects
|
||||
|
||||
project = next((row for row in list_reserved_projects(DATA_DIR)
|
||||
if row.get("chat_id") == int(chat_id)), None)
|
||||
pointer = project_question_pointer(msg, quiz_states(DATA_DIR, task_id).get(qid), project)
|
||||
if pointer:
|
||||
frame = {
|
||||
|
|
@ -1045,7 +1060,7 @@ class LocalChatBridge:
|
|||
# The complete pointer row (ChatOutbound mirrors): present only when known.
|
||||
for key in ("question", "options", "option_details", "stake", "assumption", "recommended_index",
|
||||
"answered_index", "comment", "wait_for_answer", "wait_ended_at",
|
||||
"owner_wait_resume_reason"):
|
||||
"owner_wait_resume_reason", "host_facts"):
|
||||
if key in pointer:
|
||||
frame[key] = pointer[key]
|
||||
self._broadcast_fn(frame)
|
||||
|
|
|
|||
|
|
@ -328,11 +328,16 @@ def test_project_question_pointer_display_fields_share_one_contract():
|
|||
# History: the producer itself emits every field the browser merges.
|
||||
block = {"quiz_id": "q", "state": "open", "question": "Which?", "options": ["a", "b"],
|
||||
"option_details": ["A detail", ""], "stake": "What rides on it", "assumption": "a",
|
||||
"recommended_index": 0}
|
||||
"recommended_index": 0, "host_facts": "Asked by task t, origin unknown."}
|
||||
pointer = project_question_pointer({"task_id": "t", "quiz_id": "q"}, block,
|
||||
{"id": "p", "chat_id": 12, "name": "Project"}, None)
|
||||
assert browser_fields <= set(pointer), sorted(browser_fields - set(pointer))
|
||||
assert pointer["option_details"] == ["A detail", ""] and pointer["stake"] == "What rides on it"
|
||||
assert pointer["host_facts"] == "Asked by task t, origin unknown."
|
||||
# A block that recorded no host sentence yields a pointer without the key, never an empty one.
|
||||
bare = project_question_pointer({"task_id": "t", "quiz_id": "q"}, {**block, "host_facts": ""},
|
||||
{"id": "p", "chat_id": 12, "name": "Project"}, None)
|
||||
assert "host_facts" not in bare
|
||||
|
||||
# Live delivery: the frame literal plus its copied key loop, all declared in ChatOutbound.
|
||||
send_quiz = _function_node(REPO_ROOT / "supervisor" / "message_bus.py", "send_quiz")
|
||||
|
|
|
|||
198
tests/test_quiz_host_facts.py
Normal file
198
tests/test_quiz_host_facts.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Host facts on the owner quiz card (4A) and the Project name on the ``chat.quiz`` event.
|
||||
|
||||
The host writes one sentence under the question from facts only it knows — the asking
|
||||
task, how that task's run started (``dialogue_provenance.run_origin``) and when the
|
||||
owner last wrote in the card's chat — never from the question text; an unrecorded fact
|
||||
says unknown. The sentence rides the durable block, the live frame, the host event, the
|
||||
chat row, history replay and the Main pointer; the Project name rides the event only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from ouroboros.owner_quiz import quiz_states, record_asked
|
||||
from ouroboros.task_results import STATUS_RUNNING, write_task_result
|
||||
from tests.test_quiz_answer import _escalate, _tool_ctx
|
||||
|
||||
_NOW = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
def _stamp(minutes_ago: float) -> str:
|
||||
return (_NOW - datetime.timedelta(minutes=minutes_ago)).isoformat()
|
||||
|
||||
|
||||
def _shown(iso: str) -> str:
|
||||
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def _chat_rows(tmp_path, *rows):
|
||||
path = tmp_path / "logs" / "chat.jsonl"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
for row in rows:
|
||||
handle.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def _ask(ctx):
|
||||
out = _escalate(ctx, question="Started by your message? Ship it?", options=["Ship", "Hold"],
|
||||
assumption="Hold meanwhile")
|
||||
assert out.startswith("OK: quiz "), out
|
||||
event = next(evt for evt in ctx.pending_events if evt.get("type") == "send_quiz")
|
||||
return event, quiz_states(ctx.drive_root, ctx.task_id)[event["quiz_id"]]
|
||||
|
||||
|
||||
def test_record_asked_stores_host_facts_only_when_present(tmp_path):
|
||||
with_facts = record_asked(tmp_path, "t-1", quiz_id="q-1", question="Q?", options=["a", "b"],
|
||||
assumption="a", host_facts="Asked by task t-1, origin unknown.")
|
||||
assert with_facts["host_facts"] == "Asked by task t-1, origin unknown."
|
||||
without = record_asked(tmp_path, "t-1", quiz_id="q-2", question="Q?", options=["a", "b"], assumption="a")
|
||||
assert "host_facts" not in without
|
||||
stored = quiz_states(tmp_path, "t-1")
|
||||
assert stored["q-1"]["host_facts"] == "Asked by task t-1, origin unknown."
|
||||
assert "host_facts" not in stored["q-2"]
|
||||
|
||||
|
||||
def test_owner_started_root_names_its_message_and_the_last_owner_message_in_this_chat(tmp_path):
|
||||
started, last = _stamp(90.3), _stamp(47.3)
|
||||
ctx = _tool_ctx(tmp_path, chat_id=1)
|
||||
ctx.task_metadata["origin_message_ref"] = {
|
||||
"chat_id": 1, "client_message_id": "m-1", "ts": started, "text_sha256": "0" * 64}
|
||||
_chat_rows(
|
||||
tmp_path,
|
||||
{"ts": started, "direction": "in", "chat_id": 1, "text": "please ship"},
|
||||
{"ts": last, "direction": "in", "chat_id": 1, "text": "and quickly"},
|
||||
# Neither the assistant's own row nor another chat's owner message is "your last message here".
|
||||
{"ts": _stamp(10), "direction": "out", "chat_id": 1, "text": "working"},
|
||||
{"ts": _stamp(5), "direction": "in", "chat_id": 7, "text": "other room"},
|
||||
)
|
||||
event, block = _ask(ctx)
|
||||
expected = (f"Asked by task root-1, started by your message of {_shown(started)}; "
|
||||
f"your last message in this chat: {_shown(last)} (47 minutes before this question).")
|
||||
assert block["host_facts"] == expected
|
||||
assert event["host_facts"] == expected
|
||||
|
||||
|
||||
def test_scheduled_follow_up_names_the_task_it_follows(tmp_path):
|
||||
write_task_result(tmp_path, "root-1", STATUS_RUNNING,
|
||||
metadata={"source": "task_followup", "origin_task_id": "prev-7", "schedule_id": "followup-prev-7"})
|
||||
event, block = _ask(_tool_ctx(tmp_path, chat_id=1))
|
||||
assert block["host_facts"] == ("Asked by task root-1, started as a scheduled follow-up of task prev-7; "
|
||||
"your last message in this chat: unknown.")
|
||||
assert event["host_facts"] == block["host_facts"]
|
||||
|
||||
|
||||
def test_consciousness_origin_is_named(tmp_path):
|
||||
ctx = _tool_ctx(tmp_path, chat_id=1)
|
||||
ctx.task_metadata["initiator"] = "consciousness"
|
||||
_chat_rows(tmp_path, {"ts": _stamp(3.2), "direction": "in", "chat_id": 1, "text": "hi"})
|
||||
_event, block = _ask(ctx)
|
||||
assert block["host_facts"].startswith("Asked by task root-1, started by background consciousness; ")
|
||||
assert block["host_facts"].endswith("(3 minutes before this question).")
|
||||
|
||||
|
||||
def test_unknown_origin_and_no_owner_message_are_said_as_unknown(tmp_path):
|
||||
# An unrelated source marker is shown as recorded, never mapped to a guess.
|
||||
_event, block = _ask(_tool_ctx(tmp_path, chat_id=1))
|
||||
assert block["host_facts"] == "Asked by task root-1, origin unknown; your last message in this chat: unknown."
|
||||
write_task_result(tmp_path, "root-2", STATUS_RUNNING, metadata={"source": "mystery_lane"})
|
||||
_event, block = _ask(_tool_ctx(tmp_path, task_id="root-2", chat_id=1))
|
||||
assert block["host_facts"] == ("Asked by task root-2, origin unknown (recorded source: mystery_lane); "
|
||||
"your last message in this chat: unknown.")
|
||||
|
||||
|
||||
def _bridge(tmp_path, monkeypatch):
|
||||
from supervisor import message_bus, state
|
||||
|
||||
monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path)
|
||||
state.init(tmp_path, 100)
|
||||
frames, events = [], []
|
||||
bridge = message_bus.LocalChatBridge({})
|
||||
bridge._broadcast_fn = frames.append
|
||||
monkeypatch.setattr(message_bus, "publish_event", lambda topic, data: events.append((topic, data)))
|
||||
monkeypatch.setattr(message_bus, "get_bridge", lambda: bridge)
|
||||
return bridge, frames, events
|
||||
|
||||
|
||||
_OPTIONS = [{"label": "Local"}, {"label": "Shared"}]
|
||||
|
||||
|
||||
def test_send_quiz_carries_host_facts_and_names_the_project_on_the_event_only(tmp_path, monkeypatch):
|
||||
from ouroboros.event_bus import CHAT_QUIZ
|
||||
from ouroboros.gateway.history import _assemble_history_response
|
||||
from ouroboros.projects_registry import create_project
|
||||
|
||||
project = create_project(tmp_path, "facts-project", name="Facts Project")
|
||||
record_asked(tmp_path, "task-1", quiz_id="q-1", question="Which storage?", options=["Local", "Shared"],
|
||||
assumption="Local", chat_id=project["chat_id"], host_facts="Asked by task task-1, origin unknown.")
|
||||
bridge, frames, events = _bridge(tmp_path, monkeypatch)
|
||||
ok, _ = bridge.send_quiz(project["chat_id"], quiz_id="q-1", question="Which storage?", options=_OPTIONS,
|
||||
assumption="Local", task_id="task-1", host_facts="Asked by task task-1, origin unknown.")
|
||||
assert ok
|
||||
quiz_frame, pointer = frames
|
||||
assert quiz_frame["type"] == "quiz" and quiz_frame["host_facts"] == "Asked by task task-1, origin unknown."
|
||||
assert "project_name" not in quiz_frame # the browser wire names the Project through its own pointer
|
||||
assert pointer["system_type"] == "project_question_pointer"
|
||||
assert pointer["host_facts"] == "Asked by task task-1, origin unknown."
|
||||
[(topic, event)] = events
|
||||
assert topic == CHAT_QUIZ
|
||||
assert event["host_facts"] == "Asked by task task-1, origin unknown."
|
||||
assert event["project_name"] == "Facts Project"
|
||||
stored = [json.loads(line) for line in (tmp_path / "logs/chat.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
assert stored[-1]["quiz"]["host_facts"] == "Asked by task task-1, origin unknown."
|
||||
room = json.loads(_assemble_history_response(tmp_path, project["chat_id"], 10, 0))["messages"]
|
||||
assert room[0]["msg_type"] == "quiz" and room[0]["quiz"]["host_facts"] == "Asked by task task-1, origin unknown."
|
||||
main = json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
|
||||
assert main[0]["system_type"] == "project_question_pointer"
|
||||
assert main[0]["host_facts"] == "Asked by task task-1, origin unknown."
|
||||
|
||||
|
||||
def test_send_quiz_without_host_facts_or_project_adds_neither(tmp_path, monkeypatch):
|
||||
bridge, frames, events = _bridge(tmp_path, monkeypatch)
|
||||
assert bridge.send_quiz(1, quiz_id="q-2", question="Which storage?", options=_OPTIONS,
|
||||
assumption="Local", task_id="task-2")[0]
|
||||
[quiz_frame] = frames
|
||||
[(_topic, event)] = events
|
||||
assert "host_facts" not in quiz_frame and "host_facts" not in event and "project_name" not in event
|
||||
stored = json.loads((tmp_path / "logs/chat.jsonl").read_text(encoding="utf-8").splitlines()[-1])
|
||||
assert "host_facts" not in stored["quiz"]
|
||||
|
||||
|
||||
def test_history_replays_the_block_sentence_for_a_row_logged_without_it(tmp_path, monkeypatch):
|
||||
from ouroboros.gateway.history import _assemble_history_response
|
||||
|
||||
record_asked(tmp_path, "task-3", quiz_id="q-3", question="Which storage?", options=["Local", "Shared"],
|
||||
assumption="Local", chat_id=1, host_facts="Asked by task task-3, origin unknown.")
|
||||
record_asked(tmp_path, "task-4", quiz_id="q-4", question="Which storage?", options=["Local", "Shared"],
|
||||
assumption="Local", chat_id=1)
|
||||
bridge, _frames, _events = _bridge(tmp_path, monkeypatch)
|
||||
for task_id, quiz_id in (("task-3", "q-3"), ("task-4", "q-4")):
|
||||
assert bridge.send_quiz(1, quiz_id=quiz_id, question="Which storage?", options=_OPTIONS,
|
||||
assumption="Local", task_id=task_id)[0]
|
||||
rows = {row["quiz"]["quiz_id"]: row["quiz"]
|
||||
for row in json.loads(_assemble_history_response(tmp_path, 1, 10, 0))["messages"]
|
||||
if row.get("msg_type") == "quiz"}
|
||||
assert rows["q-3"]["host_facts"] == "Asked by task task-3, origin unknown."
|
||||
assert "host_facts" not in rows["q-4"]
|
||||
|
||||
|
||||
def test_the_supervisor_event_handler_forwards_host_facts_to_send_quiz():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from supervisor.events_chat_delivery import _handle_send_quiz
|
||||
|
||||
sent = []
|
||||
|
||||
class Bridge:
|
||||
def send_quiz(self, chat_id, **kwargs):
|
||||
sent.append((chat_id, kwargs))
|
||||
return True, "ok"
|
||||
|
||||
ctx = SimpleNamespace(bridge=Bridge())
|
||||
evt = {"type": "send_quiz", "chat_id": 1, "quiz_id": "q-5", "question": "Q?", "options": _OPTIONS,
|
||||
"assumption": "a", "task_id": "t-5", "host_facts": "Asked by task t-5, origin unknown."}
|
||||
_handle_send_quiz(evt, ctx)
|
||||
_handle_send_quiz({**evt, "host_facts": ""}, ctx)
|
||||
assert sent[0][1]["host_facts"] == "Asked by task t-5, origin unknown."
|
||||
assert sent[1][1]["host_facts"] == ""
|
||||
|
|
@ -297,6 +297,7 @@
|
|||
* @property {number=} recommended_index
|
||||
* @property {number=} answered_index
|
||||
* @property {string=} comment
|
||||
* @property {string=} host_facts
|
||||
* @property {"chat"} type
|
||||
* @property {"user"|"assistant"|"system"} role
|
||||
* @property {string} content
|
||||
|
|
@ -569,6 +570,7 @@
|
|||
* @property {string} ts
|
||||
* @property {number=} answered_index
|
||||
* @property {string=} comment
|
||||
* @property {string=} host_facts
|
||||
* @property {number=} chat_id
|
||||
* @property {string=} task_id
|
||||
* @property {boolean=} project_thread
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export function createChatDecision({
|
|||
const MIRROR_SETTLE_MS = 5000;
|
||||
// Display fields a narrower delivery (the census, a lifecycle frame) may lack: an empty value
|
||||
// there never blanks what a complete row already carried.
|
||||
const MIRROR_FIELDS = ['question', 'options', 'option_details', 'stake', 'project_name', 'assumption', 'recommended_index'];
|
||||
const MIRROR_FIELDS = ['question', 'options', 'option_details', 'stake', 'project_name', 'assumption', 'recommended_index', 'host_facts'];
|
||||
const MIRROR_SIGNATURE = ['quiz_state', ...MIRROR_FIELDS, 'answered_index', 'comment', ...WAIT_FIELDS];
|
||||
// The pointer row in the shape of the Project's quiz row, so one normalizer reads both.
|
||||
const mirrorQuiz = (row) => ({ ...row, type: 'quiz', role: 'assistant', state: row.quiz_state });
|
||||
|
|
@ -373,6 +373,8 @@ export function createChatDecision({
|
|||
options,
|
||||
stake: String(src.stake || ''),
|
||||
assumption: String(src.assumption || ''),
|
||||
// The host's sentence (asking task, run start, the owner's last message): plain text.
|
||||
hostFacts: String(src.host_facts || ''),
|
||||
// The wait facts the header and the signature line read (waitFacts): the
|
||||
// original required flag, the closed bound, and the task's wait record when
|
||||
// history or a detail read attached it.
|
||||
|
|
@ -392,6 +394,13 @@ export function createChatDecision({
|
|||
};
|
||||
}
|
||||
|
||||
function hostFactsLine(text) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'chat-quiz-host-facts';
|
||||
line.textContent = text;
|
||||
return line;
|
||||
}
|
||||
|
||||
function appendRecommendedBadge(button) {
|
||||
// The asker's recommendation (the "A" option) is a badge on that option, every surface alike.
|
||||
if (button.querySelector('.chat-quiz-option-recommended')) return;
|
||||
|
|
@ -588,6 +597,10 @@ export function createChatDecision({
|
|||
const wait = waitFacts(current);
|
||||
const existing = quizViews.get(key);
|
||||
if (existing) {
|
||||
// A narrower first delivery may have lacked the host's sentence; a later one adds it.
|
||||
if (quiz.hostFacts && !existing.querySelector('.chat-quiz-host-facts')) {
|
||||
existing.querySelector('.chat-quiz-question')?.nextElementSibling?.before(hostFactsLine(quiz.hostFacts));
|
||||
}
|
||||
if (quiz.comment) existing.dataset.ownerComment = quiz.comment;
|
||||
else if (Object.hasOwn(current, 'comment')) delete existing.dataset.ownerComment;
|
||||
if (!quiz.detailsUnavailable) {
|
||||
|
|
@ -648,6 +661,7 @@ export function createChatDecision({
|
|||
if (mountMarkdown) mountMarkdown(question, questionText);
|
||||
else question.textContent = questionText;
|
||||
card.append(question);
|
||||
if (quiz.hostFacts) card.append(hostFactsLine(quiz.hostFacts));
|
||||
|
||||
if (quiz.stake) {
|
||||
const stake = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -7219,6 +7219,14 @@ textarea.chat-input {
|
|||
min-inline-size: 0;
|
||||
}
|
||||
.chat-quiz-stake .inline-code { font-size: inherit; }
|
||||
/* The host's own sentence under the question (who asks, how the run started, the owner's last message). */
|
||||
.chat-quiz-host-facts {
|
||||
font-size: var(--type-meta);
|
||||
line-height: var(--line-meta, 1.35);
|
||||
color: var(--text-meta);
|
||||
min-inline-size: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.chat-quiz-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -805,3 +805,49 @@ test('a late answer says where it went', async () => {
|
|||
} finally { fx.restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('the host facts line sits under the question when the card carries it and is absent otherwise', () => {
|
||||
const fx = fixture();
|
||||
const facts = 'Asked by task t-1, started by your message of 2026-09-25 00:21 UTC; '
|
||||
+ 'your last message in this chat: 2026-09-25 00:21 UTC (47 minutes before this question).';
|
||||
try {
|
||||
const card = fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-facts', host_facts: facts });
|
||||
const line = card.querySelector('.chat-quiz-host-facts');
|
||||
assert.equal(line.textContent, facts);
|
||||
// Plain text directly under the question, never through the markdown pipeline.
|
||||
assert.equal(line.innerHTML, undefined);
|
||||
const question = card.querySelector('.chat-quiz-question');
|
||||
assert.equal(question.nextElementSibling, line);
|
||||
// A stored history row nests the quiz; the sentence rides the nested block.
|
||||
const replay = fx.decision.buildQuizCard({ task_id: 't-1', text: WS_MSG.question,
|
||||
quiz: { ...WS_MSG, quiz_id: 'qz-facts-replay', host_facts: facts } });
|
||||
assert.equal(replay.querySelector('.chat-quiz-host-facts').textContent, facts);
|
||||
const bare = fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-bare' });
|
||||
assert.equal(bare.querySelector('.chat-quiz-host-facts'), null);
|
||||
const empty = fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-empty', host_facts: '' });
|
||||
assert.equal(empty.querySelector('.chat-quiz-host-facts'), null);
|
||||
// A later, richer delivery of an already rendered card adds the line once.
|
||||
assert.equal(fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-bare', host_facts: facts }), null);
|
||||
assert.equal(bare.querySelectorAll('.chat-quiz-host-facts').length, 1);
|
||||
assert.equal(bare.querySelector('.chat-quiz-question').nextElementSibling.textContent, facts);
|
||||
fx.decision.buildQuizCard({ ...WS_MSG, quiz_id: 'qz-bare', host_facts: facts });
|
||||
assert.equal(bare.querySelectorAll('.chat-quiz-host-facts').length, 1);
|
||||
} finally { fx.restore(); }
|
||||
});
|
||||
|
||||
test('a Main mirror of a Project question carries the host facts line from its pointer row', () => {
|
||||
const column = new NodeStub();
|
||||
const fx = fixture({ isMain: true,
|
||||
frameNode: (_msg, card) => { const bubble = new NodeStub(); bubble.append(card); return bubble; },
|
||||
insertMessageNode: (node) => { column.append(node); return true; } });
|
||||
const row = { role: 'system', system_type: 'project_question_pointer', task_id: 't-9', quiz_id: 'qz-9',
|
||||
project_id: 'p1', project_chat_id: 23, project_name: 'Storage', ts: '2026-09-25T00:00:00+00:00',
|
||||
quiz_state: 'open', question: 'Merge now?', options: ['Yes', 'No'] };
|
||||
try {
|
||||
assert.ok(fx.decision.appendQuestionPointer({ ...row, host_facts: 'Asked by task t-9, origin unknown.' }));
|
||||
const card = column.children[0].children[0];
|
||||
assert.equal(card.querySelector('.chat-quiz-host-facts').textContent, 'Asked by task t-9, origin unknown.');
|
||||
assert.ok(fx.decision.appendQuestionPointer({ ...row, task_id: 't-10' }));
|
||||
assert.equal(column.children[1].children[0].querySelector('.chat-quiz-host-facts'), null);
|
||||
} finally { fx.restore(); }
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue