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,730 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from server.domain.models import Agent, Comment, Mention, MentionSourceType, Topic, TopicComment
|
|
10
|
+
from server.services import notification_service, thread_activity
|
|
11
|
+
from server.services.text_utils import excerpt as _excerpt
|
|
12
|
+
|
|
13
|
+
MENTION_PATTERN = re.compile(r"@([a-zA-Z][a-zA-Z0-9_-]*)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _iter_text_outside_markdown_code(body: str):
|
|
17
|
+
"""Yield substrings of *body* that lie outside inline/fenced Markdown code."""
|
|
18
|
+
i = 0
|
|
19
|
+
n = len(body)
|
|
20
|
+
while i < n:
|
|
21
|
+
if body.startswith("```", i) or body.startswith("~~~", i):
|
|
22
|
+
fence = body[i : i + 3]
|
|
23
|
+
i += 3
|
|
24
|
+
while i < n and body[i] != "\n":
|
|
25
|
+
i += 1
|
|
26
|
+
if i < n:
|
|
27
|
+
i += 1
|
|
28
|
+
while i < n:
|
|
29
|
+
if body.startswith(fence, i):
|
|
30
|
+
i += 3
|
|
31
|
+
break
|
|
32
|
+
i += 1
|
|
33
|
+
continue
|
|
34
|
+
if body[i] == "`":
|
|
35
|
+
j = i
|
|
36
|
+
while j < n and body[j] == "`":
|
|
37
|
+
j += 1
|
|
38
|
+
tick_count = j - i
|
|
39
|
+
i = j
|
|
40
|
+
while i < n:
|
|
41
|
+
if body[i] == "`":
|
|
42
|
+
k = i
|
|
43
|
+
while k < n and body[k] == "`":
|
|
44
|
+
k += 1
|
|
45
|
+
if k - i >= tick_count:
|
|
46
|
+
i = k
|
|
47
|
+
break
|
|
48
|
+
i += 1
|
|
49
|
+
continue
|
|
50
|
+
start = i
|
|
51
|
+
while (
|
|
52
|
+
i < n
|
|
53
|
+
and body[i] != "`"
|
|
54
|
+
and not body.startswith("```", i)
|
|
55
|
+
and not body.startswith("~~~", i)
|
|
56
|
+
):
|
|
57
|
+
i += 1
|
|
58
|
+
if start < i:
|
|
59
|
+
yield body[start:i]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_mention_names(body: str) -> list[str]:
|
|
63
|
+
seen: set[str] = set()
|
|
64
|
+
names: list[str] = []
|
|
65
|
+
for segment in _iter_text_outside_markdown_code(body):
|
|
66
|
+
for match in MENTION_PATTERN.finditer(segment):
|
|
67
|
+
name = match.group(1)
|
|
68
|
+
if name not in seen:
|
|
69
|
+
seen.add(name)
|
|
70
|
+
names.append(name)
|
|
71
|
+
return names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_mentioned_agents(db: Session, names: list[str]) -> list[Agent]:
|
|
75
|
+
if not names:
|
|
76
|
+
return []
|
|
77
|
+
return list(db.scalars(select(Agent).where(Agent.name.in_(names))))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def partition_mention_names(
|
|
81
|
+
db: Session, names: list[str]
|
|
82
|
+
) -> tuple[list[Agent], list[str]]:
|
|
83
|
+
"""Split @tokens into resolved MAP agents and names with no matching Agent."""
|
|
84
|
+
if not names:
|
|
85
|
+
return [], []
|
|
86
|
+
agents = resolve_mentioned_agents(db, names)
|
|
87
|
+
resolved_names = {agent.name for agent in agents}
|
|
88
|
+
unresolved = [name for name in names if name not in resolved_names]
|
|
89
|
+
return agents, unresolved
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def unresolved_mention_names(db: Session, body: str) -> list[str]:
|
|
93
|
+
_, unresolved = partition_mention_names(db, extract_mention_names(body))
|
|
94
|
+
return unresolved
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def notify_unresolved_mentions(
|
|
98
|
+
db: Session,
|
|
99
|
+
*,
|
|
100
|
+
author: Agent,
|
|
101
|
+
project_id: uuid.UUID,
|
|
102
|
+
unresolved: list[str],
|
|
103
|
+
target_type: str,
|
|
104
|
+
target_id: uuid.UUID,
|
|
105
|
+
context_label: str,
|
|
106
|
+
payload: dict[str, str],
|
|
107
|
+
commit: bool = True,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Soft-fail feedback: comment is kept, author is told which @names did not match."""
|
|
110
|
+
if not unresolved:
|
|
111
|
+
return
|
|
112
|
+
labels = ", ".join(f"@{name}" for name in unresolved)
|
|
113
|
+
notification_service.enqueue_for_agents(
|
|
114
|
+
db,
|
|
115
|
+
recipient_agent_ids=[author.id],
|
|
116
|
+
project_id=project_id,
|
|
117
|
+
actor_id=author.id,
|
|
118
|
+
event="mention.unresolved",
|
|
119
|
+
summary=(
|
|
120
|
+
f"评论中的 {labels} 未匹配到 MAP Agent({context_label})。"
|
|
121
|
+
"请运行 `map persona list` 查看 agent_name,@ 时使用全名而非 persona 短名。"
|
|
122
|
+
),
|
|
123
|
+
target_type=target_type,
|
|
124
|
+
target_id=target_id,
|
|
125
|
+
payload={
|
|
126
|
+
**payload,
|
|
127
|
+
"unresolved_mentions": unresolved,
|
|
128
|
+
"hint": "Run `map persona list` and @ the exact agent_name field.",
|
|
129
|
+
},
|
|
130
|
+
exclude_actor=False,
|
|
131
|
+
commit=commit,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def process_experiment_comment_mentions(
|
|
136
|
+
db: Session,
|
|
137
|
+
*,
|
|
138
|
+
comment: Comment,
|
|
139
|
+
author: Agent,
|
|
140
|
+
project_id: uuid.UUID,
|
|
141
|
+
experiment_title: str,
|
|
142
|
+
commit: bool = True,
|
|
143
|
+
) -> list[str]:
|
|
144
|
+
names = extract_mention_names(comment.body)
|
|
145
|
+
agents, unresolved = partition_mention_names(db, names)
|
|
146
|
+
if agents:
|
|
147
|
+
excerpt = _excerpt(comment.body)
|
|
148
|
+
recipient_ids: list[uuid.UUID] = []
|
|
149
|
+
for agent in agents:
|
|
150
|
+
if agent.id == author.id:
|
|
151
|
+
continue
|
|
152
|
+
mention = Mention(
|
|
153
|
+
mentioned_agent_id=agent.id,
|
|
154
|
+
author_agent_id=author.id,
|
|
155
|
+
source_type=MentionSourceType.experiment_comment,
|
|
156
|
+
source_id=comment.id,
|
|
157
|
+
project_id=project_id,
|
|
158
|
+
experiment_id=comment.experiment_id,
|
|
159
|
+
topic_id=None,
|
|
160
|
+
excerpt=excerpt,
|
|
161
|
+
)
|
|
162
|
+
db.add(mention)
|
|
163
|
+
recipient_ids.append(agent.id)
|
|
164
|
+
|
|
165
|
+
if recipient_ids:
|
|
166
|
+
if commit:
|
|
167
|
+
db.commit()
|
|
168
|
+
notification_service.enqueue_for_agents(
|
|
169
|
+
db,
|
|
170
|
+
recipient_agent_ids=recipient_ids,
|
|
171
|
+
project_id=project_id,
|
|
172
|
+
actor_id=author.id,
|
|
173
|
+
event="agent.mentioned",
|
|
174
|
+
summary=f"{author.name} 在实验「{experiment_title}」中提及了你",
|
|
175
|
+
target_type="comment",
|
|
176
|
+
target_id=comment.id,
|
|
177
|
+
payload={
|
|
178
|
+
"experiment_id": str(comment.experiment_id),
|
|
179
|
+
"comment_id": str(comment.id),
|
|
180
|
+
"author_name": author.name,
|
|
181
|
+
"excerpt": excerpt,
|
|
182
|
+
},
|
|
183
|
+
commit=commit,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if unresolved:
|
|
187
|
+
notify_unresolved_mentions(
|
|
188
|
+
db,
|
|
189
|
+
author=author,
|
|
190
|
+
project_id=project_id,
|
|
191
|
+
unresolved=unresolved,
|
|
192
|
+
target_type="comment",
|
|
193
|
+
target_id=comment.id,
|
|
194
|
+
context_label=f"实验「{experiment_title}」",
|
|
195
|
+
payload={
|
|
196
|
+
"experiment_id": str(comment.experiment_id),
|
|
197
|
+
"comment_id": str(comment.id),
|
|
198
|
+
},
|
|
199
|
+
commit=commit,
|
|
200
|
+
)
|
|
201
|
+
return unresolved
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def process_topic_comment_mentions(
|
|
205
|
+
db: Session,
|
|
206
|
+
*,
|
|
207
|
+
comment: TopicComment,
|
|
208
|
+
author: Agent,
|
|
209
|
+
topic: Topic,
|
|
210
|
+
commit: bool = True,
|
|
211
|
+
) -> list[str]:
|
|
212
|
+
names = extract_mention_names(comment.body)
|
|
213
|
+
agents, unresolved = partition_mention_names(db, names)
|
|
214
|
+
if agents:
|
|
215
|
+
excerpt = _excerpt(comment.body)
|
|
216
|
+
recipient_ids: list[uuid.UUID] = []
|
|
217
|
+
for agent in agents:
|
|
218
|
+
if agent.id == author.id:
|
|
219
|
+
continue
|
|
220
|
+
mention = Mention(
|
|
221
|
+
mentioned_agent_id=agent.id,
|
|
222
|
+
author_agent_id=author.id,
|
|
223
|
+
source_type=MentionSourceType.topic_comment,
|
|
224
|
+
source_id=comment.id,
|
|
225
|
+
project_id=topic.project_id,
|
|
226
|
+
experiment_id=None,
|
|
227
|
+
topic_id=topic.id,
|
|
228
|
+
excerpt=excerpt,
|
|
229
|
+
)
|
|
230
|
+
db.add(mention)
|
|
231
|
+
recipient_ids.append(agent.id)
|
|
232
|
+
|
|
233
|
+
if recipient_ids:
|
|
234
|
+
if commit:
|
|
235
|
+
db.commit()
|
|
236
|
+
notification_service.enqueue_for_agents(
|
|
237
|
+
db,
|
|
238
|
+
recipient_agent_ids=recipient_ids,
|
|
239
|
+
project_id=topic.project_id,
|
|
240
|
+
actor_id=author.id,
|
|
241
|
+
event="agent.mentioned",
|
|
242
|
+
summary=f"{author.name} 在话题「{topic.title}」中提及了你",
|
|
243
|
+
target_type="topic_comment",
|
|
244
|
+
target_id=comment.id,
|
|
245
|
+
payload={
|
|
246
|
+
"topic_id": str(topic.id),
|
|
247
|
+
"comment_id": str(comment.id),
|
|
248
|
+
"author_name": author.name,
|
|
249
|
+
"excerpt": excerpt,
|
|
250
|
+
},
|
|
251
|
+
commit=commit,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if unresolved:
|
|
255
|
+
notify_unresolved_mentions(
|
|
256
|
+
db,
|
|
257
|
+
author=author,
|
|
258
|
+
project_id=topic.project_id,
|
|
259
|
+
unresolved=unresolved,
|
|
260
|
+
target_type="topic_comment",
|
|
261
|
+
target_id=comment.id,
|
|
262
|
+
context_label=f"话题「{topic.title}」",
|
|
263
|
+
payload={
|
|
264
|
+
"topic_id": str(topic.id),
|
|
265
|
+
"comment_id": str(comment.id),
|
|
266
|
+
},
|
|
267
|
+
commit=commit,
|
|
268
|
+
)
|
|
269
|
+
return unresolved
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def process_topic_mentions(
|
|
273
|
+
db: Session,
|
|
274
|
+
*,
|
|
275
|
+
topic: Topic,
|
|
276
|
+
author: Agent,
|
|
277
|
+
commit: bool = True,
|
|
278
|
+
) -> list[str]:
|
|
279
|
+
"""Create mention todos/notifications from a topic's initial description."""
|
|
280
|
+
body = topic.description or ""
|
|
281
|
+
names = extract_mention_names(body)
|
|
282
|
+
agents, unresolved = partition_mention_names(db, names)
|
|
283
|
+
if agents:
|
|
284
|
+
excerpt = _excerpt(body)
|
|
285
|
+
recipient_ids: list[uuid.UUID] = []
|
|
286
|
+
for agent in agents:
|
|
287
|
+
if agent.id == author.id:
|
|
288
|
+
continue
|
|
289
|
+
mention = Mention(
|
|
290
|
+
mentioned_agent_id=agent.id,
|
|
291
|
+
author_agent_id=author.id,
|
|
292
|
+
source_type=MentionSourceType.topic,
|
|
293
|
+
source_id=topic.id,
|
|
294
|
+
project_id=topic.project_id,
|
|
295
|
+
experiment_id=None,
|
|
296
|
+
topic_id=topic.id,
|
|
297
|
+
excerpt=excerpt,
|
|
298
|
+
)
|
|
299
|
+
db.add(mention)
|
|
300
|
+
recipient_ids.append(agent.id)
|
|
301
|
+
|
|
302
|
+
if recipient_ids:
|
|
303
|
+
if commit:
|
|
304
|
+
db.commit()
|
|
305
|
+
notification_service.enqueue_for_agents(
|
|
306
|
+
db,
|
|
307
|
+
recipient_agent_ids=recipient_ids,
|
|
308
|
+
project_id=topic.project_id,
|
|
309
|
+
actor_id=author.id,
|
|
310
|
+
event="agent.mentioned",
|
|
311
|
+
summary=f"{author.name} 在话题「{topic.title}」中提及了你",
|
|
312
|
+
target_type="topic",
|
|
313
|
+
target_id=topic.id,
|
|
314
|
+
payload={
|
|
315
|
+
"topic_id": str(topic.id),
|
|
316
|
+
"author_name": author.name,
|
|
317
|
+
"excerpt": excerpt,
|
|
318
|
+
},
|
|
319
|
+
commit=commit,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if unresolved:
|
|
323
|
+
notify_unresolved_mentions(
|
|
324
|
+
db,
|
|
325
|
+
author=author,
|
|
326
|
+
project_id=topic.project_id,
|
|
327
|
+
unresolved=unresolved,
|
|
328
|
+
target_type="topic",
|
|
329
|
+
target_id=topic.id,
|
|
330
|
+
context_label=f"话题「{topic.title}」",
|
|
331
|
+
payload={"topic_id": str(topic.id)},
|
|
332
|
+
commit=commit,
|
|
333
|
+
)
|
|
334
|
+
return unresolved
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def list_mentions_for_agent(
|
|
338
|
+
db: Session,
|
|
339
|
+
agent_id: uuid.UUID,
|
|
340
|
+
*,
|
|
341
|
+
limit: int = 50,
|
|
342
|
+
include_dismissed: bool = False,
|
|
343
|
+
) -> list[Mention]:
|
|
344
|
+
stmt = select(Mention).where(Mention.mentioned_agent_id == agent_id)
|
|
345
|
+
if not include_dismissed:
|
|
346
|
+
stmt = stmt.where(Mention.dismissed_at.is_(None))
|
|
347
|
+
stmt = stmt.order_by(Mention.created_at.desc()).limit(min(limit, 200))
|
|
348
|
+
return list(db.scalars(stmt))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _apply_mention_dismiss_cascade(
|
|
352
|
+
db: Session,
|
|
353
|
+
*,
|
|
354
|
+
actor_agent_id: uuid.UUID,
|
|
355
|
+
mentions: list[Mention],
|
|
356
|
+
now: datetime | None = None,
|
|
357
|
+
audit_action: str = "mention.dismiss",
|
|
358
|
+
audit_payload_extra: dict[str, Any] | None = None,
|
|
359
|
+
) -> int:
|
|
360
|
+
"""Dismiss mentions, cascade-read matching notifications, write audit rows."""
|
|
361
|
+
from server.services import audit_service, notification_service
|
|
362
|
+
|
|
363
|
+
if not mentions:
|
|
364
|
+
return 0
|
|
365
|
+
now = now or datetime.now(UTC)
|
|
366
|
+
touched: list[Mention] = []
|
|
367
|
+
for mention in mentions:
|
|
368
|
+
if mention.dismissed_at is not None:
|
|
369
|
+
continue
|
|
370
|
+
mention.dismissed_at = now
|
|
371
|
+
touched.append(mention)
|
|
372
|
+
payload = {
|
|
373
|
+
"mention_id": str(mention.id),
|
|
374
|
+
"source_comment_id": str(mention.source_id),
|
|
375
|
+
"actor_agent_id": str(actor_agent_id),
|
|
376
|
+
}
|
|
377
|
+
if audit_payload_extra:
|
|
378
|
+
payload.update(audit_payload_extra)
|
|
379
|
+
audit_service.log_no_commit(
|
|
380
|
+
db,
|
|
381
|
+
action=audit_action,
|
|
382
|
+
target_type="mention",
|
|
383
|
+
agent_id=actor_agent_id,
|
|
384
|
+
project_id=mention.project_id,
|
|
385
|
+
target_id=mention.id,
|
|
386
|
+
summary="Mention dismissed",
|
|
387
|
+
payload=payload,
|
|
388
|
+
)
|
|
389
|
+
if not touched:
|
|
390
|
+
return 0
|
|
391
|
+
notification_service.mark_agent_mentioned_notifications_read_no_commit(
|
|
392
|
+
db, mentions=touched, now=now
|
|
393
|
+
)
|
|
394
|
+
return len(touched)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def dismiss_mention(db: Session, *, agent: Agent, mention_id: uuid.UUID) -> Mention | None:
|
|
398
|
+
"""Mark a mention as dismissed for the mentioned agent. Idempotent."""
|
|
399
|
+
mention = db.get(Mention, mention_id)
|
|
400
|
+
if mention is None or mention.mentioned_agent_id != agent.id:
|
|
401
|
+
return None
|
|
402
|
+
if mention.dismissed_at is None:
|
|
403
|
+
_apply_mention_dismiss_cascade(db, actor_agent_id=agent.id, mentions=[mention])
|
|
404
|
+
db.commit()
|
|
405
|
+
db.refresh(mention)
|
|
406
|
+
return mention
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def dismiss_all_for_agent(db: Session, agent: Agent) -> int:
|
|
410
|
+
"""Dismiss every open mention addressed to this agent. Returns rows touched."""
|
|
411
|
+
mentions = list(
|
|
412
|
+
db.scalars(
|
|
413
|
+
select(Mention).where(
|
|
414
|
+
Mention.mentioned_agent_id == agent.id,
|
|
415
|
+
Mention.dismissed_at.is_(None),
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
count = _apply_mention_dismiss_cascade(db, actor_agent_id=agent.id, mentions=mentions)
|
|
420
|
+
if count:
|
|
421
|
+
db.commit()
|
|
422
|
+
return count
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _experiment_thread_comment_ids(
|
|
426
|
+
db: Session, *, comment_id: uuid.UUID, experiment_id: uuid.UUID
|
|
427
|
+
) -> list[uuid.UUID]:
|
|
428
|
+
"""All comment ids in the same thread (same root via parent_comment_id) within one experiment."""
|
|
429
|
+
rows = db.execute(
|
|
430
|
+
select(Comment.id, Comment.parent_comment_id).where(
|
|
431
|
+
Comment.experiment_id == experiment_id
|
|
432
|
+
)
|
|
433
|
+
).all()
|
|
434
|
+
if not rows:
|
|
435
|
+
return []
|
|
436
|
+
by_id: dict[uuid.UUID, uuid.UUID | None] = {row[0]: row[1] for row in rows}
|
|
437
|
+
if comment_id not in by_id:
|
|
438
|
+
return []
|
|
439
|
+
cur = comment_id
|
|
440
|
+
while True:
|
|
441
|
+
parent = by_id[cur]
|
|
442
|
+
if parent is None:
|
|
443
|
+
break
|
|
444
|
+
cur = parent
|
|
445
|
+
if cur not in by_id:
|
|
446
|
+
return []
|
|
447
|
+
root_id = cur
|
|
448
|
+
return [cid for cid in by_id if _walk_root(cid, by_id) == root_id]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _topic_thread_comment_ids(
|
|
452
|
+
db: Session, *, comment_id: uuid.UUID, topic_id: uuid.UUID
|
|
453
|
+
) -> list[uuid.UUID]:
|
|
454
|
+
rows = db.execute(
|
|
455
|
+
select(TopicComment.id, TopicComment.parent_comment_id).where(
|
|
456
|
+
TopicComment.topic_id == topic_id
|
|
457
|
+
)
|
|
458
|
+
).all()
|
|
459
|
+
if not rows:
|
|
460
|
+
return []
|
|
461
|
+
by_id: dict[uuid.UUID, uuid.UUID | None] = {row[0]: row[1] for row in rows}
|
|
462
|
+
if comment_id not in by_id:
|
|
463
|
+
return []
|
|
464
|
+
cur = comment_id
|
|
465
|
+
while True:
|
|
466
|
+
parent = by_id[cur]
|
|
467
|
+
if parent is None:
|
|
468
|
+
break
|
|
469
|
+
cur = parent
|
|
470
|
+
if cur not in by_id:
|
|
471
|
+
return []
|
|
472
|
+
root_id = cur
|
|
473
|
+
return [cid for cid in by_id if _walk_root(cid, by_id) == root_id]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _walk_root(
|
|
477
|
+
cid: uuid.UUID, by_id: dict[uuid.UUID, uuid.UUID | None]
|
|
478
|
+
) -> uuid.UUID:
|
|
479
|
+
cur = cid
|
|
480
|
+
seen: set[uuid.UUID] = set()
|
|
481
|
+
while True:
|
|
482
|
+
parent = by_id.get(cur)
|
|
483
|
+
if parent is None:
|
|
484
|
+
break
|
|
485
|
+
if cur in seen:
|
|
486
|
+
break
|
|
487
|
+
seen.add(cur)
|
|
488
|
+
cur = parent
|
|
489
|
+
return cur
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def auto_dismiss_mentions_after_comment(
|
|
493
|
+
db: Session,
|
|
494
|
+
*,
|
|
495
|
+
new_comment_author: Agent,
|
|
496
|
+
experiment_id: uuid.UUID | None,
|
|
497
|
+
topic_id: uuid.UUID | None,
|
|
498
|
+
new_comment_id: uuid.UUID,
|
|
499
|
+
comment_body: str | None = None,
|
|
500
|
+
commit: bool = True,
|
|
501
|
+
) -> int:
|
|
502
|
+
"""Auto-dismiss open mentions after an agent participates in a topic/experiment."""
|
|
503
|
+
if topic_id is not None:
|
|
504
|
+
from server.services import topic_ack_service
|
|
505
|
+
|
|
506
|
+
body = comment_body
|
|
507
|
+
if body is None:
|
|
508
|
+
comment = db.get(TopicComment, new_comment_id)
|
|
509
|
+
body = comment.body if comment is not None else None
|
|
510
|
+
if body is not None and topic_ack_service._ack_kind(body) is not None:
|
|
511
|
+
# Round-ack dismiss uses mention.auto_dismissed in record_participant_round_ack.
|
|
512
|
+
return 0
|
|
513
|
+
|
|
514
|
+
count = auto_dismiss_mentions_for_author_in_thread(
|
|
515
|
+
db,
|
|
516
|
+
new_comment_author=new_comment_author,
|
|
517
|
+
experiment_id=experiment_id,
|
|
518
|
+
topic_id=topic_id,
|
|
519
|
+
new_comment_id=new_comment_id,
|
|
520
|
+
commit=False,
|
|
521
|
+
)
|
|
522
|
+
count += dismiss_mentions_answered_by_participation(
|
|
523
|
+
db,
|
|
524
|
+
agent_id=new_comment_author.id,
|
|
525
|
+
topic_id=topic_id,
|
|
526
|
+
experiment_id=experiment_id,
|
|
527
|
+
commit=False,
|
|
528
|
+
)
|
|
529
|
+
if commit:
|
|
530
|
+
db.commit()
|
|
531
|
+
return count
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def dismiss_mentions_answered_by_participation(
|
|
535
|
+
db: Session,
|
|
536
|
+
*,
|
|
537
|
+
agent_id: uuid.UUID,
|
|
538
|
+
topic_id: uuid.UUID | None = None,
|
|
539
|
+
experiment_id: uuid.UUID | None = None,
|
|
540
|
+
commit: bool = True,
|
|
541
|
+
) -> int:
|
|
542
|
+
"""Dismiss open mentions when the agent already replied per thread/container rules."""
|
|
543
|
+
filters = [
|
|
544
|
+
Mention.mentioned_agent_id == agent_id,
|
|
545
|
+
Mention.dismissed_at.is_(None),
|
|
546
|
+
]
|
|
547
|
+
if topic_id is not None:
|
|
548
|
+
filters.extend(
|
|
549
|
+
[
|
|
550
|
+
Mention.topic_id == topic_id,
|
|
551
|
+
Mention.source_type.in_(
|
|
552
|
+
[MentionSourceType.topic, MentionSourceType.topic_comment]
|
|
553
|
+
),
|
|
554
|
+
]
|
|
555
|
+
)
|
|
556
|
+
elif experiment_id is not None:
|
|
557
|
+
filters.extend(
|
|
558
|
+
[
|
|
559
|
+
Mention.experiment_id == experiment_id,
|
|
560
|
+
Mention.source_type == MentionSourceType.experiment_comment,
|
|
561
|
+
]
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
return 0
|
|
565
|
+
|
|
566
|
+
open_mentions = list(db.scalars(select(Mention).where(*filters)))
|
|
567
|
+
to_dismiss_objs = [
|
|
568
|
+
mention
|
|
569
|
+
for mention in open_mentions
|
|
570
|
+
if thread_activity.comment_after(db, mention=mention, agent_id=agent_id)
|
|
571
|
+
]
|
|
572
|
+
if not to_dismiss_objs:
|
|
573
|
+
return 0
|
|
574
|
+
count = _apply_mention_dismiss_cascade(
|
|
575
|
+
db, actor_agent_id=agent_id, mentions=to_dismiss_objs
|
|
576
|
+
)
|
|
577
|
+
if commit and count:
|
|
578
|
+
db.commit()
|
|
579
|
+
return count
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _agent_replied_after_mention(
|
|
583
|
+
db: Session,
|
|
584
|
+
*,
|
|
585
|
+
mention: Mention,
|
|
586
|
+
agent_id: uuid.UUID,
|
|
587
|
+
) -> bool:
|
|
588
|
+
return thread_activity.comment_after(db, mention=mention, agent_id=agent_id)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def agent_replied_after_mention(
|
|
592
|
+
db: Session,
|
|
593
|
+
*,
|
|
594
|
+
mention: Mention,
|
|
595
|
+
agent_id: uuid.UUID,
|
|
596
|
+
) -> bool:
|
|
597
|
+
"""Public wrapper: True when agent posted in container after the mention source."""
|
|
598
|
+
return thread_activity.replied_after(db, mention=mention, agent_id=agent_id)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def auto_dismiss_mentions_for_author_in_thread(
|
|
602
|
+
db: Session,
|
|
603
|
+
*,
|
|
604
|
+
new_comment_author: Agent,
|
|
605
|
+
experiment_id: uuid.UUID | None,
|
|
606
|
+
topic_id: uuid.UUID | None,
|
|
607
|
+
new_comment_id: uuid.UUID,
|
|
608
|
+
commit: bool = True,
|
|
609
|
+
) -> int:
|
|
610
|
+
"""When an agent posts a reply in a thread, any @mention rows pointing at him
|
|
611
|
+
inside that thread (i.e. whose source_id is one of the thread comment ids)
|
|
612
|
+
are dismissed — he has obviously seen them.
|
|
613
|
+
"""
|
|
614
|
+
if experiment_id is not None:
|
|
615
|
+
thread_ids = _experiment_thread_comment_ids(
|
|
616
|
+
db, comment_id=new_comment_id, experiment_id=experiment_id
|
|
617
|
+
)
|
|
618
|
+
source_type = MentionSourceType.experiment_comment
|
|
619
|
+
elif topic_id is not None:
|
|
620
|
+
thread_ids = _topic_thread_comment_ids(
|
|
621
|
+
db, comment_id=new_comment_id, topic_id=topic_id
|
|
622
|
+
)
|
|
623
|
+
source_type = MentionSourceType.topic_comment
|
|
624
|
+
else:
|
|
625
|
+
return 0
|
|
626
|
+
if not thread_ids:
|
|
627
|
+
return 0
|
|
628
|
+
mentions = list(
|
|
629
|
+
db.scalars(
|
|
630
|
+
select(Mention).where(
|
|
631
|
+
Mention.mentioned_agent_id == new_comment_author.id,
|
|
632
|
+
Mention.source_type == source_type,
|
|
633
|
+
Mention.source_id.in_(thread_ids),
|
|
634
|
+
Mention.dismissed_at.is_(None),
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
count = _apply_mention_dismiss_cascade(
|
|
639
|
+
db, actor_agent_id=new_comment_author.id, mentions=mentions
|
|
640
|
+
)
|
|
641
|
+
if topic_id is not None:
|
|
642
|
+
topic_mentions = list(
|
|
643
|
+
db.scalars(
|
|
644
|
+
select(Mention).where(
|
|
645
|
+
Mention.mentioned_agent_id == new_comment_author.id,
|
|
646
|
+
Mention.source_type == MentionSourceType.topic,
|
|
647
|
+
Mention.topic_id == topic_id,
|
|
648
|
+
Mention.dismissed_at.is_(None),
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
count += _apply_mention_dismiss_cascade(
|
|
653
|
+
db, actor_agent_id=new_comment_author.id, mentions=topic_mentions
|
|
654
|
+
)
|
|
655
|
+
if commit and count:
|
|
656
|
+
db.commit()
|
|
657
|
+
return count
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def auto_dismiss_mentions_for_round_ack(
|
|
661
|
+
db: Session,
|
|
662
|
+
*,
|
|
663
|
+
topic: Topic,
|
|
664
|
+
agent: Agent,
|
|
665
|
+
ack_kind: str,
|
|
666
|
+
commit: bool = True,
|
|
667
|
+
) -> int:
|
|
668
|
+
"""Dismiss open mentions in the latest Round Summary subtree after accept/dismiss ack."""
|
|
669
|
+
import logging
|
|
670
|
+
|
|
671
|
+
from server.services import topic_ack_service
|
|
672
|
+
|
|
673
|
+
if ack_kind == "reject":
|
|
674
|
+
return 0
|
|
675
|
+
if ack_kind not in {"accept", "dismiss"}:
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
comments = topic_ack_service._topic_comments(db, topic.id)
|
|
679
|
+
summary = topic_ack_service.latest_host_round_summary_comment(
|
|
680
|
+
comments, host_agent_id=topic.creator_agent_id
|
|
681
|
+
)
|
|
682
|
+
if summary is None:
|
|
683
|
+
logging.getLogger(__name__).debug(
|
|
684
|
+
"round_ack auto_dismiss skip topic=%s agent=%s reason=no_summary",
|
|
685
|
+
topic.id,
|
|
686
|
+
agent.id,
|
|
687
|
+
)
|
|
688
|
+
return 0
|
|
689
|
+
|
|
690
|
+
thread_ids = _topic_thread_comment_ids(
|
|
691
|
+
db, comment_id=summary.id, topic_id=topic.id
|
|
692
|
+
)
|
|
693
|
+
if not thread_ids:
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
mentions = list(
|
|
697
|
+
db.scalars(
|
|
698
|
+
select(Mention).where(
|
|
699
|
+
Mention.topic_id == topic.id,
|
|
700
|
+
Mention.mentioned_agent_id == agent.id,
|
|
701
|
+
Mention.source_type == MentionSourceType.topic_comment,
|
|
702
|
+
Mention.source_id.in_(thread_ids),
|
|
703
|
+
Mention.dismissed_at.is_(None),
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
if not mentions:
|
|
708
|
+
logging.getLogger(__name__).debug(
|
|
709
|
+
"round_ack auto_dismiss skip topic=%s agent=%s reason=no_open_mentions",
|
|
710
|
+
topic.id,
|
|
711
|
+
agent.id,
|
|
712
|
+
)
|
|
713
|
+
return 0
|
|
714
|
+
|
|
715
|
+
audit_extra = {
|
|
716
|
+
"triggered_by": f"round_ack:{ack_kind}",
|
|
717
|
+
"summary_comment_id": str(summary.id),
|
|
718
|
+
"dismissing_agent_id": str(agent.id),
|
|
719
|
+
"round_id": topic.discussion_round.value,
|
|
720
|
+
}
|
|
721
|
+
count = _apply_mention_dismiss_cascade(
|
|
722
|
+
db,
|
|
723
|
+
actor_agent_id=agent.id,
|
|
724
|
+
mentions=mentions,
|
|
725
|
+
audit_action="mention.auto_dismissed",
|
|
726
|
+
audit_payload_extra=audit_extra,
|
|
727
|
+
)
|
|
728
|
+
if commit and count:
|
|
729
|
+
db.commit()
|
|
730
|
+
return count
|