feat: first-meeting gate on drawer Add-guest form (T72.2)
When a host->candidate edge already exists from a prior chat, the Add-guest form renders the prose textarea disabled with an "already know each other" note. Submission without the explicit "re-seed anyway" toggle skips seed_inter_bot_edges so existing edge content (affinity, trust, knowledge, summaries) survives — guest_added and group_node_initialized still fire. A small inline script enables / disables the textarea per-option based on a pre-computed existing_guest_edges dict surfaced by the GET handler.
This commit is contained in:
@@ -100,24 +100,71 @@
|
|||||||
<section class="drawer-section">
|
<section class="drawer-section">
|
||||||
<h3>Add guest</h3>
|
<h3>Add guest</h3>
|
||||||
{% if available_guests %}
|
{% if available_guests %}
|
||||||
<form class="inline-edit"
|
{% set first_guest_id = available_guests[0].id %}
|
||||||
|
{% set first_existing = existing_guest_edges.get(first_guest_id, False) %}
|
||||||
|
<form class="inline-edit add-guest-form"
|
||||||
hx-post="/chats/{{ chat.id }}/drawer/guest/add"
|
hx-post="/chats/{{ chat.id }}/drawer/guest/add"
|
||||||
hx-target="#drawer" hx-swap="innerHTML">
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
<label>
|
<label>
|
||||||
Bot:
|
Bot:
|
||||||
<select name="guest_bot_id" required>
|
<select name="guest_bot_id" required class="add-guest-select">
|
||||||
{% for b in available_guests %}
|
{% for b in available_guests %}
|
||||||
<option value="{{ b.id }}">{{ b.name }}</option>
|
<option value="{{ b.id }}"
|
||||||
|
data-existing-edge="{{ 'true' if existing_guest_edges.get(b.id) else 'false' }}">
|
||||||
|
{{ b.name }}{% if existing_guest_edges.get(b.id) %} (already met){% endif %}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<p class="muted add-guest-existing-note"
|
||||||
|
{% if not first_existing %}hidden{% endif %}>
|
||||||
|
they already know each other (edge exists from a prior chat)
|
||||||
|
</p>
|
||||||
|
<label class="add-guest-reseed-label"
|
||||||
|
{% if not first_existing %}hidden{% endif %}>
|
||||||
|
<input type="checkbox" name="reseed" value="1" class="add-guest-reseed">
|
||||||
|
re-seed anyway
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Have they met before? Describe how (leave blank if not):
|
Have they met before? Describe how (leave blank if not):
|
||||||
<textarea name="relationship_prose" rows="3"
|
<textarea name="relationship_prose" rows="3"
|
||||||
|
class="add-guest-prose"
|
||||||
|
{% if first_existing %}disabled{% endif %}
|
||||||
placeholder="e.g. Old college friends who studied physics together."></textarea>
|
placeholder="e.g. Old college friends who studied physics together."></textarea>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Add guest</button>
|
<button type="submit">Add guest</button>
|
||||||
</form>
|
</form>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.currentScript.previousElementSibling;
|
||||||
|
while (form && !form.classList.contains('add-guest-form')) {
|
||||||
|
form = form.previousElementSibling;
|
||||||
|
}
|
||||||
|
if (!form) return;
|
||||||
|
var sel = form.querySelector('.add-guest-select');
|
||||||
|
var prose = form.querySelector('.add-guest-prose');
|
||||||
|
var reseed = form.querySelector('.add-guest-reseed');
|
||||||
|
var note = form.querySelector('.add-guest-existing-note');
|
||||||
|
var reseedLabel = form.querySelector('.add-guest-reseed-label');
|
||||||
|
function refresh() {
|
||||||
|
var opt = sel.options[sel.selectedIndex];
|
||||||
|
var existing = opt && opt.getAttribute('data-existing-edge') === 'true';
|
||||||
|
if (existing) {
|
||||||
|
note.removeAttribute('hidden');
|
||||||
|
reseedLabel.removeAttribute('hidden');
|
||||||
|
prose.disabled = !reseed.checked;
|
||||||
|
} else {
|
||||||
|
note.setAttribute('hidden', '');
|
||||||
|
reseedLabel.setAttribute('hidden', '');
|
||||||
|
reseed.checked = false;
|
||||||
|
prose.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel.addEventListener('change', refresh);
|
||||||
|
reseed.addEventListener('change', refresh);
|
||||||
|
refresh();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">No other bots authored yet.</p>
|
<p class="muted">No other bots authored yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+36
-11
@@ -113,6 +113,14 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
available_guests = [
|
available_guests = [
|
||||||
b for b in list_bots(conn) if b["id"] != chat["host_bot_id"]
|
b for b in list_bots(conn) if b["id"] != chat["host_bot_id"]
|
||||||
]
|
]
|
||||||
|
# T72.2 first-meeting gate: pre-compute whether a host->candidate edge
|
||||||
|
# already exists. Template renders the prose textarea disabled and the
|
||||||
|
# POST handler skips ``seed_inter_bot_edges`` (preserving the existing
|
||||||
|
# edge content) unless the user explicitly toggles "re-seed anyway".
|
||||||
|
existing_guest_edges = {
|
||||||
|
b["id"]: get_edge(conn, chat["host_bot_id"], b["id"]) is not None
|
||||||
|
for b in available_guests
|
||||||
|
}
|
||||||
group_node = get_group_node(conn, chat_id)
|
group_node = get_group_node(conn, chat_id)
|
||||||
|
|
||||||
# Recent memories from host's POV (witness_host = 1), most recent first.
|
# Recent memories from host's POV (witness_host = 1), most recent first.
|
||||||
@@ -161,6 +169,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"edge_y2g": edge_y2g,
|
"edge_y2g": edge_y2g,
|
||||||
"edge_g2y": edge_g2y,
|
"edge_g2y": edge_g2y,
|
||||||
"available_guests": available_guests,
|
"available_guests": available_guests,
|
||||||
|
"existing_guest_edges": existing_guest_edges,
|
||||||
"group_node": group_node,
|
"group_node": group_node,
|
||||||
"recent_memories": recent_memories,
|
"recent_memories": recent_memories,
|
||||||
"pinned": pinned,
|
"pinned": pinned,
|
||||||
@@ -602,6 +611,7 @@ async def add_guest(
|
|||||||
request: Request,
|
request: Request,
|
||||||
guest_bot_id: str = Form(...),
|
guest_bot_id: str = Form(...),
|
||||||
relationship_prose: str = Form(""),
|
relationship_prose: str = Form(""),
|
||||||
|
reseed: str = Form(""),
|
||||||
conn=Depends(get_conn),
|
conn=Depends(get_conn),
|
||||||
client=Depends(get_llm_client),
|
client=Depends(get_llm_client),
|
||||||
):
|
):
|
||||||
@@ -633,17 +643,32 @@ async def add_guest(
|
|||||||
detail=f"host bot not found: {chat['host_bot_id']}",
|
detail=f"host bot not found: {chat['host_bot_id']}",
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = request.app.state.settings
|
# T72.2 first-meeting gate: when an edge already exists from a prior
|
||||||
seed = await seed_inter_bot_edges(
|
# chat, the textarea is rendered disabled. Submission without the
|
||||||
client,
|
# explicit "re-seed anyway" toggle skips ``seed_inter_bot_edges``
|
||||||
classifier_model=settings.classifier_model,
|
# entirely so the existing edge content (affinity, trust, knowledge,
|
||||||
bot_a_id=chat["host_bot_id"],
|
# summaries) survives. ``guest_added`` and ``group_node_initialized``
|
||||||
bot_a_name=host_bot["name"],
|
# still fire so the chat picks up the new participant.
|
||||||
bot_b_id=guest_bot_id,
|
existing_edge = (
|
||||||
bot_b_name=guest_bot["name"],
|
get_edge(conn, chat["host_bot_id"], guest_bot_id) is not None
|
||||||
relationship_prose=relationship_prose,
|
|
||||||
timeout_s=settings.classifier_timeout_s,
|
|
||||||
)
|
)
|
||||||
|
reseed_requested = reseed.lower() in ("1", "true", "on", "yes")
|
||||||
|
skip_seed = existing_edge and not reseed_requested
|
||||||
|
|
||||||
|
settings = request.app.state.settings
|
||||||
|
if skip_seed:
|
||||||
|
seed = None
|
||||||
|
else:
|
||||||
|
seed = await seed_inter_bot_edges(
|
||||||
|
client,
|
||||||
|
classifier_model=settings.classifier_model,
|
||||||
|
bot_a_id=chat["host_bot_id"],
|
||||||
|
bot_a_name=host_bot["name"],
|
||||||
|
bot_b_id=guest_bot_id,
|
||||||
|
bot_b_name=guest_bot["name"],
|
||||||
|
relationship_prose=relationship_prose,
|
||||||
|
timeout_s=settings.classifier_timeout_s,
|
||||||
|
)
|
||||||
|
|
||||||
append_and_apply(
|
append_and_apply(
|
||||||
conn,
|
conn,
|
||||||
@@ -658,7 +683,7 @@ async def add_guest(
|
|||||||
# per-direction summary is set via the per-pov scene-close path
|
# per-direction summary is set via the per-pov scene-close path
|
||||||
# (T27), not direct edge_update. We therefore drop seed.*_summary
|
# (T27), not direct edge_update. We therefore drop seed.*_summary
|
||||||
# here; the deltas + knowledge_facts are what materializes.
|
# here; the deltas + knowledge_facts are what materializes.
|
||||||
if not _seed_is_default(seed):
|
if seed is not None and not _seed_is_default(seed):
|
||||||
append_and_apply(
|
append_and_apply(
|
||||||
conn,
|
conn,
|
||||||
kind="edge_update",
|
kind="edge_update",
|
||||||
|
|||||||
@@ -265,6 +265,162 @@ def test_drawer_remove_guest_clears_and_closes_scene(client, tmp_path):
|
|||||||
assert kinds.index("scene_closed") < kinds.index("guest_removed")
|
assert kinds.index("scene_closed") < kinds.index("guest_removed")
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.2 first-meeting gate ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_host_to_guest_edge(db: Path) -> None:
|
||||||
|
"""Materialise a bot_a -> bot_b edge so the gate's check fires."""
|
||||||
|
from chat.eventlog.log import append_and_apply
|
||||||
|
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "bot_b",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"affinity_delta": 0,
|
||||||
|
"knowledge_facts": ["already met before"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_guest_form_disables_prose_when_edge_exists(client, tmp_path):
|
||||||
|
"""When host->candidate edge already exists, the GET partial renders
|
||||||
|
the textarea disabled and surfaces the "already know each other"
|
||||||
|
message so the user knows submitting will skip the seed.
|
||||||
|
"""
|
||||||
|
_seed_chat(tmp_path / "test.db")
|
||||||
|
_seed_host_to_guest_edge(tmp_path / "test.db")
|
||||||
|
|
||||||
|
response = client.get("/chats/chat_bot_a/drawer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.text
|
||||||
|
# Note + disabled state both present. The textarea sits next to the
|
||||||
|
# ``add-guest-prose`` class so we can match it specifically.
|
||||||
|
assert "already know each other" in body
|
||||||
|
assert 'class="add-guest-prose"' in body
|
||||||
|
# The textarea for the first (auto-selected) candidate should be
|
||||||
|
# disabled in the initial markup since an edge exists.
|
||||||
|
assert "disabled" in body.split('class="add-guest-prose"', 1)[1].split(">", 1)[0]
|
||||||
|
# And the option carries the ``data-existing-edge="true"`` attribute
|
||||||
|
# the inline JS uses to flip state on subsequent select changes.
|
||||||
|
assert 'data-existing-edge="true"' in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_guest_with_existing_edge_skips_seed_call(client, tmp_path):
|
||||||
|
"""Submitting the Add-guest form WITHOUT toggling re-seed must skip
|
||||||
|
``seed_inter_bot_edges`` entirely. We assert this via an empty mock
|
||||||
|
queue: if the seed function had been called it would have consumed
|
||||||
|
a canned response (or raised because none was available).
|
||||||
|
"""
|
||||||
|
_seed_chat(tmp_path / "test.db")
|
||||||
|
_seed_host_to_guest_edge(tmp_path / "test.db")
|
||||||
|
|
||||||
|
# Empty queue: any classifier call would raise inside MockLLMClient.
|
||||||
|
canned_queue: list[str] = []
|
||||||
|
_override_llm(canned_queue)
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/guest/add",
|
||||||
|
data={
|
||||||
|
"guest_bot_id": "bot_b",
|
||||||
|
"relationship_prose": "ignored prose",
|
||||||
|
# NO reseed flag — gate should suppress the seed call.
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
from chat.state.world import get_chat
|
||||||
|
|
||||||
|
chat = get_chat(conn, "chat_bot_a")
|
||||||
|
assert chat["guest_bot_id"] == "bot_b"
|
||||||
|
|
||||||
|
# The pre-seeded knowledge fact survives — proof the seed didn't run
|
||||||
|
# and overwrite the existing edge.
|
||||||
|
edge = get_edge(conn, "bot_a", "bot_b")
|
||||||
|
assert "already met before" in edge["knowledge"]
|
||||||
|
|
||||||
|
# Exactly one guest_added; no new edge_update events between
|
||||||
|
# bot_a and bot_b (the pre-seed edge_update from the test setup
|
||||||
|
# is the only edge_update on this pair).
|
||||||
|
added = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert added == 1
|
||||||
|
|
||||||
|
edge_updates = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'edge_update'"
|
||||||
|
).fetchall()
|
||||||
|
# Only the pre-seed edge_update from _seed_host_to_guest_edge.
|
||||||
|
ab_updates = [
|
||||||
|
json.loads(p[0])
|
||||||
|
for p in edge_updates
|
||||||
|
if {
|
||||||
|
json.loads(p[0]).get("source_id"),
|
||||||
|
json.loads(p[0]).get("target_id"),
|
||||||
|
}
|
||||||
|
== {"bot_a", "bot_b"}
|
||||||
|
]
|
||||||
|
assert len(ab_updates) == 1
|
||||||
|
assert ab_updates[0]["knowledge_facts"] == ["already met before"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_guest_with_existing_edge_and_reseed_runs_seed(client, tmp_path):
|
||||||
|
"""Toggling ``re-seed anyway`` flips the gate off — the existing
|
||||||
|
flow runs (seed produces deltas, two ``edge_update`` events fire).
|
||||||
|
"""
|
||||||
|
_seed_chat(tmp_path / "test.db")
|
||||||
|
_seed_host_to_guest_edge(tmp_path / "test.db")
|
||||||
|
|
||||||
|
canned = json.dumps(
|
||||||
|
{
|
||||||
|
"a_to_b_summary": "reconnected",
|
||||||
|
"a_to_b_knowledge_facts": ["new fact"],
|
||||||
|
"a_to_b_affinity_delta": 2,
|
||||||
|
"a_to_b_trust_delta": 1,
|
||||||
|
"b_to_a_summary": "reconnected",
|
||||||
|
"b_to_a_knowledge_facts": [],
|
||||||
|
"b_to_a_affinity_delta": 1,
|
||||||
|
"b_to_a_trust_delta": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_override_llm([canned])
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/guest/add",
|
||||||
|
data={
|
||||||
|
"guest_bot_id": "bot_b",
|
||||||
|
"relationship_prose": "fresh prose",
|
||||||
|
"reseed": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
edge_updates = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'edge_update'"
|
||||||
|
).fetchall()
|
||||||
|
# Pre-seed (1) + two from the re-seed = 3 edge_updates total.
|
||||||
|
ab_updates = [
|
||||||
|
json.loads(p[0])
|
||||||
|
for p in edge_updates
|
||||||
|
if {
|
||||||
|
json.loads(p[0]).get("source_id"),
|
||||||
|
json.loads(p[0]).get("target_id"),
|
||||||
|
}
|
||||||
|
== {"bot_a", "bot_b"}
|
||||||
|
]
|
||||||
|
assert len(ab_updates) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path):
|
def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path):
|
||||||
_seed_chat(tmp_path / "test.db")
|
_seed_chat(tmp_path / "test.db")
|
||||||
from chat.eventlog.log import append_and_apply
|
from chat.eventlog.log import append_and_apply
|
||||||
|
|||||||
Reference in New Issue
Block a user