Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 45 additions & 25 deletions relic/checkin/context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def build_topic_hint_section(
import hashlib
import sqlite3
from relic.checkin.anti_repeat import AntiRepeatGate
from relic.checkin.question_engine import select_facet
from relic.checkin.question_engine import select_facet, select_followup
from relic.checkin.topic_hint import render_topic_hint

day_seed = (
Expand Down Expand Up @@ -195,31 +195,51 @@ def build_topic_hint_section(
conn_t, datetime.now(timezone.utc)
):
return ""
sel = select_facet(
conn_t,
bl_path if bl_path.exists() else None,
seed=day_seed,
)
if sel.get("status") == "ask_now":
ar = AntiRepeatGate(conn_t, jaccard_threshold=0.60).check(
sel["question_hint"]
# Follow-up-first: mirror _select_ask_decision so the gate's
# ask_topic and this rendered block agree on the same hint.
followup = select_followup(conn_t, datetime.now(timezone.utc))
if followup is not None:
try:
recent_q = [
r[0]
for r in conn_t.execute(
"SELECT question_text FROM checkin_exchanges "
"ORDER BY asked_at DESC LIMIT 10"
).fetchall()
if r[0]
]
except Exception:
recent_q = []
topic_block = render_topic_hint(followup["hint"], recent_q)
if topic_block:
topic_facet_id = followup["facet_id"]
topic_hint_text = followup["hint"]
if not topic_block:
sel = select_facet(
conn_t,
bl_path if bl_path.exists() else None,
seed=day_seed,
)
if not ar["duplicate"]:
try:
recent_q = [
r[0]
for r in conn_t.execute(
"SELECT question_text FROM checkin_exchanges "
"ORDER BY asked_at DESC LIMIT 10"
).fetchall()
if r[0]
]
except Exception:
recent_q = []
topic_block = render_topic_hint(sel["question_hint"], recent_q)
if topic_block:
topic_facet_id = sel["selected_facet"]
topic_hint_text = sel["question_hint"]
if sel.get("status") == "ask_now":
ar = AntiRepeatGate(conn_t, jaccard_threshold=0.60).check(
sel["question_hint"]
)
if not ar["duplicate"]:
try:
recent_q = [
r[0]
for r in conn_t.execute(
"SELECT question_text FROM checkin_exchanges "
"ORDER BY asked_at DESC LIMIT 10"
).fetchall()
if r[0]
]
except Exception:
recent_q = []
topic_block = render_topic_hint(sel["question_hint"], recent_q)
if topic_block:
topic_facet_id = sel["selected_facet"]
topic_hint_text = sel["question_hint"]
finally:
if conn_t is not None:
conn_t.close()
Expand Down
5 changes: 4 additions & 1 deletion relic/checkin/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ class Decision:
Posture.QUIET: 0,
Posture.OBSERVE: 1,
Posture.BRIEF_SHARE: 2,
Posture.ASK: 2,
# 3, not 2: leaves room for the share-then-ask shape (one small
# self-disclosure before the question; reciprocity increases disclosure,
# Moon 2000) without forcing the question into a fixed second slot.
Posture.ASK: 3,
Posture.FOLLOW_UP_WARM: 3,
Posture.FOLLOW_UP_TERSE: 2,
Posture.REFLECTIVE_MIRROR: 2,
Expand Down
75 changes: 75 additions & 0 deletions relic/checkin/question_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,81 @@ def build_question_hint(f: FacetState) -> str:
return desc


# Follow-up window: a replied exchange is a follow-up candidate while the
# reply is at most this old. Beyond it the thread has gone cold and a new
# TGS-selected topic reads more natural than reviving stale context.
FOLLOWUP_WINDOW_HOURS = 72

# Cap on the reply excerpt quoted back into the follow-up hint. Keeps the
# rendered topic block inside the 200-char budget of render_topic_hint.
_FOLLOWUP_EXCERPT_CHARS = 90


def select_followup(
conn: sqlite3.Connection,
now: datetime | None = None,
window_hours: int = FOLLOWUP_WINDOW_HOURS,
) -> dict[str, Any] | None:
"""Return a follow-up candidate built from the latest answered exchange.

Follow-up questions (not topic-switch questions) are the conversational
move that deepens rapport (Huang et al. 2017, JPSP). A replied exchange is
a candidate when:
* its reply was captured within ``window_hours``;
* no newer exchange was asked after the reply (i.e. the answer has not
been followed up yet).

Returns ``{"facet_id", "question_text", "reply_excerpt", "hint"}`` or
``None``. The hint is built mostly from the reply text so the Jaccard
anti-repeat gate does not collide with the original question.
"""
now = now or datetime.now(timezone.utc)
try:
row = conn.execute(
"""SELECT facet_id, question_text, reply_text, reply_captured_at
FROM checkin_exchanges
WHERE reply_text IS NOT NULL AND facet_id IS NOT NULL
ORDER BY reply_captured_at DESC
LIMIT 1"""
).fetchone()
except sqlite3.OperationalError:
return None
if not row:
return None

facet_id, question_text, reply_text, reply_at_iso = row
reply_at = _parse_iso(reply_at_iso)
if reply_at is None:
return None
if reply_at.tzinfo is None:
reply_at = reply_at.replace(tzinfo=timezone.utc)
if (now - reply_at).total_seconds() > window_hours * 3600:
return None

# Already followed up? Any exchange asked after the reply closes the thread.
try:
newer = conn.execute(
"SELECT 1 FROM checkin_exchanges WHERE asked_at > ? LIMIT 1",
(reply_at_iso,),
).fetchone()
except sqlite3.OperationalError:
return None
if newer:
return None

excerpt = " ".join((reply_text or "").split())[:_FOLLOWUP_EXCERPT_CHARS].strip()
if not excerpt:
return None

hint = f"Approfondisci quello che ti ha risposto: «{excerpt}»."
return {
"facet_id": facet_id,
"question_text": question_text or "",
"reply_excerpt": excerpt,
"hint": hint,
}


def select_facet(
conn: sqlite3.Connection,
subject_baseline_path: Path | None = None,
Expand Down
Loading
Loading