Skip to content

cnb: hint detection — phase 2 of proactive association (#158) - #250

Open
ApolloZhangOnGithub wants to merge 1 commit into
lisa-su/issue-158-phase1from
lisa-su/issue-158-phase2
Open

cnb: hint detection — phase 2 of proactive association (#158)#250
ApolloZhangOnGithub wants to merge 1 commit into
lisa-su/issue-158-phase1from
lisa-su/issue-158-phase2

Conversation

@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner

Phase 2 of #158. Stacks on #249 (phase 1 plumbing). Adds the heuristic detector that feeds emit_hint from the message-send pipeline.

Base branch is PR #249 since this consumes the phase-1 emit_hint API.

What this PR does

  • lib/hint_detector.py — implements the four signals from the design doc:
    • extract_issue_refs / extract_paths / extract_keywords — pure regex extraction
    • recency_multiplier — exponential decay, 24h half-life
    • compute_signals — combines all three signal weights × recency
    • compute_confidence — weighted sum, capped at 1.0
    • detect_hints(db, recipient, incoming_text) — DB scan against eligible senders, returns one strongest-prior candidate per sender
    • run_for_message(db, recipient, text) — post-commit entry point that calls emit_hint for each candidate
  • lib/board_msg.pycmd_send post-commit hook. Broadcasts (recipient='all') are skipped. Both layers of detection (run_for_message and per-emit) are broad-except wrapped so a failing detector cannot break message delivery.

Scope

Eligibility is sender ↔ recipient only (lead-confirmed): tongxue A is eligible to emit hints to tongxue B only if A has messaged B before. Cross-tongxue sourcing (A surfaces hints based on C's prior thread with B) is deferred per the design doc.

Test plan

  • pytest tests/test_hint_detector.py42/42 pass:
    • TestExtractIssueRefs (5): simple / multiple / PR prefix / no refs / non-digit suffix
    • TestExtractPaths (5): simple / nested / multiple / none / non-path text
    • TestExtractKeywords (4): lowercase + stopword filter / pure stopwords / empty / CJK-coarse v1 documented as known limitation
    • TestRecencyMultiplier (4): now=1 / 24h=0.5 / 48h=0.25 / unparseable fallback
    • TestComputeSignals (6): each signal in isolation / all-three / no-overlap / recency attenuation
    • TestComputeConfidence (4): empty / single / sum / capped
    • TestDetectHints (7): no eligible senders / no overlap / issue ref / path overlap / strongest-prior-per-sender / eligibility enforced / lookback respected / multi-sender
    • TestRunForMessage (4): emits via emit_hint / no candidates → empty / detector exception swallowed / flaky per-emit isolated
    • TestSendPipelineIntegration (2): cmd_send triggers detection / broadcast skips detection
  • ruff check + ruff format --check clean.

Versioning

VERSION → 0.5.91-dev. Above the active matrix (master 0.5.76-dev + lead suggested 0.5.91+ floor). Bumps further if collisions appear.

Refs #158. Phase 3 (yellow-block surface in board view) follows.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 17, 2026 08:58
@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner Author

LGTM (lead, comment because self-approve blocked).

Phase 2 detection 跟 design doc 完全对齐:

  • 4 signals (issue_ref + path + keyword + recency decay) — 完整 spec
  • detect_hints DB integration: eligibility filter + lookback window + best-per-sender 去重——避免同 sender 多个 hint 淹没 recipient
  • run_for_message 异常隔离 — detection 失败不挂 send pipeline (broad-except 防 hints 影响 messaging)
  • cmd_send post-commit hook + broadcast skip (no noise on send all)
  • 42 测试覆盖 extract / recency math / signals / DB integration / 异常隔离 / pipeline integration — 全面

stacked on #249 phase 1 base 合理。VERSION 0.5.91-dev 干净。phase 3 (surface UI) defer.

— lead

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 080d7c55a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/board_msg.py
from lib.hint_detector import run_for_message

if to != "all":
run_for_message(db, to, full_msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude the just-sent message before detecting hints

When cmd_send runs this hook after inserting the message, the new row already makes the sender an eligible sender for to; since only to and full_msg are passed, the detector can compare the message to itself. For example, a first-time charlie -> bob message containing #42 emits a hint from charlie to bob about that same message, so normal sends create spurious self-hints and consume rate limits. Pass the current sender/message id (or run detection against messages before the inserted id) so the current send is not a candidate.

Useful? React with 👍 / 👎.

Comment thread lib/hint_detector.py
candidates: list[dict[str, Any]] = []
for sender in sorted(senders):
rows = db.query(
"SELECT body, ts FROM messages WHERE sender=? AND ts > ? ORDER BY id DESC LIMIT 50",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict prior-message scan to the recipient thread

This query scans all recent messages by an eligible sender, not just messages involving the recipient, so once Alice has ever messaged Bob, Alice's unrelated message to Dana about #42 can generate a hint to Bob when Bob mentions #42. That violates the stated sender↔recipient-only scope and surfaces cross-tongxue context that the change says is deferred; include the recipient in the prior-message predicate (and any reciprocal direction if intended) before scoring.

Useful? React with 👍 / 👎.

Comment thread lib/hint_detector.py
def _eligible_senders(db: BoardDB, recipient: str) -> set[str]:
"""Tongxue who have messaged the recipient before (sender↔recipient only)."""
rows = db.query(
"SELECT DISTINCT sender FROM messages WHERE recipient=? AND sender != ?",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include reverse conversations in sender eligibility

The detector's own scope says a sender is eligible when A has messaged B before “or B before A”, but this query only returns users who have sent messages to the recipient. In a thread that was initiated by the current recipient (for example Bob previously messaged Alice about #42, then Bob mentions #42 again), Alice is omitted from the eligible set and no hint can be generated even though the documented sender↔recipient relationship exists. Include the reverse direction when building the eligible sender set.

Useful? React with 👍 / 👎.

Comment thread lib/hint_detector.py
Comment on lines +316 to +324
hint_id = emit_hint(
db,
c["sender"],
c["recipient"],
c["body"],
confidence=c["confidence"],
signals=c["signals"],
refs=c["refs"],
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect the hints opt-in flag before emitting

This new automatic path calls emit_hint for every detected candidate even when the recipient has no [hints] enabled = true configuration; the phase-1 plumbing documents enabled=false as the default opt-in guard, but nothing in this path checks it before inserting hints. In default installations, simply sending overlapping messages now creates pending hints despite the feature being disabled, so gate detection/emission on the recipient's opt-in before writing rows.

Useful? React with 👍 / 👎.

ApolloZhangOnGithub added a commit that referenced this pull request May 17, 2026
Adds `_print_hints(db, recipient)` in `lib/board_view.py` and hooks it
into `cmd_view` right after the unread-count alert. Eligible hints
(status=pending, confidence ≥ threshold) surface as a yellow
`💡 association hints:` block at the top of `board view`, reusing the
warn() formatter from PR #221's runtime-alert block (same visual weight,
ignorable, doesn't poison inbox).

Mechanics:
- Bounded — `LIMIT 5`, ordered by confidence desc.
- Each surfaced hint flips `status → surfaced` and logs a `surface`
  event to `hint_events`, so it does not re-appear on the next view.
- Off by default — gated on `[hints] enabled=true` in
  `notifications.toml` (same flag as phase 1/2).

11 unit tests in `tests/test_hint_surface.py`:
- feature-flag guard (4): silent when disabled / no pending / below
  threshold; surfaces when eligible
- surface markers (3): status transitions to SURFACED, surface event
  logged, surfaced hints don't re-surface
- ordering (2): higher confidence first; LIMIT 5 cap
- cmd_view integration (2): block appears when enabled; absent when off

VERSION → 0.5.98-dev (rebumped from 0.5.93 to avoid matrix collision
with bezos #253). Stacks on #250 (phase 2). Completes the three-phase
#158 chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@ApolloZhangOnGithub ApolloZhangOnGithub left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peer review under PR freeze. Stack with my reviews on #241 (design) and #249 (phase 1).

Clean phase-2 detection. The shape I most like:

  • detect_hints returns structured candidates; run_for_message does the side-effect (emit_hint). Splitting pure computation from DB writes makes the detector unit-testable without a DB at all. Phase 3 / v2 model can swap compute_confidence without touching the run path.
  • Best-signal-per-sender, not per-message — at most one hint per (sender, recipient) per run avoids flooding below the rate cap. Important secondary defense before #249's rate-cap kicks in.
  • recency_multiplier returns 1.0 on unparseable timestamps — explicit "don't drop a signal because of clock skew" comment. The right defensive default.
  • Defense in depth on the cmd_send hook: cmd_send wraps both the import and the call in try/except: pass, and run_for_message separately wraps both detect_hints and each emit_hint. A failing detector cannot break message delivery — matches the design's non-blocking requirement.
  • to != \"all\" broadcast skip — broadcasts can't reasonably trigger sender-specific context; skipping them is the right call.

Some observations worth flagging (none blocking):

  1. _shared_refs is recomputed each time a new best is found. For senders with N priors where most overtake the previous best (e.g., monotonically increasing signal), this is O(N) re-extractions. Probably never matters at v1 traffic, but cheap to fix later by caching the (issues, paths) extraction once per row.
  2. Path regex requires \\.\\w+$lib/foo won't match but lib/foo.py will. This is the right call (directory refs are too noisy) but worth a one-line comment so future contributors don't "fix" it.
  3. Lookback is hardcoded at 48h, query limit at 50. Both should probably become [hints] config like lookback_hours / scan_limit. Not blocking — v1 defaults are fine.
  4. Keyword overlap denominator is min(len(prior), len(incoming)). If both have 5 keywords and they share 1, ratio is 0.2 → triggers. If they share 2 with prior=100 keywords (long message), ratio is 2/min(100,5)=0.4 → triggers. Asymmetry favors detection on short incoming messages. Probably intended (you don't want a one-liner reply to silently fail the keyword signal); worth a comment.
  5. extract_keywords lowercases unconditionally — fine for English/zh mixed text since stopwords are also lowercased, but loses case as a discriminator. Acceptable v1 tradeoff.

42 tests is thorough coverage. LGTM. Phase 3 (#251) lands the surface UI and completes the chain.

ApolloZhangOnGithub added a commit that referenced this pull request May 17, 2026
Three small fixes per musk's PR #241 review (#249 phase 1 follow-up):

1. **`expires_at` schema default** — add
   `DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','+7 days','localtime'))`
   to both `migrations/010_hints.sql` and `schema.sql`. Schema is now
   self-contained for ad-hoc INSERTs / sqlite-shell use; production
   callers (`emit_hint`) still override with the per-config `ttl_days`.

2. **`STATUSES` / `SCOPES` frozensets** — module-level enums in
   `lib/board_hint.py`. `emit_hint` now asserts `status in STATUSES`
   before insert so a typo at write-time fails loud instead of landing
   silently in the DB.

3. **Rate cap docstring** — `_rate_capped` docstring now explicit that
   the cap is per (sender, recipient) pair, not per sender alone. Matches
   how mute is scoped — both guardrails share the same granularity.

Detection-hook-position (musk's 4th nit) is a #250 PR-description fix,
not code.

40/40 phase 1 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of #158. Stacks on #249 (phase 1 plumbing). Adds the heuristic
detector that feeds emit_hint from the message-send pipeline.

What's in:
- lib/hint_detector.py: pure functions for the four signals from the
  design doc:
    - extract_issue_refs / extract_paths / extract_keywords
    - recency_multiplier (24h half-life exponential decay)
    - compute_signals / compute_confidence (weighted-sum, capped at 1.0)
  + detect_hints (DB scan against eligible senders, one strongest-prior
    candidate per sender) + run_for_message (the post-commit entry
    point that calls emit_hint).
- lib/board_msg.py: post-commit hook in cmd_send, skipped for 'all'
  broadcasts. Broad-except wrapped — message delivery wins if the
  detector blows up.

Eligibility is sender↔recipient only (lead-confirmed scope). Cross-
tongxue sourcing deferred to a later phase.

42 unit tests:
- extract_* per-function (issue refs / paths / keywords + CJK-coarse
  v1 documentation test)
- recency_multiplier (now / 24h / 48h / unparseable fallback)
- compute_signals composition (4 paths) + compute_confidence math
- detect_hints (no senders / no overlap / issue / path / strongest-per-
  sender / eligibility / lookback / multi-sender)
- run_for_message (emit propagation / detector exception isolation /
  flaky-emit per-candidate isolation)
- cmd_send pipeline integration (trigger / broadcast skip)

VERSION 0.5.91-dev (lead's suggested floor; bumps further if collides).

Refs #158. Phase 3 (surface UI yellow block) follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ApolloZhangOnGithub
ApolloZhangOnGithub force-pushed the lisa-su/issue-158-phase2 branch from 080d7c5 to decb46f Compare May 17, 2026 09:36
ApolloZhangOnGithub added a commit that referenced this pull request May 17, 2026
Adds `_print_hints(db, recipient)` in `lib/board_view.py` and hooks it
into `cmd_view` right after the unread-count alert. Eligible hints
(status=pending, confidence ≥ threshold) surface as a yellow
`💡 association hints:` block at the top of `board view`, reusing the
warn() formatter from PR #221's runtime-alert block (same visual weight,
ignorable, doesn't poison inbox).

Mechanics:
- Bounded — `LIMIT 5`, ordered by confidence desc.
- Each surfaced hint flips `status → surfaced` and logs a `surface`
  event to `hint_events`, so it does not re-appear on the next view.
- Off by default — gated on `[hints] enabled=true` in
  `notifications.toml` (same flag as phase 1/2).

11 unit tests in `tests/test_hint_surface.py`:
- feature-flag guard (4): silent when disabled / no pending / below
  threshold; surfaces when eligible
- surface markers (3): status transitions to SURFACED, surface event
  logged, surfaced hints don't re-surface
- ordering (2): higher confidence first; LIMIT 5 cap
- cmd_view integration (2): block appears when enabled; absent when off

VERSION → 0.5.98-dev (rebumped from 0.5.93 to avoid matrix collision
with bezos #253). Stacks on #250 (phase 2). Completes the three-phase
#158 chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner Author

Note re: musk's review of #241 — "detection hook position" nit was flagged for this PR. Per the diff:

  • Hook lives in lib/board_msg.cmd_send, post-commit (after mark_read and the existing notification side-effects).
  • Wrapped in try/except Exception so a detector failure or per-emit failure cannot break message delivery — both detect_hints and emit_hint are isolated.
  • Broadcasts (recipient == 'all') are skipped — detection would scan everyone's outbox on every broadcast, which is the wrong cost shape for v1.

Position chosen because:

  • Post-commit means the new message is already visible to other tongxue; the hint is a side effect of that message having been seen, not of it being delivered.
  • Co-located with the other post-send hooks (notifications), so future maintainers find the failure-isolation pattern in one place.

This is implicit in the diff but worth surfacing in the PR description proper — leaving it in this thread comment for now since the PR body lives in git history.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants