pi-pair-programmer 0.1.5 → 0.1.7
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/package.json +2 -1
- package/src/change-evidence.ts +137 -33
- package/src/finding-admission.ts +72 -37
- package/src/index.ts +37 -5
- package/src/logger.ts +25 -0
- package/src/model-usage.ts +3 -3
- package/src/pair-stats.ts +96 -0
- package/src/review-runner.ts +228 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pair-programmer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "A TypeScript extension for Pi and OMP",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@typesafe-ai/sdk": "^0.6.0",
|
|
67
|
+
"diff": "^9.0.0",
|
|
67
68
|
"minimatch": "^10.2.6",
|
|
68
69
|
"pino": "^10.3.1",
|
|
69
70
|
"typebox": "^1.3.34",
|
package/src/change-evidence.ts
CHANGED
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { promisify } from "node:util";
|
|
16
|
+
import { createTwoFilesPatch, diffArrays, FILE_HEADERS_ONLY } from "diff";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
|
|
18
19
|
const execute = promisify(execFile);
|
|
19
20
|
const NoMatchSchema = z.object({ code: z.literal(1), stderr: z.string() });
|
|
20
21
|
const MAX_FILE_BYTES = 64_000;
|
|
22
|
+
const MAX_EDIT_DISTANCE = 1000;
|
|
21
23
|
const MAX_ENTRIES = 10_000;
|
|
22
24
|
const MAX_LISTING_BYTES = 256_000_000;
|
|
23
25
|
const CAPTURE_TIMEOUT_MS = 5000;
|
|
@@ -733,32 +735,30 @@ function matchingOrigins(
|
|
|
733
735
|
return [...matches.values()];
|
|
734
736
|
}
|
|
735
737
|
|
|
736
|
-
function sourceDiff(before: SourceFile, after: SourceFile): string {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
];
|
|
761
|
-
return `--- ${before.file}\n+++ ${after.file}\n@@ -${String(contextStart + 1)},${String(oldEnd - contextStart + contextEnd)} +${String(contextStart + 1)},${String(newEnd - contextStart + contextEnd)} @@\n${changes.join("\n")}`;
|
|
738
|
+
function sourceDiff(before: SourceFile, after: SourceFile): string | undefined {
|
|
739
|
+
return createTwoFilesPatch(
|
|
740
|
+
before.file,
|
|
741
|
+
after.file,
|
|
742
|
+
before.source,
|
|
743
|
+
after.source,
|
|
744
|
+
undefined,
|
|
745
|
+
undefined,
|
|
746
|
+
{
|
|
747
|
+
context: 3,
|
|
748
|
+
maxEditLength: MAX_EDIT_DISTANCE,
|
|
749
|
+
headerOptions: FILE_HEADERS_ONLY,
|
|
750
|
+
},
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function baselineFile(
|
|
755
|
+
baseline: TaskBaseline | undefined,
|
|
756
|
+
file: string,
|
|
757
|
+
): string | undefined {
|
|
758
|
+
return baseline === undefined
|
|
759
|
+
? undefined
|
|
760
|
+
: (relativeFile(baseline.root, file) ??
|
|
761
|
+
relativeFile(baseline.requestedRoot, file));
|
|
762
762
|
}
|
|
763
763
|
|
|
764
764
|
export async function buildChangeEvidence(
|
|
@@ -769,11 +769,7 @@ export async function buildChangeEvidence(
|
|
|
769
769
|
quote: string,
|
|
770
770
|
signal?: AbortSignal,
|
|
771
771
|
): Promise<ChangeEvidence> {
|
|
772
|
-
const relative =
|
|
773
|
-
baseline === undefined
|
|
774
|
-
? undefined
|
|
775
|
-
: (relativeFile(baseline.root, file) ??
|
|
776
|
-
relativeFile(baseline.requestedRoot, file));
|
|
772
|
+
const relative = baselineFile(baseline, file);
|
|
777
773
|
const evidence: ChangeEvidence = {
|
|
778
774
|
status: "unavailable",
|
|
779
775
|
before: null,
|
|
@@ -833,8 +829,116 @@ export async function buildChangeEvidence(
|
|
|
833
829
|
"No substantial matching baseline block or same-file source";
|
|
834
830
|
return evidence;
|
|
835
831
|
}
|
|
836
|
-
|
|
832
|
+
const diff = sourceDiff(original, evidence.after);
|
|
837
833
|
evidence.status = "available";
|
|
838
|
-
evidence.
|
|
834
|
+
evidence.diff = diff ?? null;
|
|
835
|
+
evidence.reason =
|
|
836
|
+
diff === undefined
|
|
837
|
+
? "The line diff was too large to compute; compare taskStartSource with currentSource directly"
|
|
838
|
+
: null;
|
|
839
839
|
return evidence;
|
|
840
840
|
}
|
|
841
|
+
|
|
842
|
+
export interface LineHunk {
|
|
843
|
+
start: number;
|
|
844
|
+
added: number;
|
|
845
|
+
removed: readonly string[];
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export type LineChanges =
|
|
849
|
+
| { status: "available"; hunks: readonly LineHunk[] }
|
|
850
|
+
| { status: "approximate"; region: LineHunk }
|
|
851
|
+
| { status: "unavailable"; reason: string };
|
|
852
|
+
|
|
853
|
+
function compareLines(before: string, after: string): LineChanges {
|
|
854
|
+
const oldLines = before.split("\n");
|
|
855
|
+
const newLines = after.split("\n");
|
|
856
|
+
let start = 0;
|
|
857
|
+
while (
|
|
858
|
+
start < oldLines.length &&
|
|
859
|
+
start < newLines.length &&
|
|
860
|
+
oldLines.at(start) === newLines.at(start)
|
|
861
|
+
) {
|
|
862
|
+
start += 1;
|
|
863
|
+
}
|
|
864
|
+
let oldEnd = oldLines.length;
|
|
865
|
+
let newEnd = newLines.length;
|
|
866
|
+
while (
|
|
867
|
+
oldEnd > start &&
|
|
868
|
+
newEnd > start &&
|
|
869
|
+
oldLines[oldEnd - 1] === newLines[newEnd - 1]
|
|
870
|
+
) {
|
|
871
|
+
oldEnd -= 1;
|
|
872
|
+
newEnd -= 1;
|
|
873
|
+
}
|
|
874
|
+
const removed = oldLines.slice(start, oldEnd);
|
|
875
|
+
const added = newLines.slice(start, newEnd);
|
|
876
|
+
const changes = diffArrays(removed, added, {
|
|
877
|
+
maxEditLength: MAX_EDIT_DISTANCE,
|
|
878
|
+
});
|
|
879
|
+
if (changes === undefined)
|
|
880
|
+
return {
|
|
881
|
+
status: "approximate",
|
|
882
|
+
region: { start: start + 1, added: added.length, removed },
|
|
883
|
+
};
|
|
884
|
+
const hunks: LineHunk[] = [];
|
|
885
|
+
let line = start + 1;
|
|
886
|
+
let hunk: { start: number; added: number; removed: string[] } | undefined;
|
|
887
|
+
for (const change of changes) {
|
|
888
|
+
if (!change.added && !change.removed) {
|
|
889
|
+
hunk = undefined;
|
|
890
|
+
line += change.count;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (hunk === undefined) {
|
|
894
|
+
hunk = { start: line, added: 0, removed: [] };
|
|
895
|
+
hunks.push(hunk);
|
|
896
|
+
}
|
|
897
|
+
if (change.added) {
|
|
898
|
+
hunk.added += change.count;
|
|
899
|
+
line += change.count;
|
|
900
|
+
} else {
|
|
901
|
+
hunk.removed.push(...change.value);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return { status: "available", hunks };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export async function lineChanges(
|
|
908
|
+
baseline: TaskBaseline | undefined,
|
|
909
|
+
file: string,
|
|
910
|
+
source: string,
|
|
911
|
+
signal?: AbortSignal,
|
|
912
|
+
): Promise<LineChanges> {
|
|
913
|
+
if (baseline === undefined)
|
|
914
|
+
return { status: "unavailable", reason: "Task baseline is unavailable" };
|
|
915
|
+
const relative = baselineFile(baseline, file);
|
|
916
|
+
if (
|
|
917
|
+
relative === undefined ||
|
|
918
|
+
!eligible(relative) ||
|
|
919
|
+
baseline.unattributable(relative)
|
|
920
|
+
)
|
|
921
|
+
return {
|
|
922
|
+
status: "unavailable",
|
|
923
|
+
reason: "File is not covered by the task baseline",
|
|
924
|
+
};
|
|
925
|
+
if (Buffer.byteLength(source) > MAX_FILE_BYTES)
|
|
926
|
+
return { status: "unavailable", reason: "Current source is oversized" };
|
|
927
|
+
let before: BeforeState;
|
|
928
|
+
try {
|
|
929
|
+
before = await baseline.before(relative, signal);
|
|
930
|
+
} catch {
|
|
931
|
+
return { status: "unavailable", reason: "Task baseline could not be read" };
|
|
932
|
+
}
|
|
933
|
+
if (before.state === "unknown")
|
|
934
|
+
return {
|
|
935
|
+
status: "unavailable",
|
|
936
|
+
reason: "Task-start state of this file is unknown",
|
|
937
|
+
};
|
|
938
|
+
return before.state === "absent"
|
|
939
|
+
? {
|
|
940
|
+
status: "available",
|
|
941
|
+
hunks: [{ start: 1, added: source.split("\n").length, removed: [] }],
|
|
942
|
+
}
|
|
943
|
+
: compareLines(before.source, source);
|
|
944
|
+
}
|
package/src/finding-admission.ts
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { noul, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
-
import {
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
notify,
|
|
6
|
+
observeJudgment,
|
|
7
|
+
type ModelCallObserver,
|
|
8
|
+
} from "./model-usage.js";
|
|
9
|
+
import type { DedupJudgment, JudgmentObserver } from "./pair-stats.js";
|
|
4
10
|
import type { ProposedFinding } from "./review-runner.js";
|
|
5
11
|
import type { ReviewStore, Finding, StoredFinding } from "./review-store.js";
|
|
6
12
|
|
|
7
|
-
interface
|
|
13
|
+
interface FindingScope {
|
|
8
14
|
file: string;
|
|
9
15
|
reviewer: string;
|
|
10
16
|
revision: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AdmissionRequest extends FindingScope {
|
|
11
20
|
findings: readonly ProposedFinding[];
|
|
12
21
|
signal: AbortSignal;
|
|
13
22
|
onModelCall?: ModelCallObserver;
|
|
23
|
+
onJudgment?: JudgmentObserver;
|
|
14
24
|
isCurrent: () => Promise<boolean>;
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
type Observers = Pick<
|
|
28
|
+
AdmissionRequest,
|
|
29
|
+
"signal" | "onModelCall" | "onJudgment"
|
|
30
|
+
>;
|
|
17
31
|
type Candidate = Finding & { duplicateKey: string };
|
|
18
32
|
|
|
33
|
+
const DUPLICATE_THRESHOLD = 0.5;
|
|
34
|
+
const dedupSchema = z.object({
|
|
35
|
+
answers: z.object({
|
|
36
|
+
duplicate: z.object({ noul: z.number().min(0).max(1) }),
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
19
39
|
let jevClient: TypeSafeClient | undefined;
|
|
20
40
|
|
|
21
41
|
function hash(parts: readonly string[]): string {
|
|
@@ -25,8 +45,15 @@ function hash(parts: readonly string[]): string {
|
|
|
25
45
|
.slice(0, 16);
|
|
26
46
|
}
|
|
27
47
|
|
|
48
|
+
export function findingId(
|
|
49
|
+
scope: FindingScope,
|
|
50
|
+
proposed: ProposedFinding,
|
|
51
|
+
): string {
|
|
52
|
+
return candidateOf(scope, proposed).id;
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
function candidateOf(
|
|
29
|
-
request:
|
|
56
|
+
request: FindingScope,
|
|
30
57
|
proposed: ProposedFinding,
|
|
31
58
|
): Candidate {
|
|
32
59
|
const duplicateKey = hash([
|
|
@@ -52,27 +79,38 @@ function sameProblem(candidate: Candidate, finding: Finding): boolean {
|
|
|
52
79
|
return candidate.duplicateKey === (finding.duplicateKey ?? finding.id);
|
|
53
80
|
}
|
|
54
81
|
|
|
55
|
-
async function
|
|
82
|
+
async function judgeNovelty(
|
|
56
83
|
candidate: Candidate,
|
|
57
84
|
history: readonly StoredFinding[],
|
|
58
85
|
earlier: readonly Candidate[],
|
|
59
|
-
signal:
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
{ signal, onModelCall }: Observers,
|
|
87
|
+
): Promise<DedupJudgment> {
|
|
88
|
+
const priors = [...history.map(({ finding }) => finding), ...earlier];
|
|
89
|
+
const matching = priors.filter((finding) => sameProblem(candidate, finding));
|
|
90
|
+
const judgment = (
|
|
91
|
+
decision: DedupJudgment["decision"],
|
|
92
|
+
reason: DedupJudgment["reason"],
|
|
93
|
+
): DedupJudgment => ({
|
|
94
|
+
stage: "dedup",
|
|
95
|
+
decision,
|
|
96
|
+
reason,
|
|
97
|
+
findingId: candidate.id,
|
|
98
|
+
duplicateKey: candidate.duplicateKey,
|
|
99
|
+
line: candidate.line,
|
|
100
|
+
title: candidate.title,
|
|
101
|
+
history: history.map(({ finding }) => finding.id),
|
|
102
|
+
candidates: earlier.map(({ id }) => id),
|
|
103
|
+
sameKey: matching.map(({ id }) => id),
|
|
104
|
+
});
|
|
105
|
+
if (matching.some(({ evidence }) => evidence === candidate.evidence))
|
|
106
|
+
return judgment("drop", "unchanged");
|
|
107
|
+
if (priors.length === 0) return judgment("keep", "first");
|
|
108
|
+
const fallback = matching.length === 0 ? "keep" : "drop";
|
|
109
|
+
let response: unknown;
|
|
72
110
|
try {
|
|
73
111
|
jevClient ??= new TypeSafeClient({ logLevel: "off" });
|
|
74
112
|
const client = jevClient;
|
|
75
|
-
|
|
113
|
+
response = await observeJudgment("dedup", signal, onModelCall, () =>
|
|
76
114
|
client.systemOne(
|
|
77
115
|
{
|
|
78
116
|
model: "jev-latest",
|
|
@@ -99,28 +137,32 @@ async function isNovel(
|
|
|
99
137
|
{ signal, timeout: 30_000, retry: { maxRetries: 0 } },
|
|
100
138
|
),
|
|
101
139
|
);
|
|
102
|
-
return response.answers.duplicate.noul < 0.5;
|
|
103
140
|
} catch {
|
|
104
|
-
return (
|
|
105
|
-
history.every(({ finding }) => !matches(finding)) &&
|
|
106
|
-
earlier.every((finding) => !matches(finding))
|
|
107
|
-
);
|
|
141
|
+
return judgment(fallback, signal.aborted ? "cancelled" : "request_failed");
|
|
108
142
|
}
|
|
143
|
+
const parsed = dedupSchema.safeParse(response);
|
|
144
|
+
if (!parsed.success) return judgment(fallback, "response_invalid");
|
|
145
|
+
const score = parsed.data.answers.duplicate.noul;
|
|
146
|
+
return {
|
|
147
|
+
...judgment(score < DUPLICATE_THRESHOLD ? "keep" : "drop", "judged"),
|
|
148
|
+
score,
|
|
149
|
+
threshold: DUPLICATE_THRESHOLD,
|
|
150
|
+
};
|
|
109
151
|
}
|
|
110
152
|
|
|
111
153
|
async function novelFindings(
|
|
112
154
|
candidates: readonly Candidate[],
|
|
113
155
|
history: readonly StoredFinding[],
|
|
114
156
|
compareCandidates: boolean,
|
|
115
|
-
|
|
116
|
-
onModelCall: ModelCallObserver | undefined,
|
|
157
|
+
observers: Observers,
|
|
117
158
|
): Promise<Candidate[]> {
|
|
118
159
|
const novel: Candidate[] = [];
|
|
119
160
|
for (const [index, candidate] of candidates.entries()) {
|
|
120
|
-
if (signal.aborted) break;
|
|
161
|
+
if (observers.signal.aborted) break;
|
|
121
162
|
const earlier = compareCandidates ? candidates.slice(0, index) : [];
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
const judgment = await judgeNovelty(candidate, history, earlier, observers);
|
|
164
|
+
notify(observers.onJudgment, judgment);
|
|
165
|
+
if (judgment.decision === "keep") novel.push(candidate);
|
|
124
166
|
}
|
|
125
167
|
return novel;
|
|
126
168
|
}
|
|
@@ -141,8 +183,7 @@ export class FindingAdmission {
|
|
|
141
183
|
request.findings.map((finding) => candidateOf(request, finding)),
|
|
142
184
|
history,
|
|
143
185
|
true,
|
|
144
|
-
|
|
145
|
-
request.onModelCall,
|
|
186
|
+
request,
|
|
146
187
|
);
|
|
147
188
|
for (;;) {
|
|
148
189
|
if (!(await request.isCurrent()) || signal.aborted) return "obsolete";
|
|
@@ -150,13 +191,7 @@ export class FindingAdmission {
|
|
|
150
191
|
const added = latest.slice(history.length);
|
|
151
192
|
if (novel.length === 0 || added.length === 0) break;
|
|
152
193
|
history = latest;
|
|
153
|
-
novel = await novelFindings(
|
|
154
|
-
novel,
|
|
155
|
-
added,
|
|
156
|
-
false,
|
|
157
|
-
signal,
|
|
158
|
-
request.onModelCall,
|
|
159
|
-
);
|
|
194
|
+
novel = await novelFindings(novel, added, false, request);
|
|
160
195
|
}
|
|
161
196
|
let result: "unchanged" | "added" = "unchanged";
|
|
162
197
|
for (const finding of novel) {
|
package/src/index.ts
CHANGED
|
@@ -10,14 +10,16 @@ import { z } from "zod";
|
|
|
10
10
|
import {
|
|
11
11
|
buildChangeEvidence,
|
|
12
12
|
captureBaseline,
|
|
13
|
+
lineChanges,
|
|
13
14
|
type TaskBaseline,
|
|
14
15
|
} from "./change-evidence.js";
|
|
15
|
-
import { FindingAdmission } from "./finding-admission.js";
|
|
16
|
+
import { FindingAdmission, findingId } from "./finding-admission.js";
|
|
16
17
|
import { createPairLogger, type PairLogger } from "./logger.js";
|
|
17
18
|
import type { ModelCallObserver } from "./model-usage.js";
|
|
18
19
|
import {
|
|
19
20
|
SessionAccounting,
|
|
20
21
|
STATS_ENTRY,
|
|
22
|
+
type JudgmentObserver,
|
|
21
23
|
type PairStats,
|
|
22
24
|
type ReviewOutcome,
|
|
23
25
|
} from "./pair-stats.js";
|
|
@@ -373,6 +375,22 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
373
375
|
});
|
|
374
376
|
}
|
|
375
377
|
};
|
|
378
|
+
const onJudgment: JudgmentObserver = (judgment) => {
|
|
379
|
+
job.stats.judge(job.id, judgment);
|
|
380
|
+
logger?.log("finding.judged", {
|
|
381
|
+
...fields,
|
|
382
|
+
stage: judgment.stage,
|
|
383
|
+
outcome: judgment.decision === "fail" ? "invalid" : judgment.decision,
|
|
384
|
+
reasonCode: judgment.reason,
|
|
385
|
+
confidence:
|
|
386
|
+
judgment.stage === "attribution" ? judgment.confidence : undefined,
|
|
387
|
+
probability:
|
|
388
|
+
judgment.stage === "attribution"
|
|
389
|
+
? judgment.probabilities?.inherited
|
|
390
|
+
: undefined,
|
|
391
|
+
score: judgment.stage === "dedup" ? judgment.score : undefined,
|
|
392
|
+
});
|
|
393
|
+
};
|
|
376
394
|
controllers.set(controller, job.key);
|
|
377
395
|
cancelJobs.set(controller, () => {
|
|
378
396
|
finish("cancelled", false);
|
|
@@ -381,7 +399,12 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
381
399
|
logger?.log("review.started", fields);
|
|
382
400
|
void (async () => {
|
|
383
401
|
try {
|
|
384
|
-
const result = await runJob(
|
|
402
|
+
const result = await runJob(
|
|
403
|
+
job,
|
|
404
|
+
controller.signal,
|
|
405
|
+
onModelCall,
|
|
406
|
+
onJudgment,
|
|
407
|
+
);
|
|
385
408
|
const settledOutcome =
|
|
386
409
|
reviewState.outcome === "success" ? result : reviewState.outcome;
|
|
387
410
|
finish(controller.signal.aborted ? "cancelled" : settledOutcome);
|
|
@@ -424,6 +447,7 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
424
447
|
job: ReviewJob,
|
|
425
448
|
signal: AbortSignal,
|
|
426
449
|
onModelCall: ModelCallObserver,
|
|
450
|
+
onJudgment: JudgmentObserver,
|
|
427
451
|
): Promise<ReviewOutcome> {
|
|
428
452
|
const proposed = await reviewFile({
|
|
429
453
|
host: job.host,
|
|
@@ -432,8 +456,10 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
432
456
|
prompt: job.reviewer.prompt,
|
|
433
457
|
file: job.file,
|
|
434
458
|
source: job.source,
|
|
459
|
+
changes: await lineChanges(baseline, job.file, job.source, signal),
|
|
435
460
|
signal,
|
|
436
461
|
onModelCall,
|
|
462
|
+
onJudgment,
|
|
437
463
|
});
|
|
438
464
|
if (!(await current(job))) {
|
|
439
465
|
logger?.log("review.skipped", {
|
|
@@ -448,6 +474,7 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
448
474
|
reviewed.set(`${job.key}:${reviewer}:${job.model}`, job.revision);
|
|
449
475
|
return "success";
|
|
450
476
|
}
|
|
477
|
+
const scope = { file: job.file, reviewer, revision: job.revision };
|
|
451
478
|
const judged = await Promise.all(
|
|
452
479
|
proposed.map(async (finding) => ({
|
|
453
480
|
finding,
|
|
@@ -463,6 +490,12 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
463
490
|
),
|
|
464
491
|
signal,
|
|
465
492
|
onModelCall,
|
|
493
|
+
onJudgment: (attribution) => {
|
|
494
|
+
onJudgment({
|
|
495
|
+
...attribution,
|
|
496
|
+
findingId: findingId(scope, finding),
|
|
497
|
+
});
|
|
498
|
+
},
|
|
466
499
|
}),
|
|
467
500
|
})),
|
|
468
501
|
);
|
|
@@ -474,15 +507,14 @@ export default function pairProgrammer(pi: ExtensionAPI): void {
|
|
|
474
507
|
});
|
|
475
508
|
if (signal.aborted || !(await current(job))) return "obsolete";
|
|
476
509
|
const result = await admission.admit({
|
|
477
|
-
|
|
478
|
-
reviewer,
|
|
479
|
-
revision: job.revision,
|
|
510
|
+
...scope,
|
|
480
511
|
findings: judged.flatMap(({ finding, inherited }) =>
|
|
481
512
|
inherited ? [] : [finding],
|
|
482
513
|
),
|
|
483
514
|
signal,
|
|
484
515
|
isCurrent: () => current(job),
|
|
485
516
|
onModelCall,
|
|
517
|
+
onJudgment,
|
|
486
518
|
});
|
|
487
519
|
logger?.log("finding.admission", {
|
|
488
520
|
sessionId: job.stats.sessionId,
|
package/src/logger.ts
CHANGED
|
@@ -16,6 +16,7 @@ export type PairLogEvent =
|
|
|
16
16
|
| "review.skipped"
|
|
17
17
|
| "model.finished"
|
|
18
18
|
| "finding.filtered"
|
|
19
|
+
| "finding.judged"
|
|
19
20
|
| "finding.admission"
|
|
20
21
|
| "finding.verdict"
|
|
21
22
|
| "delivery.sent"
|
|
@@ -37,6 +38,9 @@ export interface PairLogFields {
|
|
|
37
38
|
cacheWriteTokens?: number;
|
|
38
39
|
totalTokens?: number;
|
|
39
40
|
costUsd?: number;
|
|
41
|
+
confidence?: number | undefined;
|
|
42
|
+
probability?: number | undefined;
|
|
43
|
+
score?: number | undefined;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
export interface PairLogger {
|
|
@@ -56,6 +60,7 @@ const events: Record<PairLogEvent, true> = {
|
|
|
56
60
|
"review.skipped": true,
|
|
57
61
|
"model.finished": true,
|
|
58
62
|
"finding.filtered": true,
|
|
63
|
+
"finding.judged": true,
|
|
59
64
|
"finding.admission": true,
|
|
60
65
|
"finding.verdict": true,
|
|
61
66
|
"delivery.sent": true,
|
|
@@ -70,6 +75,9 @@ const numericFields: Record<string, true> = {
|
|
|
70
75
|
cacheWriteTokens: true,
|
|
71
76
|
totalTokens: true,
|
|
72
77
|
costUsd: true,
|
|
78
|
+
confidence: true,
|
|
79
|
+
probability: true,
|
|
80
|
+
score: true,
|
|
73
81
|
};
|
|
74
82
|
const identifierFields: Record<string, true> = {
|
|
75
83
|
sessionId: true,
|
|
@@ -93,6 +101,8 @@ const outcomes = {
|
|
|
93
101
|
accept: true,
|
|
94
102
|
reject: true,
|
|
95
103
|
invalid: true,
|
|
104
|
+
keep: true,
|
|
105
|
+
drop: true,
|
|
96
106
|
on: true,
|
|
97
107
|
off: true,
|
|
98
108
|
};
|
|
@@ -123,6 +133,19 @@ const reasonCodes = {
|
|
|
123
133
|
baseline_unavailable: true,
|
|
124
134
|
configuration_failed: true,
|
|
125
135
|
ui_unavailable: true,
|
|
136
|
+
entry_invalid: true,
|
|
137
|
+
line_out_of_range: true,
|
|
138
|
+
quote_mismatch: true,
|
|
139
|
+
output_not_json: true,
|
|
140
|
+
envelope_invalid: true,
|
|
141
|
+
too_many_findings: true,
|
|
142
|
+
judged: true,
|
|
143
|
+
evidence_unavailable: true,
|
|
144
|
+
no_origin: true,
|
|
145
|
+
response_invalid: true,
|
|
146
|
+
request_failed: true,
|
|
147
|
+
cancelled: true,
|
|
148
|
+
first: true,
|
|
126
149
|
};
|
|
127
150
|
const maxFileBytes = 1024 * 1024;
|
|
128
151
|
const maxBufferedBytes = 64 * 1024;
|
|
@@ -163,6 +186,7 @@ const warningReasons: Record<string, true> = {
|
|
|
163
186
|
missing_model: true,
|
|
164
187
|
baseline_unavailable: true,
|
|
165
188
|
ui_unavailable: true,
|
|
189
|
+
response_invalid: true,
|
|
166
190
|
};
|
|
167
191
|
|
|
168
192
|
function severity(
|
|
@@ -192,6 +216,7 @@ function severity(
|
|
|
192
216
|
outcome === "obsolete" ||
|
|
193
217
|
event === "review.skipped" ||
|
|
194
218
|
event === "finding.filtered" ||
|
|
219
|
+
event === "finding.judged" ||
|
|
195
220
|
(event === "delivery.wake" && reason !== "ready")
|
|
196
221
|
? "debug"
|
|
197
222
|
: "info";
|
package/src/model-usage.ts
CHANGED
|
@@ -69,9 +69,9 @@ function metadata(value: unknown, judge = false): Metadata {
|
|
|
69
69
|
return result;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function notify(
|
|
73
|
-
observer:
|
|
74
|
-
event:
|
|
72
|
+
export function notify<T>(
|
|
73
|
+
observer: ((event: T) => void) | undefined,
|
|
74
|
+
event: T,
|
|
75
75
|
): void {
|
|
76
76
|
try {
|
|
77
77
|
observer?.(event);
|
package/src/pair-stats.ts
CHANGED
|
@@ -34,6 +34,78 @@ const Observation = z.object({
|
|
|
34
34
|
})
|
|
35
35
|
.optional(),
|
|
36
36
|
});
|
|
37
|
+
const Probability = z.number().min(0).max(1);
|
|
38
|
+
const Decision = z.enum(["keep", "drop"]);
|
|
39
|
+
const ReviewDrop = z.object({
|
|
40
|
+
stage: z.literal("review"),
|
|
41
|
+
decision: z.literal("drop"),
|
|
42
|
+
reason: z.enum(["entry_invalid", "line_out_of_range", "quote_mismatch"]),
|
|
43
|
+
line: z.number().nullable(),
|
|
44
|
+
title: z.string().nullable(),
|
|
45
|
+
quote: z.string().nullable(),
|
|
46
|
+
lineText: z.string().nullable(),
|
|
47
|
+
quoteLines: z.array(z.number()),
|
|
48
|
+
invalidFields: z.array(z.string()),
|
|
49
|
+
});
|
|
50
|
+
const ReviewFailure = z.object({
|
|
51
|
+
stage: z.literal("review"),
|
|
52
|
+
decision: z.literal("fail"),
|
|
53
|
+
reason: z.enum(["output_not_json", "envelope_invalid", "too_many_findings"]),
|
|
54
|
+
count: Count.nullable(),
|
|
55
|
+
});
|
|
56
|
+
const Attribution = z.object({
|
|
57
|
+
stage: z.literal("attribution"),
|
|
58
|
+
decision: Decision,
|
|
59
|
+
reason: z.enum([
|
|
60
|
+
"judged",
|
|
61
|
+
"evidence_unavailable",
|
|
62
|
+
"no_origin",
|
|
63
|
+
"response_invalid",
|
|
64
|
+
"request_failed",
|
|
65
|
+
"cancelled",
|
|
66
|
+
]),
|
|
67
|
+
findingId: z.string(),
|
|
68
|
+
line: z.number(),
|
|
69
|
+
title: z.string(),
|
|
70
|
+
quote: z.string(),
|
|
71
|
+
evidenceStatus: z.enum(["available", "unavailable"]),
|
|
72
|
+
evidenceReason: z.string().nullable(),
|
|
73
|
+
choice: z.enum(["inherited", "introduced", "unknown"]).optional(),
|
|
74
|
+
confidence: Probability.optional(),
|
|
75
|
+
probabilities: z
|
|
76
|
+
.object({
|
|
77
|
+
inherited: Probability,
|
|
78
|
+
introduced: Probability,
|
|
79
|
+
unknown: Probability,
|
|
80
|
+
})
|
|
81
|
+
.optional(),
|
|
82
|
+
thresholds: z
|
|
83
|
+
.object({ confidence: Probability, inherited: Probability })
|
|
84
|
+
.optional(),
|
|
85
|
+
issues: z.array(z.string()).optional(),
|
|
86
|
+
});
|
|
87
|
+
const Dedup = z.object({
|
|
88
|
+
stage: z.literal("dedup"),
|
|
89
|
+
decision: Decision,
|
|
90
|
+
reason: z.enum([
|
|
91
|
+
"first",
|
|
92
|
+
"unchanged",
|
|
93
|
+
"judged",
|
|
94
|
+
"response_invalid",
|
|
95
|
+
"request_failed",
|
|
96
|
+
"cancelled",
|
|
97
|
+
]),
|
|
98
|
+
findingId: z.string(),
|
|
99
|
+
duplicateKey: z.string(),
|
|
100
|
+
line: z.number(),
|
|
101
|
+
title: z.string(),
|
|
102
|
+
history: z.array(z.string()),
|
|
103
|
+
candidates: z.array(z.string()),
|
|
104
|
+
sameKey: z.array(z.string()),
|
|
105
|
+
score: Probability.optional(),
|
|
106
|
+
threshold: Probability.optional(),
|
|
107
|
+
});
|
|
108
|
+
const Judgment = z.union([ReviewDrop, ReviewFailure, Attribution, Dedup]);
|
|
37
109
|
const Event = z.discriminatedUnion("action", [
|
|
38
110
|
z.object({
|
|
39
111
|
action: z.literal("start"),
|
|
@@ -60,6 +132,13 @@ const Event = z.discriminatedUnion("action", [
|
|
|
60
132
|
jobId: z.string(),
|
|
61
133
|
observation: Observation,
|
|
62
134
|
}),
|
|
135
|
+
z.object({
|
|
136
|
+
action: z.literal("judgment"),
|
|
137
|
+
id: z.string(),
|
|
138
|
+
sessionId: z.string(),
|
|
139
|
+
jobId: z.string(),
|
|
140
|
+
judgment: Judgment,
|
|
141
|
+
}),
|
|
63
142
|
]);
|
|
64
143
|
const Entry = z.object({
|
|
65
144
|
type: z.literal("custom"),
|
|
@@ -69,6 +148,12 @@ const Entry = z.object({
|
|
|
69
148
|
|
|
70
149
|
type StatsEvent = z.infer<typeof Event>;
|
|
71
150
|
export type ReviewOutcome = z.infer<typeof Outcome>;
|
|
151
|
+
export type ReviewDropJudgment = z.infer<typeof ReviewDrop>;
|
|
152
|
+
export type ReviewFailureJudgment = z.infer<typeof ReviewFailure>;
|
|
153
|
+
export type AttributionJudgment = z.infer<typeof Attribution>;
|
|
154
|
+
export type DedupJudgment = z.infer<typeof Dedup>;
|
|
155
|
+
export type Judgment = z.infer<typeof Judgment>;
|
|
156
|
+
export type JudgmentObserver = (judgment: Judgment) => void;
|
|
72
157
|
export interface MeasuredTotal {
|
|
73
158
|
value: number;
|
|
74
159
|
measured: number;
|
|
@@ -175,6 +260,16 @@ export class PairStats {
|
|
|
175
260
|
});
|
|
176
261
|
}
|
|
177
262
|
|
|
263
|
+
judge(jobId: string, judgment: Judgment): void {
|
|
264
|
+
this.record({
|
|
265
|
+
action: "judgment",
|
|
266
|
+
id: randomUUID(),
|
|
267
|
+
sessionId: this.sessionId,
|
|
268
|
+
jobId,
|
|
269
|
+
judgment,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
178
273
|
snapshot(): StatsSnapshot {
|
|
179
274
|
const result: StatsSnapshot = {
|
|
180
275
|
reviews: {
|
|
@@ -225,6 +320,7 @@ export class PairStats {
|
|
|
225
320
|
break;
|
|
226
321
|
}
|
|
227
322
|
case "settled":
|
|
323
|
+
case "judgment":
|
|
228
324
|
break;
|
|
229
325
|
}
|
|
230
326
|
}
|
package/src/review-runner.ts
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { choice, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ChangeEvidence,
|
|
6
|
+
LineChanges,
|
|
7
|
+
LineHunk,
|
|
8
|
+
} from "./change-evidence.js";
|
|
5
9
|
import {
|
|
10
|
+
notify,
|
|
6
11
|
observeJudgment,
|
|
7
12
|
ReviewEventStream,
|
|
8
13
|
type ModelCallObserver,
|
|
9
14
|
} from "./model-usage.js";
|
|
15
|
+
import type {
|
|
16
|
+
AttributionJudgment,
|
|
17
|
+
JudgmentObserver,
|
|
18
|
+
ReviewDropJudgment,
|
|
19
|
+
ReviewFailureJudgment,
|
|
20
|
+
} from "./pair-stats.js";
|
|
10
21
|
|
|
11
22
|
const MAX_SOURCE_CHARACTERS = 60_000;
|
|
12
23
|
const MAX_FINDINGS = 5;
|
|
24
|
+
const MAX_RECORDED_TEXT = 500;
|
|
25
|
+
const MAX_QUOTE_LINES = 5;
|
|
26
|
+
const ATTRIBUTION_THRESHOLDS = { confidence: 0.95, inherited: 0.95 } as const;
|
|
13
27
|
const proposedFindingSchema = z.looseObject({
|
|
14
28
|
line: z.number().int().positive(),
|
|
15
29
|
title: z.string().min(1).max(160).regex(/\S/u),
|
|
16
30
|
quote: z.string().min(1).max(240).regex(/\S/u),
|
|
17
31
|
evidence: z.string().min(1).max(500).regex(/\S/u),
|
|
18
32
|
});
|
|
33
|
+
const rawFindingSchema = z.looseObject({
|
|
34
|
+
line: z.unknown(),
|
|
35
|
+
title: z.unknown(),
|
|
36
|
+
quote: z.unknown(),
|
|
37
|
+
});
|
|
19
38
|
const reviewResultSchema = z.object({
|
|
20
39
|
findings: z.array(z.unknown()).max(MAX_FINDINGS),
|
|
21
40
|
});
|
|
@@ -69,8 +88,67 @@ interface ReviewRequest {
|
|
|
69
88
|
prompt: string;
|
|
70
89
|
file: string;
|
|
71
90
|
source: string;
|
|
91
|
+
changes: LineChanges;
|
|
72
92
|
signal: AbortSignal;
|
|
73
93
|
onModelCall?: ModelCallObserver;
|
|
94
|
+
onJudgment?: JudgmentObserver;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function numberedExcerpt(
|
|
98
|
+
lines: readonly string[],
|
|
99
|
+
changes: LineChanges,
|
|
100
|
+
truncated: boolean,
|
|
101
|
+
): string {
|
|
102
|
+
const changed = new Set<number>();
|
|
103
|
+
const removed = new Map<number, readonly string[]>();
|
|
104
|
+
for (const hunk of markedHunks(changes)) {
|
|
105
|
+
for (let line = hunk.start; line < hunk.start + hunk.added; line += 1)
|
|
106
|
+
changed.add(line);
|
|
107
|
+
removed.set(hunk.start, hunk.removed);
|
|
108
|
+
}
|
|
109
|
+
const marker = changes.status === "approximate" ? "~" : "+";
|
|
110
|
+
const rows: string[] = [];
|
|
111
|
+
const pushRemoved = (line: number): void => {
|
|
112
|
+
for (const text of removed.get(line) ?? []) rows.push(`-: ${text}`);
|
|
113
|
+
};
|
|
114
|
+
for (const [index, text] of lines.entries()) {
|
|
115
|
+
const line = index + 1;
|
|
116
|
+
pushRemoved(line);
|
|
117
|
+
rows.push(`${String(line)}${changed.has(line) ? marker : ""}: ${text}`);
|
|
118
|
+
}
|
|
119
|
+
if (!truncated) pushRemoved(lines.length + 1);
|
|
120
|
+
return rows.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function markedHunks(changes: LineChanges): readonly LineHunk[] {
|
|
124
|
+
if (changes.status === "available") return changes.hunks;
|
|
125
|
+
return changes.status === "approximate" ? [changes.region] : [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const changeOnlyScope = `Report only violations the changes cause. That includes violations on changed lines, and unchanged code the changes left inconsistent: code that changed lines now duplicate, contradict, or break, or code the changes updated elsewhere but missed here (cite whichever line shows it best). Violations already present at task start that the changes neither create nor worsen are out of scope, even next to changed lines.`;
|
|
129
|
+
|
|
130
|
+
function changeScope(file: string, changes: LineChanges): string {
|
|
131
|
+
if (changes.status === "unavailable")
|
|
132
|
+
return `Review ${file} against the criterion. Which lines changed since task start is unknown, so evaluate the whole file.`;
|
|
133
|
+
const review = `Review the changes made to ${file} since the task started against the criterion. The excerpt below is the current file.`;
|
|
134
|
+
if (changes.status === "approximate")
|
|
135
|
+
return `${review} The exact changed lines could not be computed. A gutter of "N~:" marks line N inside one region that contains every change since task start but may also contain unchanged lines, "N:" marks an unchanged line outside that region, and "-:" lists the task-start lines that region replaced. Removed lines no longer exist, so never cite them. Compare the "N~:" lines with the "-:" lines to tell which of them changed; a line is not changed merely because it is marked "N~:". ${changeOnlyScope}`;
|
|
136
|
+
const legend = `${review} A gutter of "N+:" marks line N as added or changed since task start, "N:" marks an unchanged line, and "-:" shows a task-start line removed at that position. Removed lines no longer exist, so never cite them.`;
|
|
137
|
+
return changes.hunks.length === 0
|
|
138
|
+
? `${legend} No line has changed since task start.`
|
|
139
|
+
: `${legend} ${changeOnlyScope}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reviewInstructions(
|
|
143
|
+
file: string,
|
|
144
|
+
changes: LineChanges,
|
|
145
|
+
lastLine: number | undefined,
|
|
146
|
+
): string {
|
|
147
|
+
const truncation =
|
|
148
|
+
lastLine === undefined
|
|
149
|
+
? ""
|
|
150
|
+
: ` The excerpt stops at line ${String(lastLine)}; the rest of the file is omitted.`;
|
|
151
|
+
return `${changeScope(file, changes)} Line numbers refer to the numbered excerpt below; cite the line number and quote code text only, never the gutter.${truncation}`;
|
|
74
152
|
}
|
|
75
153
|
|
|
76
154
|
function invoke(
|
|
@@ -152,7 +230,7 @@ async function complete(
|
|
|
152
230
|
prompt: string,
|
|
153
231
|
signal: AbortSignal,
|
|
154
232
|
onModelCall: ModelCallObserver | undefined,
|
|
155
|
-
): Promise<
|
|
233
|
+
): Promise<string> {
|
|
156
234
|
const args = [
|
|
157
235
|
"--no-session",
|
|
158
236
|
"--no-tools",
|
|
@@ -177,21 +255,16 @@ async function complete(
|
|
|
177
255
|
args.unshift(script);
|
|
178
256
|
}
|
|
179
257
|
|
|
180
|
-
|
|
181
|
-
await invoke(args, cwd, prompt, signal, model, onModelCall)
|
|
182
|
-
).trim();
|
|
183
|
-
const fenced = /^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/iu.exec(stdout);
|
|
184
|
-
return JSON.parse(fenced?.[1] ?? stdout) as unknown;
|
|
258
|
+
return (await invoke(args, cwd, prompt, signal, model, onModelCall)).trim();
|
|
185
259
|
}
|
|
186
260
|
|
|
187
261
|
export async function reviewFile(
|
|
188
262
|
request: ReviewRequest,
|
|
189
263
|
): Promise<ProposedFinding[]> {
|
|
190
264
|
const excerpt = request.source.slice(0, MAX_SOURCE_CHARACTERS);
|
|
265
|
+
const truncated = excerpt.length < request.source.length;
|
|
191
266
|
const lines = excerpt.split("\n");
|
|
192
|
-
const numbered = lines
|
|
193
|
-
.map((line, index) => `${String(index + 1)}: ${line}`)
|
|
194
|
-
.join("\n");
|
|
267
|
+
const numbered = numberedExcerpt(lines, request.changes, truncated);
|
|
195
268
|
const result = await complete(
|
|
196
269
|
request.host,
|
|
197
270
|
request.cwd,
|
|
@@ -223,13 +296,41 @@ Return only JSON: {"findings":[{"line":1,"title":"specific criterion violation",
|
|
|
223
296
|
Return at most ${String(MAX_FINDINGS)} findings.
|
|
224
297
|
Return {"findings":[]} when the criterion is satisfied or the evidence is insufficient. An empty result is a successful review.
|
|
225
298
|
Reply with the JSON object alone, without any other text.`,
|
|
226
|
-
|
|
299
|
+
`${reviewInstructions(request.file, request.changes, truncated ? lines.length : undefined)}\n\n${numbered}`,
|
|
227
300
|
request.signal,
|
|
228
301
|
request.onModelCall,
|
|
229
302
|
);
|
|
230
303
|
|
|
231
|
-
|
|
304
|
+
return validFindings(result, lines, request.onJudgment);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validFindings(
|
|
308
|
+
answer: string,
|
|
309
|
+
lines: readonly string[],
|
|
310
|
+
onJudgment: JudgmentObserver | undefined,
|
|
311
|
+
): ProposedFinding[] {
|
|
312
|
+
const fail = (
|
|
313
|
+
reason: ReviewFailureJudgment["reason"],
|
|
314
|
+
count: number | null,
|
|
315
|
+
): void => {
|
|
316
|
+
notify(onJudgment, { stage: "review", decision: "fail", reason, count });
|
|
317
|
+
};
|
|
318
|
+
const fenced = /^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/iu.exec(answer);
|
|
319
|
+
let output: unknown;
|
|
320
|
+
try {
|
|
321
|
+
output = JSON.parse(fenced?.[1] ?? answer);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
fail("output_not_json", null);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
const envelope = reviewResultSchema.safeParse(output);
|
|
232
327
|
if (!envelope.success) {
|
|
328
|
+
const entries = z
|
|
329
|
+
.object({ findings: z.array(z.unknown()) })
|
|
330
|
+
.safeParse(output);
|
|
331
|
+
if (entries.success)
|
|
332
|
+
fail("too_many_findings", entries.data.findings.length);
|
|
333
|
+
else fail("envelope_invalid", null);
|
|
233
334
|
throw new Error("Reviewer returned an invalid findings array");
|
|
234
335
|
}
|
|
235
336
|
const findings: ProposedFinding[] = [];
|
|
@@ -240,28 +341,109 @@ Reply with the JSON object alone, without any other text.`,
|
|
|
240
341
|
lines[parsed.data.line - 1]?.includes(parsed.data.quote) === true
|
|
241
342
|
) {
|
|
242
343
|
findings.push(parsed.data);
|
|
344
|
+
} else {
|
|
345
|
+
notify(onJudgment, rejectedEntry(entry, parsed, lines));
|
|
243
346
|
}
|
|
244
347
|
}
|
|
245
348
|
return findings;
|
|
246
349
|
}
|
|
247
350
|
|
|
248
|
-
|
|
351
|
+
function rejectedEntry(
|
|
352
|
+
entry: unknown,
|
|
353
|
+
parsed: z.ZodSafeParseResult<z.infer<typeof proposedFindingSchema>>,
|
|
354
|
+
lines: readonly string[],
|
|
355
|
+
): ReviewDropJudgment {
|
|
356
|
+
if (parsed.success) {
|
|
357
|
+
const { line, title, quote } = parsed.data;
|
|
358
|
+
const lineText = lines[line - 1];
|
|
359
|
+
return {
|
|
360
|
+
stage: "review",
|
|
361
|
+
decision: "drop",
|
|
362
|
+
reason: lineText === undefined ? "line_out_of_range" : "quote_mismatch",
|
|
363
|
+
line,
|
|
364
|
+
title,
|
|
365
|
+
quote,
|
|
366
|
+
lineText: recordedText(lineText),
|
|
367
|
+
quoteLines: linesContaining(lines, quote),
|
|
368
|
+
invalidFields: [],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const raw = rawFindingSchema.safeParse(entry);
|
|
372
|
+
const fields: { line?: unknown; title?: unknown; quote?: unknown } =
|
|
373
|
+
raw.success ? raw.data : {};
|
|
374
|
+
const line = Number.isSafeInteger(fields.line) ? Number(fields.line) : null;
|
|
375
|
+
const quote = typeof fields.quote === "string" ? fields.quote : null;
|
|
376
|
+
return {
|
|
377
|
+
stage: "review",
|
|
378
|
+
decision: "drop",
|
|
379
|
+
reason: "entry_invalid",
|
|
380
|
+
line,
|
|
381
|
+
title: recordedText(fields.title),
|
|
382
|
+
quote: recordedText(quote),
|
|
383
|
+
lineText: line === null ? null : recordedText(lines[line - 1]),
|
|
384
|
+
quoteLines:
|
|
385
|
+
quote === null || !/\S/u.test(quote) ? [] : linesContaining(lines, quote),
|
|
386
|
+
invalidFields: [
|
|
387
|
+
...new Set(
|
|
388
|
+
parsed.error.issues.map((issue) =>
|
|
389
|
+
issue.path.length === 0 ? "entry" : String(issue.path[0]),
|
|
390
|
+
),
|
|
391
|
+
),
|
|
392
|
+
],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function recordedText(value: unknown): string | null {
|
|
397
|
+
return typeof value === "string" ? value.slice(0, MAX_RECORDED_TEXT) : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function linesContaining(lines: readonly string[], quote: string): number[] {
|
|
401
|
+
const found: number[] = [];
|
|
402
|
+
for (const [index, line] of lines.entries()) {
|
|
403
|
+
if (found.length === MAX_QUOTE_LINES) break;
|
|
404
|
+
if (line.includes(quote)) found.push(index + 1);
|
|
405
|
+
}
|
|
406
|
+
return found;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type Attribution = Omit<AttributionJudgment, "findingId">;
|
|
410
|
+
|
|
411
|
+
interface AttributionRequest {
|
|
249
412
|
finding: ProposedFinding;
|
|
250
413
|
evidence: ChangeEvidence;
|
|
251
414
|
signal: AbortSignal;
|
|
252
415
|
onModelCall?: ModelCallObserver;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
416
|
+
onJudgment?: (judgment: Attribution) => void;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function isInherited(
|
|
420
|
+
request: AttributionRequest,
|
|
421
|
+
): Promise<boolean> {
|
|
422
|
+
const judgment = await attribute(request);
|
|
423
|
+
notify(request.onJudgment, judgment);
|
|
424
|
+
return judgment.decision === "drop";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function attribute(request: AttributionRequest): Promise<Attribution> {
|
|
428
|
+
const { evidence, finding } = request;
|
|
429
|
+
const keep = (reason: Attribution["reason"]): Attribution => ({
|
|
430
|
+
stage: "attribution",
|
|
431
|
+
decision: "keep",
|
|
432
|
+
reason,
|
|
433
|
+
line: finding.line,
|
|
434
|
+
title: finding.title,
|
|
435
|
+
quote: finding.quote,
|
|
436
|
+
evidenceStatus: evidence.status,
|
|
437
|
+
evidenceReason: evidence.reason,
|
|
438
|
+
});
|
|
439
|
+
if (evidence.status !== "available") return keep("evidence_unavailable");
|
|
440
|
+
if (evidence.before === null && evidence.origins.length === 0) {
|
|
441
|
+
return keep("no_origin");
|
|
261
442
|
}
|
|
443
|
+
let response: unknown;
|
|
262
444
|
try {
|
|
263
445
|
const client = jev();
|
|
264
|
-
|
|
446
|
+
response = await observeJudgment(
|
|
265
447
|
"attribution",
|
|
266
448
|
request.signal,
|
|
267
449
|
request.onModelCall,
|
|
@@ -295,14 +477,30 @@ export async function isInherited(request: {
|
|
|
295
477
|
{ signal: request.signal, timeout: 30_000, retry: { maxRetries: 0 } },
|
|
296
478
|
),
|
|
297
479
|
);
|
|
298
|
-
const parsed = attributionSchema.safeParse(response);
|
|
299
|
-
return (
|
|
300
|
-
parsed.success &&
|
|
301
|
-
parsed.data.answers.category.choice === "inherited" &&
|
|
302
|
-
parsed.data.answers.category.confidence >= 0.95 &&
|
|
303
|
-
parsed.data.answers.category.probabilities.inherited >= 0.95
|
|
304
|
-
);
|
|
305
480
|
} catch {
|
|
306
|
-
return
|
|
481
|
+
return keep(request.signal.aborted ? "cancelled" : "request_failed");
|
|
307
482
|
}
|
|
483
|
+
const parsed = attributionSchema.safeParse(response);
|
|
484
|
+
if (!parsed.success) {
|
|
485
|
+
return {
|
|
486
|
+
...keep("response_invalid"),
|
|
487
|
+
issues: [
|
|
488
|
+
...new Set(parsed.error.issues.map((issue) => issue.path.join("."))),
|
|
489
|
+
],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const category = parsed.data.answers.category;
|
|
493
|
+
const { confidence, probabilities } = category;
|
|
494
|
+
const inherited =
|
|
495
|
+
category.choice === "inherited" &&
|
|
496
|
+
confidence >= ATTRIBUTION_THRESHOLDS.confidence &&
|
|
497
|
+
probabilities.inherited >= ATTRIBUTION_THRESHOLDS.inherited;
|
|
498
|
+
return {
|
|
499
|
+
...keep("judged"),
|
|
500
|
+
decision: inherited ? "drop" : "keep",
|
|
501
|
+
choice: category.choice,
|
|
502
|
+
confidence,
|
|
503
|
+
probabilities: { ...probabilities },
|
|
504
|
+
thresholds: { ...ATTRIBUTION_THRESHOLDS },
|
|
505
|
+
};
|
|
308
506
|
}
|