pi2web 0.2.1 → 0.2.6
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/README.md +44 -5
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +518 -141
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +190 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +221 -45
- package/dist/server.js +444 -620
- package/dist/session-namer.js +66 -17
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +6 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3098 -743
- package/web/backend-state.js +59 -27
- package/web/collab.js +668 -255
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +4 -1
- package/web/styles.css +14 -0
package/dist/collab/schemas.js
CHANGED
|
@@ -6,10 +6,23 @@ import { anyJson, arr, bool, num, obj, oneOf, optional, record, refine, str, wit
|
|
|
6
6
|
* instead of guessing what the hub wanted.
|
|
7
7
|
*/
|
|
8
8
|
const clientRequestId = () => str({ min: 8, max: 200 });
|
|
9
|
-
const usage = () => optional(obj({
|
|
10
|
-
|
|
9
|
+
const usage = () => optional(obj({
|
|
10
|
+
inputTokens: optional(num({ min: 0, max: 100_000_000, integer: true })),
|
|
11
|
+
outputTokens: optional(num({ min: 0, max: 100_000_000, integer: true })),
|
|
12
|
+
totalTokens: optional(num({ min: 0, max: 100_000_000, integer: true }))
|
|
13
|
+
}));
|
|
14
|
+
const codeLocation = () => obj({
|
|
15
|
+
path: str({ min: 1, max: 400 }),
|
|
16
|
+
startLine: optional(num({ integer: true, min: 1 })),
|
|
17
|
+
endLine: optional(num({ integer: true, min: 1 }))
|
|
18
|
+
});
|
|
11
19
|
/** Evidence must point at a file: an unanchored claim is indistinguishable from a hallucination. */
|
|
12
|
-
const evidenceItem = () => obj({
|
|
20
|
+
const evidenceItem = () => obj({
|
|
21
|
+
path: str({ min: 1, max: 400 }),
|
|
22
|
+
startLine: optional(num({ integer: true, min: 1 })),
|
|
23
|
+
endLine: optional(num({ integer: true, min: 1 })),
|
|
24
|
+
excerpt: optional(str({ max: 2000 }))
|
|
25
|
+
});
|
|
13
26
|
export const findingSchema = obj({
|
|
14
27
|
externalId: optional(str({ max: 100 })),
|
|
15
28
|
title: str({ min: 8, max: 200 }),
|
|
@@ -39,20 +52,32 @@ export const findingsRequest = obj({
|
|
|
39
52
|
/** Batched after blind collection: one request validates every issue this reviewer did not report. */
|
|
40
53
|
export const issueVotesRequest = obj({
|
|
41
54
|
clientRequestId: clientRequestId(),
|
|
42
|
-
votes: arr(obj({
|
|
55
|
+
votes: arr(obj({
|
|
56
|
+
issueId: str({ min: 1, max: 100 }),
|
|
57
|
+
stance: oneOf(['approve', 'reject']),
|
|
58
|
+
rationale: optional(str({ max: 4000 }))
|
|
59
|
+
}), { max: 200 }),
|
|
43
60
|
mergeProposals: withDefault(arr(obj({ issueIds: arr(str({ min: 1, max: 100 }), { min: 2, max: 20 }), rationale: str({ min: 10, max: 2000 }) }), { max: 50 }), () => []),
|
|
44
61
|
complete: withDefault(bool(), () => true),
|
|
45
62
|
usage: usage()
|
|
46
63
|
});
|
|
47
64
|
export const mergeVotesRequest = obj({
|
|
48
65
|
clientRequestId: clientRequestId(),
|
|
49
|
-
votes: arr(obj({
|
|
66
|
+
votes: arr(obj({
|
|
67
|
+
proposalId: str({ min: 1, max: 100 }),
|
|
68
|
+
stance: oneOf(['approve', 'reject']),
|
|
69
|
+
rationale: optional(str({ max: 4000 }))
|
|
70
|
+
}), { max: 100 }),
|
|
50
71
|
complete: withDefault(bool(), () => true),
|
|
51
72
|
usage: usage()
|
|
52
73
|
});
|
|
53
74
|
export const issueDiscussionsRequest = obj({
|
|
54
75
|
clientRequestId: clientRequestId(),
|
|
55
|
-
discussions: withDefault(arr(obj({
|
|
76
|
+
discussions: withDefault(arr(obj({
|
|
77
|
+
issueId: str({ min: 1, max: 100 }),
|
|
78
|
+
argument: str({ min: 20, max: 4000 }),
|
|
79
|
+
respondingTo: optional(str({ max: 100 }))
|
|
80
|
+
}), { max: 200 }), () => []),
|
|
56
81
|
/** The reporter's way out of a finding it no longer stands by, in the same call that defends the rest. */
|
|
57
82
|
withdrawals: withDefault(arr(obj({ issueId: str({ min: 1, max: 100 }), rationale: str({ min: 10, max: 4000 }) }), { max: 200 }), () => []),
|
|
58
83
|
complete: withDefault(bool(), () => true),
|
|
@@ -63,7 +88,11 @@ export const escalationRequest = obj({
|
|
|
63
88
|
kind: oneOf(ESCALATION_KINDS),
|
|
64
89
|
refId: optional(str({ max: 100 })),
|
|
65
90
|
summary: str({ min: 20, max: 2000 }),
|
|
66
|
-
positions: withDefault(arr(obj({
|
|
91
|
+
positions: withDefault(arr(obj({
|
|
92
|
+
participantId: str({ min: 1, max: 100 }),
|
|
93
|
+
stance: str({ max: 200 }),
|
|
94
|
+
rationale: str({ min: 10, max: 2000 })
|
|
95
|
+
}), { max: 20 }), () => []),
|
|
67
96
|
question: str({ min: 10, max: 1000 }),
|
|
68
97
|
options: withDefault(arr(str({ min: 1, max: 200 }), { max: 10 }), () => []),
|
|
69
98
|
urgency: withDefault(oneOf(['low', 'normal', 'high']), () => 'normal'),
|
|
@@ -77,9 +106,19 @@ export const escalationRequest = obj({
|
|
|
77
106
|
const scoringScale = refine(obj({ min: num({ min: 0, max: 1000 }), max: num({ min: 1, max: 1000 }), step: num({ min: 0.01, max: 100 }) }), scale => {
|
|
78
107
|
const errors = [];
|
|
79
108
|
if (scale.max <= scale.min)
|
|
80
|
-
errors.push({
|
|
109
|
+
errors.push({
|
|
110
|
+
path: 'max',
|
|
111
|
+
code: 'OUT_OF_ORDER',
|
|
112
|
+
message: `Expected max (${scale.max}) to be greater than min (${scale.min})`,
|
|
113
|
+
expected: `> ${scale.min}`
|
|
114
|
+
});
|
|
81
115
|
else if (scale.step > scale.max - scale.min)
|
|
82
|
-
errors.push({
|
|
116
|
+
errors.push({
|
|
117
|
+
path: 'step',
|
|
118
|
+
code: 'TOO_LARGE',
|
|
119
|
+
message: `Expected a step that fits inside the ${scale.min}-${scale.max} range`,
|
|
120
|
+
expected: `<= ${scale.max - scale.min}`
|
|
121
|
+
});
|
|
83
122
|
return errors;
|
|
84
123
|
});
|
|
85
124
|
const scoringPolicyPatch = refine(obj({
|
|
@@ -91,8 +130,17 @@ const scoringPolicyPatch = refine(obj({
|
|
|
91
130
|
convergenceRange: optional(num({ min: 0, max: 1000 })),
|
|
92
131
|
maxDebateRounds: optional(num({ integer: true, min: 0, max: 10 })),
|
|
93
132
|
blindScoring: optional(bool())
|
|
94
|
-
}), patch => patch.minCriteria !== undefined &&
|
|
95
|
-
|
|
133
|
+
}), patch => patch.minCriteria !== undefined &&
|
|
134
|
+
patch.maxCriteria !== undefined &&
|
|
135
|
+
patch.minCriteria > patch.maxCriteria
|
|
136
|
+
? [
|
|
137
|
+
{
|
|
138
|
+
path: 'maxCriteria',
|
|
139
|
+
code: 'OUT_OF_ORDER',
|
|
140
|
+
message: `Expected maxCriteria (${patch.maxCriteria}) to be at least minCriteria (${patch.minCriteria})`,
|
|
141
|
+
expected: `>= ${patch.minCriteria}`
|
|
142
|
+
}
|
|
143
|
+
]
|
|
96
144
|
: undefined);
|
|
97
145
|
export const policyPatch = obj({
|
|
98
146
|
maxIssueRounds: optional(num({ integer: true, min: 1, max: 20 })),
|
|
@@ -124,7 +172,11 @@ export const createSessionRequest = obj({
|
|
|
124
172
|
title: str({ min: 3, max: 200 }),
|
|
125
173
|
workspaceId: str({ min: 1, max: 200 }),
|
|
126
174
|
relativeCwd: withDefault(str({ max: 400 }), () => '.'),
|
|
127
|
-
subject: obj({
|
|
175
|
+
subject: obj({
|
|
176
|
+
type: oneOf(['diff', 'paths', 'commit_range', 'free']),
|
|
177
|
+
value: str({ min: 1, max: 2000 }),
|
|
178
|
+
notes: optional(str({ max: 4000 }))
|
|
179
|
+
}),
|
|
128
180
|
policy: optional(policyPatch)
|
|
129
181
|
});
|
|
130
182
|
export const createParticipantRequest = obj({
|
|
@@ -221,5 +273,9 @@ export const debateArgumentsRequest = obj({
|
|
|
221
273
|
});
|
|
222
274
|
export const finalizeRequest = obj({
|
|
223
275
|
/** Human overrides for criteria the panel could not settle, keyed by criterionId. */
|
|
224
|
-
rulings: withDefault(arr(obj({
|
|
276
|
+
rulings: withDefault(arr(obj({
|
|
277
|
+
criterionId: str({ min: 1, max: 100 }),
|
|
278
|
+
score: num({ min: 0, max: 1000 }),
|
|
279
|
+
rationale: str({ min: 10, max: 2000 })
|
|
280
|
+
}), { max: 40 }), () => [])
|
|
225
281
|
});
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { COLLAB_ERRORS, can } from './types.js';
|
|
2
2
|
import { flowError } from './review-flow.js';
|
|
3
3
|
const mean = (values) => values.reduce((total, value) => total + value, 0) / values.length;
|
|
4
|
-
const median = (values) => {
|
|
5
|
-
const
|
|
6
|
-
return
|
|
4
|
+
const median = (values) => {
|
|
5
|
+
const sorted = [...values].sort((a, b) => a - b), middle = sorted.length >> 1;
|
|
6
|
+
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
7
|
+
};
|
|
8
|
+
const stdev = (values) => {
|
|
9
|
+
if (values.length < 2)
|
|
10
|
+
return 0;
|
|
11
|
+
const average = mean(values);
|
|
12
|
+
return Math.sqrt(values.reduce((total, value) => total + (value - average) ** 2, 0) / (values.length - 1));
|
|
13
|
+
};
|
|
7
14
|
const round2 = (value) => Math.round(value * 100) / 100;
|
|
8
15
|
/** Only reviewers score. The implementer is recused so the party under review cannot grade itself. */
|
|
9
16
|
export const scoringPanel = (snapshot) => snapshot.participants.filter(participant => participant.state !== 'left' && participant.role === 'reviewer');
|
|
@@ -27,7 +34,9 @@ export const contestedCriteria = (snapshot) => analyse(snapshot).filter(entry =>
|
|
|
27
34
|
/** Everyone on the panel must submit before a phase closes; nothing is inferred from silence. */
|
|
28
35
|
export function scoringWaitingOn(snapshot) {
|
|
29
36
|
const panel = scoringPanel(snapshot).map(participant => participant.participantId);
|
|
30
|
-
const done = (phase, round = snapshot.round) => new Set(snapshot.completions
|
|
37
|
+
const done = (phase, round = snapshot.round) => new Set(snapshot.completions
|
|
38
|
+
.filter(entry => entry.phase === phase && entry.round === round)
|
|
39
|
+
.map(entry => entry.participantId));
|
|
31
40
|
switch (snapshot.phase) {
|
|
32
41
|
case 'nominating': {
|
|
33
42
|
const finished = done('nominating');
|
|
@@ -37,11 +46,15 @@ export function scoringWaitingOn(snapshot) {
|
|
|
37
46
|
const candidates = candidateCriteria(snapshot);
|
|
38
47
|
if (!candidates.length)
|
|
39
48
|
return [];
|
|
40
|
-
return panel.filter(id => candidates.some(criterion => !snapshot.votes.some(vote => vote.criterionId === criterion.criterionId &&
|
|
49
|
+
return panel.filter(id => candidates.some(criterion => !snapshot.votes.some(vote => vote.criterionId === criterion.criterionId &&
|
|
50
|
+
vote.participantId === id &&
|
|
51
|
+
vote.round === snapshot.round)));
|
|
41
52
|
}
|
|
42
53
|
case 'scoring': {
|
|
43
54
|
const criteria = approvedCriteria(snapshot);
|
|
44
|
-
return panel.filter(id => criteria.some(criterion => !snapshot.scores.some(score => score.criterionId === criterion.criterionId &&
|
|
55
|
+
return panel.filter(id => criteria.some(criterion => !snapshot.scores.some(score => score.criterionId === criterion.criterionId &&
|
|
56
|
+
score.participantId === id &&
|
|
57
|
+
score.round === snapshot.debateRound)));
|
|
45
58
|
}
|
|
46
59
|
case 'debating': {
|
|
47
60
|
const open = snapshot.debates.filter(debate => debate.status === 'open' && debate.round === snapshot.debateRound);
|
|
@@ -49,10 +62,15 @@ export function scoringWaitingOn(snapshot) {
|
|
|
49
62
|
return panel.filter(id => open.some(debate => snapshot.scores.some(score => score.criterionId === debate.criterionId && score.participantId === id) && !debate.arguments.some(entry => entry.participantId === id)));
|
|
50
63
|
}
|
|
51
64
|
case 'rescoring': {
|
|
52
|
-
const contested = snapshot.debates
|
|
53
|
-
|
|
65
|
+
const contested = snapshot.debates
|
|
66
|
+
.filter(debate => debate.round === snapshot.debateRound)
|
|
67
|
+
.map(debate => debate.criterionId);
|
|
68
|
+
return panel.filter(id => contested.some(criterionId => !snapshot.scores.some(score => score.criterionId === criterionId &&
|
|
69
|
+
score.participantId === id &&
|
|
70
|
+
score.round === snapshot.debateRound + 1)));
|
|
54
71
|
}
|
|
55
|
-
default:
|
|
72
|
+
default:
|
|
73
|
+
return [];
|
|
56
74
|
}
|
|
57
75
|
}
|
|
58
76
|
/**
|
|
@@ -72,7 +90,9 @@ export function isScoringReadyToAdvance(snapshot) {
|
|
|
72
90
|
return false;
|
|
73
91
|
if (['consolidating', 'rubric_locked', 'analysis', 'awaiting_human'].includes(snapshot.phase))
|
|
74
92
|
return true;
|
|
75
|
-
if (snapshot.phase === 'nominating' &&
|
|
93
|
+
if (snapshot.phase === 'nominating' &&
|
|
94
|
+
!candidateCriteria(snapshot).length &&
|
|
95
|
+
!scoringWaitingOn(snapshot).length)
|
|
76
96
|
return true;
|
|
77
97
|
return scoringWaitingOn(snapshot).length === 0;
|
|
78
98
|
}
|
|
@@ -85,9 +105,18 @@ export function tallyVotes(snapshot, round = snapshot.round) {
|
|
|
85
105
|
const votes = snapshot.votes.filter(vote => vote.criterionId === criterion.criterionId && vote.round === round);
|
|
86
106
|
const approve = votes.filter(vote => vote.stance === 'approve'), reject = votes.filter(vote => vote.stance === 'reject');
|
|
87
107
|
const decided = approve.length + reject.length, ratio = decided ? approve.length / decided : 0;
|
|
88
|
-
const weights = approve
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
const weights = approve
|
|
109
|
+
.map(vote => vote.weight)
|
|
110
|
+
.filter((value) => typeof value === 'number' && value > 0);
|
|
111
|
+
return {
|
|
112
|
+
criterionId: criterion.criterionId,
|
|
113
|
+
approve: approve.length,
|
|
114
|
+
reject: reject.length,
|
|
115
|
+
abstain: votes.filter(vote => vote.stance === 'abstain').length,
|
|
116
|
+
ratio: round2(ratio),
|
|
117
|
+
approved: ratio >= snapshot.policy.scoring.approvalThreshold && approve.length >= 2,
|
|
118
|
+
weight: weights.length ? median(weights) : 1
|
|
119
|
+
};
|
|
91
120
|
});
|
|
92
121
|
}
|
|
93
122
|
/**
|
|
@@ -97,23 +126,38 @@ export function tallyVotes(snapshot, round = snapshot.round) {
|
|
|
97
126
|
*/
|
|
98
127
|
export function lockRubric(snapshot) {
|
|
99
128
|
const tallies = tallyVotes(snapshot), { minCriteria, maxCriteria, maxVotingRounds } = snapshot.policy.scoring;
|
|
100
|
-
const passed = tallies
|
|
129
|
+
const passed = tallies
|
|
130
|
+
.filter(tally => tally.approved)
|
|
131
|
+
.sort((a, b) => b.ratio - a.ratio || b.approve - a.approve);
|
|
101
132
|
const lastRound = snapshot.round >= maxVotingRounds;
|
|
102
133
|
const withinRange = passed.length >= minCriteria && passed.length <= maxCriteria;
|
|
103
134
|
if (withinRange)
|
|
104
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
criteria: normalizeWeights(passed),
|
|
137
|
+
lockedBy: 'panel',
|
|
138
|
+
reason: 'approved_by_panel',
|
|
139
|
+
rejected: tallies.filter(tally => !tally.approved).map(tally => tally.criterionId)
|
|
140
|
+
};
|
|
105
141
|
if (!lastRound)
|
|
106
142
|
return undefined;
|
|
107
143
|
const fallback = (passed.length ? passed : [...tallies].sort((a, b) => b.ratio - a.ratio || b.approve - a.approve)).slice(0, maxCriteria);
|
|
108
144
|
if (!fallback.length)
|
|
109
145
|
return undefined;
|
|
110
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
criteria: normalizeWeights(fallback),
|
|
148
|
+
lockedBy: 'policy',
|
|
111
149
|
reason: passed.length > maxCriteria ? 'too_many_approved_criteria' : 'panel_never_reached_the_minimum',
|
|
112
|
-
rejected: tallies
|
|
150
|
+
rejected: tallies
|
|
151
|
+
.filter(tally => !fallback.some(entry => entry.criterionId === tally.criterionId))
|
|
152
|
+
.map(tally => tally.criterionId)
|
|
153
|
+
};
|
|
113
154
|
}
|
|
114
155
|
export function normalizeWeights(tallies) {
|
|
115
156
|
const total = tallies.reduce((sum, tally) => sum + (tally.weight || 0), 0) || tallies.length;
|
|
116
|
-
return tallies.map(tally => ({
|
|
157
|
+
return tallies.map(tally => ({
|
|
158
|
+
criterionId: tally.criterionId,
|
|
159
|
+
weight: round2((tally.weight || 1) / total)
|
|
160
|
+
}));
|
|
117
161
|
}
|
|
118
162
|
/**
|
|
119
163
|
* The scores that count for a criterion at `round`: each participant's latest submission at or before it.
|
|
@@ -137,11 +181,29 @@ export function analyse(snapshot, round = snapshot.debateRound) {
|
|
|
137
181
|
const entries = effectiveScores(snapshot, criterion.criterionId, round);
|
|
138
182
|
const values = entries.map(entry => entry.score);
|
|
139
183
|
if (!values.length)
|
|
140
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
criterionId: criterion.criterionId,
|
|
186
|
+
scores: [],
|
|
187
|
+
min: 0,
|
|
188
|
+
max: 0,
|
|
189
|
+
mean: 0,
|
|
190
|
+
median: 0,
|
|
191
|
+
stdev: 0,
|
|
192
|
+
range: 0,
|
|
193
|
+
contested: false
|
|
194
|
+
};
|
|
141
195
|
const min = Math.min(...values), max = Math.max(...values);
|
|
142
|
-
return {
|
|
143
|
-
|
|
144
|
-
|
|
196
|
+
return {
|
|
197
|
+
criterionId: criterion.criterionId,
|
|
198
|
+
scores: entries.map(entry => ({ participantId: entry.participantId, score: entry.score })),
|
|
199
|
+
min,
|
|
200
|
+
max,
|
|
201
|
+
mean: round2(mean(values)),
|
|
202
|
+
median: round2(median(values)),
|
|
203
|
+
stdev: round2(stdev(values)),
|
|
204
|
+
range: round2(max - min),
|
|
205
|
+
contested: values.length > 1 && max - min > snapshot.policy.scoring.convergenceRange
|
|
206
|
+
};
|
|
145
207
|
});
|
|
146
208
|
}
|
|
147
209
|
export function nextScoringPhase(snapshot, options = {}) {
|
|
@@ -155,22 +217,35 @@ export function nextScoringPhase(snapshot, options = {}) {
|
|
|
155
217
|
throw flowError(COLLAB_ERRORS.wrongPhase, `${snapshot.pendingEscalations} escalation(s) still await a human ruling`);
|
|
156
218
|
throw flowError(COLLAB_ERRORS.wrongPhase, `Still waiting on ${pending.length} participant(s): ${pending.join(', ')}`);
|
|
157
219
|
}
|
|
158
|
-
const base = {
|
|
220
|
+
const base = {
|
|
221
|
+
round: snapshot.round,
|
|
222
|
+
debateRound: snapshot.debateRound,
|
|
223
|
+
...(options.forced && pending.length ? { skipped: pending } : {})
|
|
224
|
+
};
|
|
159
225
|
switch (snapshot.phase) {
|
|
160
|
-
case 'nominating':
|
|
161
|
-
|
|
226
|
+
case 'nominating':
|
|
227
|
+
return { ...base, phase: 'consolidating', reason: 'nominations_complete' };
|
|
228
|
+
case 'consolidating':
|
|
229
|
+
return { ...base, phase: 'voting', reason: 'candidates_ready' };
|
|
162
230
|
case 'voting': {
|
|
163
231
|
const rubric = lockRubric(snapshot);
|
|
164
232
|
if (rubric)
|
|
165
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
phase: 'rubric_locked',
|
|
236
|
+
reason: rubric.lockedBy === 'panel' ? 'rubric_approved' : 'rubric_forced_by_policy',
|
|
237
|
+
rubric
|
|
238
|
+
};
|
|
166
239
|
const approved = tallyVotes(snapshot).filter(tally => tally.approved).length;
|
|
167
240
|
// Too few criteria means the panel needs more proposals; too many means it needs to narrow down.
|
|
168
241
|
return approved < snapshot.policy.scoring.minCriteria
|
|
169
242
|
? { ...base, phase: 'nominating', round: snapshot.round + 1, reason: 'not_enough_approved_criteria' }
|
|
170
243
|
: { ...base, phase: 'voting', round: snapshot.round + 1, reason: 'too_many_approved_criteria' };
|
|
171
244
|
}
|
|
172
|
-
case 'rubric_locked':
|
|
173
|
-
|
|
245
|
+
case 'rubric_locked':
|
|
246
|
+
return { ...base, phase: 'scoring', reason: 'rubric_locked' };
|
|
247
|
+
case 'scoring':
|
|
248
|
+
return { ...base, phase: 'analysis', reason: 'scores_submitted' };
|
|
174
249
|
case 'analysis': {
|
|
175
250
|
const contested = contestedCriteria(snapshot).map(entry => entry.criterionId);
|
|
176
251
|
if (!contested.length)
|
|
@@ -179,10 +254,14 @@ export function nextScoringPhase(snapshot, options = {}) {
|
|
|
179
254
|
return { ...base, phase: 'awaiting_human', reason: 'scores_still_contested', contested };
|
|
180
255
|
return { ...base, phase: 'debating', reason: 'contested_criteria', contested };
|
|
181
256
|
}
|
|
182
|
-
case 'debating':
|
|
183
|
-
|
|
184
|
-
case '
|
|
185
|
-
|
|
257
|
+
case 'debating':
|
|
258
|
+
return { ...base, phase: 'rescoring', reason: 'debate_complete' };
|
|
259
|
+
case 'rescoring':
|
|
260
|
+
return { ...base, phase: 'analysis', debateRound: snapshot.debateRound + 1, reason: 'rescored' };
|
|
261
|
+
case 'awaiting_human':
|
|
262
|
+
return { ...base, phase: 'finalized', reason: 'human_ruled' };
|
|
263
|
+
default:
|
|
264
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
|
|
186
265
|
}
|
|
187
266
|
}
|
|
188
267
|
/**
|
|
@@ -197,24 +276,51 @@ export function finalizeScores(snapshot, rulings = {}) {
|
|
|
197
276
|
const analysis = analyses.find(entry => entry.criterionId === criterion.criterionId);
|
|
198
277
|
const ruled = rulings[criterion.criterionId];
|
|
199
278
|
const finalScore = ruled !== undefined ? ruled : analysis?.scores.length ? analysis.median : 0;
|
|
200
|
-
return {
|
|
279
|
+
return {
|
|
280
|
+
criterionId: criterion.criterionId,
|
|
281
|
+
name: criterion.name,
|
|
282
|
+
weight: criterion.weight ?? round2(1 / Math.max(1, approvedCriteria(snapshot).length)),
|
|
201
283
|
finalScore: round2(finalScore),
|
|
202
|
-
method: (ruled !== undefined
|
|
203
|
-
|
|
284
|
+
method: (ruled !== undefined
|
|
285
|
+
? 'human_ruled'
|
|
286
|
+
: analysis?.contested
|
|
287
|
+
? 'forced'
|
|
288
|
+
: snapshot.debateRound > 0
|
|
289
|
+
? 'debated'
|
|
290
|
+
: 'converged'),
|
|
291
|
+
spread: analysis?.range ?? 0,
|
|
292
|
+
perReviewer: Object.fromEntries((analysis?.scores ?? []).map(entry => [entry.participantId, entry.score]))
|
|
293
|
+
};
|
|
204
294
|
});
|
|
205
295
|
const weightTotal = criteria.reduce((sum, criterion) => sum + criterion.weight, 0) || 1;
|
|
206
296
|
const totalScore = round2(criteria.reduce((sum, criterion) => sum + criterion.finalScore * criterion.weight, 0) / weightTotal);
|
|
207
297
|
const spreads = analyses.filter(entry => entry.scores.length > 1).map(entry => entry.range / span);
|
|
208
298
|
const dissents = criteria.flatMap(criterion => Object.entries(criterion.perReviewer)
|
|
209
|
-
.map(([participantId, score]) => ({
|
|
299
|
+
.map(([participantId, score]) => ({
|
|
300
|
+
participantId,
|
|
301
|
+
criterionId: criterion.criterionId,
|
|
302
|
+
score,
|
|
303
|
+
distanceFromFinal: round2(Math.abs(score - criterion.finalScore))
|
|
304
|
+
}))
|
|
210
305
|
.filter(entry => entry.distanceFromFinal > snapshot.policy.scoring.convergenceRange / 2));
|
|
211
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
criteria,
|
|
308
|
+
totalScore,
|
|
309
|
+
scale: { min: scale.min, max: scale.max },
|
|
310
|
+
agreement: round2(spreads.length ? 1 - mean(spreads) : 1),
|
|
311
|
+
dissents
|
|
312
|
+
};
|
|
212
313
|
}
|
|
213
314
|
export function scoringProgress(snapshot) {
|
|
214
|
-
return {
|
|
315
|
+
return {
|
|
316
|
+
phase: snapshot.phase,
|
|
317
|
+
round: snapshot.round,
|
|
318
|
+
debateRound: snapshot.debateRound,
|
|
215
319
|
criteria: { candidates: candidateCriteria(snapshot).length, approved: approvedCriteria(snapshot).length },
|
|
216
320
|
contested: contestedCriteria(snapshot).map(entry => entry.criterionId),
|
|
217
|
-
waitingOn: scoringWaitingOn(snapshot),
|
|
321
|
+
waitingOn: scoringWaitingOn(snapshot),
|
|
322
|
+
readyToAdvance: isScoringReadyToAdvance(snapshot)
|
|
323
|
+
};
|
|
218
324
|
}
|
|
219
325
|
/** Blind scoring: nobody sees the distribution until every panelist has committed to a number. */
|
|
220
326
|
export function scoresSealed(snapshot) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const words = (value) => new Set(value
|
|
2
|
+
.toLowerCase()
|
|
3
|
+
.split(/[^a-z0-9\u4e00-\u9fff]+/)
|
|
4
|
+
.filter(token => token.length > 1));
|
|
5
|
+
/** Rough 0..1 score used to suggest possible duplicate findings. */
|
|
6
|
+
export function similarity(a, b) {
|
|
7
|
+
const overlapping = a.location.path === b.location.path &&
|
|
8
|
+
(a.location.startLine === undefined ||
|
|
9
|
+
b.location.startLine === undefined ||
|
|
10
|
+
Math.max(a.location.startLine, b.location.startLine) <=
|
|
11
|
+
Math.min(a.location.endLine ?? a.location.startLine, b.location.endLine ?? b.location.startLine));
|
|
12
|
+
const left = words(`${a.title} ${a.suggestion ?? ''}`), right = words(`${b.title} ${b.suggestion ?? ''}`);
|
|
13
|
+
const shared = [...left].filter(token => right.has(token)).length, union = new Set([...left, ...right]).size;
|
|
14
|
+
return (overlapping ? 0.5 : 0) + (union ? 0.5 * (shared / union) : 0);
|
|
15
|
+
}
|