@wyattjoh/demur 0.4.2 → 0.6.0

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.
@@ -0,0 +1,175 @@
1
+ import type {
2
+ TrainingRecord,
3
+ TrainingReview,
4
+ TrainingReviewInput,
5
+ } from "../extensions/demur/training-store.ts";
6
+ import type { Decision } from "./types.ts";
7
+
8
+ /**
9
+ * Complete append-only training evidence and human review state.
10
+ */
11
+ export type TrainingReviewSnapshot = {
12
+ records: ReadonlyArray<TrainingRecord>;
13
+ reviews: ReadonlyArray<TrainingReview>;
14
+ globalEstimatedCostUsd: number;
15
+ };
16
+
17
+ /**
18
+ * One training record paired with every review appended for its stable ID.
19
+ */
20
+ export type TrainingReviewEntry = {
21
+ record: TrainingRecord;
22
+ reviews: ReadonlyArray<TrainingReview>;
23
+ };
24
+
25
+ /**
26
+ * Latest human-review status derived for one training entry.
27
+ */
28
+ export type TrainingReviewStatus = "unreviewed" | Decision;
29
+
30
+ /**
31
+ * Status tabs available in the interactive training-review queue.
32
+ */
33
+ export type TrainingReviewFilter = "all" | TrainingReviewStatus;
34
+
35
+ /**
36
+ * Pair each captured record with its append-only review history.
37
+ *
38
+ * @param snapshot - Complete record and review state
39
+ * @returns Entries in capture order with reviews in append order
40
+ */
41
+ export function buildTrainingReviewEntries(
42
+ snapshot: TrainingReviewSnapshot,
43
+ ): ReadonlyArray<TrainingReviewEntry> {
44
+ const reviewsByRecord = new Map<string, Array<TrainingReview>>();
45
+ for (const review of snapshot.reviews) {
46
+ const reviews = reviewsByRecord.get(review.recordId) ?? [];
47
+ reviews.push(review);
48
+ reviewsByRecord.set(review.recordId, reviews);
49
+ }
50
+
51
+ return snapshot.records.map((record) => ({
52
+ record,
53
+ reviews: reviewsByRecord.get(record.id) ?? [],
54
+ }));
55
+ }
56
+
57
+ /**
58
+ * Return the latest append-only human review for an entry.
59
+ *
60
+ * @param entry - Training record and its review history
61
+ * @returns Latest review, or undefined when the record is unreviewed
62
+ */
63
+ export function getLatestTrainingReview(
64
+ entry: TrainingReviewEntry,
65
+ ): TrainingReview | undefined {
66
+ return entry.reviews.at(-1);
67
+ }
68
+
69
+ /**
70
+ * Classify an entry by its latest human answer.
71
+ *
72
+ * @param entry - Training record and its review history
73
+ * @returns Unreviewed or the latest expected decision
74
+ */
75
+ export function getTrainingReviewFilter(
76
+ entry: TrainingReviewEntry,
77
+ ): TrainingReviewStatus {
78
+ return getLatestTrainingReview(entry)?.expectedDecision ?? "unreviewed";
79
+ }
80
+
81
+ /**
82
+ * Select one status tab and fuzzy-search matching working directories.
83
+ *
84
+ * @param entries - Complete training history
85
+ * @param filter - Active latest-answer status
86
+ * @param cwdQuery - Optional fuzzy working-directory query
87
+ * @returns Matching entries, ranked by path match when a query is present
88
+ */
89
+ export function filterTrainingReviewEntries(
90
+ entries: ReadonlyArray<TrainingReviewEntry>,
91
+ filter: TrainingReviewFilter,
92
+ cwdQuery: string,
93
+ ): ReadonlyArray<TrainingReviewEntry> {
94
+ const statusMatches = filter === "all"
95
+ ? entries
96
+ : entries.filter((entry) => getTrainingReviewFilter(entry) === filter);
97
+ const query = cwdQuery.trim().toLowerCase();
98
+ if (query === "") return statusMatches;
99
+
100
+ return statusMatches
101
+ .map((entry, index) => ({
102
+ entry,
103
+ index,
104
+ score: fuzzyPathScore(entry.record.cwd, query),
105
+ }))
106
+ .filter(
107
+ (candidate): candidate is {
108
+ entry: TrainingReviewEntry;
109
+ index: number;
110
+ score: number;
111
+ } => candidate.score !== undefined,
112
+ )
113
+ .sort((left, right) => right.score - left.score || left.index - right.index)
114
+ .map((candidate) => candidate.entry);
115
+ }
116
+
117
+ /**
118
+ * Build an append-only review revision from a human decision.
119
+ *
120
+ * @param record - Training evidence being reviewed
121
+ * @param expectedDecision - Human-selected expected outcome
122
+ * @param note - Optional explanation for the human decision
123
+ * @returns Normalized review input for persistence
124
+ */
125
+ export function createTrainingReviewInput(
126
+ record: TrainingRecord,
127
+ expectedDecision: Decision,
128
+ note: string | undefined,
129
+ ): TrainingReviewInput {
130
+ return {
131
+ recordId: record.id,
132
+ originalDecision: record.verdict.decision,
133
+ expectedDecision,
134
+ note: note?.trim() || undefined,
135
+ };
136
+ }
137
+
138
+ function fuzzyPathScore(path: string, query: string): number | undefined {
139
+ const candidate = path.toLowerCase();
140
+ const substringIndex = candidate.indexOf(query);
141
+ if (substringIndex >= 0) {
142
+ return 10_000 + segmentStartBonus(candidate, substringIndex) -
143
+ substringIndex;
144
+ }
145
+
146
+ const positions: Array<number> = [];
147
+ let candidateIndex = 0;
148
+ for (const character of query) {
149
+ const matchIndex = candidate.indexOf(character, candidateIndex);
150
+ if (matchIndex < 0) return undefined;
151
+ positions.push(matchIndex);
152
+ candidateIndex = matchIndex + 1;
153
+ }
154
+
155
+ const first = positions[0];
156
+ const last = positions.at(-1);
157
+ if (first === undefined || last === undefined) return undefined;
158
+
159
+ const span = last - first + 1;
160
+ let consecutive = 0;
161
+ let segmentStarts = 0;
162
+ for (const [index, position] of positions.entries()) {
163
+ if (index > 0 && position === positions[index - 1]! + 1) {
164
+ consecutive += 1;
165
+ }
166
+ segmentStarts += segmentStartBonus(candidate, position);
167
+ }
168
+
169
+ return query.length * 100 - span * 4 - first + consecutive * 12 +
170
+ segmentStarts;
171
+ }
172
+
173
+ function segmentStartBonus(path: string, index: number): number {
174
+ return index === 0 || path[index - 1] === "/" ? 40 : 0;
175
+ }