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,534 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
|
|
4
|
+
from map_types.enums import TopicActionItemStatus, TopicDiscussionRound, TopicStatus
|
|
5
|
+
from map_types.schemas import TopicSummaryRead
|
|
6
|
+
from sqlalchemy import func, select
|
|
7
|
+
from sqlalchemy.exc import IntegrityError
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from server.domain.models import (
|
|
11
|
+
Agent,
|
|
12
|
+
AgentRole,
|
|
13
|
+
Experiment,
|
|
14
|
+
ExperimentPhase,
|
|
15
|
+
PlanVersion,
|
|
16
|
+
Project,
|
|
17
|
+
ProjectStatusVersion,
|
|
18
|
+
Topic,
|
|
19
|
+
)
|
|
20
|
+
from server.domain.schemas import (
|
|
21
|
+
ExperimentBundleRead,
|
|
22
|
+
ExperimentCreate,
|
|
23
|
+
ExperimentDetailRead,
|
|
24
|
+
ExperimentLogRead,
|
|
25
|
+
ExperimentSummaryRead,
|
|
26
|
+
ExperimentUpdate,
|
|
27
|
+
PlanVersionRead,
|
|
28
|
+
ProjectCreate,
|
|
29
|
+
ProjectRead,
|
|
30
|
+
ProjectStatusRead,
|
|
31
|
+
ProjectUpdate,
|
|
32
|
+
TopicActionItemRead,
|
|
33
|
+
TopicDecisionRead,
|
|
34
|
+
)
|
|
35
|
+
from server.domain.state_machine import TERMINAL_PHASES
|
|
36
|
+
from server.services import project_status_service as status_doc_service
|
|
37
|
+
from server.services import topic_service
|
|
38
|
+
|
|
39
|
+
# ``get_project`` 下沉到 ``_lookups`` 以打破 project_service ↔ topic_service 循环 import;
|
|
40
|
+
# 这里 re-export 保持 ``from server.services.project_service import get_project`` 兼容。
|
|
41
|
+
from server.services._lookups import get_project
|
|
42
|
+
from server.services.acceptance_service import parse_acceptance_status
|
|
43
|
+
from server.services.errors import ConflictError, ForbiddenError, NotFoundError
|
|
44
|
+
|
|
45
|
+
_ACTIVE_TOPIC_EXPERIMENT_PHASES = (
|
|
46
|
+
ExperimentPhase.draft,
|
|
47
|
+
ExperimentPhase.review,
|
|
48
|
+
ExperimentPhase.approved,
|
|
49
|
+
ExperimentPhase.running,
|
|
50
|
+
ExperimentPhase.result_review,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_project(db: Session, payload: ProjectCreate, *, author_agent_id: uuid.UUID) -> Project:
|
|
55
|
+
existing = db.scalar(select(Project).where(Project.project_key == payload.project_key))
|
|
56
|
+
if existing is not None:
|
|
57
|
+
raise ConflictError("project_key already exists")
|
|
58
|
+
project = Project(**payload.model_dump())
|
|
59
|
+
db.add(project)
|
|
60
|
+
db.flush()
|
|
61
|
+
status_doc_service.create_initial_status(db, project=project, author_agent_id=author_agent_id)
|
|
62
|
+
db.commit()
|
|
63
|
+
db.refresh(project)
|
|
64
|
+
return project
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def list_projects(
|
|
68
|
+
db: Session,
|
|
69
|
+
*,
|
|
70
|
+
include_archived: bool = False,
|
|
71
|
+
project_id: uuid.UUID | None = None,
|
|
72
|
+
) -> list[Project]:
|
|
73
|
+
stmt = select(Project).order_by(Project.created_at.desc())
|
|
74
|
+
if project_id is not None:
|
|
75
|
+
stmt = stmt.where(Project.id == project_id)
|
|
76
|
+
if not include_archived:
|
|
77
|
+
stmt = stmt.where(Project.archived_at.is_(None))
|
|
78
|
+
return list(db.scalars(stmt))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_project_by_key(db: Session, project_key: str) -> Project:
|
|
82
|
+
project = db.scalar(select(Project).where(Project.project_key == project_key))
|
|
83
|
+
if project is None:
|
|
84
|
+
raise NotFoundError("Project not found")
|
|
85
|
+
return project
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def update_project(db: Session, project_id: uuid.UUID, payload: ProjectUpdate) -> Project:
|
|
89
|
+
project = get_project(db, project_id)
|
|
90
|
+
data = payload.model_dump(exclude_unset=True)
|
|
91
|
+
archived = data.pop("archived", None)
|
|
92
|
+
for key, value in data.items():
|
|
93
|
+
setattr(project, key, value)
|
|
94
|
+
if archived is not None:
|
|
95
|
+
project.archived_at = datetime.now(UTC) if archived else None
|
|
96
|
+
db.commit()
|
|
97
|
+
db.refresh(project)
|
|
98
|
+
return project
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_project_status(db: Session, project_id: uuid.UUID) -> ProjectStatusRead:
|
|
102
|
+
project = get_project(db, project_id)
|
|
103
|
+
return build_projects_status(db, [project])[0]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def list_project_decisions(
|
|
107
|
+
db: Session,
|
|
108
|
+
project_id: uuid.UUID,
|
|
109
|
+
*,
|
|
110
|
+
limit: int = 20,
|
|
111
|
+
) -> list[TopicDecisionRead]:
|
|
112
|
+
get_project(db, project_id)
|
|
113
|
+
return topic_service.list_project_decisions(db, project_id, limit=limit)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def list_project_action_items(
|
|
117
|
+
db: Session,
|
|
118
|
+
project_id: uuid.UUID,
|
|
119
|
+
*,
|
|
120
|
+
owner_agent_id: uuid.UUID | None = None,
|
|
121
|
+
status: TopicActionItemStatus | None = None,
|
|
122
|
+
limit: int = 100,
|
|
123
|
+
) -> list[TopicActionItemRead]:
|
|
124
|
+
get_project(db, project_id)
|
|
125
|
+
return topic_service.list_action_items(
|
|
126
|
+
db,
|
|
127
|
+
project_id,
|
|
128
|
+
owner_agent_id=owner_agent_id,
|
|
129
|
+
status=status,
|
|
130
|
+
limit=limit,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_projects_status(db: Session, projects: list[Project]) -> list[ProjectStatusRead]:
|
|
135
|
+
if not projects:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
project_ids = [project.id for project in projects]
|
|
139
|
+
counts_map: dict[uuid.UUID, dict[str, int]] = {pid: {} for pid in project_ids}
|
|
140
|
+
counts_stmt = (
|
|
141
|
+
select(Experiment.project_id, Experiment.phase, func.count())
|
|
142
|
+
.where(
|
|
143
|
+
Experiment.project_id.in_(project_ids),
|
|
144
|
+
Experiment.deleted_at.is_(None),
|
|
145
|
+
Experiment.archived_at.is_(None),
|
|
146
|
+
)
|
|
147
|
+
.group_by(Experiment.project_id, Experiment.phase)
|
|
148
|
+
)
|
|
149
|
+
for project_id, phase, count in db.execute(counts_stmt):
|
|
150
|
+
counts_map[project_id][phase.value] = count
|
|
151
|
+
for project_id in project_ids:
|
|
152
|
+
for phase in ExperimentPhase:
|
|
153
|
+
counts_map[project_id].setdefault(phase.value, 0)
|
|
154
|
+
|
|
155
|
+
active_phases = (
|
|
156
|
+
ExperimentPhase.draft,
|
|
157
|
+
ExperimentPhase.review,
|
|
158
|
+
ExperimentPhase.approved,
|
|
159
|
+
ExperimentPhase.running,
|
|
160
|
+
ExperimentPhase.result_review,
|
|
161
|
+
)
|
|
162
|
+
active_map: dict[uuid.UUID, list[ExperimentSummaryRead]] = {pid: [] for pid in project_ids}
|
|
163
|
+
active_stmt = (
|
|
164
|
+
select(Experiment)
|
|
165
|
+
.where(
|
|
166
|
+
Experiment.project_id.in_(project_ids),
|
|
167
|
+
Experiment.deleted_at.is_(None),
|
|
168
|
+
Experiment.archived_at.is_(None),
|
|
169
|
+
Experiment.phase.in_(active_phases),
|
|
170
|
+
)
|
|
171
|
+
.order_by(Experiment.project_id, Experiment.updated_at.desc())
|
|
172
|
+
)
|
|
173
|
+
for experiment in db.scalars(active_stmt):
|
|
174
|
+
active_map[experiment.project_id].append(ExperimentSummaryRead.model_validate(experiment))
|
|
175
|
+
|
|
176
|
+
recent_map: dict[uuid.UUID, list[ExperimentSummaryRead]] = {pid: [] for pid in project_ids}
|
|
177
|
+
recent_stmt = (
|
|
178
|
+
select(Experiment)
|
|
179
|
+
.where(
|
|
180
|
+
Experiment.project_id.in_(project_ids),
|
|
181
|
+
Experiment.deleted_at.is_(None),
|
|
182
|
+
Experiment.archived_at.is_(None),
|
|
183
|
+
)
|
|
184
|
+
.order_by(Experiment.project_id, Experiment.updated_at.desc())
|
|
185
|
+
)
|
|
186
|
+
for experiment in db.scalars(recent_stmt):
|
|
187
|
+
recent = recent_map[experiment.project_id]
|
|
188
|
+
if len(recent) < 5:
|
|
189
|
+
recent.append(ExperimentSummaryRead.model_validate(experiment))
|
|
190
|
+
|
|
191
|
+
open_topics_by_project: dict[uuid.UUID, list[Topic]] = {pid: [] for pid in project_ids}
|
|
192
|
+
open_topics_stmt = (
|
|
193
|
+
select(Topic)
|
|
194
|
+
.where(
|
|
195
|
+
Topic.project_id.in_(project_ids),
|
|
196
|
+
Topic.deleted_at.is_(None),
|
|
197
|
+
Topic.archived_at.is_(None),
|
|
198
|
+
Topic.status == TopicStatus.open,
|
|
199
|
+
)
|
|
200
|
+
.order_by(Topic.pinned.desc(), Topic.updated_at.desc())
|
|
201
|
+
)
|
|
202
|
+
for topic in db.scalars(open_topics_stmt):
|
|
203
|
+
open_topics_by_project[topic.project_id].append(topic)
|
|
204
|
+
|
|
205
|
+
open_topics_map: dict[uuid.UUID, list[TopicSummaryRead]] = {
|
|
206
|
+
project_id: topic_service.topic_summaries_for_topics(db, topics)
|
|
207
|
+
for project_id, topics in open_topics_by_project.items()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
status_rows = list(
|
|
211
|
+
db.scalars(select(ProjectStatusVersion).where(ProjectStatusVersion.project_id.in_(project_ids)))
|
|
212
|
+
)
|
|
213
|
+
status_by_key = {(row.project_id, row.version): row for row in status_rows}
|
|
214
|
+
|
|
215
|
+
results: list[ProjectStatusRead] = []
|
|
216
|
+
for project in projects:
|
|
217
|
+
status_version = 0
|
|
218
|
+
status_md: str | None = None
|
|
219
|
+
status_updated_at = None
|
|
220
|
+
if project.current_status_version > 0:
|
|
221
|
+
row = status_by_key.get((project.id, project.current_status_version))
|
|
222
|
+
if row is not None:
|
|
223
|
+
status_version = row.version
|
|
224
|
+
status_md = row.content_md
|
|
225
|
+
status_updated_at = row.created_at
|
|
226
|
+
else:
|
|
227
|
+
status_version = project.current_status_version
|
|
228
|
+
|
|
229
|
+
results.append(
|
|
230
|
+
ProjectStatusRead(
|
|
231
|
+
project=ProjectRead.model_validate(project),
|
|
232
|
+
experiment_counts_by_phase=counts_map[project.id],
|
|
233
|
+
active_experiments=active_map[project.id],
|
|
234
|
+
recent_experiments=recent_map[project.id],
|
|
235
|
+
open_topics=open_topics_map[project.id],
|
|
236
|
+
status_version=status_version,
|
|
237
|
+
status_md=status_md,
|
|
238
|
+
status_updated_at=status_updated_at,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return results
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def create_experiment_warnings(
|
|
245
|
+
db: Session,
|
|
246
|
+
project_id: uuid.UUID,
|
|
247
|
+
topic_id: uuid.UUID | None,
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
if topic_id is not None:
|
|
250
|
+
topic = db.get(Topic, topic_id)
|
|
251
|
+
if topic is None or topic.deleted_at is not None or topic.project_id != project_id:
|
|
252
|
+
return []
|
|
253
|
+
if topic.discussion_round != TopicDiscussionRound.ready:
|
|
254
|
+
return ["topic_not_ready_for_experiment"]
|
|
255
|
+
return []
|
|
256
|
+
open_count = (
|
|
257
|
+
db.scalar(
|
|
258
|
+
select(func.count())
|
|
259
|
+
.select_from(Topic)
|
|
260
|
+
.where(
|
|
261
|
+
Topic.project_id == project_id,
|
|
262
|
+
Topic.status == TopicStatus.open,
|
|
263
|
+
Topic.deleted_at.is_(None),
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
or 0
|
|
267
|
+
)
|
|
268
|
+
if open_count > 0:
|
|
269
|
+
return ["no_topic_id"]
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def create_experiment(
|
|
274
|
+
db: Session,
|
|
275
|
+
project_id: uuid.UUID,
|
|
276
|
+
creator_agent_id: uuid.UUID,
|
|
277
|
+
payload: ExperimentCreate,
|
|
278
|
+
) -> Experiment:
|
|
279
|
+
get_project(db, project_id)
|
|
280
|
+
# a764abf6 I1.(a): enforce plan frontmatter lint at create time
|
|
281
|
+
# so missing required fields fail with STATE_MACHINE_PLAN_MARKER_MISSING
|
|
282
|
+
# instead of writing a plan that will be rejected at revision.
|
|
283
|
+
from server.services.plan_marker_service import assert_plan_frontmatter_ok
|
|
284
|
+
|
|
285
|
+
assert_plan_frontmatter_ok(payload.plan.content_md)
|
|
286
|
+
if payload.topic_id is not None:
|
|
287
|
+
topic = db.get(Topic, payload.topic_id)
|
|
288
|
+
if topic is None or topic.deleted_at is not None or topic.project_id != project_id:
|
|
289
|
+
raise NotFoundError("Topic not found")
|
|
290
|
+
if topic.status != TopicStatus.open:
|
|
291
|
+
raise ConflictError("Cannot create experiment on a closed topic")
|
|
292
|
+
creator = db.get(Agent, creator_agent_id)
|
|
293
|
+
if topic.creator_agent_id != creator_agent_id and (creator is None or creator.role != AgentRole.admin):
|
|
294
|
+
raise ForbiddenError("Only the topic host can create an experiment from this topic")
|
|
295
|
+
active = db.scalar(
|
|
296
|
+
select(Experiment).where(
|
|
297
|
+
Experiment.topic_id == payload.topic_id,
|
|
298
|
+
Experiment.deleted_at.is_(None),
|
|
299
|
+
Experiment.archived_at.is_(None),
|
|
300
|
+
Experiment.phase.in_(_ACTIVE_TOPIC_EXPERIMENT_PHASES),
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
if active is not None:
|
|
304
|
+
raise ConflictError(
|
|
305
|
+
f"Topic already has an active experiment ({active.id}); complete or cancel it first"
|
|
306
|
+
)
|
|
307
|
+
phase = ExperimentPhase.review if payload.submit_for_review else ExperimentPhase.draft
|
|
308
|
+
# I1(b): mirror the phase_owner column to the resolver's answer at
|
|
309
|
+
# creation time so the ``informational_only`` auto-classification
|
|
310
|
+
# works for the create-with-submit path too (not just for the
|
|
311
|
+
# post-create submit_for_review path, which goes through
|
|
312
|
+
# ``phase_service.submit_for_review``).
|
|
313
|
+
from server.services.phase_owner_resolver import owner_for
|
|
314
|
+
|
|
315
|
+
experiment = Experiment(
|
|
316
|
+
project_id=project_id,
|
|
317
|
+
creator_agent_id=creator_agent_id,
|
|
318
|
+
title=payload.title,
|
|
319
|
+
description=payload.description,
|
|
320
|
+
phase=phase,
|
|
321
|
+
current_plan_version=1,
|
|
322
|
+
topic_id=payload.topic_id,
|
|
323
|
+
phase_owner=owner_for(phase).value,
|
|
324
|
+
)
|
|
325
|
+
db.add(experiment)
|
|
326
|
+
db.flush()
|
|
327
|
+
|
|
328
|
+
plan = PlanVersion(
|
|
329
|
+
experiment_id=experiment.id,
|
|
330
|
+
version=1,
|
|
331
|
+
content_md=payload.plan.content_md,
|
|
332
|
+
author_agent_id=creator_agent_id,
|
|
333
|
+
change_note=payload.plan.change_note or "初始版本",
|
|
334
|
+
)
|
|
335
|
+
db.add(plan)
|
|
336
|
+
try:
|
|
337
|
+
db.commit()
|
|
338
|
+
except IntegrityError as exc:
|
|
339
|
+
db.rollback()
|
|
340
|
+
raise ConflictError(
|
|
341
|
+
"Topic already has an active experiment; complete or cancel it first"
|
|
342
|
+
) from exc
|
|
343
|
+
db.refresh(experiment)
|
|
344
|
+
return experiment
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def list_experiments(
|
|
348
|
+
db: Session,
|
|
349
|
+
project_id: uuid.UUID,
|
|
350
|
+
*,
|
|
351
|
+
phase: ExperimentPhase | None = None,
|
|
352
|
+
creator_agent_id: uuid.UUID | None = None,
|
|
353
|
+
q: str | None = None,
|
|
354
|
+
page: int = 1,
|
|
355
|
+
page_size: int = 100,
|
|
356
|
+
include_archived: bool = False,
|
|
357
|
+
) -> tuple[list[Experiment], int]:
|
|
358
|
+
get_project(db, project_id)
|
|
359
|
+
stmt = select(Experiment).where(
|
|
360
|
+
Experiment.project_id == project_id, Experiment.deleted_at.is_(None)
|
|
361
|
+
)
|
|
362
|
+
if not include_archived:
|
|
363
|
+
stmt = stmt.where(Experiment.archived_at.is_(None))
|
|
364
|
+
if phase is not None:
|
|
365
|
+
stmt = stmt.where(Experiment.phase == phase)
|
|
366
|
+
if creator_agent_id is not None:
|
|
367
|
+
stmt = stmt.where(Experiment.creator_agent_id == creator_agent_id)
|
|
368
|
+
if q:
|
|
369
|
+
pattern = f"%{q}%"
|
|
370
|
+
stmt = stmt.where(Experiment.title.ilike(pattern) | Experiment.description.ilike(pattern))
|
|
371
|
+
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
|
372
|
+
page = max(1, page)
|
|
373
|
+
page_size = max(1, min(page_size, 100))
|
|
374
|
+
stmt = (
|
|
375
|
+
stmt.order_by(Experiment.updated_at.desc())
|
|
376
|
+
.offset((page - 1) * page_size)
|
|
377
|
+
.limit(page_size)
|
|
378
|
+
)
|
|
379
|
+
return list(db.scalars(stmt)), total
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def get_experiment(db: Session, experiment_id: uuid.UUID) -> Experiment:
|
|
383
|
+
experiment = db.get(Experiment, experiment_id)
|
|
384
|
+
if experiment is None or experiment.deleted_at is not None:
|
|
385
|
+
raise NotFoundError("Experiment not found")
|
|
386
|
+
return experiment
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def get_experiment_detail(
|
|
390
|
+
db: Session, experiment_id: uuid.UUID, actor: Agent | None = None
|
|
391
|
+
) -> ExperimentDetailRead:
|
|
392
|
+
from server.domain.models import ExperimentLog, Review
|
|
393
|
+
from server.services.experiment_capabilities_service import (
|
|
394
|
+
apply_capabilities_to_detail,
|
|
395
|
+
compute_experiment_capabilities,
|
|
396
|
+
compute_legacy_self_review,
|
|
397
|
+
)
|
|
398
|
+
from server.services.log_service import get_latest_log
|
|
399
|
+
from server.services.phase_owner_resolver import (
|
|
400
|
+
is_informational_only,
|
|
401
|
+
owner_for,
|
|
402
|
+
)
|
|
403
|
+
from server.services.review_service import count_open_unreasonable_for_experiment
|
|
404
|
+
|
|
405
|
+
experiment = get_experiment(db, experiment_id)
|
|
406
|
+
current_plan = None
|
|
407
|
+
acceptance_status = []
|
|
408
|
+
latest = get_latest_log(db, experiment.id)
|
|
409
|
+
if experiment.current_plan_version > 0:
|
|
410
|
+
plan_stmt = select(PlanVersion).where(
|
|
411
|
+
PlanVersion.experiment_id == experiment.id,
|
|
412
|
+
PlanVersion.version == experiment.current_plan_version,
|
|
413
|
+
)
|
|
414
|
+
plan = db.scalar(plan_stmt)
|
|
415
|
+
if plan:
|
|
416
|
+
current_plan = PlanVersionRead.model_validate(plan)
|
|
417
|
+
acceptance_status = parse_acceptance_status(
|
|
418
|
+
plan.content_md,
|
|
419
|
+
completion_metadata=latest.metadata_json if latest else None,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
plan_version_count = db.scalar(
|
|
423
|
+
select(func.count()).select_from(PlanVersion).where(PlanVersion.experiment_id == experiment.id)
|
|
424
|
+
) or 0
|
|
425
|
+
review_count = db.scalar(
|
|
426
|
+
select(func.count()).select_from(Review).where(Review.experiment_id == experiment.id)
|
|
427
|
+
) or 0
|
|
428
|
+
log_count = db.scalar(
|
|
429
|
+
select(func.count()).select_from(ExperimentLog).where(ExperimentLog.experiment_id == experiment.id)
|
|
430
|
+
) or 0
|
|
431
|
+
|
|
432
|
+
detail = ExperimentDetailRead(
|
|
433
|
+
id=experiment.id,
|
|
434
|
+
project_id=experiment.project_id,
|
|
435
|
+
creator_agent_id=experiment.creator_agent_id,
|
|
436
|
+
title=experiment.title,
|
|
437
|
+
description=experiment.description,
|
|
438
|
+
phase=experiment.phase,
|
|
439
|
+
current_plan_version=experiment.current_plan_version,
|
|
440
|
+
topic_id=experiment.topic_id,
|
|
441
|
+
created_at=experiment.created_at,
|
|
442
|
+
updated_at=experiment.updated_at,
|
|
443
|
+
archived_at=experiment.archived_at,
|
|
444
|
+
current_plan=current_plan,
|
|
445
|
+
plan_version_count=plan_version_count,
|
|
446
|
+
open_unreasonable_count=count_open_unreasonable_for_experiment(db, experiment.id),
|
|
447
|
+
review_count=review_count,
|
|
448
|
+
acceptance_status=acceptance_status,
|
|
449
|
+
log_count=log_count,
|
|
450
|
+
latest_log_summary=latest.summary if latest else None,
|
|
451
|
+
lock_holder_experiment_id=experiment.lock_holder_experiment_id,
|
|
452
|
+
lock_acquired_at=experiment.lock_acquired_at,
|
|
453
|
+
lock_ttl_seconds=experiment.lock_ttl_seconds,
|
|
454
|
+
next_attempt_at=experiment.next_attempt_at,
|
|
455
|
+
lock_skip_count=int(experiment.lock_skip_count or 0),
|
|
456
|
+
# I1(b): phase_owner read straight from the ORM column — the
|
|
457
|
+
# column is kept in sync by ``phase_service._sync_phase_owner``
|
|
458
|
+
# on every transition and by ``create_experiment`` at creation
|
|
459
|
+
# time, so this is the single source of truth.
|
|
460
|
+
phase_owner=owner_for(experiment.phase),
|
|
461
|
+
)
|
|
462
|
+
if actor is not None:
|
|
463
|
+
actions, blocked_on = compute_experiment_capabilities(db, experiment, actor)
|
|
464
|
+
legacy = compute_legacy_self_review(db, experiment)
|
|
465
|
+
informational_only = is_informational_only(
|
|
466
|
+
experiment.phase, actions=actions, blocked_on=blocked_on
|
|
467
|
+
)
|
|
468
|
+
return apply_capabilities_to_detail(
|
|
469
|
+
detail,
|
|
470
|
+
actions,
|
|
471
|
+
blocked_on,
|
|
472
|
+
legacy_self_review=legacy,
|
|
473
|
+
phase_owner=owner_for(experiment.phase),
|
|
474
|
+
informational_only=informational_only,
|
|
475
|
+
)
|
|
476
|
+
return detail
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def get_experiment_bundle(
|
|
480
|
+
db: Session, experiment_id: uuid.UUID, actor: Agent | None = None
|
|
481
|
+
) -> ExperimentBundleRead:
|
|
482
|
+
from server.services import comment_service, log_service, plan_service, review_service
|
|
483
|
+
|
|
484
|
+
experiment = get_experiment_detail(db, experiment_id, actor)
|
|
485
|
+
plans = [PlanVersionRead.model_validate(p) for p in plan_service.list_plans(db, experiment_id)]
|
|
486
|
+
# perf experiment (193a5074) PR4 Layer 3: hoist the verdict-reasons
|
|
487
|
+
# lookup out of the ``review_to_read`` loop. The previous code issued
|
|
488
|
+
# one ``SELECT … FROM experiment_logs`` per review — every query hit
|
|
489
|
+
# the same latest verdict log for this experiment_id. Computing it
|
|
490
|
+
# once and passing the map collapses R queries to 1.
|
|
491
|
+
reviews_orm = review_service.list_reviews(db, experiment_id)
|
|
492
|
+
verdict_reasons = (
|
|
493
|
+
review_service._latest_verdict_reasons_by_item(db, experiment_id)
|
|
494
|
+
if reviews_orm
|
|
495
|
+
else {}
|
|
496
|
+
)
|
|
497
|
+
reviews = [review_service.review_to_read(db, r, verdict_reasons=verdict_reasons) for r in reviews_orm]
|
|
498
|
+
comments = comment_service.build_comment_tree(db, comment_service.list_comments(db, experiment_id))
|
|
499
|
+
logs = [ExperimentLogRead.model_validate(entry) for entry in log_service.list_logs(db, experiment_id)]
|
|
500
|
+
return ExperimentBundleRead(
|
|
501
|
+
experiment=experiment,
|
|
502
|
+
plans=plans,
|
|
503
|
+
reviews=reviews,
|
|
504
|
+
comments=comments,
|
|
505
|
+
logs=logs,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def update_experiment(
|
|
510
|
+
db: Session,
|
|
511
|
+
experiment_id: uuid.UUID,
|
|
512
|
+
payload: ExperimentUpdate,
|
|
513
|
+
) -> Experiment:
|
|
514
|
+
experiment = get_experiment(db, experiment_id)
|
|
515
|
+
data = payload.model_dump(exclude_unset=True)
|
|
516
|
+
archived = data.pop("archived", None)
|
|
517
|
+
for key, value in data.items():
|
|
518
|
+
setattr(experiment, key, value)
|
|
519
|
+
if archived is not None:
|
|
520
|
+
if archived and experiment.phase not in TERMINAL_PHASES:
|
|
521
|
+
raise ConflictError(
|
|
522
|
+
"Cannot archive experiment while it is "
|
|
523
|
+
f"{experiment.phase.value}; complete or cancel it first"
|
|
524
|
+
)
|
|
525
|
+
experiment.archived_at = datetime.now(UTC) if archived else None
|
|
526
|
+
db.commit()
|
|
527
|
+
db.refresh(experiment)
|
|
528
|
+
return experiment
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def soft_delete_experiment(db: Session, experiment_id: uuid.UUID) -> None:
|
|
532
|
+
experiment = get_experiment(db, experiment_id)
|
|
533
|
+
experiment.deleted_at = datetime.now(UTC)
|
|
534
|
+
db.commit()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from server.domain.models import Project, ProjectStatusVersion
|
|
10
|
+
from server.domain.schemas import ProjectStatusRevise, ProjectStatusVersionRead
|
|
11
|
+
from server.services.errors import NotFoundError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_project(db: Session, project_id: uuid.UUID) -> Project:
|
|
15
|
+
project = db.get(Project, project_id)
|
|
16
|
+
if project is None:
|
|
17
|
+
raise NotFoundError("Project not found")
|
|
18
|
+
return project
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_status_template(*, project_key: str, created_at: datetime | None = None) -> str:
|
|
22
|
+
# 使用 tz-aware 的 UTC 时间,避免向 DateTime(timezone=True) 列写入 naive datetime
|
|
23
|
+
# 导致时区信息丢失(与 server 其余路径保持一致)。
|
|
24
|
+
ts = (created_at or datetime.now(UTC)).isoformat()
|
|
25
|
+
return f"""# Current Status — {project_key}
|
|
26
|
+
|
|
27
|
+
## 当前目标
|
|
28
|
+
|
|
29
|
+
- (待填写)
|
|
30
|
+
|
|
31
|
+
## 阻塞 / 风险
|
|
32
|
+
|
|
33
|
+
- 无
|
|
34
|
+
|
|
35
|
+
## 下一步
|
|
36
|
+
|
|
37
|
+
- (待填写)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
_最后更新:{ts} · 版本 v1_
|
|
41
|
+
|
|
42
|
+
> 实验/话题清单由 `get_project_status` 快照字段提供(`active_experiments`、`recent_experiments` 等),勿在本 MD 中维护。
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_initial_status(
|
|
47
|
+
db: Session,
|
|
48
|
+
*,
|
|
49
|
+
project: Project,
|
|
50
|
+
author_agent_id: uuid.UUID,
|
|
51
|
+
) -> ProjectStatusVersion:
|
|
52
|
+
version = ProjectStatusVersion(
|
|
53
|
+
project_id=project.id,
|
|
54
|
+
version=1,
|
|
55
|
+
content_md=default_status_template(project_key=project.project_key, created_at=project.created_at),
|
|
56
|
+
author_agent_id=author_agent_id,
|
|
57
|
+
change_note="初始版本",
|
|
58
|
+
)
|
|
59
|
+
project.current_status_version = 1
|
|
60
|
+
db.add(version)
|
|
61
|
+
return version
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_current_status_md(db: Session, project_id: uuid.UUID) -> tuple[int, str | None, datetime | None]:
|
|
65
|
+
project = db.get(Project, project_id)
|
|
66
|
+
if project is None:
|
|
67
|
+
raise NotFoundError("Project not found")
|
|
68
|
+
if project.current_status_version <= 0:
|
|
69
|
+
return 0, None, None
|
|
70
|
+
row = _get_status_version_row(db, project_id, project.current_status_version)
|
|
71
|
+
if row is None:
|
|
72
|
+
return project.current_status_version, None, None
|
|
73
|
+
return row.version, row.content_md, row.created_at
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def revise_status(
|
|
77
|
+
db: Session,
|
|
78
|
+
project_id: uuid.UUID,
|
|
79
|
+
author_agent_id: uuid.UUID,
|
|
80
|
+
payload: ProjectStatusRevise,
|
|
81
|
+
) -> ProjectStatusVersion:
|
|
82
|
+
project = _require_project(db, project_id)
|
|
83
|
+
new_version_number = project.current_status_version + 1
|
|
84
|
+
version = ProjectStatusVersion(
|
|
85
|
+
project_id=project.id,
|
|
86
|
+
version=new_version_number,
|
|
87
|
+
content_md=payload.content_md,
|
|
88
|
+
author_agent_id=author_agent_id,
|
|
89
|
+
change_note=payload.change_note,
|
|
90
|
+
)
|
|
91
|
+
project.current_status_version = new_version_number
|
|
92
|
+
db.add(version)
|
|
93
|
+
db.commit()
|
|
94
|
+
db.refresh(version)
|
|
95
|
+
return version
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def list_status_versions(
|
|
99
|
+
db: Session,
|
|
100
|
+
project_id: uuid.UUID,
|
|
101
|
+
*,
|
|
102
|
+
limit: int = 50,
|
|
103
|
+
) -> list[ProjectStatusVersionRead]:
|
|
104
|
+
_require_project(db, project_id)
|
|
105
|
+
stmt = (
|
|
106
|
+
select(ProjectStatusVersion)
|
|
107
|
+
.where(ProjectStatusVersion.project_id == project_id)
|
|
108
|
+
.order_by(ProjectStatusVersion.version.desc())
|
|
109
|
+
.limit(max(1, min(limit, 200)))
|
|
110
|
+
)
|
|
111
|
+
rows = list(db.scalars(stmt))
|
|
112
|
+
return [ProjectStatusVersionRead.model_validate(row) for row in rows]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_status_version(db: Session, project_id: uuid.UUID, version: int) -> ProjectStatusVersionRead:
|
|
116
|
+
_require_project(db, project_id)
|
|
117
|
+
row = _get_status_version_row(db, project_id, version)
|
|
118
|
+
if row is None:
|
|
119
|
+
raise NotFoundError("Project status version not found")
|
|
120
|
+
return ProjectStatusVersionRead.model_validate(row)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _get_status_version_row(
|
|
124
|
+
db: Session,
|
|
125
|
+
project_id: uuid.UUID,
|
|
126
|
+
version: int,
|
|
127
|
+
) -> ProjectStatusVersion | None:
|
|
128
|
+
stmt = select(ProjectStatusVersion).where(
|
|
129
|
+
ProjectStatusVersion.project_id == project_id,
|
|
130
|
+
ProjectStatusVersion.version == version,
|
|
131
|
+
)
|
|
132
|
+
return db.scalar(stmt)
|