merge: T72 drawer polish (deferred edits + first-meeting gate + witness flag editing)
This commit is contained in:
@@ -6,7 +6,7 @@ be reversed by emitting an inverse ``manual_edit`` later. This module
|
|||||||
applies the new value to the appropriate target table; the snapshot of
|
applies the new value to the appropriate target table; the snapshot of
|
||||||
``prior_value`` is taken by the route handler before this fires.
|
``prior_value`` is taken by the route handler before this fires.
|
||||||
|
|
||||||
Phase 1 covers four target kinds:
|
Phase 1 covers five target kinds:
|
||||||
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
|
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
|
||||||
clamped to 0..100.
|
clamped to 0..100.
|
||||||
- ``memory_significance`` — dropdown edit, clamped to 0..3.
|
- ``memory_significance`` — dropdown edit, clamped to 0..3.
|
||||||
@@ -17,8 +17,18 @@ Phase 1 covers four target kinds:
|
|||||||
field. Driven by T27 from the classifier's ``relationship_summary``
|
field. Driven by T27 from the classifier's ``relationship_summary``
|
||||||
output combined with the prior summary.
|
output combined with the prior summary.
|
||||||
|
|
||||||
Other §6.4 editable fields (activity verb / attention / posture,
|
T72.1 (Phase 2.5) adds one list-shaped edit:
|
||||||
knowledge_facts list manipulation) are deferred to Phase 1.5.
|
- ``edge_knowledge_fact`` — add/remove a single fact on an edge's
|
||||||
|
``knowledge_json`` list. Payload carries an ``action`` of ``"add"`` or
|
||||||
|
``"remove"`` and a ``fact`` string; remove matches the first occurrence
|
||||||
|
by string equality so the route handler doesn't have to track fact
|
||||||
|
indices across re-renders.
|
||||||
|
|
||||||
|
T72.3 adds a per-flag witness toggle:
|
||||||
|
- ``memory_witness`` — flip one of ``witness_you`` / ``witness_host`` /
|
||||||
|
``witness_guest`` on a memory row. Payload's ``new_value`` is a dict
|
||||||
|
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
|
||||||
|
mirrors the same shape so an inverse edit can restore the flag.
|
||||||
|
|
||||||
Pin toggles intentionally use the existing ``memory_pin_changed`` event
|
Pin toggles intentionally use the existing ``memory_pin_changed`` event
|
||||||
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
|
||||||
@@ -27,11 +37,14 @@ the projection writes both ``pinned`` and ``auto_pinned`` atomically.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from sqlite3 import Connection
|
from sqlite3 import Connection
|
||||||
|
|
||||||
from chat.eventlog.log import Event
|
from chat.eventlog.log import Event
|
||||||
from chat.eventlog.projector import on
|
from chat.eventlog.projector import on
|
||||||
|
|
||||||
|
_VALID_WITNESS_FLAGS = {"you", "host", "guest"}
|
||||||
|
|
||||||
|
|
||||||
def _clamp(value: int, lo: int, hi: int) -> int:
|
def _clamp(value: int, lo: int, hi: int) -> int:
|
||||||
return max(lo, min(hi, value))
|
return max(lo, min(hi, value))
|
||||||
@@ -87,5 +100,43 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
|
|||||||
target_id["target_id"],
|
target_id["target_id"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
elif kind == "edge_knowledge_fact":
|
||||||
|
# T72.1: add or remove a single fact on an edge's knowledge list.
|
||||||
|
# ``target_id`` is the {"source_id", "target_id"} edge pair;
|
||||||
|
# ``new_value`` carries ``{"action": "add"|"remove", "fact": str}``.
|
||||||
|
# Remove matches by string equality (first occurrence) so callers
|
||||||
|
# don't have to thread a fact_index through re-rendered drawers.
|
||||||
|
action = new_value["action"]
|
||||||
|
fact = str(new_value["fact"])
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT knowledge_json FROM edges "
|
||||||
|
"WHERE source_id = ? AND target_id = ?",
|
||||||
|
(target_id["source_id"], target_id["target_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
knowledge = json.loads(row[0])
|
||||||
|
if action == "add":
|
||||||
|
knowledge.append(fact)
|
||||||
|
elif action == "remove" and fact in knowledge:
|
||||||
|
knowledge.remove(fact)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE edges SET knowledge_json = ? "
|
||||||
|
"WHERE source_id = ? AND target_id = ?",
|
||||||
|
(
|
||||||
|
json.dumps(knowledge),
|
||||||
|
target_id["source_id"],
|
||||||
|
target_id["target_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif kind == "memory_witness":
|
||||||
|
# T72.3: toggle one of the three witness flags on a memory row.
|
||||||
|
# ``new_value`` is the dict ``{"flag", "value"}``; ``prior_value``
|
||||||
|
# mirrors the same shape so an inverse edit restores the flag.
|
||||||
|
flag = new_value["flag"]
|
||||||
|
if flag in _VALID_WITNESS_FLAGS:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
|
||||||
|
(1 if int(new_value["value"]) else 0, int(target_id)),
|
||||||
|
)
|
||||||
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
# Unknown target_kind: silently no-op for v1. Future kinds (activity
|
||||||
# fields, knowledge_facts list manipulation) extend the dispatch above.
|
# fields, etc.) extend the dispatch above.
|
||||||
|
|||||||
+160
-10
@@ -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 %}
|
||||||
@@ -156,19 +203,95 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
{% if edge_b2y.summary %}<p class="muted">{{ edge_b2y.summary }}</p>{% endif %}
|
<form class="inline-edit"
|
||||||
{% if edge_b2y.knowledge %}
|
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
|
||||||
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
<ul>{% for fact in edge_b2y.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||||
</details>
|
<input type="hidden" name="target_id" value="you">
|
||||||
{% endif %}
|
<label>
|
||||||
|
Trust:
|
||||||
|
<input type="range" name="new_value" min="0" max="100"
|
||||||
|
value="{{ edge_b2y.trust }}"
|
||||||
|
oninput="this.nextElementSibling.value = this.value">
|
||||||
|
<output>{{ edge_b2y.trust }}</output>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||||
|
<input type="hidden" name="target_id" value="you">
|
||||||
|
<label>
|
||||||
|
Summary:
|
||||||
|
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_b2y.summary or "" }}</textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save summary</button>
|
||||||
|
</form>
|
||||||
|
<details>
|
||||||
|
<summary>Knowledge ({{ (edge_b2y.knowledge or [])|length }})</summary>
|
||||||
|
{% if edge_b2y.knowledge %}
|
||||||
|
<ul>
|
||||||
|
{% for fact in edge_b2y.knowledge %}
|
||||||
|
<li>
|
||||||
|
{{ fact }}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||||
|
<input type="hidden" name="target_id" value="you">
|
||||||
|
<input type="hidden" name="action" value="remove">
|
||||||
|
<input type="hidden" name="fact" value="{{ fact }}">
|
||||||
|
<button type="submit">Remove</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
|
||||||
|
<input type="hidden" name="target_id" value="you">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<label>
|
||||||
|
Add fact:
|
||||||
|
<input type="text" name="fact" maxlength="500" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if edge_y2b %}
|
{% if edge_y2b %}
|
||||||
<div class="edge-row">
|
<div class="edge-row">
|
||||||
<strong>you → {{ host_bot.name }}</strong>
|
<strong>you → {{ host_bot.name }}</strong>
|
||||||
<p>Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100</p>
|
<p>Affinity: {{ edge_y2b.affinity }}/100 · Trust: {{ edge_y2b.trust }}/100</p>
|
||||||
{% if edge_y2b.summary %}<p class="muted">{{ edge_y2b.summary }}</p>{% endif %}
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="source_id" value="you">
|
||||||
|
<input type="hidden" name="target_id" value="{{ host_bot.id }}">
|
||||||
|
<label>
|
||||||
|
Trust:
|
||||||
|
<input type="range" name="new_value" min="0" max="100"
|
||||||
|
value="{{ edge_y2b.trust }}"
|
||||||
|
oninput="this.nextElementSibling.value = this.value">
|
||||||
|
<output>{{ edge_y2b.trust }}</output>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="source_id" value="you">
|
||||||
|
<input type="hidden" name="target_id" value="{{ host_bot.id }}">
|
||||||
|
<label>
|
||||||
|
Summary:
|
||||||
|
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_y2b.summary or "" }}</textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save summary</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not edge_b2y and not edge_y2b %}
|
{% if not edge_b2y and not edge_y2b %}
|
||||||
@@ -224,6 +347,33 @@
|
|||||||
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
|
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
|
||||||
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
|
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="witness-row">
|
||||||
|
{% for flag in ['you', 'host', 'guest'] %}
|
||||||
|
{% set witnessed = m['witness_' ~ flag] %}
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/memory/witness"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="memory_id" value="{{ m.id }}">
|
||||||
|
<input type="hidden" name="flag" value="{{ flag }}">
|
||||||
|
<input type="hidden" name="new_value" value="{{ 0 if witnessed else 1 }}">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" {% if witnessed %}checked{% endif %}
|
||||||
|
onchange="this.form.requestSubmit()">
|
||||||
|
{{ flag }}
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>Edit POV summary</summary>
|
||||||
|
<form class="inline-edit"
|
||||||
|
hx-post="/chats/{{ chat.id }}/drawer/memory/pov-summary"
|
||||||
|
hx-target="#drawer" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="memory_id" value="{{ m.id }}">
|
||||||
|
<textarea name="new_summary" rows="3" maxlength="2000">{{ m.pov_summary }}</textarea>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+335
-20
@@ -1,4 +1,4 @@
|
|||||||
"""Chat drawer — read view (T24) and inline edits (T25).
|
"""Chat drawer — read view (T24) and inline edits (T25, T72).
|
||||||
|
|
||||||
The GET endpoint renders an HTML partial showing the current scene +
|
The GET endpoint renders an HTML partial showing the current scene +
|
||||||
container, per-entity activity, host <-> you edges, pinned memories with
|
container, per-entity activity, host <-> you edges, pinned memories with
|
||||||
@@ -13,14 +13,16 @@ returning the refreshed drawer partial so HTMX can swap it in:
|
|||||||
* pin toggle on a memory (emits ``memory_pin_changed`` with
|
* pin toggle on a memory (emits ``memory_pin_changed`` with
|
||||||
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
|
``auto_pinned=0`` so a manual pin is not subject to auto-eviction).
|
||||||
|
|
||||||
|
T72 (Phase 2.5) extends the inline-edit set to cover the remaining
|
||||||
|
§6.4 editable fields whose state-layer support already lands in the
|
||||||
|
``manual_edit`` projector: edge trust slider, edge summary textarea,
|
||||||
|
memory POV summary textarea, and per-edge knowledge-fact add/remove. It
|
||||||
|
also exposes a witness-flag toggle (``you/host/guest``) per memory row
|
||||||
|
and a "first-meeting gate" on the Add-guest form so an existing edge
|
||||||
|
isn't quietly overwritten by a re-seed.
|
||||||
|
|
||||||
Each ``manual_edit`` payload snapshots the prior value alongside the new
|
Each ``manual_edit`` payload snapshots the prior value alongside the new
|
||||||
one so a later inverse edit can restore state (§6.4 final paragraph).
|
one so a later inverse edit can restore state (§6.4 final paragraph).
|
||||||
|
|
||||||
Other §6.4 editable fields (activity verb/attention/posture, edge_trust,
|
|
||||||
edge summary, knowledge_facts list, memory pov_summary) are deferred to
|
|
||||||
a Phase 1.5 follow-up — the dispatch in :mod:`chat.state.manual_edit`
|
|
||||||
already accepts more ``target_kind`` values, so adding their routes is a
|
|
||||||
mechanical extension.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -55,6 +57,13 @@ PIN_CAP = 8
|
|||||||
# Recent-memories list is bounded to keep the drawer cheap to render.
|
# Recent-memories list is bounded to keep the drawer cheap to render.
|
||||||
RECENT_LIMIT = 10
|
RECENT_LIMIT = 10
|
||||||
|
|
||||||
|
# T72.1 caps on free-form textarea edits. Edge summaries and per-POV
|
||||||
|
# memory summaries are drawer-driven prose — bound them so a stray paste
|
||||||
|
# can't blow up the projected row size or the SSE drawer refresh payload.
|
||||||
|
EDGE_SUMMARY_MAX = 2000
|
||||||
|
MEMORY_POV_SUMMARY_MAX = 2000
|
||||||
|
KNOWLEDGE_FACT_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
|
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
|
||||||
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
||||||
@@ -104,14 +113,25 @@ 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.
|
||||||
# Raw query keeps this read self-contained — no projector helper exposes
|
# Raw query keeps this read self-contained — no projector helper exposes
|
||||||
# "latest N for an owner" yet and the drawer is the only consumer.
|
# "latest N for an owner" yet and the drawer is the only consumer. The
|
||||||
|
# three witness flags ride along so T72.3's per-row checkboxes can
|
||||||
|
# render the current state without a second query per memory.
|
||||||
recent_rows = conn.execute(
|
recent_rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, pov_summary, significance, pinned, created_at
|
SELECT id, pov_summary, significance, pinned, created_at,
|
||||||
|
witness_you, witness_host, witness_guest
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE owner_id = ? AND witness_host = 1
|
WHERE owner_id = ? AND witness_host = 1
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
@@ -126,6 +146,9 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
|
|||||||
"significance": r[2],
|
"significance": r[2],
|
||||||
"pinned": r[3],
|
"pinned": r[3],
|
||||||
"created_at": r[4],
|
"created_at": r[4],
|
||||||
|
"witness_you": r[5],
|
||||||
|
"witness_host": r[6],
|
||||||
|
"witness_guest": r[7],
|
||||||
}
|
}
|
||||||
for r in recent_rows
|
for r in recent_rows
|
||||||
]
|
]
|
||||||
@@ -152,6 +175,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,
|
||||||
@@ -342,6 +366,281 @@ async def toggle_memory_pin(
|
|||||||
return await drawer(chat_id, request, conn)
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.1 deferred v1 drawer edits --------------------------------------
|
||||||
|
#
|
||||||
|
# These four endpoints round out the §6.4 editable surface — the
|
||||||
|
# ``manual_edit`` projector already dispatches ``edge_trust``,
|
||||||
|
# ``edge_summary``, and ``memory_pov_summary`` (T25); ``edge_knowledge_fact``
|
||||||
|
# is a new dispatch branch added alongside this commit. Each route follows
|
||||||
|
# the T25 pattern: snapshot the prior value, append + apply ``manual_edit``,
|
||||||
|
# then re-render the drawer partial.
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/edge/trust",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def edit_edge_trust(
|
||||||
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
|
source_id: str = Form(...),
|
||||||
|
target_id: str = Form(...),
|
||||||
|
new_value: int = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
if not 0 <= int(new_value) <= 100:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"trust must be in [0, 100], got {new_value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
edge = get_edge(conn, source_id, target_id)
|
||||||
|
if edge is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"edge not found: {source_id}->{target_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior = int(edge["trust"])
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "edge_trust",
|
||||||
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||||
|
"prior_value": prior,
|
||||||
|
"new_value": int(new_value),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/edge/summary",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def edit_edge_summary(
|
||||||
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
|
source_id: str = Form(...),
|
||||||
|
target_id: str = Form(...),
|
||||||
|
new_summary: str = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
if len(new_summary) > EDGE_SUMMARY_MAX:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"edge summary exceeds {EDGE_SUMMARY_MAX} chars "
|
||||||
|
f"(got {len(new_summary)})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
edge = get_edge(conn, source_id, target_id)
|
||||||
|
if edge is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"edge not found: {source_id}->{target_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior = edge.get("summary") or ""
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "edge_summary",
|
||||||
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||||
|
"prior_value": prior,
|
||||||
|
"new_value": new_summary,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/memory/pov-summary",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def edit_memory_pov_summary(
|
||||||
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
|
memory_id: int = Form(...),
|
||||||
|
new_summary: str = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
if len(new_summary) > MEMORY_POV_SUMMARY_MAX:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"memory pov_summary exceeds {MEMORY_POV_SUMMARY_MAX} chars "
|
||||||
|
f"(got {len(new_summary)})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 404 when the memory either doesn't exist or belongs to a different
|
||||||
|
# chat — the drawer never surfaces cross-chat memories so editing one
|
||||||
|
# would be a path-traversal-style mistake.
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT pov_summary FROM memories WHERE id = ? AND chat_id = ?",
|
||||||
|
(int(memory_id), chat_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"memory not found in chat: {memory_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior = row[0] or ""
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "memory_pov_summary",
|
||||||
|
"target_id": int(memory_id),
|
||||||
|
"prior_value": prior,
|
||||||
|
"new_value": new_summary,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/edge/knowledge-facts",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def edit_edge_knowledge_facts(
|
||||||
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
|
source_id: str = Form(...),
|
||||||
|
target_id: str = Form(...),
|
||||||
|
action: str = Form(...),
|
||||||
|
fact: str = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
"""Add or remove a single knowledge_fact on an edge.
|
||||||
|
|
||||||
|
Remove semantics are by string match (first occurrence) — the drawer
|
||||||
|
re-renders after every edit so threading a stable index through is
|
||||||
|
fragile when concurrent ``edge_update`` events can append more facts
|
||||||
|
between renders. The projector is a no-op when the fact isn't found,
|
||||||
|
keeping the route idempotent for stale form submissions.
|
||||||
|
"""
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
if action not in ("add", "remove"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"action must be 'add' or 'remove', got {action!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(fact) > KNOWLEDGE_FACT_MAX:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"fact exceeds {KNOWLEDGE_FACT_MAX} chars (got {len(fact)})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not fact.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="fact must not be empty")
|
||||||
|
|
||||||
|
edge = get_edge(conn, source_id, target_id)
|
||||||
|
if edge is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"edge not found: {source_id}->{target_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior = list(edge.get("knowledge") or [])
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "edge_knowledge_fact",
|
||||||
|
"target_id": {"source_id": source_id, "target_id": target_id},
|
||||||
|
"prior_value": prior,
|
||||||
|
"new_value": {"action": action, "fact": fact},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.3 witness flag inline-edit --------------------------------------
|
||||||
|
#
|
||||||
|
# Witness flags decide which entities can recall a memory (§7 retrieval).
|
||||||
|
# Editing them is rare but high-impact — flipping ``witness_guest`` from 0
|
||||||
|
# to 1 makes the memory available to the guest's prompt context. The route
|
||||||
|
# follows the T25 / T72.1 pattern: snapshot prior, append + apply
|
||||||
|
# ``manual_edit`` with a ``{flag, value}`` payload, refresh the partial.
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_WITNESS_FLAGS = ("you", "host", "guest")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/chats/{chat_id}/drawer/memory/witness",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
async def edit_memory_witness(
|
||||||
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
|
memory_id: int = Form(...),
|
||||||
|
flag: str = Form(...),
|
||||||
|
new_value: int = Form(...),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
chat = get_chat(conn, chat_id)
|
||||||
|
if chat is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
|
||||||
|
|
||||||
|
if flag not in _VALID_WITNESS_FLAGS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"flag must be one of {list(_VALID_WITNESS_FLAGS)}, "
|
||||||
|
f"got {flag!r}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT witness_{flag} FROM memories "
|
||||||
|
"WHERE id = ? AND chat_id = ?",
|
||||||
|
(int(memory_id), chat_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"memory not found in chat: {memory_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
prior_int = int(row[0])
|
||||||
|
new_int = 1 if int(new_value) else 0
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="manual_edit",
|
||||||
|
payload={
|
||||||
|
"target_kind": "memory_witness",
|
||||||
|
"target_id": int(memory_id),
|
||||||
|
"prior_value": {"flag": flag, "value": prior_int},
|
||||||
|
"new_value": {"flag": flag, "value": new_int},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await drawer(chat_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
# --- T42 guest add/remove -------------------------------------------------
|
# --- T42 guest add/remove -------------------------------------------------
|
||||||
#
|
#
|
||||||
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
# Adding a guest fans out into up to four events: a ``guest_added`` to flip
|
||||||
@@ -381,6 +680,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),
|
||||||
):
|
):
|
||||||
@@ -412,17 +712,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,
|
||||||
@@ -437,7 +752,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",
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"""T72: deferred v1 drawer edits + witness flag inline-edit.
|
||||||
|
|
||||||
|
T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
|
||||||
|
§6.4 editable surface whose ``manual_edit`` projector dispatch was already
|
||||||
|
in place (or, for ``edge_knowledge_fact``, added alongside the route):
|
||||||
|
|
||||||
|
* ``POST /chats/{chat_id}/drawer/edge/trust`` — slider 0..100.
|
||||||
|
* ``POST /chats/{chat_id}/drawer/edge/summary`` — textarea, capped 2000.
|
||||||
|
* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
|
||||||
|
* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
|
||||||
|
|
||||||
|
T72.3 layers a witness-flag toggle on top:
|
||||||
|
|
||||||
|
* ``POST /chats/{chat_id}/drawer/memory/witness`` — ``manual_edit`` with
|
||||||
|
``target_kind`` = ``memory_witness`` and a ``{flag, value}`` payload.
|
||||||
|
|
||||||
|
Each test asserts (a) the ``manual_edit`` event lands in the log,
|
||||||
|
(b) the projected table reflects the new value, and (c) the response is
|
||||||
|
the refreshed drawer partial.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('featherless_api_key = "test"\n')
|
||||||
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
if hasattr(app.state, "background_worker"):
|
||||||
|
app.state.background_worker.enabled = False
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: Path) -> None:
|
||||||
|
"""Seed a chat with one host bot, one host->you edge with a fact and
|
||||||
|
summary already set, and one memory authored by ``bot_a`` witnessed by
|
||||||
|
all three roles. Tests reach into projected state to verify edits.
|
||||||
|
"""
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "...",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="chat_created",
|
||||||
|
payload={
|
||||||
|
"id": "chat_bot_a",
|
||||||
|
"host_bot_id": "bot_a",
|
||||||
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||||
|
"narrative_anchor": "Day 1",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Materialise edge bot_a -> you with a knowledge_fact already on it
|
||||||
|
# so the remove path has something to consume.
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"affinity_delta": 0,
|
||||||
|
"knowledge_facts": ["studied physics together"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"pov_summary": "Original summary text.",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 0,
|
||||||
|
"significance": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.1 tests ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_trust_emits_manual_edit_and_updates(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/trust",
|
||||||
|
data={"source_id": "bot_a", "target_id": "you", "new_value": "73"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Refresh shows the new trust value somewhere in the partial.
|
||||||
|
assert "73" in response.text
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "edge_trust"
|
||||||
|
assert payload["prior_value"] == 50
|
||||||
|
assert payload["new_value"] == 73
|
||||||
|
assert payload["target_id"] == {
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
}
|
||||||
|
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
|
||||||
|
edge = get_edge(conn, "bot_a", "you")
|
||||||
|
assert edge["trust"] == 73
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_trust_400_on_out_of_range(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/trust",
|
||||||
|
data={"source_id": "bot_a", "target_id": "you", "new_value": "150"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_summary_emits_manual_edit_and_updates(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/summary",
|
||||||
|
data={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"new_summary": "BotA respects you and shares lab notes.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "edge_summary"
|
||||||
|
assert payload["new_value"].startswith("BotA respects")
|
||||||
|
assert payload["target_id"] == {
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = conn.execute(
|
||||||
|
"SELECT summary FROM edges "
|
||||||
|
"WHERE source_id = ? AND target_id = ?",
|
||||||
|
("bot_a", "you"),
|
||||||
|
).fetchone()[0]
|
||||||
|
assert "respects" in summary
|
||||||
|
|
||||||
|
# And the refreshed partial echoes the new summary back.
|
||||||
|
assert "respects" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_summary_400_on_overflow(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/summary",
|
||||||
|
data={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"new_summary": "x" * 2001,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_memory_pov_summary_emits_manual_edit_and_updates(
|
||||||
|
client, tmp_path
|
||||||
|
):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/pov-summary",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"new_summary": "Cleaner per-POV restatement of the moment.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "memory_pov_summary"
|
||||||
|
assert payload["prior_value"] == "Original summary text."
|
||||||
|
assert payload["new_value"].startswith("Cleaner per-POV")
|
||||||
|
assert payload["target_id"] == memory_id
|
||||||
|
|
||||||
|
pov = conn.execute(
|
||||||
|
"SELECT pov_summary FROM memories WHERE id = ?", (memory_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
assert pov.startswith("Cleaner per-POV")
|
||||||
|
|
||||||
|
assert "Cleaner per-POV" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_memory_pov_summary_404_when_wrong_chat(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
# Re-home the memory to a different chat to confirm the route's
|
||||||
|
# cross-chat guard fires.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE memories SET chat_id = 'other_chat' WHERE id = ?",
|
||||||
|
(memory_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/pov-summary",
|
||||||
|
data={"memory_id": str(memory_id), "new_summary": "..."},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_knowledge_facts_add_emits_event_and_appends(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
|
||||||
|
data={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"action": "add",
|
||||||
|
"fact": "lent you a textbook",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "edge_knowledge_fact"
|
||||||
|
assert payload["new_value"] == {
|
||||||
|
"action": "add",
|
||||||
|
"fact": "lent you a textbook",
|
||||||
|
}
|
||||||
|
# Prior value snapshots the entire knowledge list before the edit.
|
||||||
|
assert payload["prior_value"] == ["studied physics together"]
|
||||||
|
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
|
||||||
|
edge = get_edge(conn, "bot_a", "you")
|
||||||
|
assert "lent you a textbook" in edge["knowledge"]
|
||||||
|
assert "studied physics together" in edge["knowledge"]
|
||||||
|
|
||||||
|
assert "lent you a textbook" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_knowledge_facts_remove_drops_matching_fact(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
|
||||||
|
data={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"action": "remove",
|
||||||
|
"fact": "studied physics together",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
from chat.state.edges import get_edge
|
||||||
|
|
||||||
|
edge = get_edge(conn, "bot_a", "you")
|
||||||
|
assert "studied physics together" not in edge["knowledge"]
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "edge_knowledge_fact"
|
||||||
|
assert payload["new_value"]["action"] == "remove"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/edge/knowledge-facts",
|
||||||
|
data={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"action": "delete",
|
||||||
|
"fact": "x",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- T72.3 tests (witness flag inline-edit) -------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_witness_flag_toggle_updates_memory_row(client, tmp_path):
|
||||||
|
"""Memory seeded with witness [you=1, host=1, guest=0]; toggling
|
||||||
|
``guest`` to 1 lands as ``witness_guest = 1`` after projection.
|
||||||
|
"""
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/witness",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"flag": "guest",
|
||||||
|
"new_value": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT witness_you, witness_host, witness_guest "
|
||||||
|
"FROM memories WHERE id = ?",
|
||||||
|
(memory_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row == (1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_witness_flag_toggle_emits_manual_edit_event(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/witness",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"flag": "guest",
|
||||||
|
"new_value": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(rows) == 1
|
||||||
|
payload = json.loads(rows[0][0])
|
||||||
|
assert payload["target_kind"] == "memory_witness"
|
||||||
|
assert payload["target_id"] == memory_id
|
||||||
|
assert payload["prior_value"] == {"flag": "guest", "value": 0}
|
||||||
|
assert payload["new_value"] == {"flag": "guest", "value": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_witness_flag_toggle_400_on_bad_flag(client, tmp_path):
|
||||||
|
_seed(tmp_path / "test.db")
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/chats/chat_bot_a/drawer/memory/witness",
|
||||||
|
data={
|
||||||
|
"memory_id": str(memory_id),
|
||||||
|
"flag": "narrator",
|
||||||
|
"new_value": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@@ -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