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.
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/PKG-INFO +2 -2
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/filterspec.py +4 -1
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/__init__.py +8 -1
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/signals.py +161 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/sourcekind.py +2 -1
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/spec.py +3 -1
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/pyproject.toml +2 -2
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/mining.rs +288 -2
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/protocol.rs +2 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/Cargo.lock +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/Cargo.toml +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/LICENSE +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/README.md +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/__main__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/activity.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/backend.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/builders.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/cli.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/command.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/context.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/corrections.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/corrections_cli.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/cost.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/decisions.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/discovery.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/evidence.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/extract/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/extract/correct.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/facts.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/ids.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/judge/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/judge/llm.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/judge/verdicts.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/engine.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/models.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/parser.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/py.typed +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/query.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/render.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/rust.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/store.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/cc_transcript/tools.py +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/Cargo.toml +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/command.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/event.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/filter.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/lexicon.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/lib.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/model.rs +0 -0
- {cc_transcript-8.0.0 → cc_transcript-8.1.0}/rust/src/score.rs +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|