Compare commits
7 Commits
phase-1
...
22db9f3554
| Author | SHA1 | Date | |
|---|---|---|---|
| 22db9f3554 | |||
| 6b726b2a4a | |||
| e58cdbd527 | |||
| b69a74e69b | |||
| c6b3531c64 | |||
| a0d7debce5 | |||
| a1b4e251c5 |
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE group_node (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
members_json TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
dynamic TEXT NOT NULL DEFAULT '',
|
||||
threads_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Parse user-supplied "have they met?" prose into per-direction seed
|
||||
content for two bots' edges (T38).
|
||||
|
||||
Per Requirements §5.2, when two bots first co-appear in a chat, the user
|
||||
is offered a small drawer asking "Have they met before? If yes, write a
|
||||
short prose seed describing how." That prose lands here and is parsed
|
||||
into a :class:`RelationshipSeed` whose two halves populate the
|
||||
``botA -> botB`` and ``botB -> botA`` edges respectively (summary,
|
||||
initial knowledge facts, and small affinity/trust deltas around the
|
||||
default 50/50 baseline).
|
||||
|
||||
The two directions can differ — A may know more about B than B knows
|
||||
about A, or A may trust B less than the reverse — so the schema carries
|
||||
both halves independently.
|
||||
|
||||
Empty/whitespace-only prose short-circuits to a default
|
||||
``RelationshipSeed`` (all zeroes, empty strings); the caller treats
|
||||
that as "they haven't met" and writes no edge content. The wrapper uses
|
||||
:func:`chat.llm.classify.classify` with ``default=RelationshipSeed()``
|
||||
so a flapping classifier degrades to the same no-op rather than
|
||||
blocking the chat-creation flow (§3.3 graceful-degradation rule).
|
||||
|
||||
T42 (the inter-bot relationship drawer) calls this from the route layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from chat.llm.classify import classify
|
||||
from chat.llm.client import LLMClient
|
||||
|
||||
|
||||
class RelationshipSeed(BaseModel):
|
||||
"""Structured per-direction seed for two bots' edges.
|
||||
|
||||
Defaults are a deliberate no-op: empty summaries, empty knowledge
|
||||
lists, zero deltas. Both the empty-prose short-circuit and the
|
||||
classifier-failure fallback return this default so the caller can
|
||||
treat them identically.
|
||||
"""
|
||||
|
||||
a_to_b_summary: str = ""
|
||||
a_to_b_knowledge_facts: list[str] = Field(default_factory=list)
|
||||
a_to_b_affinity_delta: int = 0 # signed, -10..+10 typical
|
||||
a_to_b_trust_delta: int = 0
|
||||
b_to_a_summary: str = ""
|
||||
b_to_a_knowledge_facts: list[str] = Field(default_factory=list)
|
||||
b_to_a_affinity_delta: int = 0
|
||||
b_to_a_trust_delta: int = 0
|
||||
|
||||
|
||||
_SYSTEM = (
|
||||
"You parse a short prose seed describing how two characters know each "
|
||||
"other into structured per-direction edge content. For each direction "
|
||||
"(A -> B, B -> A) extract: summary (one sentence from that POV), "
|
||||
"knowledge_facts (list of factual claims that direction can carry "
|
||||
"into future scenes), affinity_delta (-10..+10 — small adjustments to "
|
||||
"the default 50/50 baseline), trust_delta (-10..+10). Default deltas "
|
||||
"to 0 when prose is neutral. The two directions can differ — A may "
|
||||
"trust B more than B trusts A. Output strict JSON matching the schema."
|
||||
)
|
||||
|
||||
|
||||
async def seed_inter_bot_edges(
|
||||
client: LLMClient,
|
||||
*,
|
||||
classifier_model: str,
|
||||
bot_a_id: str,
|
||||
bot_a_name: str,
|
||||
bot_b_id: str,
|
||||
bot_b_name: str,
|
||||
relationship_prose: str,
|
||||
timeout_s: float = 30.0,
|
||||
) -> RelationshipSeed:
|
||||
"""Parse user-supplied prose into structured edge content for both
|
||||
directed pairs.
|
||||
|
||||
Empty/whitespace prose short-circuits to an empty
|
||||
:class:`RelationshipSeed` (the caller treats this as "they haven't
|
||||
met" and writes no edge content). Classifier failure also returns
|
||||
the default — see module docstring for the rationale.
|
||||
|
||||
The ``bot_a_id`` / ``bot_b_id`` arguments are accepted for symmetry
|
||||
with the caller (T42's drawer route uses them when emitting
|
||||
``edge_update`` events); they're embedded in the prompt alongside
|
||||
the names so the classifier can disambiguate when names collide.
|
||||
"""
|
||||
if not relationship_prose or not relationship_prose.strip():
|
||||
return RelationshipSeed()
|
||||
user = (
|
||||
f"Bot A: {bot_a_name} (id={bot_a_id})\n"
|
||||
f"Bot B: {bot_b_name} (id={bot_b_id})\n\n"
|
||||
f"Prose seed:\n{relationship_prose.strip()}"
|
||||
)
|
||||
return await classify(
|
||||
client,
|
||||
model=classifier_model,
|
||||
system=_SYSTEM,
|
||||
user=user,
|
||||
schema=RelationshipSeed,
|
||||
default=RelationshipSeed(),
|
||||
timeout_s=timeout_s,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RelationshipSeed", "seed_inter_bot_edges"]
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from sqlite3 import Connection
|
||||
from chat.eventlog.projector import on
|
||||
from chat.eventlog.log import Event
|
||||
|
||||
|
||||
@on("group_node_initialized")
|
||||
def _apply_group_node_initialized(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO group_node "
|
||||
"(chat_id, members_json, summary, dynamic, threads_json) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
p["chat_id"],
|
||||
json.dumps(p["members"]),
|
||||
p.get("summary", ""),
|
||||
p.get("dynamic", ""),
|
||||
json.dumps(p.get("threads", [])),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@on("group_node_updated")
|
||||
def _apply_group_node_updated(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE group_node SET summary = ?, dynamic = ?, updated_at = datetime('now') "
|
||||
"WHERE chat_id = ?",
|
||||
(p.get("summary", ""), p.get("dynamic", ""), p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
def get_group_node(conn: Connection, chat_id: str) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT chat_id, members_json, summary, dynamic, threads_json, updated_at "
|
||||
"FROM group_node WHERE chat_id = ?",
|
||||
(chat_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"chat_id": row[0],
|
||||
"members": json.loads(row[1]),
|
||||
"summary": row[2],
|
||||
"dynamic": row[3],
|
||||
"threads": json.loads(row[4]),
|
||||
"updated_at": row[5],
|
||||
}
|
||||
@@ -29,6 +29,24 @@ def _apply_chat_created(conn: Connection, e: Event) -> None:
|
||||
)
|
||||
|
||||
|
||||
@on("guest_added")
|
||||
def _apply_guest_added(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chats SET guest_bot_id = ? WHERE id = ?",
|
||||
(p["guest_bot_id"], p["chat_id"]),
|
||||
)
|
||||
|
||||
|
||||
@on("guest_removed")
|
||||
def _apply_guest_removed(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
conn.execute(
|
||||
"UPDATE chats SET guest_bot_id = NULL WHERE id = ?",
|
||||
(p["chat_id"],),
|
||||
)
|
||||
|
||||
|
||||
@on("container_created")
|
||||
def _apply_container_created(conn: Connection, e: Event) -> None:
|
||||
p = e.payload
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # registers handlers
|
||||
import chat.state.world # registers handlers
|
||||
import chat.state.group_node # registers handlers
|
||||
from chat.state.group_node import get_group_node
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "thoughtful, observant",
|
||||
"voice_samples": [],
|
||||
"traits": [],
|
||||
"backstory": "",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"kickoff_prose": "",
|
||||
}
|
||||
|
||||
|
||||
def _chat_payload(chat_id: str = "chat_bot_a") -> dict:
|
||||
return {
|
||||
"id": chat_id,
|
||||
"host_bot_id": "bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
}
|
||||
|
||||
|
||||
def test_group_node_initialized_creates_row(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="group_node_initialized",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"members": ["you", "bot_a", "bot_b"],
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
gn = get_group_node(conn, "chat_bot_a")
|
||||
assert gn is not None
|
||||
assert gn["chat_id"] == "chat_bot_a"
|
||||
assert gn["members"] == ["you", "bot_a", "bot_b"]
|
||||
assert gn["summary"] == ""
|
||||
assert gn["dynamic"] == ""
|
||||
assert gn["threads"] == []
|
||||
|
||||
|
||||
def test_group_node_updated_changes_summary_and_dynamic(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(
|
||||
conn,
|
||||
kind="group_node_initialized",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"members": ["you", "bot_a", "bot_b"],
|
||||
},
|
||||
)
|
||||
append_event(
|
||||
conn,
|
||||
kind="group_node_updated",
|
||||
payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"summary": "Three coworkers chatting about the project.",
|
||||
"dynamic": "Tense but cordial.",
|
||||
},
|
||||
)
|
||||
project(conn)
|
||||
|
||||
gn = get_group_node(conn, "chat_bot_a")
|
||||
assert gn is not None
|
||||
assert gn["summary"] == "Three coworkers chatting about the project."
|
||||
assert gn["dynamic"] == "Tense but cordial."
|
||||
# Members preserved across update
|
||||
assert gn["members"] == ["you", "bot_a", "bot_b"]
|
||||
|
||||
|
||||
def test_get_group_node_returns_none_for_missing_chat(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
assert get_group_node(conn, "chat_missing") is None
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from chat.db.connection import open_db
|
||||
from chat.db.migrate import apply_migrations
|
||||
from chat.eventlog.log import append_event
|
||||
from chat.eventlog.projector import project
|
||||
import chat.state.entities # registers bot_authored handler
|
||||
import chat.state.world # registers chat_created / guest_added / guest_removed
|
||||
from chat.state.world import get_chat
|
||||
|
||||
|
||||
def _bot_payload(bot_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": bot_id,
|
||||
"name": name,
|
||||
"persona": "...",
|
||||
"voice_samples": ["sample"],
|
||||
"traits": ["shy"],
|
||||
"backstory": "...",
|
||||
"initial_relationship_to_you": "coworker",
|
||||
"kickoff_prose": "you stay late",
|
||||
}
|
||||
|
||||
|
||||
def _chat_payload(**overrides) -> dict:
|
||||
payload = {
|
||||
"id": "chat_bot_a",
|
||||
"host_bot_id": "bot_a",
|
||||
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||
"narrative_anchor": "Day 1 evening",
|
||||
"weather": "clear",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_guest_added_sets_guest_bot_id(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(conn, kind="guest_added", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat is not None
|
||||
assert chat["guest_bot_id"] == "bot_b"
|
||||
|
||||
|
||||
def test_guest_removed_clears_guest_bot_id(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(conn, kind="guest_added", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
})
|
||||
append_event(conn, kind="guest_removed", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat is not None
|
||||
assert chat["guest_bot_id"] is None
|
||||
|
||||
|
||||
def test_guest_added_idempotent_overwrite(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
|
||||
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_c", "BotC"))
|
||||
append_event(conn, kind="chat_created", payload=_chat_payload())
|
||||
append_event(conn, kind="guest_added", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"guest_bot_id": "bot_b",
|
||||
})
|
||||
append_event(conn, kind="guest_added", payload={
|
||||
"chat_id": "chat_bot_a",
|
||||
"guest_bot_id": "bot_c",
|
||||
})
|
||||
project(conn)
|
||||
|
||||
chat = get_chat(conn, "chat_bot_a")
|
||||
assert chat is not None
|
||||
assert chat["guest_bot_id"] == "bot_c"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the relationship-seed service (T38).
|
||||
|
||||
Per Requirements §5.2, when two bots first co-appear in a chat, the user
|
||||
is prompted with "Have they met before? If yes, write a short prose
|
||||
seed." The prose is parsed via classifier into structured directed-edge
|
||||
content for the ``botA -> botB`` and ``botB -> botA`` edges.
|
||||
|
||||
These tests cover:
|
||||
|
||||
* The happy path: a canned classifier response parses cleanly into a
|
||||
populated :class:`RelationshipSeed` with both directions filled.
|
||||
* Empty prose short-circuits before any classifier call (mock has no
|
||||
canned responses; an accidental call would raise ``IndexError``).
|
||||
* Whitespace-only prose has the same short-circuit behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chat.llm.mock import MockLLMClient
|
||||
from chat.services.relationship_seed import (
|
||||
RelationshipSeed,
|
||||
seed_inter_bot_edges,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_parses_canned_prose():
|
||||
canned = json.dumps(
|
||||
{
|
||||
"a_to_b_summary": "old college friend who now distrusts him slightly",
|
||||
"a_to_b_knowledge_facts": [
|
||||
"studied physics together",
|
||||
"lost touch after a falling out",
|
||||
],
|
||||
"a_to_b_affinity_delta": 2,
|
||||
"a_to_b_trust_delta": -1,
|
||||
"b_to_a_summary": "former roommate; warm memories, mild resentment",
|
||||
"b_to_a_knowledge_facts": ["lived together junior year"],
|
||||
"b_to_a_affinity_delta": 3,
|
||||
"b_to_a_trust_delta": 0,
|
||||
}
|
||||
)
|
||||
mock = MockLLMClient(canned=[canned])
|
||||
result = await seed_inter_bot_edges(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
bot_a_id="bot_a",
|
||||
bot_a_name="Alice",
|
||||
bot_b_id="bot_b",
|
||||
bot_b_name="Bob",
|
||||
relationship_prose=(
|
||||
"Alice and Bob met in college. They studied physics together and "
|
||||
"lived as roommates junior year, but drifted apart after a fight."
|
||||
),
|
||||
)
|
||||
assert isinstance(result, RelationshipSeed)
|
||||
assert (
|
||||
result.a_to_b_summary
|
||||
== "old college friend who now distrusts him slightly"
|
||||
)
|
||||
assert result.a_to_b_knowledge_facts == [
|
||||
"studied physics together",
|
||||
"lost touch after a falling out",
|
||||
]
|
||||
assert result.a_to_b_affinity_delta == 2
|
||||
assert result.a_to_b_trust_delta == -1
|
||||
assert (
|
||||
result.b_to_a_summary
|
||||
== "former roommate; warm memories, mild resentment"
|
||||
)
|
||||
assert result.b_to_a_knowledge_facts == ["lived together junior year"]
|
||||
assert result.b_to_a_affinity_delta == 3
|
||||
assert result.b_to_a_trust_delta == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_empty_prose_returns_empty():
|
||||
"""Empty prose short-circuits — classifier must not be called."""
|
||||
mock = MockLLMClient(canned=[])
|
||||
result = await seed_inter_bot_edges(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
bot_a_id="bot_a",
|
||||
bot_a_name="Alice",
|
||||
bot_b_id="bot_b",
|
||||
bot_b_name="Bob",
|
||||
relationship_prose="",
|
||||
)
|
||||
assert result == RelationshipSeed()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_whitespace_only_prose_returns_empty():
|
||||
"""Whitespace-only prose is treated the same as empty."""
|
||||
mock = MockLLMClient(canned=[])
|
||||
result = await seed_inter_bot_edges(
|
||||
mock,
|
||||
classifier_model="x",
|
||||
bot_a_id="bot_a",
|
||||
bot_a_name="Alice",
|
||||
bot_b_id="bot_b",
|
||||
bot_b_name="Bob",
|
||||
relationship_prose=" \n ",
|
||||
)
|
||||
assert result == RelationshipSeed()
|
||||
+2
-2
@@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path):
|
||||
assert active_scene(conn, "chat_missing") is None
|
||||
|
||||
|
||||
def test_schema_version_after_migration_is_7(tmp_path):
|
||||
def test_schema_version_after_migration_is_8(tmp_path):
|
||||
db = tmp_path / "t.db"
|
||||
apply_migrations(db)
|
||||
with open_db(db) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'schema_version'"
|
||||
).fetchone()
|
||||
assert int(row[0]) == 7
|
||||
assert int(row[0]) == 8
|
||||
|
||||
Reference in New Issue
Block a user