feat: relationship-seed service for first-co-appearance prompt

This commit is contained in:
Joseph Doherty
2026-04-26 15:47:12 -04:00
parent b8335895e1
commit c6b3531c64
2 changed files with 216 additions and 0 deletions
+107
View File
@@ -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"]
+109
View File
@@ -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()