diff --git a/chat/services/relationship_seed.py b/chat/services/relationship_seed.py new file mode 100644 index 0000000..bd14592 --- /dev/null +++ b/chat/services/relationship_seed.py @@ -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"] diff --git a/tests/test_relationship_seed.py b/tests/test_relationship_seed.py new file mode 100644 index 0000000..b2b7312 --- /dev/null +++ b/tests/test_relationship_seed.py @@ -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()