cc-transcript 8.0.0__tar.gz → 8.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/PKG-INFO +2 -2
  2. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/filterspec.py +4 -1
  3. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/__init__.py +8 -1
  4. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/signals.py +161 -0
  5. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/sourcekind.py +2 -1
  6. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/spec.py +3 -1
  7. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/pyproject.toml +2 -2
  8. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/mining.rs +288 -2
  9. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/protocol.rs +2 -0
  10. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/Cargo.lock +0 -0
  11. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/Cargo.toml +0 -0
  12. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/LICENSE +0 -0
  13. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/README.md +0 -0
  14. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/__init__.py +0 -0
  15. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/__main__.py +0 -0
  16. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/_parser_rs.pyi +0 -0
  17. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/activity.py +0 -0
  18. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/backend.py +0 -0
  19. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/builders.py +0 -0
  20. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/cli.py +0 -0
  21. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/command.py +0 -0
  22. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/context.py +0 -0
  23. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/corrections.py +0 -0
  24. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/corrections_cli.py +0 -0
  25. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/cost.py +0 -0
  26. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/decisions.py +0 -0
  27. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/discovery.py +0 -0
  28. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/disktruth.py +0 -0
  29. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/evidence.py +0 -0
  30. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/extract/__init__.py +0 -0
  31. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/extract/correct.py +0 -0
  32. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/facts.py +0 -0
  33. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/ids.py +0 -0
  34. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/judge/__init__.py +0 -0
  35. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/judge/llm.py +0 -0
  36. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/judge/verdicts.py +0 -0
  37. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/candidates.py +0 -0
  38. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/confidence.py +0 -0
  39. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/engine.py +0 -0
  40. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/filterspec.py +0 -0
  41. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/formats.py +0 -0
  42. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/store.py +0 -0
  43. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/models.py +0 -0
  44. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/parser.py +0 -0
  45. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/py.typed +0 -0
  46. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/query.py +0 -0
  47. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/render.py +0 -0
  48. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/rust.py +0 -0
  49. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/__init__.py +0 -0
  50. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/buckets.py +0 -0
  51. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/engine.py +0 -0
  52. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/lexicon.py +0 -0
  53. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/scorespec.py +0 -0
  54. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/store.py +0 -0
  55. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/tools.py +0 -0
  56. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/Cargo.toml +0 -0
  57. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/data/afinn-en-165.tsv +0 -0
  58. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/data/domain_overrides.tsv +0 -0
  59. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/command.rs +0 -0
  60. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/event.rs +0 -0
  61. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/filter.rs +0 -0
  62. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/lexicon.rs +0 -0
  63. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/lib.rs +0 -0
  64. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/model.rs +0 -0
  65. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/score.rs +0 -0
  66. {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/value.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 8.0.0
3
+ Version: 8.1.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -18,7 +18,7 @@ Requires-Dist: tree-sitter-bash>=0.25
18
18
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
19
19
  Requires-Dist: ty>=0.0.44 ; extra == 'dev'
20
20
  Requires-Dist: ruff>=0.8 ; extra == 'dev'
21
- Requires-Dist: spawnllm>=0.5.1,<0.6 ; extra == 'llm'
21
+ Requires-Dist: spawnllm>=0.5.4,<0.6 ; extra == 'llm'
22
22
  Requires-Dist: spacy>=3.8 ; extra == 'sentiment'
23
23
  Requires-Dist: afinn>=0.1 ; extra == 'sentiment'
24
24
  Provides-Extra: dev
@@ -70,10 +70,13 @@ INTERRUPT_MARKER_GROUPS: tuple[tuple[str, str], ...] = (("interrupt", r"^\s*\[Re
70
70
  STOP_HOOK_GROUPS: tuple[tuple[str, str], ...] = (("stop_hook", r"Stop hook feedback:"),)
71
71
 
72
72
  # Raw CC-injected protocol strings carried in tool-result content: the denial banner
73
- # and the markers that wrap the user's verbatim instruction in a rejected tool use.
73
+ # and the markers that wrap the user's verbatim instruction in a rejected tool use,
74
+ # and the banner pair that wraps an answered AskUserQuestion round.
74
75
  DENIAL_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected"
75
76
  USER_SAID_MARKER = "To tell you how to proceed, the user said:\n"
76
77
  USER_SAID_TRAILER = "Note: The user's next message"
78
+ ANSWERED_PREFIX = "Your questions have been answered: "
79
+ ANSWERED_TRAILER = ". You can now continue with these answers in mind."
77
80
 
78
81
  # Approve-and-advance directives: a user telling the agent to proceed/commit/push or
79
82
  # to resume killed work. They follow an assistant turn but advance it rather than
@@ -21,7 +21,13 @@ when :func:`mining_spec_is_portable` holds, the Rust backend both interpret.
21
21
 
22
22
  from __future__ import annotations
23
23
 
24
- from cc_transcript.filterspec import DENIAL_PREFIX, USER_SAID_MARKER, USER_SAID_TRAILER
24
+ from cc_transcript.filterspec import (
25
+ ANSWERED_PREFIX,
26
+ ANSWERED_TRAILER,
27
+ DENIAL_PREFIX,
28
+ USER_SAID_MARKER,
29
+ USER_SAID_TRAILER,
30
+ )
25
31
  from cc_transcript.mining.candidates import DedupKey, FeedbackCandidate, dedup_key
26
32
  from cc_transcript.mining.confidence import (
27
33
  HIGH,
@@ -64,6 +70,7 @@ from cc_transcript.mining.signals import (
64
70
  from cc_transcript.mining.sourcekind import (
65
71
  INTERRUPT_REJECTION,
66
72
  PLAN_REVIEW,
73
+ QUESTION_ANSWER,
67
74
  REVIEW_COMMENT,
68
75
  TRANSCRIPT_MESSAGE,
69
76
  SourceKind,
@@ -25,6 +25,8 @@ from typing import TYPE_CHECKING, NamedTuple
25
25
 
26
26
  from cc_transcript.facts import is_denial
27
27
  from cc_transcript.filterspec import (
28
+ ANSWERED_PREFIX,
29
+ ANSWERED_TRAILER,
28
30
  INTERRUPT_MARKER_RE,
29
31
  compile_groups,
30
32
  embedded_user_text,
@@ -39,10 +41,12 @@ from cc_transcript.mining.formats import extract_structured
39
41
  from cc_transcript.mining.sourcekind import (
40
42
  INTERRUPT_REJECTION,
41
43
  PLAN_REVIEW,
44
+ QUESTION_ANSWER,
42
45
  REVIEW_COMMENT,
43
46
  TRANSCRIPT_MESSAGE,
44
47
  )
45
48
  from cc_transcript.mining.spec import (
49
+ ASK_USER_QUESTION_DETECTOR,
46
50
  DENIAL_DETECTOR,
47
51
  EXIT_PLAN_REJECTION_DETECTOR,
48
52
  INTERRUPT_DETECTOR,
@@ -71,6 +75,13 @@ if TYPE_CHECKING:
71
75
  from cc_transcript.mining.spec import ReviewSpec
72
76
  from cc_transcript.models import CcVersion, EventUuid, SessionId, ToolUseId, TranscriptEvent
73
77
 
78
+ # An answered AskUserQuestion round renders each pair as '"Q"="A"' (or
79
+ # '"Q"=(no option selected)'), optionally followed by ' selected preview:\n<raw>'
80
+ # and/or ' notes: <text>', with pairs joined by ', ' inside the ANSWERED banner.
81
+ ANSWER_PREVIEW_SEP = " selected preview:\n"
82
+ ANSWER_NOTES_SEP = " notes: "
83
+ NO_OPTION_SELECTED = "(no option selected)"
84
+
74
85
 
75
86
  @dataclass(frozen=True, slots=True)
76
87
  class MiningSignal:
@@ -400,6 +411,154 @@ def iter_review_comment_signals(events: Sequence[TranscriptEvent], spec: MiningS
400
411
  )
401
412
 
402
413
 
414
+ class AnsweredPair(NamedTuple):
415
+ question: Mapping[str, Any]
416
+ answer: str | None
417
+ preview: str | None
418
+ notes: str | None
419
+
420
+
421
+ def answered_question_results(event: UserEvent) -> Iterator[ToolResultBlock]:
422
+ return (
423
+ block
424
+ for block in event.blocks
425
+ if isinstance(block, ToolResultBlock)
426
+ if not block.is_error
427
+ if block.content.startswith(ANSWERED_PREFIX)
428
+ )
429
+
430
+
431
+ def split_answer_segment(segment: str) -> tuple[str, str | None, str | None]:
432
+ if (at := segment.find(ANSWER_PREVIEW_SEP)) != -1:
433
+ rest = segment[at + len(ANSWER_PREVIEW_SEP) :]
434
+ if (notes_at := rest.find(ANSWER_NOTES_SEP)) != -1:
435
+ return segment[:at], rest[:notes_at], rest[notes_at + len(ANSWER_NOTES_SEP) :]
436
+ return segment[:at], rest, None
437
+ if (at := segment.find(ANSWER_NOTES_SEP)) != -1:
438
+ return segment[:at], None, segment[at + len(ANSWER_NOTES_SEP) :]
439
+ return segment, None, None
440
+
441
+
442
+ def find_anchor(body: str, anchor: str, pos: int) -> int:
443
+ while (at := body.find(anchor, pos)) != -1:
444
+ if at == 0 or body[:at].endswith(", "):
445
+ return at
446
+ pos = at + 1
447
+ return -1
448
+
449
+
450
+ def answered_pairs(body: str, questions: Sequence[Mapping[str, Any]]) -> Iterator[AnsweredPair]:
451
+ found: list[tuple[Mapping[str, Any], int, int]] = []
452
+ pos = 0
453
+ for question in questions:
454
+ if not isinstance(text := question.get("question"), str):
455
+ continue
456
+ anchor = f'"{text}"='
457
+ if (at := find_anchor(body, anchor, pos)) == -1:
458
+ continue
459
+ found.append((question, at, at + len(anchor)))
460
+ pos = at + len(anchor)
461
+ for i, (question, _, value_at) in enumerate(found):
462
+ last = i + 1 == len(found)
463
+ segment = body[value_at : len(body) if last else found[i + 1][1]]
464
+ if not last:
465
+ segment = segment.removesuffix(", ")
466
+ head, preview, notes = split_answer_segment(segment)
467
+ if head.startswith('"'):
468
+ yield AnsweredPair(question, head[1:].removesuffix('"'), preview, notes)
469
+ elif head.startswith(NO_OPTION_SELECTED):
470
+ yield AnsweredPair(question, None, preview, notes)
471
+
472
+
473
+ def join_labels(answer: str, labels: Sequence[str]) -> list[str] | None:
474
+ picked: list[str] = []
475
+ start = 0
476
+ acc: str | None = None
477
+ for part in answer.split(", "):
478
+ acc = part if acc is None else f"{acc}, {part}"
479
+ if (at := next((i for i in range(start, len(labels)) if labels[i] == acc), None)) is not None:
480
+ picked.append(labels[at])
481
+ start = at + 1
482
+ acc = None
483
+ return picked if picked and acc is None else None
484
+
485
+
486
+ def ordinal_label(answer: str, labels: Sequence[str]) -> tuple[str, bool] | None:
487
+ rest = answer.lstrip("0123456789")
488
+ digits = answer[: len(answer) - len(rest)]
489
+ if not digits or not 1 <= (n := int(digits)) <= len(labels) or (rest and rest[0] not in ",. )"):
490
+ return None
491
+ return labels[n - 1], not rest
492
+
493
+
494
+ def resolve_pick(answer: str | None, labels: Sequence[str]) -> tuple[list[str], bool]:
495
+ if answer is None:
496
+ return [], False
497
+ if (joined := join_labels(answer, labels)) is not None:
498
+ return joined, True
499
+ if (ordinal := ordinal_label(answer, labels)) is not None:
500
+ label, bare = ordinal
501
+ return [label], bare
502
+ return [], False
503
+
504
+
505
+ def question_answer_signal(
506
+ events: Sequence[TranscriptEvent], event: UserEvent, index: int, pair: AnsweredPair, spec: MiningSpec
507
+ ) -> MiningSignal | None:
508
+ options = pair.question.get("options")
509
+ labels = [
510
+ label
511
+ for option in (options if isinstance(options, list) else ())
512
+ if isinstance(option, dict) and isinstance(label := option.get("label"), str)
513
+ ]
514
+ picked, option_pick = resolve_pick(pair.answer, labels)
515
+ text = pair.notes if pair.notes is not None else pair.answer
516
+ if text is None:
517
+ return None
518
+ evidence: dict[str, Any] = {
519
+ "question": question if isinstance(question := pair.question.get("question"), str) else None,
520
+ "header": header if isinstance(header := pair.question.get("header"), str) else None,
521
+ "multi_select": isinstance(multi := pair.question.get("multiSelect"), bool) and multi,
522
+ "option_pick": option_pick,
523
+ "picked_labels": picked,
524
+ "recommended_pick": any("(Recommended)" in label for label in picked),
525
+ }
526
+ if pair.preview is not None:
527
+ evidence["preview"] = pair.preview
528
+ if pair.notes is not None:
529
+ evidence["notes"] = pair.notes
530
+ return MiningSignal(
531
+ kind=QUESTION_ANSWER,
532
+ detector="ask_user_question",
533
+ session_id=event.meta.session_id,
534
+ event_index=index,
535
+ event_uuid=event.meta.uuid,
536
+ occurred_at=event.meta.timestamp,
537
+ text=text,
538
+ cc_version=event.meta.cc_version,
539
+ trigger_index=nearest_assistant_index(events, index),
540
+ signal=weak("option_pick") if option_pick else calibrated(spec.calibrated, text, seed="freeform_answer"),
541
+ evidence=evidence,
542
+ )
543
+
544
+
545
+ def iter_ask_user_question_signals(events: Sequence[TranscriptEvent], spec: MiningSpec) -> Iterator[MiningSignal]:
546
+ uses = tool_uses(events)
547
+ return (
548
+ signal
549
+ for index, event in enumerate(events)
550
+ if isinstance(event, UserEvent)
551
+ for block in answered_question_results(event)
552
+ if (use := uses.get(block.tool_use_id)) is not None
553
+ if use.name == "AskUserQuestion"
554
+ if block.content.endswith(ANSWERED_TRAILER)
555
+ for pair in answered_pairs(
556
+ block.content[len(ANSWERED_PREFIX) : -len(ANSWERED_TRAILER)], use.input["questions"]
557
+ )
558
+ if (signal := question_answer_signal(events, event, index, pair, spec)) is not None
559
+ )
560
+
561
+
403
562
  def mine(events: Sequence[TranscriptEvent], spec: MiningSpec) -> Iterator[MiningSignal]:
404
563
  """Yields every :class:`MiningSignal` the enabled detectors recognize in ``events``.
405
564
 
@@ -423,3 +582,5 @@ def mine(events: Sequence[TranscriptEvent], spec: MiningSpec) -> Iterator[Mining
423
582
  yield from iter_interrupt_marker_signals(events, spec)
424
583
  if REVIEW_COMMENT_DETECTOR in spec.detectors:
425
584
  yield from iter_review_comment_signals(events, spec)
585
+ if ASK_USER_QUESTION_DETECTOR in spec.detectors:
586
+ yield from iter_ask_user_question_signals(events, spec)
@@ -7,7 +7,7 @@ from typing import NewType
7
7
  SourceKind = NewType("SourceKind", str)
8
8
  """A descriptive category for a mined feedback fact.
9
9
 
10
- The four module constants are the common categories the fact-detectors emit; apps
10
+ The five module constants are the common categories the fact-detectors emit; apps
11
11
  may define their own ``SourceKind`` values for categories the core does not name.
12
12
  """
13
13
 
@@ -15,3 +15,4 @@ TRANSCRIPT_MESSAGE = SourceKind("transcript_message")
15
15
  PLAN_REVIEW = SourceKind("plan_review")
16
16
  INTERRUPT_REJECTION = SourceKind("interrupt_rejection")
17
17
  REVIEW_COMMENT = SourceKind("review_comment")
18
+ QUESTION_ANSWER = SourceKind("question_answer")
@@ -49,7 +49,7 @@ if TYPE_CHECKING:
49
49
  from cc_transcript.mining.signals import MiningSignal
50
50
 
51
51
  DetectorName = NewType("DetectorName", str)
52
- """One of the six core detector identifiers a :class:`MiningSpec` may enable."""
52
+ """One of the seven core detector identifiers a :class:`MiningSpec` may enable."""
53
53
 
54
54
  TRANSCRIPT_MESSAGE_DETECTOR = DetectorName("transcript_message")
55
55
  EXIT_PLAN_REJECTION_DETECTOR = DetectorName("exit_plan_rejection")
@@ -57,6 +57,7 @@ PLAN_REENTRY_DETECTOR = DetectorName("plan_reentry")
57
57
  DENIAL_DETECTOR = DetectorName("denial")
58
58
  INTERRUPT_DETECTOR = DetectorName("interrupt")
59
59
  REVIEW_COMMENT_DETECTOR = DetectorName("review_comment")
60
+ ASK_USER_QUESTION_DETECTOR = DetectorName("ask_user_question")
60
61
 
61
62
  ALL_DETECTORS: frozenset[DetectorName] = frozenset(
62
63
  {
@@ -66,6 +67,7 @@ ALL_DETECTORS: frozenset[DetectorName] = frozenset(
66
67
  DENIAL_DETECTOR,
67
68
  INTERRUPT_DETECTOR,
68
69
  REVIEW_COMMENT_DETECTOR,
70
+ ASK_USER_QUESTION_DETECTOR,
69
71
  }
70
72
  )
71
73
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "8.0.0"
3
+ version = "8.1.0"
4
4
  description = "Grep every Claude Code session you've ever run."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -48,7 +48,7 @@ sentiment = [
48
48
  "spacy>=3.8",
49
49
  "afinn>=0.1",
50
50
  ]
51
- llm = ["spawnllm>=0.5.1,<0.6"]
51
+ llm = ["spawnllm>=0.5.4,<0.6"]
52
52
 
53
53
  [project.scripts]
54
54
  cc-transcript = "cc_transcript.cli:cli"
@@ -10,8 +10,8 @@ use sonic_rs::{Index, JsonContainerTrait, JsonValueTrait, Value};
10
10
  use crate::event::{parse_timestamp, require_str, truthy_str};
11
11
  use crate::filter::{compile_group_array, Kind};
12
12
  use crate::protocol::{
13
- embedded_user_text, interrupt_marker, is_bare_interrupt_marker, DENIAL_PREFIX,
14
- INTERRUPT_MARKER_RE,
13
+ embedded_user_text, interrupt_marker, is_bare_interrupt_marker, ANSWERED_PREFIX, ANSWERED_TRAILER,
14
+ DENIAL_PREFIX, INTERRUPT_MARKER_RE,
15
15
  };
16
16
  use crate::value::{block_type, content_text, field, field_bool, field_str};
17
17
 
@@ -20,6 +20,7 @@ const TRANSCRIPT_MESSAGE: &str = "transcript_message";
20
20
  const PLAN_REVIEW: &str = "plan_review";
21
21
  const INTERRUPT_REJECTION: &str = "interrupt_rejection";
22
22
  const REVIEW_COMMENT: &str = "review_comment";
23
+ const QUESTION_ANSWER: &str = "question_answer";
23
24
 
24
25
  // Detector ids (mining/spec.py TRANSCRIPT_MESSAGE_DETECTOR etc.).
25
26
  const DETECTOR_TRANSCRIPT_MESSAGE: &str = "transcript_message";
@@ -28,6 +29,12 @@ const DETECTOR_PLAN_REENTRY: &str = "plan_reentry";
28
29
  const DETECTOR_DENIAL: &str = "denial";
29
30
  const DETECTOR_INTERRUPT: &str = "interrupt";
30
31
  const DETECTOR_REVIEW_COMMENT: &str = "review_comment";
32
+ const DETECTOR_ASK_USER_QUESTION: &str = "ask_user_question";
33
+
34
+ // AskUserQuestion answered-round segment markers (mining/signals.py).
35
+ const ANSWER_PREVIEW_SEP: &str = " selected preview:\n";
36
+ const ANSWER_NOTES_SEP: &str = " notes: ";
37
+ const NO_OPTION_SELECTED: &str = "(no option selected)";
31
38
 
32
39
  // Confidence bands (mining/confidence.py NONE / LOW); the hardcoded marker-correction seeds.
33
40
  const NONE: f64 = 0.0;
@@ -1164,6 +1171,233 @@ fn iter_review_comment<'py>(
1164
1171
 
1165
1172
  // ── dispatch (mining/signals.py mine) ────────────────────────────────────────
1166
1173
 
1174
+ /// The answered AskUserQuestion tool-result blocks of a user event
1175
+ /// (signals.py answered_question_results): non-error blocks whose flattened
1176
+ /// content starts with the answered banner.
1177
+ fn answered_results(event: &Value) -> Vec<&Value> {
1178
+ message_content(event)
1179
+ .and_then(JsonContainerTrait::as_array)
1180
+ .into_iter()
1181
+ .flatten()
1182
+ .filter(|b| block_type(b) == Some("tool_result"))
1183
+ .filter(|b| !field_bool(b, "is_error"))
1184
+ .filter(|b| result_content_text(b).is_some_and(|c| c.starts_with(ANSWERED_PREFIX)))
1185
+ .collect()
1186
+ }
1187
+
1188
+ struct AnsweredPair<'a> {
1189
+ question: &'a Value,
1190
+ answer: Option<&'a str>,
1191
+ preview: Option<&'a str>,
1192
+ notes: Option<&'a str>,
1193
+ }
1194
+
1195
+ /// split_answer_segment (signals.py split_answer_segment): the answer head plus
1196
+ /// the optional preview and notes parts, split on the first marker occurrence in
1197
+ /// render order (answer, preview, notes).
1198
+ fn split_answer_segment(segment: &str) -> (&str, Option<&str>, Option<&str>) {
1199
+ if let Some(at) = segment.find(ANSWER_PREVIEW_SEP) {
1200
+ let rest = &segment[at + ANSWER_PREVIEW_SEP.len()..];
1201
+ return match rest.find(ANSWER_NOTES_SEP) {
1202
+ Some(notes_at) => (
1203
+ &segment[..at],
1204
+ Some(&rest[..notes_at]),
1205
+ Some(&rest[notes_at + ANSWER_NOTES_SEP.len()..]),
1206
+ ),
1207
+ None => (&segment[..at], Some(rest), None),
1208
+ };
1209
+ }
1210
+ match segment.find(ANSWER_NOTES_SEP) {
1211
+ Some(at) => (&segment[..at], None, Some(&segment[at + ANSWER_NOTES_SEP.len()..])),
1212
+ None => (segment, None, None),
1213
+ }
1214
+ }
1215
+
1216
+ /// find_anchor (signals.py find_anchor): the next anchor at or after `pos` that
1217
+ /// renders at the body start or right after the ", " pair join, skipping the same
1218
+ /// literal embedded inside an earlier freeform answer.
1219
+ fn find_anchor(body: &str, anchor: &str, pos: usize) -> Option<usize> {
1220
+ let mut from = pos;
1221
+ while let Some(i) = body[from..].find(anchor) {
1222
+ let at = from + i;
1223
+ if at == 0 || body[..at].ends_with(", ") {
1224
+ return Some(at);
1225
+ }
1226
+ from = at + 1;
1227
+ }
1228
+ None
1229
+ }
1230
+
1231
+ /// answered_pairs (signals.py answered_pairs): anchors each question in order on
1232
+ /// '"<question>"=', slices each pair's segment to the next found anchor, strips
1233
+ /// the exact ', ' pair join on middle segments and the answer's exact closing '"',
1234
+ /// and skips questions whose anchor never rendered.
1235
+ fn answered_pairs<'a>(body: &'a str, questions: &'a sonic_rs::Array) -> Vec<AnsweredPair<'a>> {
1236
+ let mut found: Vec<(&Value, usize, usize)> = Vec::new();
1237
+ let mut pos = 0usize;
1238
+ for question in questions.iter() {
1239
+ let Some(text) = field_str(question, "question") else { continue };
1240
+ let anchor = format!("\"{text}\"=");
1241
+ let Some(at) = find_anchor(body, &anchor, pos) else { continue };
1242
+ found.push((question, at, at + anchor.len()));
1243
+ pos = at + anchor.len();
1244
+ }
1245
+ let mut pairs = Vec::new();
1246
+ for (i, &(question, _, value_at)) in found.iter().enumerate() {
1247
+ let end = found.get(i + 1).map_or(body.len(), |&(_, at, _)| at);
1248
+ let mut segment = &body[value_at..end];
1249
+ if i + 1 < found.len() {
1250
+ segment = segment.strip_suffix(", ").unwrap_or(segment);
1251
+ }
1252
+ let (head, preview, notes) = split_answer_segment(segment);
1253
+ let answer = if let Some(quoted) = head.strip_prefix('"') {
1254
+ Some(quoted.strip_suffix('"').unwrap_or(quoted))
1255
+ } else if head.starts_with(NO_OPTION_SELECTED) {
1256
+ None
1257
+ } else {
1258
+ continue;
1259
+ };
1260
+ pairs.push(AnsweredPair { question, answer, preview, notes });
1261
+ }
1262
+ pairs
1263
+ }
1264
+
1265
+ /// join_labels (signals.py join_labels): the multiSelect resolution — split the
1266
+ /// answer on ', ' and greedily re-join consecutive parts until each accumulation
1267
+ /// equals the next unused label in option order; succeeds only when every part is
1268
+ /// consumed.
1269
+ fn join_labels(answer: &str, labels: &[String]) -> Option<Vec<String>> {
1270
+ let mut picked: Vec<String> = Vec::new();
1271
+ let mut start = 0usize;
1272
+ let mut acc: Option<String> = None;
1273
+ for part in answer.split(", ") {
1274
+ let joined = match acc {
1275
+ None => part.to_string(),
1276
+ Some(prior) => format!("{prior}, {part}"),
1277
+ };
1278
+ match (start..labels.len()).find(|&i| labels[i] == joined) {
1279
+ Some(at) => {
1280
+ picked.push(labels[at].clone());
1281
+ start = at + 1;
1282
+ acc = None;
1283
+ }
1284
+ None => acc = Some(joined),
1285
+ }
1286
+ }
1287
+ (!picked.is_empty() && acc.is_none()).then_some(picked)
1288
+ }
1289
+
1290
+ /// ordinal_label (signals.py ordinal_label): a leading ASCII-digit run in
1291
+ /// 1..=len(options), followed by nothing or one of ',' '.' ' ' ')', resolves to
1292
+ /// that option's label; the bool is whether the ordinal stood alone.
1293
+ fn ordinal_label(answer: &str, labels: &[String]) -> Option<(String, bool)> {
1294
+ let rest = answer.trim_start_matches(|c: char| c.is_ascii_digit());
1295
+ let digits = &answer[..answer.len() - rest.len()];
1296
+ let n: usize = digits.parse().ok()?;
1297
+ if !(1..=labels.len()).contains(&n) || (!rest.is_empty() && !rest.starts_with([',', '.', ' ', ')'])) {
1298
+ return None;
1299
+ }
1300
+ Some((labels[n - 1].clone(), rest.is_empty()))
1301
+ }
1302
+
1303
+ /// resolve_pick (signals.py resolve_pick): verbatim/multiSelect join → a full
1304
+ /// pick; leading ordinal → resolved label with option_pick only when bare; else
1305
+ /// pure freeform.
1306
+ fn resolve_pick(answer: Option<&str>, labels: &[String]) -> (Vec<String>, bool) {
1307
+ let Some(answer) = answer else { return (Vec::new(), false) };
1308
+ if let Some(joined) = join_labels(answer, labels) {
1309
+ return (joined, true);
1310
+ }
1311
+ match ordinal_label(answer, labels) {
1312
+ Some((label, bare)) => (vec![label], bare),
1313
+ None => (Vec::new(), false),
1314
+ }
1315
+ }
1316
+
1317
+ /// iter_ask_user_question_signals (signals.py iter_ask_user_question_signals):
1318
+ /// one signal per answered question/answer pair, with the pick resolution facts
1319
+ /// as evidence (absent preview/notes keys are omitted, never None).
1320
+ fn iter_ask_user_question<'py>(
1321
+ py: Python<'py>,
1322
+ events: &Events,
1323
+ spec: &CompiledMiningSpec,
1324
+ uses: &HashMap<String, &Value>,
1325
+ out: &mut Vec<Bound<'py, PyDict>>,
1326
+ ) -> PyResult<()> {
1327
+ for index in 0..events.len() {
1328
+ if events.kinds[index] != Kind::User {
1329
+ continue;
1330
+ }
1331
+ let event = &events.lines[index];
1332
+ for block in answered_results(event) {
1333
+ let Some(use_block) = field_str(block, "tool_use_id").and_then(|id| uses.get(id).copied())
1334
+ else {
1335
+ continue;
1336
+ };
1337
+ if field_str(use_block, "name") != Some("AskUserQuestion") {
1338
+ continue;
1339
+ }
1340
+ let Some(content) = result_content_text(block) else { continue };
1341
+ if !content.ends_with(ANSWERED_TRAILER)
1342
+ || content.len() < ANSWERED_PREFIX.len() + ANSWERED_TRAILER.len()
1343
+ {
1344
+ continue;
1345
+ }
1346
+ let body = &content[ANSWERED_PREFIX.len()..content.len() - ANSWERED_TRAILER.len()];
1347
+ let Some(questions) = field(use_block, "input")
1348
+ .and_then(|input| field(input, "questions"))
1349
+ .and_then(JsonContainerTrait::as_array)
1350
+ else {
1351
+ continue;
1352
+ };
1353
+ let trigger = events.nearest_assistant_index(index);
1354
+ for pair in answered_pairs(body, questions) {
1355
+ let labels: Vec<String> = field(pair.question, "options")
1356
+ .and_then(JsonContainerTrait::as_array)
1357
+ .into_iter()
1358
+ .flatten()
1359
+ .filter_map(|option| field_str(option, "label"))
1360
+ .map(String::from)
1361
+ .collect();
1362
+ let (picked, option_pick) = resolve_pick(pair.answer, &labels);
1363
+ let recommended = picked.iter().any(|label| label.contains("(Recommended)"));
1364
+ let Some(text) = pair.notes.or(pair.answer) else { continue };
1365
+ let sig = if option_pick {
1366
+ weak("option_pick")
1367
+ } else {
1368
+ calibrated(&spec.calibrated, text, "freeform_answer")
1369
+ };
1370
+ let evidence = PyDict::new(py);
1371
+ evidence.set_item("question", field_str(pair.question, "question"))?;
1372
+ evidence.set_item("header", field_str(pair.question, "header"))?;
1373
+ evidence.set_item("multi_select", field_bool(pair.question, "multiSelect"))?;
1374
+ evidence.set_item("option_pick", option_pick)?;
1375
+ evidence.set_item("picked_labels", picked)?;
1376
+ evidence.set_item("recommended_pick", recommended)?;
1377
+ if let Some(preview) = pair.preview {
1378
+ evidence.set_item("preview", preview)?;
1379
+ }
1380
+ if let Some(notes) = pair.notes {
1381
+ evidence.set_item("notes", notes)?;
1382
+ }
1383
+ out.push(build_signal_dict(
1384
+ py,
1385
+ QUESTION_ANSWER,
1386
+ DETECTOR_ASK_USER_QUESTION,
1387
+ event,
1388
+ index,
1389
+ text,
1390
+ trigger,
1391
+ &sig,
1392
+ None,
1393
+ evidence,
1394
+ )?);
1395
+ }
1396
+ }
1397
+ }
1398
+ Ok(())
1399
+ }
1400
+
1167
1401
  pub fn mine<'py>(
1168
1402
  py: Python<'py>,
1169
1403
  raw: &[u8],
@@ -1191,6 +1425,9 @@ pub fn mine<'py>(
1191
1425
  if spec.detectors.contains(DETECTOR_REVIEW_COMMENT) {
1192
1426
  iter_review_comment(py, &events, spec, &uses, &mut out)?;
1193
1427
  }
1428
+ if spec.detectors.contains(DETECTOR_ASK_USER_QUESTION) {
1429
+ iter_ask_user_question(py, &events, spec, &uses, &mut out)?;
1430
+ }
1194
1431
  Ok(out)
1195
1432
  }
1196
1433
 
@@ -1224,4 +1461,53 @@ mod tests {
1224
1461
  let low = bump(CandidateSig { confidence: 0.1, reasons: vec![], durable: true }, -0.25, "r");
1225
1462
  assert_eq!(low.confidence, 0.0);
1226
1463
  }
1464
+
1465
+ #[test]
1466
+ fn split_answer_segment_variants() {
1467
+ assert_eq!(split_answer_segment("\"A\""), ("\"A\"", None, None));
1468
+ assert_eq!(
1469
+ split_answer_segment("\"A\" selected preview:\nraw text"),
1470
+ ("\"A\"", Some("raw text"), None)
1471
+ );
1472
+ assert_eq!(
1473
+ split_answer_segment("\"A\" selected preview:\nraw notes: typed"),
1474
+ ("\"A\"", Some("raw"), Some("typed"))
1475
+ );
1476
+ assert_eq!(
1477
+ split_answer_segment("(no option selected) notes: typed"),
1478
+ ("(no option selected)", None, Some("typed"))
1479
+ );
1480
+ }
1481
+
1482
+ #[test]
1483
+ fn answered_pairs_slices_segments_and_skips_omitted() {
1484
+ let questions: Value =
1485
+ sonic_rs::from_str(r#"[{"question": "Q1"}, {"question": "Q2"}, {"question": "Q3"}]"#).unwrap();
1486
+ let body = "\"Q1\"=\"first, with comma\", \"Q3\"=\"he said \"hi\"\"";
1487
+ let pairs = answered_pairs(body, questions.as_array().unwrap());
1488
+ assert_eq!(pairs.len(), 2);
1489
+ assert_eq!(pairs[0].answer, Some("first, with comma"));
1490
+ assert_eq!(pairs[1].answer, Some("he said \"hi\""));
1491
+ }
1492
+
1493
+ #[test]
1494
+ fn join_labels_greedy_over_comma_containing_label() {
1495
+ let labels = vec!["BeforeEdit / AfterEdit".to_string(), "One, Two".to_string(), "Three".to_string()];
1496
+ assert_eq!(
1497
+ join_labels("One, Two, Three", &labels),
1498
+ Some(vec!["One, Two".to_string(), "Three".to_string()])
1499
+ );
1500
+ assert_eq!(join_labels("One, Two, Four", &labels), None);
1501
+ assert_eq!(join_labels("Three", &labels), Some(vec!["Three".to_string()]));
1502
+ }
1503
+
1504
+ #[test]
1505
+ fn ordinal_label_bounds_and_separators() {
1506
+ let labels = vec!["A".to_string(), "B".to_string(), "C".to_string()];
1507
+ assert_eq!(ordinal_label("3, and more", &labels), Some(("C".to_string(), false)));
1508
+ assert_eq!(ordinal_label("2", &labels), Some(("B".to_string(), true)));
1509
+ assert_eq!(ordinal_label("2026 timeline works", &labels), None);
1510
+ assert_eq!(ordinal_label("1x", &labels), None);
1511
+ assert_eq!(ordinal_label("freeform", &labels), None);
1512
+ }
1227
1513
  }
@@ -8,6 +8,8 @@ pub(crate) const DENIAL_PREFIX: &str =
8
8
  "The user doesn't want to proceed with this tool use. The tool use was rejected";
9
9
  pub(crate) const USER_SAID_MARKER: &str = "To tell you how to proceed, the user said:\n";
10
10
  pub(crate) const USER_SAID_TRAILER: &str = "Note: The user's next message";
11
+ pub(crate) const ANSWERED_PREFIX: &str = "Your questions have been answered: ";
12
+ pub(crate) const ANSWERED_TRAILER: &str = ". You can now continue with these answers in mind.";
11
13
  const INTERRUPT_MARKER_PATTERN: &str = r"^\s*\[Request interrupted by user";
12
14
 
13
15
  /// The one interrupt-marker regex (filterspec.py INTERRUPT_MARKER_RE): the pattern
File without changes
File without changes
File without changes
File without changes