pi-pair-programmer 0.1.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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/package.json +72 -0
- package/src/change-evidence.ts +840 -0
- package/src/finding-admission.ts +167 -0
- package/src/index.ts +952 -0
- package/src/logger.ts +396 -0
- package/src/model-usage.ts +285 -0
- package/src/pair-stats.ts +372 -0
- package/src/review-runner.ts +301 -0
- package/src/review-store.ts +268 -0
- package/src/reviewers.ts +165 -0
- package/src/stats-view.ts +172 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { noul, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
+
import { observeJudgment, type ModelCallObserver } from "./model-usage.js";
|
|
4
|
+
import type { ProposedFinding } from "./review-runner.js";
|
|
5
|
+
import type { ReviewStore, Finding, StoredFinding } from "./review-store.js";
|
|
6
|
+
|
|
7
|
+
interface AdmissionRequest {
|
|
8
|
+
file: string;
|
|
9
|
+
reviewer: string;
|
|
10
|
+
revision: string;
|
|
11
|
+
findings: readonly ProposedFinding[];
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
onModelCall?: ModelCallObserver;
|
|
14
|
+
isCurrent: () => Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Candidate = Finding & { duplicateKey: string };
|
|
18
|
+
|
|
19
|
+
let jevClient: TypeSafeClient | undefined;
|
|
20
|
+
|
|
21
|
+
function hash(parts: readonly string[]): string {
|
|
22
|
+
return createHash("sha256")
|
|
23
|
+
.update(JSON.stringify(parts))
|
|
24
|
+
.digest("hex")
|
|
25
|
+
.slice(0, 16);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function candidateOf(
|
|
29
|
+
request: AdmissionRequest,
|
|
30
|
+
proposed: ProposedFinding,
|
|
31
|
+
): Candidate {
|
|
32
|
+
const duplicateKey = hash([
|
|
33
|
+
request.file,
|
|
34
|
+
request.reviewer,
|
|
35
|
+
proposed.title.toLowerCase().trim(),
|
|
36
|
+
proposed.quote.trim(),
|
|
37
|
+
]);
|
|
38
|
+
const evidence = `${proposed.quote} — ${proposed.evidence}`;
|
|
39
|
+
return {
|
|
40
|
+
id: hash([duplicateKey, request.revision, evidence]),
|
|
41
|
+
duplicateKey,
|
|
42
|
+
reviewer: request.reviewer,
|
|
43
|
+
file: request.file,
|
|
44
|
+
revision: request.revision,
|
|
45
|
+
line: proposed.line,
|
|
46
|
+
title: proposed.title,
|
|
47
|
+
evidence,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sameProblem(candidate: Candidate, finding: Finding): boolean {
|
|
52
|
+
return candidate.duplicateKey === (finding.duplicateKey ?? finding.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function isNovel(
|
|
56
|
+
candidate: Candidate,
|
|
57
|
+
history: readonly StoredFinding[],
|
|
58
|
+
earlier: readonly Candidate[],
|
|
59
|
+
signal: AbortSignal,
|
|
60
|
+
onModelCall: ModelCallObserver | undefined,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
const matches = (finding: Finding): boolean =>
|
|
63
|
+
sameProblem(candidate, finding);
|
|
64
|
+
const unchanged = (finding: Finding): boolean =>
|
|
65
|
+
matches(finding) && candidate.evidence === finding.evidence;
|
|
66
|
+
if (
|
|
67
|
+
history.some(({ finding }) => unchanged(finding)) ||
|
|
68
|
+
earlier.some(unchanged)
|
|
69
|
+
)
|
|
70
|
+
return false;
|
|
71
|
+
if (history.length === 0 && earlier.length === 0) return true;
|
|
72
|
+
try {
|
|
73
|
+
jevClient ??= new TypeSafeClient({ logLevel: "off" });
|
|
74
|
+
const client = jevClient;
|
|
75
|
+
const response = await observeJudgment("dedup", signal, onModelCall, () =>
|
|
76
|
+
client.systemOne(
|
|
77
|
+
{
|
|
78
|
+
model: "jev-latest",
|
|
79
|
+
state: {
|
|
80
|
+
candidate: { ...candidate },
|
|
81
|
+
history: history.map(({ finding, verdict, reason }) => ({
|
|
82
|
+
...finding,
|
|
83
|
+
duplicateKey: finding.duplicateKey ?? finding.id,
|
|
84
|
+
verdict: verdict ?? null,
|
|
85
|
+
reason: reason ?? null,
|
|
86
|
+
})),
|
|
87
|
+
earlierCandidates: earlier.map((finding) => ({ ...finding })),
|
|
88
|
+
},
|
|
89
|
+
questions: {
|
|
90
|
+
duplicate: noul(
|
|
91
|
+
"Is the candidate the same underlying problem with unchanged evidence as any history finding or earlier candidate? Previously accepted or rejected history findings count as duplicates. A material change to the evidence after a fix is not a duplicate.",
|
|
92
|
+
{
|
|
93
|
+
true: "Same root problem with unchanged evidence, even if wording or line number differs",
|
|
94
|
+
false: "Distinct root problem or materially changed evidence",
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{ signal, timeout: 30_000, retry: { maxRetries: 0 } },
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
return response.answers.duplicate.noul < 0.5;
|
|
103
|
+
} catch {
|
|
104
|
+
return (
|
|
105
|
+
history.every(({ finding }) => !matches(finding)) &&
|
|
106
|
+
earlier.every((finding) => !matches(finding))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function novelFindings(
|
|
112
|
+
candidates: readonly Candidate[],
|
|
113
|
+
history: readonly StoredFinding[],
|
|
114
|
+
compareCandidates: boolean,
|
|
115
|
+
signal: AbortSignal,
|
|
116
|
+
onModelCall: ModelCallObserver | undefined,
|
|
117
|
+
): Promise<Candidate[]> {
|
|
118
|
+
const novel: Candidate[] = [];
|
|
119
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
120
|
+
if (signal.aborted) break;
|
|
121
|
+
const earlier = compareCandidates ? candidates.slice(0, index) : [];
|
|
122
|
+
if (await isNovel(candidate, history, earlier, signal, onModelCall))
|
|
123
|
+
novel.push(candidate);
|
|
124
|
+
}
|
|
125
|
+
return novel;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class FindingAdmission {
|
|
129
|
+
private readonly store: ReviewStore;
|
|
130
|
+
|
|
131
|
+
constructor(store: ReviewStore) {
|
|
132
|
+
this.store = store;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async admit(
|
|
136
|
+
request: AdmissionRequest,
|
|
137
|
+
): Promise<"obsolete" | "unchanged" | "added"> {
|
|
138
|
+
const { file, signal } = request;
|
|
139
|
+
let history = this.store.history(file);
|
|
140
|
+
let novel = await novelFindings(
|
|
141
|
+
request.findings.map((finding) => candidateOf(request, finding)),
|
|
142
|
+
history,
|
|
143
|
+
true,
|
|
144
|
+
signal,
|
|
145
|
+
request.onModelCall,
|
|
146
|
+
);
|
|
147
|
+
for (;;) {
|
|
148
|
+
if (!(await request.isCurrent()) || signal.aborted) return "obsolete";
|
|
149
|
+
const latest = this.store.history(file);
|
|
150
|
+
const added = latest.slice(history.length);
|
|
151
|
+
if (novel.length === 0 || added.length === 0) break;
|
|
152
|
+
history = latest;
|
|
153
|
+
novel = await novelFindings(
|
|
154
|
+
novel,
|
|
155
|
+
added,
|
|
156
|
+
false,
|
|
157
|
+
signal,
|
|
158
|
+
request.onModelCall,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
let result: "unchanged" | "added" = "unchanged";
|
|
162
|
+
for (const finding of novel) {
|
|
163
|
+
if (this.store.add(finding)) result = "added";
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
}
|