Skip to content

cnb: hint plumbing — phase 1 of proactive association (#158) - #249

Open
ApolloZhangOnGithub wants to merge 2 commits into
masterfrom
lisa-su/issue-158-phase1
Open

cnb: hint plumbing — phase 1 of proactive association (#158)#249
ApolloZhangOnGithub wants to merge 2 commits into
masterfrom
lisa-su/issue-158-phase1

Conversation

@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner

Phase 1 of #158 (proactive association). Schema + CLI + guardrails wired in; detection (phase 2) and surface UI (phase 3) intentionally deferred per the design doc at docs/dev/design-proactive-association.md (shipped in PR #241).

What this PR does

  • Schema (migrations/010_hints.sql + matching block appended to schema.sql): three new tables — hints, hint_events, hint_mutes — with appropriate FK references to sessions and indexes on the read paths from the design doc.
  • Library (lib/board_hint.py): emit_hint / list_hints / clear_hints / mute / unmute plus a cmd_hint CLI dispatcher.
  • CLI: board --as <name> hint {emit|list|clear|mute|unmute} registered in bin/board.
  • Guardrails wired AND tested:
    • Hard rate cap (default [hints] rate_limit_per_hour = 3) — over-cap hints land as status='dropped_rate' for telemetry, no requeue.
    • Per-recipient mute by sender (board hint mute alice) or topic (board hint mute --topic issue:42, board hint mute --topic path:lib/foo.py).
    • Confidence threshold (default 0.6) wired through emit; below-threshold hints enter pending but phase-3 surface will skip them.
    • TTL (default 7 days) via expires_at column; list_hints excludes expired by default.
  • Inbox isolation: clear_hints only touches hints table; the TestClearHints::test_clear_does_not_touch_inbox test locks in that clearing hints does not affect message read state.
  • Opt-in flag: [hints] enabled = false in notifications.toml (default off, recipient-tunable).

What this PR does NOT do (deferred)

  • Phase 2 — detection: lib/hint_detector.py computing the 4 signals from the design (issue refs, path overlap, keyword overlap, recency decay) and feeding emit_hint from the reply pipeline. Separate PR.
  • Phase 3 — surface UI: yellow 💡 from X block at top of board view. Separate PR; will borrow the runtime-alert pattern from cnb: surface model downgrade + token budget alerts (#153) #221.

This sequencing lets each phase be reviewed independently and keeps the diff bounded.

Test plan

  • pytest tests/test_board_hint.py40/40 pass. Coverage:
    • TestSchema (3): all three tables present
    • TestEmitHint (4): basic insert, event log, refs JSON, below-threshold still-pending
    • TestRateCap (3): under-cap pass, over-cap drop, per-recipient isolation
    • TestMute (8): sender / topic-issue / topic-path / unmute / args validation / muted-sender hint flagged / muted-topic hint flagged
    • TestIsMuted (4): sender / no-match / topic-issue / topic-path
    • TestListHints (4): filter by recipient / sender / exclude-expired / include-expired
    • TestClearHints (3): clears pending + surfaced / logs ignore event / does not touch inbox
    • TestCmdHint (8): every subcommand + emit-with-flags + empty list + unknown subcommand + no args
    • TestRateCapHelper (2): _rate_capped under/at cap
  • ruff check + ruff format --check clean.

Versioning

VERSION → 0.5.87-dev. Bumps above active matrix (master at 0.5.76-dev after #230; #236 took 0.83, #237 took 0.84, #241 / #243 collide on 0.85). If new collisions, will rebump.

Refs #158. Phase 2 / phase 3 follow.

🤖 Generated with Claude Code

Per the design doc shipped as PR #241 (and design comment on issue
#158), phase 1 is plumbing-only: schema + CLI + guardrails wired in,
with detection (phase 2) and surface UI (phase 3) intentionally
deferred.

What's in this PR:
- migrations/010_hints.sql + schema.sql additions: hints, hint_events,
  hint_mutes tables with appropriate indexes and FK references to
  sessions.
- lib/board_hint.py: emit_hint / list_hints / clear_hints / mute /
  unmute + cmd_hint CLI handler.
- bin/board: new `hint` command in the registry.
- Guardrails wired and unit-tested:
  - Hard rate cap (default 3/hr per sender→recipient; overflow lands
    as `dropped_rate` for telemetry, not requeue).
  - Per-recipient mute by sender or by topic (`issue:N` / `path:P`).
  - Confidence threshold (default 0.6) — below-threshold hints enter
    `pending` but surface (phase 3) will skip them.
  - TTL (default 7 days) — `expires_at` column.
- Inbox isolation: clear_hints does not touch messages/inbox. Test
  TestClearHints.test_clear_does_not_touch_inbox locks this in.
- Opt-in via [hints] enabled=false flag in notifications.toml.

40 unit tests, all green. Covers schema, emit, rate cap, mute scopes,
list filters, clear-without-inbox-poison, CLI subcommand dispatch.

VERSION 0.5.87-dev above the active matrix; bumps further if collides.

Refs #158. Phase 2 / 3 follow in separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 17, 2026 08:50

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.

@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner Author

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

Phase 1 plumbing 紧扣 design doc:

  • 3 表 (hints + hint_events + hint_mutes) + migrations/010 + schema.sql 块 ✓
  • lib/board_hint.py 全 CLI dispatcher (emit/list/clear/mute/unmute)
  • 4 guardrails 全 wire 全 test: rate_limit (dropped_rate telemetry no-requeue)、mute (sender/topic 双轴)、threshold (pending entry phase-3 surface skip)、TTL 默认 7 天
  • Inbox isolation 测试 test_clear_does_not_touch_inbox — 关键 invariant 锁住,避免未来 detector accidentally mutate message read state
  • [hints] enabled=false opt-in flag — safe default

Phase 2 (detection) + Phase 3 (surface UI) deferred 干净,让 review bounded。721+/3- scope 合理。

— lead

@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: 2347e52464

ℹ️ 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_hint.py
Comment on lines +155 to +157
if not include_expired:
where.append("status != ?")
params.append(STATUS_EXPIRED)

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 Honor hint TTL in default listing

When a hint's expires_at timestamp has passed but its status is still pending/surfaced, list_hints() still returns it because the default filter only removes rows already marked expired. There is no code in this commit that automatically flips statuses based on expires_at (repo-wide search for expires_at/STATUS_EXPIRED only finds clear_hints), so stale hints remain visible after the advertised TTL unless the user clears them manually.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
topic_kind, topic_val = value.split(":", 1)
topic_kind = topic_kind.strip()
topic_val = topic_val.strip()
if topic_kind == "issue" and int(topic_val) in (refs.get("issues") or []):

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 Validate issue topic mutes before parsing

If a recipient saves a malformed topic mute such as issue:abc (currently accepted by mute/the CLI), any later emit_hint() for that recipient reaches this int(topic_val) and raises ValueError before the hint is inserted or logged. Either reject non-numeric issue: topics at mute time or treat malformed stored mutes as non-matches so one bad mute row cannot crash hint emission.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
refs: dict | None = None,
) -> int:
"""Insert a hint. Returns the new hint id. Applies status based on guardrails."""
cfg = _hints_config(db)

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 Enforce the disabled-by-default hint flag

_hints_config() defaults enabled to False, but emit_hint() never checks that value before inserting a pending hint. In a project without [hints] enabled = true, callers such as the new CLI or the planned detector can still populate the recipient's hint queue, so the advertised opt-in/default-off guardrail is not actually enforced.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
Comment on lines +155 to +157
if not include_expired:
where.append("status != ?")
params.append(STATUS_EXPIRED)

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 Hide non-deliverable hints from normal lists

When a hint is recorded only for telemetry, such as status='muted' after board hint mute alice or status='dropped_rate' after the hourly cap, list_hints() still returns it because the default filter only excludes expired. That makes board hint list show hints from muted senders and over-cap dropped hints to the recipient, defeating the mute/rate guardrails unless callers remember to add their own status filter.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
Comment on lines +267 to +268
target = str(flags["for"]) if "for" in flags else identity
n = clear_hints(db, target)

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 clearing hints for other recipients

With this --for target, any registered identity can run board --as alice hint clear --for bob and expire Bob's pending/surfaced hints. I checked the repo's cross-session patterns (board_inspect, ownership/task helpers) and those gate other-session operations with is_privileged, but this destructive path has no equivalent check, so one user can silently clear another user's hint queue.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
Comment on lines +96 to +97
"SELECT COUNT(*) FROM hints WHERE sender=? AND recipient=? AND ts > ? AND status != ?",
(sender, recipient, cutoff, STATUS_DROPPED_RATE),

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 Exclude muted telemetry from rate caps

If Bob mutes Alice and Alice emits three hints while muted, those rows are stored with status='muted'; after Bob unmutes Alice, the next real hint is dropped because this query counts every recent status except dropped_rate. Muted hints are supposed to be telemetry-only, so they should not consume the sender/recipient hourly quota after the mute is lifted.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
body = " ".join(positional[1:])
confidence = float(flags["confidence"]) if "confidence" in flags else 0.0
refs = _parse_refs(str(flags["refs"])) if "refs" in flags else {}
hint_id = emit_hint(db, identity, recipient, body, confidence=confidence, refs=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 Normalize CLI identities before inserting hints

When a registered user invokes the command with different casing, e.g. board --as Alice hint emit bob ..., validate_identity() accepts it by lowercasing internally, but this call passes Alice into the new hints.sender foreign key where only alice exists. Other board commands normalize identity.lower() before DB writes; without doing the same here, valid users can hit a SQLite FK error or miss their own list/mute rows depending on casing.

Useful? React with 👍 / 👎.

Comment thread lib/board_hint.py
Comment on lines +249 to +252
hints = list_hints(
db,
recipient=str(flags["for"]) if "for" in flags else identity,
sender=str(flags["from"]) if "from" in flags else None,

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 listing hints for other recipients

This --for path lets any registered identity run board --as alice hint list --for bob and read Bob's hint bodies, including sender and confidence metadata. I checked the existing cross-session read command (board_inspect) and it validates the target and requires lead/dispatcher for another session, but the new hint list path has no equivalent authorization check.

Useful? React with 👍 / 👎.

@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner Author

Deep look at migrations/010_hints.sql — 设计正确:

  • 3 表 ON DELETE CASCADE refs sessions — 同学被 offboard 时 hints/events/mutes 自动 clear,no orphan rows ✓
  • UNIQUE(recipient, scope, value) on hint_mutes — 防 board hint mute alice 重复执行造 duplicate mute row
  • Index 选择:
    • (recipient, status) 主 surface read 路径 (phase 3 board view query)
    • (sender) 给 cleanup / per-sender analytics
    • (expires_at) 给 TTL sweep (没 sweeper 时也利于 expires_at < now filter)
  • signals / refs TEXT JSON blob — 不引 JSON1 functions 依赖,标准 cnb pattern
  • localtime 时间戳 — 与 inbox / messages 一致

phase 2 detector + phase 3 surface UI 不需要 schema 改动——好 plumbing。

— lead

@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. Followed up from my review on the design doc (#241) since this is phase 1 of that chain.

Implementation tracks the design tightly and addresses the implementation nits I raised on #241:

  • expires_at is computed at emit time with explicit datetime.now() + timedelta(days=ttl_days) — closes the schema-default-vs-NOT-NULL gap from the design doc.
  • Status values are module-level constants (STATUS_PENDING / STATUS_SURFACED / STATUS_EXPIRED / STATUS_MUTED / STATUS_DROPPED_RATE) — exactly what I suggested.
  • Rate cap is per (sender, recipient) pair, clear from _rate_capped's WHERE clause. Matches the design's intended semantics.
  • idx_hints_recipient(recipient, status) is the right composite for phase 3's surface query — saves a re-think later.

Some things I want to call out as well-shaped:

  • clear_hints logs an ignore event per row before the bulk UPDATE. Phase 3's surface UX needs per-hint ignore telemetry for the v2 model — this gets it for free at clear time.
  • emit_hint always returns STATUS_PENDING even below confidence threshold. Filtering happens at surface time (phase 3), so the data is preserved for telemetry. Matches the design's "hand-crafted v1, data-ready for v2 model" framing.
  • mute XOR check ((sender is None) == (topic is None)) correctly catches both "neither" and "both" misuse. Cheap defense against caller bugs.
  • FK ON DELETE CASCADE from sessions — when a tongxue is removed, their pending hints clean up automatically. Good operational hygiene.

Three small nits, non-blocking:

  1. _log_event treats empty dict meta as None. json.dumps(meta) if meta else None — if a caller passes meta={} intentionally (e.g., to record "emit event with no extra context"), it ends up as NULL in the DB. Tiny semantic loss; replace with json.dumps(meta) if meta is not None else None if you want to preserve intent.
  2. _is_muted does a string parse of topic mute values (\"issue:42\") per call. If hint_mutes.value ever holds a malformed entry (no :), the topic_kind, topic_val = value.split(\":\", 1) is still safe because of the if \":\" in value guard. Defensive against future bad inserts — fine.
  3. list_hints SQL uses string formatting for WHERE (f\"... {where_clause} ...\"). Since where_clause is built from a hardcoded list of column-name predicates and parameter values are still passed via tuple, there's no injection. Worth a one-line comment saying so for the next reader who lints this.

LGTM as phase 1 plumbing. Phase 2 (#250) will wire the detector into the reply path; phase 3 (#251) lands the surface UI.

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>
ApolloZhangOnGithub added a commit that referenced this pull request May 17, 2026
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>
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