multi-agent-platform 0.1.0__py3-none-any.whl
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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- server/services/webhook_service.py +273 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
|
|
4
|
+
from map_types.enums import ResolutionReason, ReviewSubstituteKind, ReviewVerdict
|
|
5
|
+
from sqlalchemy import func, select
|
|
6
|
+
from sqlalchemy.orm import Session, joinedload
|
|
7
|
+
|
|
8
|
+
from server.domain.models import (
|
|
9
|
+
Agent,
|
|
10
|
+
AgentRole,
|
|
11
|
+
ExperimentLog,
|
|
12
|
+
ExperimentPhase,
|
|
13
|
+
Review,
|
|
14
|
+
ReviewItem,
|
|
15
|
+
ReviewItemKind,
|
|
16
|
+
ReviewItemStatus,
|
|
17
|
+
)
|
|
18
|
+
from server.domain.schemas import ReviewCreate, ReviewItemRead, ReviewItemUpdate, ReviewRead
|
|
19
|
+
from server.domain.state_machine import ReviewItemTransitionContext, validate_review_item_transition
|
|
20
|
+
from server.services import audit_service
|
|
21
|
+
from server.services.errors import ConflictError, ForbiddenError, NotFoundError, StateTransitionError
|
|
22
|
+
from server.services.project_service import get_experiment
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CreatorSelfReviewBlockedError(ForbiddenError):
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
actor_id: uuid.UUID,
|
|
31
|
+
experiment_id: uuid.UUID,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.reason = "creator_self_review_blocked"
|
|
35
|
+
self.actor_id = str(actor_id)
|
|
36
|
+
self.experiment_id = str(experiment_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApproveEligibilityError(ConflictError):
|
|
40
|
+
def __init__(self, message: str, *, reason: str) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.reason = reason
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _qualifying_non_creator_reviews(reviews: list[Review], creator_id: uuid.UUID) -> list[Review]:
|
|
46
|
+
return [
|
|
47
|
+
review
|
|
48
|
+
for review in reviews
|
|
49
|
+
if review.reviewer_agent_id != creator_id
|
|
50
|
+
and review.substitute_kind != ReviewSubstituteKind.admin_self_substitute
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_legacy_self_review(db: Session, experiment) -> bool:
|
|
55
|
+
if experiment.phase in (ExperimentPhase.draft, ExperimentPhase.review):
|
|
56
|
+
return False
|
|
57
|
+
reviews = list(
|
|
58
|
+
db.scalars(select(Review).where(Review.experiment_id == experiment.id))
|
|
59
|
+
)
|
|
60
|
+
return len(_qualifying_non_creator_reviews(reviews, experiment.creator_agent_id)) == 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _review_has_item_activity(review: Review) -> bool:
|
|
64
|
+
for item in review.items:
|
|
65
|
+
if item.kind != ReviewItemKind.unreasonable:
|
|
66
|
+
continue
|
|
67
|
+
if item.status is not None and item.status != ReviewItemStatus.open:
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_unreasonable_items(db: Session, experiment_id: uuid.UUID) -> list[ReviewItem]:
|
|
73
|
+
stmt = (
|
|
74
|
+
select(ReviewItem)
|
|
75
|
+
.join(Review)
|
|
76
|
+
.where(
|
|
77
|
+
Review.experiment_id == experiment_id,
|
|
78
|
+
Review.archived_at.is_(None),
|
|
79
|
+
ReviewItem.kind == ReviewItemKind.unreasonable,
|
|
80
|
+
)
|
|
81
|
+
.options(joinedload(ReviewItem.review))
|
|
82
|
+
)
|
|
83
|
+
return list(db.scalars(stmt))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def count_open_unreasonable_for_experiment(db: Session, experiment_id: uuid.UUID) -> int:
|
|
87
|
+
stmt = (
|
|
88
|
+
select(func.count())
|
|
89
|
+
.select_from(ReviewItem)
|
|
90
|
+
.join(Review)
|
|
91
|
+
.where(
|
|
92
|
+
Review.experiment_id == experiment_id,
|
|
93
|
+
Review.archived_at.is_(None),
|
|
94
|
+
ReviewItem.kind == ReviewItemKind.unreasonable,
|
|
95
|
+
ReviewItem.status.in_(
|
|
96
|
+
(
|
|
97
|
+
ReviewItemStatus.open,
|
|
98
|
+
ReviewItemStatus.addressed,
|
|
99
|
+
ReviewItemStatus.rebutted,
|
|
100
|
+
ReviewItemStatus.escalated,
|
|
101
|
+
)
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return db.scalar(stmt) or 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def open_unreasonable_count_by_experiment(
|
|
109
|
+
db: Session, experiment_ids: list[uuid.UUID]
|
|
110
|
+
) -> dict[uuid.UUID, int]:
|
|
111
|
+
"""Per-experiment count of open unreasonable review items in one GROUP BY.
|
|
112
|
+
|
|
113
|
+
Items counted: ``status in (open, addressed, rebutted, escalated)`` —
|
|
114
|
+
mirrors :func:`count_open_unreasonable_for_experiment`. Returns a dict
|
|
115
|
+
keyed by experiment_id; experiments with no open items map to 0.
|
|
116
|
+
Empty input → empty dict without hitting the database.
|
|
117
|
+
"""
|
|
118
|
+
if not experiment_ids:
|
|
119
|
+
return {}
|
|
120
|
+
stmt = (
|
|
121
|
+
select(Review.experiment_id, func.count())
|
|
122
|
+
.join(ReviewItem, ReviewItem.review_id == Review.id)
|
|
123
|
+
.where(
|
|
124
|
+
Review.experiment_id.in_(experiment_ids),
|
|
125
|
+
Review.archived_at.is_(None),
|
|
126
|
+
ReviewItem.kind == ReviewItemKind.unreasonable,
|
|
127
|
+
ReviewItem.status.in_(
|
|
128
|
+
(
|
|
129
|
+
ReviewItemStatus.open,
|
|
130
|
+
ReviewItemStatus.addressed,
|
|
131
|
+
ReviewItemStatus.rebutted,
|
|
132
|
+
ReviewItemStatus.escalated,
|
|
133
|
+
)
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
.group_by(Review.experiment_id)
|
|
137
|
+
)
|
|
138
|
+
found = {eid: int(count) for eid, count in db.execute(stmt).all()}
|
|
139
|
+
# Fill in zeros for experiments with no open unreasonable items so the
|
|
140
|
+
# caller can index by experiment_id without a defensive ``.get``.
|
|
141
|
+
return {eid: found.get(eid, 0) for eid in experiment_ids}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def has_review_on_older_plan_version(db: Session, experiment) -> bool:
|
|
145
|
+
"""True when plan was revised after at least one review on a prior version."""
|
|
146
|
+
stmt = (
|
|
147
|
+
select(func.count())
|
|
148
|
+
.select_from(Review)
|
|
149
|
+
.where(
|
|
150
|
+
Review.experiment_id == experiment.id,
|
|
151
|
+
Review.plan_version < experiment.current_plan_version,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return (db.scalar(stmt) or 0) > 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def count_open_status_unreasonable_for_experiment(
|
|
158
|
+
db: Session, experiment_id: uuid.UUID
|
|
159
|
+
) -> int:
|
|
160
|
+
"""Unreasonable items with status=open only (plan revision obligation)."""
|
|
161
|
+
stmt = (
|
|
162
|
+
select(func.count())
|
|
163
|
+
.select_from(ReviewItem)
|
|
164
|
+
.join(Review)
|
|
165
|
+
.where(
|
|
166
|
+
Review.experiment_id == experiment_id,
|
|
167
|
+
Review.archived_at.is_(None),
|
|
168
|
+
ReviewItem.kind == ReviewItemKind.unreasonable,
|
|
169
|
+
ReviewItem.status == ReviewItemStatus.open,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return db.scalar(stmt) or 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _prior_version_fully_resolved_from_reviews(
|
|
176
|
+
prior_reviews: list[Review],
|
|
177
|
+
*,
|
|
178
|
+
creator_agent_id: uuid.UUID,
|
|
179
|
+
) -> bool:
|
|
180
|
+
"""Pure counterpart of :func:`_prior_version_reviews_fully_resolved`.
|
|
181
|
+
|
|
182
|
+
Operates on an already-loaded review list so callers can batch-fetch
|
|
183
|
+
reviews for many experiments and evaluate the carve-out in memory.
|
|
184
|
+
"""
|
|
185
|
+
prior_non_creator = _qualifying_non_creator_reviews(prior_reviews, creator_agent_id)
|
|
186
|
+
has_unreasonable = False
|
|
187
|
+
for review in prior_non_creator:
|
|
188
|
+
for item in review.items:
|
|
189
|
+
if item.kind != ReviewItemKind.unreasonable:
|
|
190
|
+
continue
|
|
191
|
+
has_unreasonable = True
|
|
192
|
+
# I1(c): the canonical "fully resolved" signal is now
|
|
193
|
+
# ``status=closed`` with ``last_resolution_reason=resolved``.
|
|
194
|
+
# Legacy rows that still carry ``status=resolved`` are accepted
|
|
195
|
+
# for backward compatibility.
|
|
196
|
+
is_fully_resolved = (
|
|
197
|
+
item.status == ReviewItemStatus.closed
|
|
198
|
+
and item.last_resolution_reason == ResolutionReason.resolved
|
|
199
|
+
) or item.status == ReviewItemStatus.resolved
|
|
200
|
+
if not is_fully_resolved:
|
|
201
|
+
return False
|
|
202
|
+
return has_unreasonable
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _prior_version_reviews_fully_resolved(db: Session, experiment) -> bool:
|
|
206
|
+
"""True when a non-creator review on an older plan version raised at least
|
|
207
|
+
one unreasonable item and all such items are now ``resolved``.
|
|
208
|
+
|
|
209
|
+
Reviewer resolving every unreasonable item they raised on a prior plan
|
|
210
|
+
version counts as explicit acceptance of the revision, so the creator may
|
|
211
|
+
approve without waiting for a fresh review on the current plan version.
|
|
212
|
+
Reviews with no unreasonable items do not qualify — the reviewer has not
|
|
213
|
+
acknowledged the revision, so a fresh review on the current version is
|
|
214
|
+
still required.
|
|
215
|
+
"""
|
|
216
|
+
prior_reviews = list(
|
|
217
|
+
db.scalars(
|
|
218
|
+
select(Review)
|
|
219
|
+
.where(
|
|
220
|
+
Review.experiment_id == experiment.id,
|
|
221
|
+
Review.plan_version < experiment.current_plan_version,
|
|
222
|
+
)
|
|
223
|
+
.options(joinedload(Review.items))
|
|
224
|
+
).unique()
|
|
225
|
+
)
|
|
226
|
+
return _prior_version_fully_resolved_from_reviews(
|
|
227
|
+
prior_reviews,
|
|
228
|
+
creator_agent_id=experiment.creator_agent_id,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def prior_version_reviews_fully_resolved_by_experiment(
|
|
233
|
+
db: Session,
|
|
234
|
+
experiments: list,
|
|
235
|
+
) -> dict[uuid.UUID, bool]:
|
|
236
|
+
"""Batch form of :func:`_prior_version_reviews_fully_resolved`.
|
|
237
|
+
|
|
238
|
+
Returns ``{experiment_id: True}`` when that experiment should be
|
|
239
|
+
excluded from ``pending_reviews`` (prior-version carve-out satisfied).
|
|
240
|
+
Issues at most one ``reviews`` SELECT for the whole input set.
|
|
241
|
+
"""
|
|
242
|
+
if not experiments:
|
|
243
|
+
return {}
|
|
244
|
+
exp_by_id = {exp.id: exp for exp in experiments}
|
|
245
|
+
rows = list(
|
|
246
|
+
db.scalars(
|
|
247
|
+
select(Review)
|
|
248
|
+
.where(Review.experiment_id.in_(exp_by_id.keys()))
|
|
249
|
+
.options(joinedload(Review.items))
|
|
250
|
+
).unique()
|
|
251
|
+
)
|
|
252
|
+
grouped: dict[uuid.UUID, list[Review]] = {eid: [] for eid in exp_by_id}
|
|
253
|
+
for review in rows:
|
|
254
|
+
exp = exp_by_id[review.experiment_id]
|
|
255
|
+
if review.plan_version < exp.current_plan_version:
|
|
256
|
+
grouped[review.experiment_id].append(review)
|
|
257
|
+
return {
|
|
258
|
+
eid: _prior_version_fully_resolved_from_reviews(
|
|
259
|
+
grouped.get(eid, []),
|
|
260
|
+
creator_agent_id=exp.creator_agent_id,
|
|
261
|
+
)
|
|
262
|
+
for eid, exp in exp_by_id.items()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def assert_approve_eligibility(db: Session, experiment) -> None:
|
|
267
|
+
reviews = list(
|
|
268
|
+
db.scalars(
|
|
269
|
+
select(Review).where(
|
|
270
|
+
Review.experiment_id == experiment.id,
|
|
271
|
+
Review.plan_version == experiment.current_plan_version,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
non_creator_reviews = _qualifying_non_creator_reviews(reviews, experiment.creator_agent_id)
|
|
276
|
+
# Carve-out: a prior-version review whose unreasonable items have all
|
|
277
|
+
# been explicitly ``resolved`` stands in for a fresh current-version
|
|
278
|
+
# review (reviewer has accepted the revision).
|
|
279
|
+
if not non_creator_reviews and not _prior_version_reviews_fully_resolved(db, experiment):
|
|
280
|
+
if not reviews:
|
|
281
|
+
if has_review_on_older_plan_version(db, experiment):
|
|
282
|
+
raise ApproveEligibilityError(
|
|
283
|
+
"Cannot approve: no review for the current plan version "
|
|
284
|
+
f"(v{experiment.current_plan_version}); reviewer must submit review "
|
|
285
|
+
"for this plan version after plan revise",
|
|
286
|
+
reason="no_review_for_current_plan_version",
|
|
287
|
+
)
|
|
288
|
+
raise ApproveEligibilityError(
|
|
289
|
+
"Cannot approve: no review from a non-creator agent",
|
|
290
|
+
reason="no_review",
|
|
291
|
+
)
|
|
292
|
+
raise ApproveEligibilityError(
|
|
293
|
+
"Cannot approve: only creator reviews exist",
|
|
294
|
+
reason="creator_only_review",
|
|
295
|
+
)
|
|
296
|
+
if count_open_unreasonable_for_experiment(db, experiment.id) > 0:
|
|
297
|
+
raise ApproveEligibilityError(
|
|
298
|
+
"Cannot approve: open unreasonable items remain",
|
|
299
|
+
reason="open_unreasonable_item",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def create_review(
|
|
304
|
+
db: Session,
|
|
305
|
+
experiment_id: uuid.UUID,
|
|
306
|
+
reviewer: Agent,
|
|
307
|
+
payload: ReviewCreate,
|
|
308
|
+
) -> Review:
|
|
309
|
+
experiment = get_experiment(db, experiment_id)
|
|
310
|
+
if experiment.phase != ExperimentPhase.review:
|
|
311
|
+
raise StateTransitionError("Reviews can only be submitted during review phase")
|
|
312
|
+
if (
|
|
313
|
+
reviewer.id == experiment.creator_agent_id
|
|
314
|
+
and reviewer.role != AgentRole.admin
|
|
315
|
+
):
|
|
316
|
+
raise CreatorSelfReviewBlockedError(
|
|
317
|
+
"Experiment creator cannot submit a review for their own experiment",
|
|
318
|
+
actor_id=reviewer.id,
|
|
319
|
+
experiment_id=experiment_id,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
substitute_kind = ReviewSubstituteKind.none
|
|
323
|
+
if reviewer.role == AgentRole.admin:
|
|
324
|
+
if reviewer.id == experiment.creator_agent_id:
|
|
325
|
+
substitute_kind = ReviewSubstituteKind.admin_self_substitute
|
|
326
|
+
else:
|
|
327
|
+
substitute_kind = ReviewSubstituteKind.admin_for_others
|
|
328
|
+
if not payload.substitute_reason or not payload.substitute_reason.strip():
|
|
329
|
+
raise ConflictError(
|
|
330
|
+
"Admin substitute review requires substitute_reason",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
existing = db.scalar(
|
|
334
|
+
select(Review).where(
|
|
335
|
+
Review.experiment_id == experiment_id,
|
|
336
|
+
Review.reviewer_agent_id == reviewer.id,
|
|
337
|
+
Review.plan_version == experiment.current_plan_version,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
if existing:
|
|
341
|
+
raise ConflictError("Reviewer already submitted a review for this plan version")
|
|
342
|
+
|
|
343
|
+
review = Review(
|
|
344
|
+
experiment_id=experiment_id,
|
|
345
|
+
reviewer_agent_id=reviewer.id,
|
|
346
|
+
plan_version=experiment.current_plan_version,
|
|
347
|
+
substitute_kind=substitute_kind,
|
|
348
|
+
)
|
|
349
|
+
db.add(review)
|
|
350
|
+
db.flush()
|
|
351
|
+
|
|
352
|
+
if substitute_kind != ReviewSubstituteKind.none:
|
|
353
|
+
audit_service.log_no_commit(
|
|
354
|
+
db,
|
|
355
|
+
action="review_substitute",
|
|
356
|
+
target_type="experiment",
|
|
357
|
+
agent_id=reviewer.id,
|
|
358
|
+
project_id=experiment.project_id,
|
|
359
|
+
target_id=experiment_id,
|
|
360
|
+
summary=f"Admin substitute review ({substitute_kind.value})",
|
|
361
|
+
payload={
|
|
362
|
+
"actor_id": str(reviewer.id),
|
|
363
|
+
"actor_role": reviewer.role.value,
|
|
364
|
+
"action": "review_substitute",
|
|
365
|
+
"target": str(experiment_id),
|
|
366
|
+
"target_plan_version": experiment.current_plan_version,
|
|
367
|
+
"substitute_kind": substitute_kind.value,
|
|
368
|
+
"reason": payload.substitute_reason,
|
|
369
|
+
"review_id": str(review.id),
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
for content in payload.reasonable_items:
|
|
374
|
+
db.add(
|
|
375
|
+
ReviewItem(
|
|
376
|
+
review_id=review.id,
|
|
377
|
+
kind=ReviewItemKind.reasonable,
|
|
378
|
+
content=content,
|
|
379
|
+
status=None,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
for content in payload.unreasonable_items:
|
|
383
|
+
db.add(
|
|
384
|
+
ReviewItem(
|
|
385
|
+
review_id=review.id,
|
|
386
|
+
kind=ReviewItemKind.unreasonable,
|
|
387
|
+
content=content,
|
|
388
|
+
status=ReviewItemStatus.open,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
db.flush()
|
|
392
|
+
|
|
393
|
+
# I1(e): emit a ``review_item.mutation`` audit row for each item created
|
|
394
|
+
# during the review submit so admins can reconstruct the review timeline
|
|
395
|
+
# from ``map audit list --kind review_item_mutation --experiment <id>``.
|
|
396
|
+
for item in review.items:
|
|
397
|
+
audit_service.log_review_item_mutation_no_commit(
|
|
398
|
+
db,
|
|
399
|
+
item=item,
|
|
400
|
+
experiment_id=experiment_id,
|
|
401
|
+
actor_id=reviewer.id,
|
|
402
|
+
project_id=experiment.project_id,
|
|
403
|
+
action="add_item",
|
|
404
|
+
before_state=None,
|
|
405
|
+
after_state=item.status.value if item.status is not None else None,
|
|
406
|
+
reason=None,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
db.commit()
|
|
410
|
+
stmt = select(Review).where(Review.id == review.id).options(joinedload(Review.items))
|
|
411
|
+
reloaded = db.scalar(stmt)
|
|
412
|
+
assert reloaded is not None
|
|
413
|
+
return reloaded
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def withdraw_review(
|
|
417
|
+
db: Session,
|
|
418
|
+
experiment_id: uuid.UUID,
|
|
419
|
+
review_id: uuid.UUID,
|
|
420
|
+
actor: Agent,
|
|
421
|
+
) -> None:
|
|
422
|
+
experiment = get_experiment(db, experiment_id)
|
|
423
|
+
if experiment.phase != ExperimentPhase.review:
|
|
424
|
+
raise StateTransitionError("Reviews can only be withdrawn during review phase")
|
|
425
|
+
|
|
426
|
+
stmt = (
|
|
427
|
+
select(Review)
|
|
428
|
+
.where(Review.id == review_id, Review.experiment_id == experiment_id)
|
|
429
|
+
.options(joinedload(Review.items))
|
|
430
|
+
)
|
|
431
|
+
review = db.scalar(stmt)
|
|
432
|
+
if review is None:
|
|
433
|
+
raise NotFoundError("Review not found")
|
|
434
|
+
# I1(d): withdrawing an archived review is structurally meaningless.
|
|
435
|
+
_ensure_review_not_archived(review)
|
|
436
|
+
if review.reviewer_agent_id != actor.id and actor.role != AgentRole.admin:
|
|
437
|
+
raise ForbiddenError("Only the review author can withdraw a review")
|
|
438
|
+
if _review_has_item_activity(review):
|
|
439
|
+
raise ConflictError("Cannot withdraw review after review items have been acted on")
|
|
440
|
+
|
|
441
|
+
for item in review.items:
|
|
442
|
+
db.delete(item)
|
|
443
|
+
db.delete(review)
|
|
444
|
+
db.commit()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def list_reviews(
|
|
448
|
+
db: Session,
|
|
449
|
+
experiment_id: uuid.UUID,
|
|
450
|
+
*,
|
|
451
|
+
include_archived: bool = False,
|
|
452
|
+
plan_version: int | None = None,
|
|
453
|
+
limit: int = 50,
|
|
454
|
+
) -> list[Review]:
|
|
455
|
+
get_experiment(db, experiment_id)
|
|
456
|
+
stmt = select(Review).where(Review.experiment_id == experiment_id)
|
|
457
|
+
if not include_archived:
|
|
458
|
+
stmt = stmt.where(Review.archived_at.is_(None))
|
|
459
|
+
if plan_version is not None:
|
|
460
|
+
stmt = stmt.where(Review.plan_version == plan_version)
|
|
461
|
+
stmt = (
|
|
462
|
+
stmt.options(joinedload(Review.items))
|
|
463
|
+
.order_by(Review.created_at.asc())
|
|
464
|
+
.limit(max(1, min(limit, 200)))
|
|
465
|
+
)
|
|
466
|
+
return list(db.scalars(stmt).unique())
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def get_review_item(db: Session, item_id: uuid.UUID) -> ReviewItem:
|
|
470
|
+
stmt = select(ReviewItem).where(ReviewItem.id == item_id).options(joinedload(ReviewItem.review))
|
|
471
|
+
item = db.scalar(stmt)
|
|
472
|
+
if item is None:
|
|
473
|
+
raise NotFoundError("Review item not found")
|
|
474
|
+
return item
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _ensure_review_not_archived(review: Review) -> None:
|
|
478
|
+
"""Guard against mutating an archived review.
|
|
479
|
+
|
|
480
|
+
I1(d) — once a review row carries ``archived_at`` (set by ``plan_revise``
|
|
481
|
+
auto-archive, manual admin archive, or the backfill marker from
|
|
482
|
+
migration 034), it is no longer canonical and resolve / withdraw /
|
|
483
|
+
update-item flows must refuse with a structured subcode instead of
|
|
484
|
+
silently mutating stale state. The CLI / SDK use this subcode to
|
|
485
|
+
surface the recovery hint pointing at ``--include-archived``.
|
|
486
|
+
"""
|
|
487
|
+
if review.archived_at is None:
|
|
488
|
+
return
|
|
489
|
+
reason = (
|
|
490
|
+
review.archived_reason.value
|
|
491
|
+
if review.archived_reason is not None
|
|
492
|
+
else "auto"
|
|
493
|
+
)
|
|
494
|
+
raise StateTransitionError(
|
|
495
|
+
(
|
|
496
|
+
"Review "
|
|
497
|
+
f"{review.id} has been archived (reason={reason}); "
|
|
498
|
+
"resolve / withdraw / update-item flows are not allowed on archived reviews."
|
|
499
|
+
),
|
|
500
|
+
error_code="REVIEW_ALREADY_ARCHIVED",
|
|
501
|
+
hint=(
|
|
502
|
+
"查看 --include-archived 历史; 若需要修改 item, 请在新的 plan_version 提交新 review。"
|
|
503
|
+
),
|
|
504
|
+
retryable=False,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def update_review_item(
|
|
509
|
+
db: Session,
|
|
510
|
+
item_id: uuid.UUID,
|
|
511
|
+
actor: Agent,
|
|
512
|
+
payload: ReviewItemUpdate,
|
|
513
|
+
) -> ReviewItem:
|
|
514
|
+
item = get_review_item(db, item_id)
|
|
515
|
+
experiment = get_experiment(db, item.review.experiment_id)
|
|
516
|
+
|
|
517
|
+
# I1(d): archived reviews are not mutable. Refuse with REVIEW_ALREADY_ARCHIVED
|
|
518
|
+
# so the CLI / SDK can surface the --include-archived recovery hint.
|
|
519
|
+
_ensure_review_not_archived(item.review)
|
|
520
|
+
|
|
521
|
+
if item.kind != ReviewItemKind.unreasonable:
|
|
522
|
+
raise StateTransitionError("Only unreasonable items have mutable status")
|
|
523
|
+
if item.status is None:
|
|
524
|
+
raise StateTransitionError("Item has no status")
|
|
525
|
+
|
|
526
|
+
is_creator = experiment.creator_agent_id == actor.id
|
|
527
|
+
is_reviewer = item.review.reviewer_agent_id == actor.id
|
|
528
|
+
is_admin = actor.role == AgentRole.admin
|
|
529
|
+
|
|
530
|
+
# I1(d): rebutting a single review item is only meaningful while the
|
|
531
|
+
# experiment is in the review phase. Once the experiment has reached
|
|
532
|
+
# ``result_review`` the host creator's intent ("this result should not
|
|
533
|
+
# be accepted") is structurally modelled by ``reject-result``, not by
|
|
534
|
+
# a per-item rebuttal. Surface that as the same structured subcode so
|
|
535
|
+
# the CLI / SDK can route the user to the right command.
|
|
536
|
+
if (
|
|
537
|
+
payload.status == ReviewItemStatus.rebutted
|
|
538
|
+
and is_creator
|
|
539
|
+
and not is_admin
|
|
540
|
+
and experiment.phase != ExperimentPhase.review
|
|
541
|
+
):
|
|
542
|
+
raise StateTransitionError(
|
|
543
|
+
(
|
|
544
|
+
"Cannot rebut a single review item after the experiment has "
|
|
545
|
+
"left review phase (current phase: "
|
|
546
|
+
f"{experiment.phase.value}). 整个实验结果驳回请让 reviewer / admin 调用 "
|
|
547
|
+
"reject-result。"
|
|
548
|
+
),
|
|
549
|
+
error_code="REVIEW_REJECT_RESULT_MISUSE",
|
|
550
|
+
hint=(
|
|
551
|
+
"单 item 驳回仅在 review 阶段有效;实验已过 review, 若要驳回整个 result, "
|
|
552
|
+
"请让 reviewer / admin 调用 reject-result, host creator 不可拒绝自己的 result。"
|
|
553
|
+
),
|
|
554
|
+
retryable=False,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
ctx = ReviewItemTransitionContext(
|
|
558
|
+
is_creator=is_creator,
|
|
559
|
+
is_reviewer=is_reviewer,
|
|
560
|
+
is_admin=is_admin,
|
|
561
|
+
)
|
|
562
|
+
try:
|
|
563
|
+
validate_review_item_transition(item.status, payload.status, ctx)
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
raise StateTransitionError(str(exc)) from exc
|
|
566
|
+
|
|
567
|
+
before_state = item.status.value if item.status is not None else None
|
|
568
|
+
item.status, item.last_resolution_reason = _normalize_terminal_status(payload.status)
|
|
569
|
+
item.updated_at = datetime.now(UTC)
|
|
570
|
+
after_state = item.status.value
|
|
571
|
+
|
|
572
|
+
# I1(e): emit a ``review_item.mutation`` audit row capturing the
|
|
573
|
+
# before / after state for admin timeline reconstruction. The
|
|
574
|
+
# ``reason`` field stays ``None`` for resolve-item mutations; the
|
|
575
|
+
# free-form reason lives in the experiment log.
|
|
576
|
+
audit_service.log_review_item_mutation_no_commit(
|
|
577
|
+
db,
|
|
578
|
+
item=item,
|
|
579
|
+
experiment_id=experiment.id,
|
|
580
|
+
actor_id=actor.id,
|
|
581
|
+
project_id=experiment.project_id,
|
|
582
|
+
action="resolve_item",
|
|
583
|
+
before_state=before_state,
|
|
584
|
+
after_state=after_state,
|
|
585
|
+
reason=None,
|
|
586
|
+
)
|
|
587
|
+
db.commit()
|
|
588
|
+
db.refresh(item)
|
|
589
|
+
return item
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# Terminal status values that the I1(c) state migration collapses into
|
|
593
|
+
# ``closed``. Existing callers (CLI ``--status resolved``, review dashboard
|
|
594
|
+
# PATCHes, MCP tools) keep sending the legacy values; the API layer rewrites
|
|
595
|
+
# them on the way to the database so the new ``closed`` terminal becomes the
|
|
596
|
+
# single source of truth.
|
|
597
|
+
#
|
|
598
|
+
# ``rebutted`` is intentionally absent: it is a *mid-cycle* signal (host
|
|
599
|
+
# pushes back on the item while keeping the experiment moving). A reviewer
|
|
600
|
+
# may still transition ``rebutted → resolved`` or ``rebutted → open`` to
|
|
601
|
+
# accept the rebuttal or restart discussion. Collapsing it to ``closed``
|
|
602
|
+
# would break that follow-up path, so the legacy value is preserved as-is
|
|
603
|
+
# at the database layer until a follow-up transition completes.
|
|
604
|
+
_TERMINAL_STATUS_REASONS: dict[ReviewItemStatus, ResolutionReason] = {
|
|
605
|
+
ReviewItemStatus.resolved: ResolutionReason.resolved,
|
|
606
|
+
ReviewItemStatus.withdrawn: ResolutionReason.superseded,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _normalize_terminal_status(
|
|
611
|
+
target: ReviewItemStatus,
|
|
612
|
+
) -> tuple[ReviewItemStatus, ResolutionReason | None]:
|
|
613
|
+
"""Rewrite legacy terminal values to ``closed{reason}``.
|
|
614
|
+
|
|
615
|
+
Callers that send ``resolved`` / ``rebutted`` / ``withdrawn`` continue to
|
|
616
|
+
succeed; the row is stored as ``closed`` with the matching
|
|
617
|
+
``last_resolution_reason`` so the new terminal state is the single
|
|
618
|
+
canonical representation in the database.
|
|
619
|
+
"""
|
|
620
|
+
reason = _TERMINAL_STATUS_REASONS.get(target)
|
|
621
|
+
if reason is None:
|
|
622
|
+
return target, None
|
|
623
|
+
return ReviewItemStatus.closed, reason
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _latest_verdict_reasons_by_item(
|
|
627
|
+
db: Session, experiment_id: uuid.UUID
|
|
628
|
+
) -> dict[uuid.UUID, str]:
|
|
629
|
+
"""Look up the most recent accept-result verdict_file and return a map of
|
|
630
|
+
item_id → waived_reason for items where verdict == 'waived'.
|
|
631
|
+
|
|
632
|
+
Reads ``experiment_logs.metadata_json.verdict_file`` (written by
|
|
633
|
+
``phase_service.accept_result`` when a structured verdict file is
|
|
634
|
+
supplied). Returns an empty map when no verdict log exists yet.
|
|
635
|
+
"""
|
|
636
|
+
latest = db.scalar(
|
|
637
|
+
select(ExperimentLog)
|
|
638
|
+
.where(
|
|
639
|
+
ExperimentLog.experiment_id == experiment_id,
|
|
640
|
+
ExperimentLog.metadata_json.is_not(None),
|
|
641
|
+
)
|
|
642
|
+
.order_by(ExperimentLog.created_at.desc())
|
|
643
|
+
.limit(1)
|
|
644
|
+
)
|
|
645
|
+
if latest is None or not isinstance(latest.metadata_json, dict):
|
|
646
|
+
return {}
|
|
647
|
+
verdict_file = latest.metadata_json.get("verdict_file")
|
|
648
|
+
if not isinstance(verdict_file, dict):
|
|
649
|
+
return {}
|
|
650
|
+
reasons: dict[uuid.UUID, str] = {}
|
|
651
|
+
for verdict in verdict_file.get("verdicts", []) or []:
|
|
652
|
+
if not isinstance(verdict, dict):
|
|
653
|
+
continue
|
|
654
|
+
if verdict.get("verdict") != ReviewVerdict.waived.value:
|
|
655
|
+
continue
|
|
656
|
+
raw_id = verdict.get("item_id")
|
|
657
|
+
reason = verdict.get("reason")
|
|
658
|
+
if not raw_id or not reason:
|
|
659
|
+
continue
|
|
660
|
+
try:
|
|
661
|
+
reasons[uuid.UUID(str(raw_id))] = str(reason)
|
|
662
|
+
except (ValueError, TypeError):
|
|
663
|
+
continue
|
|
664
|
+
return reasons
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def review_to_read(
|
|
668
|
+
db: Session,
|
|
669
|
+
review: Review,
|
|
670
|
+
*,
|
|
671
|
+
verdict_reasons: dict[uuid.UUID, str] | None = None,
|
|
672
|
+
) -> ReviewRead:
|
|
673
|
+
"""Render a ``Review`` ORM row as the API read model.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
db: SQLAlchemy session.
|
|
677
|
+
review: ORM row (must have ``items`` eagerly loaded — see
|
|
678
|
+
:func:`list_reviews` which uses ``joinedload(Review.items)``).
|
|
679
|
+
verdict_reasons: Pre-computed map of ``ReviewItem.id → waived_reason``
|
|
680
|
+
for items where the latest accept-result verdict was ``waived``.
|
|
681
|
+
When ``None`` (the default for single-review callers), the
|
|
682
|
+
latest verdict log is fetched on demand. When the caller is
|
|
683
|
+
rendering multiple reviews for the same experiment (e.g.
|
|
684
|
+
``get_experiment_bundle``), passing a precomputed map
|
|
685
|
+
collapses R redundant ``SELECT … FROM experiment_logs`` to 1.
|
|
686
|
+
"""
|
|
687
|
+
reasons = (
|
|
688
|
+
verdict_reasons
|
|
689
|
+
if verdict_reasons is not None
|
|
690
|
+
else _latest_verdict_reasons_by_item(db, review.experiment_id)
|
|
691
|
+
)
|
|
692
|
+
items = []
|
|
693
|
+
for i in review.items:
|
|
694
|
+
item_read = ReviewItemRead.model_validate(i)
|
|
695
|
+
item_read.waived_reason = reasons.get(i.id)
|
|
696
|
+
items.append(item_read)
|
|
697
|
+
return ReviewRead(
|
|
698
|
+
id=review.id,
|
|
699
|
+
experiment_id=review.experiment_id,
|
|
700
|
+
reviewer_agent_id=review.reviewer_agent_id,
|
|
701
|
+
plan_version=review.plan_version,
|
|
702
|
+
substitute_kind=review.substitute_kind,
|
|
703
|
+
created_at=review.created_at,
|
|
704
|
+
archived_at=review.archived_at,
|
|
705
|
+
archived_reason=review.archived_reason,
|
|
706
|
+
items=items,
|
|
707
|
+
)
|