cnb: hint plumbing — phase 1 of proactive association (#158) - #249
cnb: hint plumbing — phase 1 of proactive association (#158)#249ApolloZhangOnGithub wants to merge 2 commits into
Conversation
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>
|
LGTM (lead, comment because self-approve blocked). Phase 1 plumbing 紧扣 design doc:
Phase 2 (detection) + Phase 3 (surface UI) deferred 干净,让 review bounded。721+/3- scope 合理。 — lead |
There was a problem hiding this comment.
💡 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".
| if not include_expired: | ||
| where.append("status != ?") | ||
| params.append(STATUS_EXPIRED) |
There was a problem hiding this comment.
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 👍 / 👎.
| 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 []): |
There was a problem hiding this comment.
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 👍 / 👎.
| refs: dict | None = None, | ||
| ) -> int: | ||
| """Insert a hint. Returns the new hint id. Applies status based on guardrails.""" | ||
| cfg = _hints_config(db) |
There was a problem hiding this comment.
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 👍 / 👎.
| if not include_expired: | ||
| where.append("status != ?") | ||
| params.append(STATUS_EXPIRED) |
There was a problem hiding this comment.
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 👍 / 👎.
| target = str(flags["for"]) if "for" in flags else identity | ||
| n = clear_hints(db, target) |
There was a problem hiding this comment.
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 👍 / 👎.
| "SELECT COUNT(*) FROM hints WHERE sender=? AND recipient=? AND ts > ? AND status != ?", | ||
| (sender, recipient, cutoff, STATUS_DROPPED_RATE), |
There was a problem hiding this comment.
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 👍 / 👎.
| 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) |
There was a problem hiding this comment.
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 👍 / 👎.
| hints = list_hints( | ||
| db, | ||
| recipient=str(flags["for"]) if "for" in flags else identity, | ||
| sender=str(flags["from"]) if "from" in flags else None, |
There was a problem hiding this comment.
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 👍 / 👎.
|
Deep look at
phase 2 detector + phase 3 surface UI 不需要 schema 改动——好 plumbing。 — lead |
ApolloZhangOnGithub
left a comment
There was a problem hiding this comment.
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_atis computed at emit time with explicitdatetime.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_hintslogs anignoreevent 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_hintalways returnsSTATUS_PENDINGeven 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.muteXOR check ((sender is None) == (topic is None)) correctly catches both "neither" and "both" misuse. Cheap defense against caller bugs.- FK
ON DELETE CASCADEfrom sessions — when a tongxue is removed, their pending hints clean up automatically. Good operational hygiene.
Three small nits, non-blocking:
_log_eventtreats empty dict meta as None.json.dumps(meta) if meta else None— if a caller passesmeta={}intentionally (e.g., to record "emit event with no extra context"), it ends up as NULL in the DB. Tiny semantic loss; replace withjson.dumps(meta) if meta is not None else Noneif you want to preserve intent._is_muteddoes a string parse of topic mute values (\"issue:42\") per call. Ifhint_mutes.valueever holds a malformed entry (no:), thetopic_kind, topic_val = value.split(\":\", 1)is still safe because of theif \":\" in valueguard. Defensive against future bad inserts — fine.list_hintsSQL uses string formatting forWHERE(f\"... {where_clause} ...\"). Sincewhere_clauseis 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>
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>
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
migrations/010_hints.sql+ matching block appended toschema.sql): three new tables —hints,hint_events,hint_mutes— with appropriate FK references tosessionsand indexes on the read paths from the design doc.lib/board_hint.py):emit_hint/list_hints/clear_hints/mute/unmuteplus acmd_hintCLI dispatcher.board --as <name> hint {emit|list|clear|mute|unmute}registered inbin/board.[hints] rate_limit_per_hour = 3) — over-cap hints land asstatus='dropped_rate'for telemetry, no requeue.board hint mute alice) or topic (board hint mute --topic issue:42,board hint mute --topic path:lib/foo.py).pendingbut phase-3 surface will skip them.expires_atcolumn;list_hintsexcludes expired by default.clear_hintsonly toucheshintstable; theTestClearHints::test_clear_does_not_touch_inboxtest locks in that clearing hints does not affect message read state.[hints] enabled = falseinnotifications.toml(default off, recipient-tunable).What this PR does NOT do (deferred)
lib/hint_detector.pycomputing the 4 signals from the design (issue refs, path overlap, keyword overlap, recency decay) and feedingemit_hintfrom the reply pipeline. Separate PR.💡 from Xblock at top ofboard 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.py— 40/40 pass. Coverage:TestSchema(3): all three tables presentTestEmitHint(4): basic insert, event log, refs JSON, below-threshold still-pendingTestRateCap(3): under-cap pass, over-cap drop, per-recipient isolationTestMute(8): sender / topic-issue / topic-path / unmute / args validation / muted-sender hint flagged / muted-topic hint flaggedTestIsMuted(4): sender / no-match / topic-issue / topic-pathTestListHints(4): filter by recipient / sender / exclude-expired / include-expiredTestClearHints(3): clears pending + surfaced / logs ignore event / does not touch inboxTestCmdHint(8): every subcommand + emit-with-flags + empty list + unknown subcommand + no argsTestRateCapHelper(2): _rate_capped under/at capruff check+ruff format --checkclean.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