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,478 @@
|
|
|
1
|
+
"""Topic CRUD / summary / round lifecycle helpers.
|
|
2
|
+
|
|
3
|
+
拆分自 ``topic_service.py``。本模块负责:
|
|
4
|
+
- ``topic_summary`` / ``topic_summaries_for_topics`` 及评论聚合
|
|
5
|
+
- ``create_topic`` / ``list_topics`` / ``get_topic_detail``
|
|
6
|
+
- ``update_topic`` / ``soft_delete_topic`` / ``set_topic_status``
|
|
7
|
+
- ``advance_topic_round`` / ``record_participant_round_ack``
|
|
8
|
+
- ``dismiss_topic`` / ``mark_topic_read``
|
|
9
|
+
|
|
10
|
+
``get_topic_detail`` 组装 comments + decision,委托
|
|
11
|
+
``topic_comment_service`` 与 ``topic_resolve_service``。
|
|
12
|
+
``topic_service`` re-exports 保持 import 兼容。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
|
|
19
|
+
from map_types.enums import ExperimentPhase, TopicDiscussionRound
|
|
20
|
+
from sqlalchemy import func, select
|
|
21
|
+
from sqlalchemy.orm import Session
|
|
22
|
+
|
|
23
|
+
from server.domain.models import (
|
|
24
|
+
Agent,
|
|
25
|
+
Experiment,
|
|
26
|
+
Topic,
|
|
27
|
+
TopicComment,
|
|
28
|
+
TopicReadCursor,
|
|
29
|
+
TopicStatus,
|
|
30
|
+
)
|
|
31
|
+
from server.domain.schemas import (
|
|
32
|
+
ExperimentSummaryRead,
|
|
33
|
+
TopicCommentCreate,
|
|
34
|
+
TopicCreate,
|
|
35
|
+
TopicRead,
|
|
36
|
+
TopicReadCursorRead,
|
|
37
|
+
TopicSummaryRead,
|
|
38
|
+
TopicUpdate,
|
|
39
|
+
)
|
|
40
|
+
from server.services import mention_service, topic_ack_service, topic_comment_service
|
|
41
|
+
from server.services._lookups import get_project
|
|
42
|
+
from server.services.errors import ConflictError, NotFoundError, StateTransitionError
|
|
43
|
+
from server.services.text_utils import excerpt
|
|
44
|
+
from server.services.thread_activity import topic_comment_order_clauses, topic_comment_order_clauses_desc
|
|
45
|
+
from server.services.topic_helpers import _agent_names_by_ids, _get_topic
|
|
46
|
+
from server.services.topic_resolve_service import _load_decision, topic_decision_read
|
|
47
|
+
|
|
48
|
+
_TOPIC_CLOSE_BLOCKING_EXPERIMENT_PHASES = (
|
|
49
|
+
ExperimentPhase.draft,
|
|
50
|
+
ExperimentPhase.review,
|
|
51
|
+
ExperimentPhase.approved,
|
|
52
|
+
ExperimentPhase.running,
|
|
53
|
+
ExperimentPhase.result_review,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def topic_summary(db: Session, topic: Topic, *, viewer_agent_id: uuid.UUID | None = None) -> TopicSummaryRead:
|
|
57
|
+
return topic_summaries_for_topics(db, [topic], viewer_agent_id=viewer_agent_id)[0]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dismiss_topic(db: Session, *, agent: Agent, topic_id: uuid.UUID) -> Topic | None:
|
|
61
|
+
"""Host-only: hide an open topic from the creator's /todos.
|
|
62
|
+
|
|
63
|
+
Re-surfaces automatically when the topic receives new activity
|
|
64
|
+
(`updated_at` is bumped on new comments), so the dismiss is a soft
|
|
65
|
+
"I've seen the current state" rather than a permanent archive.
|
|
66
|
+
"""
|
|
67
|
+
topic = db.get(Topic, topic_id)
|
|
68
|
+
if topic is None or topic.creator_agent_id != agent.id:
|
|
69
|
+
return None
|
|
70
|
+
if topic.dismissed_at is None:
|
|
71
|
+
topic.dismissed_at = datetime.now(UTC)
|
|
72
|
+
topic.dismissed_by_agent_id = agent.id
|
|
73
|
+
db.commit()
|
|
74
|
+
db.refresh(topic)
|
|
75
|
+
return topic
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def mark_topic_read(
|
|
79
|
+
db: Session,
|
|
80
|
+
*,
|
|
81
|
+
agent: Agent,
|
|
82
|
+
topic_id: uuid.UUID,
|
|
83
|
+
) -> TopicReadCursorRead:
|
|
84
|
+
"""Advance per-agent read cursor to the latest comment_seq (T3 D4).
|
|
85
|
+
|
|
86
|
+
Independent transaction from comment INSERT (T3-1). Does not clear
|
|
87
|
+
obligation work items (reply / mention / round_ack).
|
|
88
|
+
"""
|
|
89
|
+
from server.services.permissions import ensure_topic_access
|
|
90
|
+
|
|
91
|
+
topic = ensure_topic_access(db, agent, topic_id)
|
|
92
|
+
max_seq = int(
|
|
93
|
+
db.scalar(
|
|
94
|
+
select(func.coalesce(func.max(TopicComment.comment_seq), 0)).where(
|
|
95
|
+
TopicComment.topic_id == topic.id
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
or 0
|
|
99
|
+
)
|
|
100
|
+
now = datetime.now(UTC)
|
|
101
|
+
cursor = db.scalar(
|
|
102
|
+
select(TopicReadCursor).where(
|
|
103
|
+
TopicReadCursor.topic_id == topic.id,
|
|
104
|
+
TopicReadCursor.agent_id == agent.id,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
if cursor is None:
|
|
108
|
+
cursor = TopicReadCursor(
|
|
109
|
+
topic_id=topic.id,
|
|
110
|
+
agent_id=agent.id,
|
|
111
|
+
last_read_comment_seq=max_seq,
|
|
112
|
+
)
|
|
113
|
+
db.add(cursor)
|
|
114
|
+
else:
|
|
115
|
+
cursor.last_read_comment_seq = max_seq
|
|
116
|
+
cursor.updated_at = now
|
|
117
|
+
db.commit()
|
|
118
|
+
db.refresh(cursor)
|
|
119
|
+
return TopicReadCursorRead(
|
|
120
|
+
topic_id=cursor.topic_id,
|
|
121
|
+
agent_id=cursor.agent_id,
|
|
122
|
+
last_read_comment_seq=cursor.last_read_comment_seq,
|
|
123
|
+
updated_at=cursor.updated_at,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def topic_summaries_for_topics(
|
|
128
|
+
db: Session,
|
|
129
|
+
topics: list[Topic],
|
|
130
|
+
*,
|
|
131
|
+
viewer_agent_id: uuid.UUID | None = None,
|
|
132
|
+
) -> list[TopicSummaryRead]:
|
|
133
|
+
if not topics:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
topic_ids = [topic.id for topic in topics]
|
|
137
|
+
comment_counts = {
|
|
138
|
+
topic_id: count
|
|
139
|
+
for topic_id, count in db.execute(
|
|
140
|
+
select(TopicComment.topic_id, func.count())
|
|
141
|
+
.where(TopicComment.topic_id.in_(topic_ids))
|
|
142
|
+
.group_by(TopicComment.topic_id)
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
experiment_counts = {
|
|
146
|
+
topic_id: count
|
|
147
|
+
for topic_id, count in db.execute(
|
|
148
|
+
select(Experiment.topic_id, func.count())
|
|
149
|
+
.where(
|
|
150
|
+
Experiment.topic_id.in_(topic_ids),
|
|
151
|
+
Experiment.deleted_at.is_(None),
|
|
152
|
+
Experiment.phase != ExperimentPhase.cancelled,
|
|
153
|
+
)
|
|
154
|
+
.group_by(Experiment.topic_id)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
creator_names = _agent_names_by_ids(db, {topic.creator_agent_id for topic in topics})
|
|
158
|
+
latest_comments = _latest_topic_comments_by_topic(db, topic_ids)
|
|
159
|
+
latest_author_names = _agent_names_by_ids(
|
|
160
|
+
db, {comment.author_agent_id for comment in latest_comments.values()}
|
|
161
|
+
)
|
|
162
|
+
my_comment_counts: dict[uuid.UUID, int] = {}
|
|
163
|
+
if viewer_agent_id is not None:
|
|
164
|
+
my_comment_counts = _my_comment_counts_by_topic(db, topic_ids, viewer_agent_id)
|
|
165
|
+
return [
|
|
166
|
+
TopicSummaryRead(
|
|
167
|
+
id=topic.id,
|
|
168
|
+
project_id=topic.project_id,
|
|
169
|
+
creator_agent_id=topic.creator_agent_id,
|
|
170
|
+
creator_name=creator_names.get(topic.creator_agent_id),
|
|
171
|
+
title=topic.title,
|
|
172
|
+
description=topic.description,
|
|
173
|
+
status=topic.status,
|
|
174
|
+
pinned=topic.pinned,
|
|
175
|
+
discussion_round=topic.discussion_round,
|
|
176
|
+
round_summary_count=topic.round_summary_count,
|
|
177
|
+
comment_count=comment_counts.get(topic.id, 0),
|
|
178
|
+
experiment_count=experiment_counts.get(topic.id, 0),
|
|
179
|
+
last_comment_id=latest_comments[topic.id].id if topic.id in latest_comments else None,
|
|
180
|
+
last_comment_author_agent_id=(
|
|
181
|
+
latest_comments[topic.id].author_agent_id if topic.id in latest_comments else None
|
|
182
|
+
),
|
|
183
|
+
last_comment_author_name=(
|
|
184
|
+
latest_author_names.get(latest_comments[topic.id].author_agent_id)
|
|
185
|
+
if topic.id in latest_comments
|
|
186
|
+
else None
|
|
187
|
+
),
|
|
188
|
+
last_comment_excerpt=(
|
|
189
|
+
excerpt(latest_comments[topic.id].body)
|
|
190
|
+
if topic.id in latest_comments
|
|
191
|
+
else None
|
|
192
|
+
),
|
|
193
|
+
my_comment_count=my_comment_counts.get(topic.id, 0) if viewer_agent_id is not None else None,
|
|
194
|
+
created_at=topic.created_at,
|
|
195
|
+
updated_at=topic.updated_at,
|
|
196
|
+
archived_at=topic.archived_at,
|
|
197
|
+
dismissed_at=topic.dismissed_at,
|
|
198
|
+
stale_since=topic.advance_round_pending_since,
|
|
199
|
+
)
|
|
200
|
+
for topic in topics
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _latest_topic_comments_by_topic(
|
|
205
|
+
db: Session, topic_ids: list[uuid.UUID]
|
|
206
|
+
) -> dict[uuid.UUID, TopicComment]:
|
|
207
|
+
"""Return the latest TopicComment per topic in a single query.
|
|
208
|
+
|
|
209
|
+
Previously this was an N+1 loop firing ``SELECT ... ORDER BY ... LIMIT 1``
|
|
210
|
+
per topic. Topic list pages scale linearly with the number of topics, and
|
|
211
|
+
the cost showed up on projects with many open topics. The replacement uses
|
|
212
|
+
``ROW_NUMBER() OVER (PARTITION BY topic_id ORDER BY <sort>)`` so each
|
|
213
|
+
topic's winning row is selected in one round-trip.
|
|
214
|
+
|
|
215
|
+
The ORDER BY mirrors :func:`thread_activity.topic_comment_order_clauses_desc`
|
|
216
|
+
so the result is the same comment the old per-topic query would have picked.
|
|
217
|
+
"""
|
|
218
|
+
if not topic_ids:
|
|
219
|
+
return {}
|
|
220
|
+
rn = (
|
|
221
|
+
func.row_number()
|
|
222
|
+
.over(
|
|
223
|
+
partition_by=TopicComment.topic_id,
|
|
224
|
+
order_by=topic_comment_order_clauses_desc(),
|
|
225
|
+
)
|
|
226
|
+
.label("rn")
|
|
227
|
+
)
|
|
228
|
+
subq = (
|
|
229
|
+
select(TopicComment.id.label("cid"), rn)
|
|
230
|
+
.where(TopicComment.topic_id.in_(topic_ids))
|
|
231
|
+
.subquery()
|
|
232
|
+
)
|
|
233
|
+
winning_ids = db.scalars(
|
|
234
|
+
select(subq.c.cid).where(subq.c.rn == 1)
|
|
235
|
+
).all()
|
|
236
|
+
if not winning_ids:
|
|
237
|
+
return {}
|
|
238
|
+
comments = db.scalars(
|
|
239
|
+
select(TopicComment).where(TopicComment.id.in_(winning_ids))
|
|
240
|
+
).all()
|
|
241
|
+
return {comment.topic_id: comment for comment in comments}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _my_comment_counts_by_topic(
|
|
245
|
+
db: Session, topic_ids: list[uuid.UUID], agent_id: uuid.UUID
|
|
246
|
+
) -> dict[uuid.UUID, int]:
|
|
247
|
+
if not topic_ids:
|
|
248
|
+
return {}
|
|
249
|
+
return {
|
|
250
|
+
topic_id: count
|
|
251
|
+
for topic_id, count in db.execute(
|
|
252
|
+
select(TopicComment.topic_id, func.count())
|
|
253
|
+
.where(
|
|
254
|
+
TopicComment.topic_id.in_(topic_ids),
|
|
255
|
+
TopicComment.author_agent_id == agent_id,
|
|
256
|
+
)
|
|
257
|
+
.group_by(TopicComment.topic_id)
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _latest_comment_authors_by_topic(
|
|
263
|
+
db: Session, topic_ids: list[uuid.UUID]
|
|
264
|
+
) -> dict[uuid.UUID, uuid.UUID]:
|
|
265
|
+
return {
|
|
266
|
+
topic_id: comment.author_agent_id
|
|
267
|
+
for topic_id, comment in _latest_topic_comments_by_topic(db, topic_ids).items()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
def create_topic(
|
|
271
|
+
db: Session,
|
|
272
|
+
project_id: uuid.UUID,
|
|
273
|
+
creator_agent_id: uuid.UUID,
|
|
274
|
+
payload: TopicCreate,
|
|
275
|
+
) -> Topic:
|
|
276
|
+
get_project(db, project_id)
|
|
277
|
+
creator = db.get(Agent, creator_agent_id)
|
|
278
|
+
if creator is None:
|
|
279
|
+
raise NotFoundError("Creator agent not found")
|
|
280
|
+
topic = Topic(
|
|
281
|
+
project_id=project_id,
|
|
282
|
+
creator_agent_id=creator_agent_id,
|
|
283
|
+
title=payload.title,
|
|
284
|
+
description=payload.description,
|
|
285
|
+
status=TopicStatus.open,
|
|
286
|
+
)
|
|
287
|
+
db.add(topic)
|
|
288
|
+
db.flush()
|
|
289
|
+
mention_service.process_topic_mentions(db, topic=topic, author=creator, commit=False)
|
|
290
|
+
db.commit()
|
|
291
|
+
db.refresh(topic)
|
|
292
|
+
return topic
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def list_topics(
|
|
296
|
+
db: Session,
|
|
297
|
+
project_id: uuid.UUID,
|
|
298
|
+
*,
|
|
299
|
+
status: TopicStatus | None = None,
|
|
300
|
+
creator_agent_id: uuid.UUID | None = None,
|
|
301
|
+
q: str | None = None,
|
|
302
|
+
page: int = 1,
|
|
303
|
+
page_size: int = 100,
|
|
304
|
+
include_archived: bool = False,
|
|
305
|
+
viewer_agent_id: uuid.UUID | None = None,
|
|
306
|
+
) -> tuple[list[TopicSummaryRead], int]:
|
|
307
|
+
get_project(db, project_id)
|
|
308
|
+
stmt = select(Topic).where(Topic.project_id == project_id, Topic.deleted_at.is_(None))
|
|
309
|
+
if not include_archived:
|
|
310
|
+
stmt = stmt.where(Topic.archived_at.is_(None))
|
|
311
|
+
if status is not None:
|
|
312
|
+
stmt = stmt.where(Topic.status == status)
|
|
313
|
+
if creator_agent_id is not None:
|
|
314
|
+
stmt = stmt.where(Topic.creator_agent_id == creator_agent_id)
|
|
315
|
+
if q:
|
|
316
|
+
pattern = f"%{q}%"
|
|
317
|
+
stmt = stmt.where(Topic.title.ilike(pattern) | Topic.description.ilike(pattern))
|
|
318
|
+
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
|
319
|
+
page = max(1, page)
|
|
320
|
+
page_size = max(1, min(page_size, 100))
|
|
321
|
+
stmt = stmt.order_by(Topic.pinned.desc(), Topic.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
|
322
|
+
topics = list(db.scalars(stmt))
|
|
323
|
+
return topic_summaries_for_topics(db, topics, viewer_agent_id=viewer_agent_id), total
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def get_topic_detail(db: Session, topic_id: uuid.UUID) -> TopicRead:
|
|
327
|
+
topic = _get_topic(db, topic_id)
|
|
328
|
+
summary = topic_summary(db, topic)
|
|
329
|
+
|
|
330
|
+
exp_stmt = (
|
|
331
|
+
select(Experiment)
|
|
332
|
+
.where(
|
|
333
|
+
Experiment.topic_id == topic.id,
|
|
334
|
+
Experiment.deleted_at.is_(None),
|
|
335
|
+
Experiment.phase != ExperimentPhase.cancelled,
|
|
336
|
+
)
|
|
337
|
+
.order_by(Experiment.created_at.desc())
|
|
338
|
+
)
|
|
339
|
+
experiments = [ExperimentSummaryRead.model_validate(e) for e in db.scalars(exp_stmt)]
|
|
340
|
+
comments = list(db.scalars(
|
|
341
|
+
select(TopicComment)
|
|
342
|
+
.where(TopicComment.topic_id == topic.id)
|
|
343
|
+
.order_by(*topic_comment_order_clauses())
|
|
344
|
+
))
|
|
345
|
+
comments_tree = topic_comment_service._build_comment_tree(comments, _agent_names_by_ids(
|
|
346
|
+
db, {comment.author_agent_id for comment in comments}
|
|
347
|
+
))
|
|
348
|
+
|
|
349
|
+
decision = _load_decision(db, topic.id)
|
|
350
|
+
return TopicRead(
|
|
351
|
+
**summary.model_dump(),
|
|
352
|
+
experiments=experiments,
|
|
353
|
+
comments=comments_tree,
|
|
354
|
+
decision=topic_decision_read(db, decision) if decision is not None else None,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def update_topic(db: Session, topic_id: uuid.UUID, payload: TopicUpdate) -> Topic:
|
|
358
|
+
topic = _get_topic(db, topic_id)
|
|
359
|
+
data = payload.model_dump(exclude_unset=True)
|
|
360
|
+
archived = data.pop("archived", None)
|
|
361
|
+
for key, value in data.items():
|
|
362
|
+
setattr(topic, key, value)
|
|
363
|
+
if archived is not None:
|
|
364
|
+
topic.archived_at = datetime.now(UTC) if archived else None
|
|
365
|
+
db.commit()
|
|
366
|
+
db.refresh(topic)
|
|
367
|
+
return topic
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def soft_delete_topic(db: Session, topic_id: uuid.UUID) -> None:
|
|
371
|
+
topic = _get_topic(db, topic_id)
|
|
372
|
+
topic.deleted_at = datetime.now(UTC)
|
|
373
|
+
db.commit()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def set_topic_status(db: Session, topic_id: uuid.UUID, target: TopicStatus) -> Topic:
|
|
377
|
+
topic = _get_topic(db, topic_id)
|
|
378
|
+
if topic.status == target:
|
|
379
|
+
return topic
|
|
380
|
+
valid = (
|
|
381
|
+
(target == TopicStatus.closed and topic.status == TopicStatus.open)
|
|
382
|
+
or (target == TopicStatus.open and topic.status == TopicStatus.closed)
|
|
383
|
+
)
|
|
384
|
+
if not valid:
|
|
385
|
+
raise StateTransitionError(f"Topic cannot move from {topic.status.value} to {target.value}")
|
|
386
|
+
if target == TopicStatus.closed:
|
|
387
|
+
blocking = db.scalar(
|
|
388
|
+
select(Experiment).where(
|
|
389
|
+
Experiment.topic_id == topic.id,
|
|
390
|
+
Experiment.deleted_at.is_(None),
|
|
391
|
+
Experiment.archived_at.is_(None),
|
|
392
|
+
Experiment.phase.in_(_TOPIC_CLOSE_BLOCKING_EXPERIMENT_PHASES),
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
if blocking is not None:
|
|
396
|
+
raise ConflictError(
|
|
397
|
+
"Cannot close topic while linked experiment "
|
|
398
|
+
f"{blocking.id} is {blocking.phase.value}; complete or cancel it first"
|
|
399
|
+
)
|
|
400
|
+
topic.status = target
|
|
401
|
+
db.commit()
|
|
402
|
+
db.refresh(topic)
|
|
403
|
+
return topic
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def advance_topic_round(
|
|
407
|
+
db: Session,
|
|
408
|
+
topic_id: uuid.UUID,
|
|
409
|
+
*,
|
|
410
|
+
increment_summary: bool = True,
|
|
411
|
+
acknowledged_by: list[uuid.UUID] | None = None,
|
|
412
|
+
) -> Topic:
|
|
413
|
+
topic = _get_topic(db, topic_id)
|
|
414
|
+
if topic.status != TopicStatus.open:
|
|
415
|
+
raise ConflictError("Cannot advance a closed topic")
|
|
416
|
+
if topic.discussion_round == TopicDiscussionRound.ready:
|
|
417
|
+
raise ConflictError("Topic discussion round is already ready")
|
|
418
|
+
|
|
419
|
+
topic_ack_service.validate_advance_ack(
|
|
420
|
+
db,
|
|
421
|
+
topic,
|
|
422
|
+
acknowledged_by=acknowledged_by or [],
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
current_count = topic.round_summary_count or 0
|
|
426
|
+
next_count = current_count + 1 if increment_summary else current_count
|
|
427
|
+
|
|
428
|
+
if topic.discussion_round == TopicDiscussionRound.round1:
|
|
429
|
+
if next_count < 1:
|
|
430
|
+
raise ConflictError("Cannot advance round1 before at least one round summary")
|
|
431
|
+
topic.discussion_round = TopicDiscussionRound.round2
|
|
432
|
+
elif topic.discussion_round == TopicDiscussionRound.round2:
|
|
433
|
+
if next_count < 2:
|
|
434
|
+
raise ConflictError("Cannot advance round2 before two round summaries")
|
|
435
|
+
topic.discussion_round = TopicDiscussionRound.ready
|
|
436
|
+
else:
|
|
437
|
+
raise ConflictError(f"Unknown topic discussion round: {topic.discussion_round}")
|
|
438
|
+
|
|
439
|
+
topic.round_summary_count = next_count
|
|
440
|
+
topic.advance_round_pending_since = None
|
|
441
|
+
db.commit()
|
|
442
|
+
db.refresh(topic)
|
|
443
|
+
return topic
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def record_participant_round_ack(
|
|
447
|
+
db: Session,
|
|
448
|
+
topic_id: uuid.UUID,
|
|
449
|
+
agent: Agent,
|
|
450
|
+
kind: str,
|
|
451
|
+
) -> Topic:
|
|
452
|
+
topic = _get_topic(db, topic_id)
|
|
453
|
+
if topic.status != TopicStatus.open:
|
|
454
|
+
raise ConflictError("Cannot ack a closed topic")
|
|
455
|
+
if topic.archived_at is not None:
|
|
456
|
+
raise ConflictError("Cannot ack an archived topic")
|
|
457
|
+
if agent.id == topic.creator_agent_id:
|
|
458
|
+
raise ConflictError("Host cannot post participant ack; use acknowledged_by when advancing")
|
|
459
|
+
|
|
460
|
+
# ack 评论 + mention dismiss 写在同一事务内,单次 commit;任一步失败全部
|
|
461
|
+
# 回滚,避免「ack 评论已写但 mention 未 dismiss」或重试时重复写多条 ack。
|
|
462
|
+
topic_comment_service.create_topic_comment(
|
|
463
|
+
db,
|
|
464
|
+
topic_id,
|
|
465
|
+
agent,
|
|
466
|
+
TopicCommentCreate(body=topic_ack_service.participant_ack_body(kind)),
|
|
467
|
+
commit=False,
|
|
468
|
+
)
|
|
469
|
+
if kind in {"accept", "dismiss"}:
|
|
470
|
+
from server.services import mention_service
|
|
471
|
+
|
|
472
|
+
mention_service.auto_dismiss_mentions_for_round_ack(
|
|
473
|
+
db, topic=topic, agent=agent, ack_kind=kind, commit=False
|
|
474
|
+
)
|
|
475
|
+
db.commit()
|
|
476
|
+
db.refresh(topic)
|
|
477
|
+
return topic
|
|
478
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Per-agent unread topic activity for open discussions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.orm import Session
|
|
6
|
+
|
|
7
|
+
from server.domain.models import Agent
|
|
8
|
+
from server.domain.schemas import TopicProgressItemRead, TopicProgressListRead
|
|
9
|
+
from server.services import topic_work_item_service as work_items
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def list_topic_progress_for_agent(
|
|
13
|
+
db: Session,
|
|
14
|
+
agent: Agent,
|
|
15
|
+
*,
|
|
16
|
+
bundle: work_items.AgentTopicWorkItems | None = None,
|
|
17
|
+
) -> TopicProgressListRead:
|
|
18
|
+
"""List open topics with work items for ``agent`` (unified diff view)."""
|
|
19
|
+
if agent.project_id is None:
|
|
20
|
+
return TopicProgressListRead(items=[], total=0)
|
|
21
|
+
|
|
22
|
+
if bundle is None:
|
|
23
|
+
bundle = work_items.topic_work_items_bundle_for_agent(db, agent)
|
|
24
|
+
all_items = bundle.items
|
|
25
|
+
items: list[TopicProgressItemRead] = []
|
|
26
|
+
for topic in bundle.open_topics:
|
|
27
|
+
topic_items = [i for i in all_items if i.topic_id == topic.id]
|
|
28
|
+
if not topic_items:
|
|
29
|
+
continue
|
|
30
|
+
comments = bundle.comments_by_topic.get(topic.id)
|
|
31
|
+
item = work_items.topic_progress_item_from_work_items(
|
|
32
|
+
db,
|
|
33
|
+
topic,
|
|
34
|
+
agent,
|
|
35
|
+
topic_items,
|
|
36
|
+
comments=comments,
|
|
37
|
+
)
|
|
38
|
+
if item is not None:
|
|
39
|
+
items.append(item)
|
|
40
|
+
return TopicProgressListRead(items=items, total=len(items))
|