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.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,97 @@
1
+ """Fernet-based at-rest encryption for sensitive string columns (cleanup follow-up PR3).
2
+
3
+ Used by ``server.domain.encrypted_types.EncryptedString`` to make
4
+ ``Webhook.secret`` (and future sensitive columns) opaque in DB dumps —
5
+ the column at rest is a Fernet token; the application decrypts on read.
6
+
7
+ Key resolution
8
+ --------------
9
+ The Fernet key (32-byte urlsafe-base64) is loaded from
10
+ ``MAP_WEBHOOK_SECRET_ENCRYPTION_KEY``. If unset, ``get_fernet()`` raises
11
+ ``SecretEncryptionKeyMissing`` — we deliberately do NOT silently derive a
12
+ key from another secret (e.g. ``MAP_ADMIN_TOKEN``), because that would
13
+ make "key rotation by env var change" ambiguous.
14
+
15
+ Migration coupling
16
+ ------------------
17
+ Alembic migration 037 calls :func:`encrypt_value` directly to backfill
18
+ existing plaintext ``Webhook.secret`` rows. The migration loads the same
19
+ env var via ``server.config.get_settings`` so prod / dev / test share one
20
+ key path.
21
+
22
+ Test override
23
+ -------------
24
+ Tests can call :func:`set_key_for_tests` to inject a deterministic key
25
+ without touching env vars / settings cache.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import threading
31
+
32
+ from cryptography.fernet import Fernet
33
+
34
+ from server.config import get_settings
35
+
36
+ _TEST_KEY_LOCK = threading.Lock()
37
+ _TEST_KEY: bytes | None = None
38
+
39
+
40
+ class SecretEncryptionKeyMissing(RuntimeError):
41
+ """Raised when ``MAP_WEBHOOK_SECRET_ENCRYPTION_KEY`` is unset and no
42
+ test key has been injected via :func:`set_key_for_tests`.
43
+
44
+ This is intentional: we never silently fall back to deriving a key
45
+ from another secret, because that would make key rotation
46
+ ambiguous.
47
+ """
48
+
49
+
50
+ def _load_key_from_settings() -> bytes:
51
+ raw = get_settings().webhook_secret_encryption_key
52
+ if not raw:
53
+ raise SecretEncryptionKeyMissing(
54
+ "MAP_WEBHOOK_SECRET_ENCRYPTION_KEY is not set. "
55
+ "Generate one with `python -c 'from cryptography.fernet import "
56
+ "Fernet; print(Fernet.generate_key().decode())'` and add it "
57
+ "to your environment / .env file."
58
+ )
59
+ return raw.encode("utf-8")
60
+
61
+
62
+ def get_fernet() -> Fernet:
63
+ """Return a :class:`cryptography.fernet.Fernet` keyed for the current process."""
64
+ if _TEST_KEY is not None:
65
+ return Fernet(_TEST_KEY)
66
+ return Fernet(_load_key_from_settings())
67
+
68
+
69
+ def set_key_for_tests(key: bytes | None) -> None:
70
+ """Inject a deterministic key for the current test. Pass ``None`` to clear.
71
+
72
+ Only intended for unit tests; production code paths should resolve the
73
+ key via env var.
74
+ """
75
+ with _TEST_KEY_LOCK:
76
+ global _TEST_KEY
77
+ _TEST_KEY = key
78
+
79
+
80
+ def encrypt_value(plaintext: str) -> str:
81
+ """Encrypt ``plaintext`` and return the Fernet token as a UTF-8 string."""
82
+ if plaintext is None:
83
+ raise ValueError("encrypt_value requires a non-None plaintext")
84
+ return get_fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
85
+
86
+
87
+ def decrypt_value(ciphertext: str) -> str:
88
+ """Decrypt a Fernet token back to its plaintext UTF-8 string.
89
+
90
+ Raises ``InvalidToken`` if the ciphertext was encrypted with a different
91
+ key, or if it isn't a valid Fernet token. Callers (TypeDecorator,
92
+ migration) must NOT swallow this — silent decryption failures would
93
+ send an empty / garbage HMAC secret to webhook receivers.
94
+ """
95
+ if ciphertext is None:
96
+ raise ValueError("decrypt_value requires a non-None ciphertext")
97
+ return get_fernet().decrypt(ciphertext.encode("utf-8")).decode("utf-8")
@@ -0,0 +1,119 @@
1
+ """b72d0542 I1.b(2)(b) — Log content similarity soft validator.
2
+
3
+ Wraps the plan-(b) "embedding cosine + threshold 0.7" check with a
4
+ structured warning shape and the soft-validation invariant
5
+ (``valid`` is always True so ``experiment log`` is never blocked).
6
+
7
+ The real cosine implementation lands in plan-(f) (``sentence-transformers
8
+ /all-MiniLM-L6-v2``); for now this stub fires a deterministic
9
+ placeholder similarity so the (e) ``--force`` + audit plumbing can be
10
+ exercised end-to-end:
11
+
12
+ * identical body to the previous log on the same experiment →
13
+ ``score=1.0`` → ``HIGH_CONTENT_SIMILARITY`` warning
14
+ * otherwise → ``score=0.0`` → no warning
15
+
16
+ Threshold, model id, and warning code are exported as constants so the
17
+ audit/CLI layers read them from a single source of truth. Once (f)
18
+ ships, ``_score_against_last_log`` is swapped to the real embedding
19
+ without touching callers.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from typing import Literal
27
+
28
+ from sqlalchemy import select
29
+ from sqlalchemy.orm import Session
30
+
31
+ from server.domain.models import ExperimentLog
32
+
33
+ SIMILARITY_THRESHOLD: float = 0.7
34
+ SIMILARITY_MODEL_ID: str = "placeholder:jaccard-v0"
35
+
36
+ SimilarityWarningCode = Literal["HIGH_CONTENT_SIMILARITY"]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SimilarityWarning:
41
+ code: SimilarityWarningCode
42
+ score: float
43
+ threshold: float
44
+ ref_log_id: uuid.UUID
45
+ model: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SimilarityValidationResult:
50
+ warnings: list[SimilarityWarning] = field(default_factory=list)
51
+ threshold: float = SIMILARITY_THRESHOLD
52
+ model: str = SIMILARITY_MODEL_ID
53
+
54
+ @property
55
+ def valid(self) -> bool:
56
+ """Soft validation invariant: always True (b72d0542 I1.b(2)).
57
+
58
+ Mirrors :class:`evidence_service.EvidenceValidationResult.valid`.
59
+ Host is expected to follow up with another log or pass
60
+ ``force_skip_similarity=True`` to acknowledge the warning.
61
+ """
62
+ return True
63
+
64
+
65
+ def _score_against_last_log(
66
+ db: Session,
67
+ *,
68
+ experiment_id: uuid.UUID,
69
+ content_md: str,
70
+ ) -> tuple[float, uuid.UUID | None]:
71
+ """Return ``(score, ref_log_id)`` against the most recent prior log.
72
+
73
+ Placeholder similarity (b72d0542 I1.b(2) pre-(f)):
74
+ score = 1.0 if previous log body is byte-identical, else 0.0
75
+ ref_log_id = previous log id (always set when prior log exists)
76
+
77
+ Real implementation in plan-(f) uses
78
+ ``sentence-transformers/all-MiniLM-L6-v2`` cosine similarity.
79
+ """
80
+ stmt = (
81
+ select(ExperimentLog)
82
+ .where(ExperimentLog.experiment_id == experiment_id)
83
+ .order_by(ExperimentLog.log_index.desc())
84
+ .limit(1)
85
+ )
86
+ last = db.scalar(stmt)
87
+ if last is None:
88
+ return 0.0, None
89
+ if last.content_md == content_md:
90
+ return 1.0, last.id
91
+ return 0.0, last.id
92
+
93
+
94
+ def validate_log_similarity(
95
+ db: Session,
96
+ *,
97
+ experiment_id: uuid.UUID,
98
+ content_md: str,
99
+ ) -> SimilarityValidationResult:
100
+ """Compute similarity against the most-recent prior log.
101
+
102
+ Soft validation — returns warnings but never raises. The log create
103
+ path always succeeds; the warning is purely advisory.
104
+ """
105
+ score, ref_log_id = _score_against_last_log(
106
+ db, experiment_id=experiment_id, content_md=content_md
107
+ )
108
+ warnings: list[SimilarityWarning] = []
109
+ if ref_log_id is not None and score >= SIMILARITY_THRESHOLD:
110
+ warnings.append(
111
+ SimilarityWarning(
112
+ code="HIGH_CONTENT_SIMILARITY",
113
+ score=score,
114
+ threshold=SIMILARITY_THRESHOLD,
115
+ ref_log_id=ref_log_id,
116
+ model=SIMILARITY_MODEL_ID,
117
+ )
118
+ )
119
+ return SimilarityValidationResult(warnings=warnings)
@@ -0,0 +1,17 @@
1
+ """SSE wire-format schemas (transport layer)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TypedDict
6
+
7
+ # Fields allowed only on the SSE transport envelope (not domain models).
8
+ SSE_TRANSPORT_ONLY_FIELDS = frozenset({"type"})
9
+
10
+ # Published by notification_service._emit_created via notification_stream.publish.
11
+ NOTIFICATION_CREATED_SSE_FIELDS = frozenset({"type", "event", "notification_id"})
12
+
13
+
14
+ class NotificationCreatedSseEvent(TypedDict):
15
+ type: str
16
+ event: str
17
+ notification_id: str
@@ -0,0 +1,68 @@
1
+ import uuid
2
+
3
+ from sqlalchemy import func, select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from server.domain.models import Experiment, ExperimentPhase
7
+ from server.domain.schemas import ExperimentSummaryRead, GlobalStatusRead
8
+ from server.services.project_service import build_projects_status, get_project, list_projects
9
+
10
+
11
+ def get_global_status(db: Session, *, project_id: uuid.UUID | None = None) -> GlobalStatusRead:
12
+ if project_id is not None:
13
+ project = get_project(db, project_id)
14
+ project_status = build_projects_status(db, [project])[0]
15
+ counts_stmt = (
16
+ select(Experiment.phase, func.count())
17
+ .where(
18
+ Experiment.project_id == project_id,
19
+ Experiment.deleted_at.is_(None),
20
+ Experiment.archived_at.is_(None),
21
+ )
22
+ .group_by(Experiment.phase)
23
+ )
24
+ counts = {phase.value: count for phase, count in db.execute(counts_stmt)}
25
+ for phase in ExperimentPhase:
26
+ counts.setdefault(phase.value, 0)
27
+ recent_stmt = (
28
+ select(Experiment)
29
+ .where(
30
+ Experiment.project_id == project_id,
31
+ Experiment.deleted_at.is_(None),
32
+ Experiment.archived_at.is_(None),
33
+ )
34
+ .order_by(Experiment.updated_at.desc())
35
+ .limit(10)
36
+ )
37
+ recent = [ExperimentSummaryRead.model_validate(e) for e in db.scalars(recent_stmt)]
38
+ return GlobalStatusRead(
39
+ total_experiments_by_phase=counts,
40
+ projects=[project_status],
41
+ recent_experiments=recent,
42
+ )
43
+
44
+ projects = list_projects(db)
45
+ project_statuses = build_projects_status(db, projects)
46
+
47
+ counts_stmt = (
48
+ select(Experiment.phase, func.count())
49
+ .where(Experiment.deleted_at.is_(None), Experiment.archived_at.is_(None))
50
+ .group_by(Experiment.phase)
51
+ )
52
+ counts = {phase.value: count for phase, count in db.execute(counts_stmt)}
53
+ for phase in ExperimentPhase:
54
+ counts.setdefault(phase.value, 0)
55
+
56
+ recent_stmt = (
57
+ select(Experiment)
58
+ .where(Experiment.deleted_at.is_(None), Experiment.archived_at.is_(None))
59
+ .order_by(Experiment.updated_at.desc())
60
+ .limit(10)
61
+ )
62
+ recent = [ExperimentSummaryRead.model_validate(e) for e in db.scalars(recent_stmt)]
63
+
64
+ return GlobalStatusRead(
65
+ total_experiments_by_phase=counts,
66
+ projects=project_statuses,
67
+ recent_experiments=recent,
68
+ )
@@ -0,0 +1,134 @@
1
+ """b72d0542 I1.a — Result submission 4-段 template soft validator.
2
+
3
+ Wraps :mod:`map_client.result_template` with structured warnings and
4
+ the soft-validation invariant (``valid`` is always True so the
5
+ ``experiment complete`` call is never blocked).
6
+
7
+ Used by the complete path (planned I1.b: wire into ``complete_experiment``
8
+ endpoint and surface via CLI). Soft warnings:
9
+
10
+ * ``MISSING_TEMPLATE_SECTION`` — one of the 4 required H2 sections is
11
+ absent from the result submission content.
12
+ * ``NO_LINK_IN_LOG_SECTION`` — ``## 实施 log`` section is present but
13
+ contains no ``[text](url)`` markdown link.
14
+ * ``MALFORMED_MARKDOWN_LINK`` — ``## 实施 log`` section has a
15
+ link-like fragment that is unbalanced (``[text`` without ``]`` or
16
+ ``(url`` without ``)``).
17
+
18
+ The warnings are returned as a list of frozen dataclasses; the API/SDK
19
+ wrappers (planned I1.b) convert these into Pydantic schemas with the
20
+ same shape as the evidence warnings (8ac93d4e).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Literal
27
+
28
+ from map_client.result_template import (
29
+ REQUIRED_SECTION_NAMES,
30
+ extract_malformed_link_fragment,
31
+ parse_result_submission,
32
+ )
33
+
34
+ TemplateWarningCode = Literal[
35
+ "MISSING_TEMPLATE_SECTION",
36
+ "NO_LINK_IN_LOG_SECTION",
37
+ "MALFORMED_MARKDOWN_LINK",
38
+ ]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class TemplateWarning:
43
+ code: TemplateWarningCode
44
+ section: str | None = None
45
+ detail: str | None = None
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class TemplateValidationResult:
50
+ warnings: list[TemplateWarning] = field(default_factory=list)
51
+ sections_present: tuple[str, ...] = ()
52
+ log_link_count: int = 0
53
+
54
+ @property
55
+ def valid(self) -> bool:
56
+ """Soft validation invariant: always True.
57
+
58
+ Mirrors :class:`evidence_service.EvidenceValidationResult.valid`.
59
+ Host is expected to follow up with another log if warnings
60
+ indicate missing sections; the validator never blocks the save.
61
+ """
62
+ return True
63
+
64
+
65
+ def validate_result_submission_template(
66
+ content_md: str | None,
67
+ ) -> TemplateValidationResult:
68
+ """Validate that ``content_md`` follows the 4-段 template contract.
69
+
70
+ Soft validation — returns warnings but never raises. The complete
71
+ path always succeeds; the validator is purely advisory.
72
+
73
+ Acceptance cases (from plan acceptance (a)):
74
+
75
+ 1. **完整 4 段**: all 4 sections present, ``## 实施 log`` has at
76
+ least one well-formed ``[text](url)`` → ``warnings=[]``.
77
+ 2. **缺 summary**: any of the 4 sections missing →
78
+ ``MISSING_TEMPLATE_SECTION`` per missing section.
79
+ 3. **缺 acceptance 清单**: same as case 2 — coverage by
80
+ ``MISSING_TEMPLATE_SECTION``.
81
+ 4. **markdown link 格式错误**: ``## 实施 log`` present but contains
82
+ an unbalanced ``[text`` or ``(url`` fragment →
83
+ ``MALFORMED_MARKDOWN_LINK`` with the offending fragment in
84
+ ``detail``. A section with no link at all produces
85
+ ``NO_LINK_IN_LOG_SECTION`` instead (different code).
86
+ """
87
+ if not content_md:
88
+ # Empty body → all 4 sections missing.
89
+ warnings = [
90
+ TemplateWarning(code="MISSING_TEMPLATE_SECTION", section=name)
91
+ for name in REQUIRED_SECTION_NAMES
92
+ ]
93
+ return TemplateValidationResult(warnings=warnings, sections_present=())
94
+
95
+ parsed = parse_result_submission(content_md)
96
+
97
+ # Sections present in canonical order.
98
+ present_in_order = tuple(
99
+ name for name in REQUIRED_SECTION_NAMES if name in parsed.present
100
+ )
101
+
102
+ warnings: list[TemplateWarning] = []
103
+
104
+ # 1. Missing-section warnings.
105
+ for name in REQUIRED_SECTION_NAMES:
106
+ if not parsed.present[name]:
107
+ warnings.append(TemplateWarning(code="MISSING_TEMPLATE_SECTION", section=name))
108
+
109
+ # 2 & 3. Log-section link warnings (only meaningful when log section exists).
110
+ if parsed.log_present:
111
+ if not parsed.log_links:
112
+ warnings.append(
113
+ TemplateWarning(code="NO_LINK_IN_LOG_SECTION", section="实施 log")
114
+ )
115
+ else:
116
+ # Detect malformed fragments inside the log section body.
117
+ from map_client.result_template import _slice_section_body
118
+
119
+ log_body = _slice_section_body(content_md, "实施 log") or ""
120
+ fragment = extract_malformed_link_fragment(log_body)
121
+ if fragment is not None:
122
+ warnings.append(
123
+ TemplateWarning(
124
+ code="MALFORMED_MARKDOWN_LINK",
125
+ section="实施 log",
126
+ detail=fragment,
127
+ )
128
+ )
129
+
130
+ return TemplateValidationResult(
131
+ warnings=warnings,
132
+ sections_present=present_in_order,
133
+ log_link_count=len(parsed.log_links),
134
+ )
@@ -0,0 +1,19 @@
1
+ """Shared text helpers for service-layer excerpts.
2
+
3
+ Centralises the 200-char excerpt previously duplicated as
4
+ ``mention_service._excerpt``, ``topic_service._topic_comment_excerpt`` and
5
+ ``todo_service._excerpt``. Co-locating it here also breaks the
6
+ ``todo_service ↔ topic_work_item_service`` import cycle: ``topic_work_item_service``
7
+ used to import these helpers from ``todo_service``, forcing ``todo_service`` to
8
+ lazily import ``topic_work_item_service``. The helpers now live in this
9
+ dependency-free module.
10
+ """
11
+
12
+ _EXCERPT_LEN = 200
13
+
14
+
15
+ def excerpt(body: str, *, limit: int = _EXCERPT_LEN) -> str:
16
+ text = body.strip().replace("\n", " ")
17
+ if len(text) <= limit:
18
+ return text
19
+ return text[: limit - 1] + "…"
@@ -0,0 +1,180 @@
1
+ """Thread / container activity helpers (T3 D3 single module)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Protocol
7
+
8
+ from sqlalchemy import ColumnElement, select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from server.domain.models import Comment, Mention, MentionSourceType, Topic, TopicComment
12
+
13
+
14
+ def topic_comment_sort_key(comment: TopicComment) -> tuple:
15
+ """Canonical per-topic total order: wall clock, then seq, then id."""
16
+ return (comment.created_at, comment.comment_seq, comment.id)
17
+
18
+
19
+ def topic_comment_order_clauses() -> tuple[ColumnElement[bool], ColumnElement[bool], ColumnElement[bool]]:
20
+ """SQLAlchemy ORDER BY for topic comments (ascending)."""
21
+ return (
22
+ TopicComment.created_at.asc(),
23
+ TopicComment.comment_seq.asc(),
24
+ TopicComment.id.asc(),
25
+ )
26
+
27
+
28
+ def topic_comment_order_clauses_desc() -> tuple[ColumnElement[bool], ColumnElement[bool], ColumnElement[bool]]:
29
+ """SQLAlchemy ORDER BY for topic comments (descending / latest first)."""
30
+ return (
31
+ TopicComment.created_at.desc(),
32
+ TopicComment.comment_seq.desc(),
33
+ TopicComment.id.desc(),
34
+ )
35
+
36
+
37
+ class _CommentLike(Protocol):
38
+ id: uuid.UUID
39
+ author_agent_id: uuid.UUID
40
+ parent_comment_id: uuid.UUID | None
41
+ created_at: object
42
+
43
+
44
+ def thread_root_id(
45
+ comment_id: uuid.UUID,
46
+ by_id: dict[uuid.UUID, TopicComment],
47
+ ) -> uuid.UUID:
48
+ """Walk parent_comment_id to the top-level comment; that id is the thread root."""
49
+ current = by_id[comment_id]
50
+ while current.parent_comment_id is not None:
51
+ current = by_id[current.parent_comment_id]
52
+ return current.id
53
+
54
+
55
+ def host_replied_after(
56
+ comment: TopicComment,
57
+ host_comment_ids: set[uuid.UUID],
58
+ by_id: dict[uuid.UUID, TopicComment],
59
+ *,
60
+ comment_order: list[uuid.UUID] | None = None,
61
+ ) -> bool:
62
+ """True when the host posted in the same thread after ``comment``."""
63
+ root = thread_root_id(comment.id, by_id)
64
+ if comment_order is None:
65
+ comment_order = sorted(by_id.keys(), key=lambda cid: topic_comment_sort_key(by_id[cid]))
66
+ try:
67
+ comment_pos = comment_order.index(comment.id)
68
+ except ValueError:
69
+ return False
70
+ for cid in host_comment_ids:
71
+ if thread_root_id(cid, by_id) != root:
72
+ continue
73
+ try:
74
+ host_pos = comment_order.index(cid)
75
+ except ValueError:
76
+ continue
77
+ if host_pos > comment_pos:
78
+ return True
79
+ return False
80
+
81
+
82
+ def _is_reply_in_thread_to(
83
+ comment: TopicComment | Comment,
84
+ source: TopicComment | Comment,
85
+ by_id: dict[uuid.UUID, TopicComment | Comment],
86
+ ) -> bool:
87
+ cur: TopicComment | Comment | None = comment
88
+ while cur is not None and cur.parent_comment_id is not None:
89
+ if cur.parent_comment_id == source.id:
90
+ return True
91
+ cur = by_id.get(cur.parent_comment_id)
92
+ return False
93
+
94
+
95
+ def comment_after(
96
+ db: Session,
97
+ *,
98
+ mention: Mention,
99
+ agent_id: uuid.UUID,
100
+ ) -> bool:
101
+ """True when ``agent_id`` posted in the same container after the mention source."""
102
+ if mention.source_type == MentionSourceType.topic and mention.topic_id is not None:
103
+ topic = db.get(Topic, mention.topic_id)
104
+ if topic is None:
105
+ return False
106
+ return (
107
+ db.scalar(
108
+ select(TopicComment.id)
109
+ .where(
110
+ TopicComment.topic_id == mention.topic_id,
111
+ TopicComment.author_agent_id == agent_id,
112
+ TopicComment.created_at >= topic.created_at,
113
+ )
114
+ .order_by(*topic_comment_order_clauses())
115
+ .limit(1)
116
+ )
117
+ is not None
118
+ )
119
+
120
+ if mention.source_type == MentionSourceType.topic_comment and mention.topic_id is not None:
121
+ comments = list(
122
+ db.scalars(
123
+ select(TopicComment)
124
+ .where(TopicComment.topic_id == mention.topic_id)
125
+ .order_by(*topic_comment_order_clauses())
126
+ )
127
+ )
128
+ elif (
129
+ mention.source_type == MentionSourceType.experiment_comment
130
+ and mention.experiment_id is not None
131
+ ):
132
+ comments = list(
133
+ db.scalars(
134
+ select(Comment)
135
+ .where(Comment.experiment_id == mention.experiment_id)
136
+ .order_by(Comment.created_at.asc(), Comment.id.asc())
137
+ )
138
+ )
139
+ else:
140
+ return False
141
+
142
+ by_id = {comment.id: comment for comment in comments}
143
+ source = by_id.get(mention.source_id)
144
+ if source is None:
145
+ return False
146
+
147
+ for comment in comments:
148
+ if comment.author_agent_id != agent_id:
149
+ continue
150
+ if comment.id == mention.source_id:
151
+ continue
152
+ from server.services import topic_ack_service
153
+
154
+ if topic_ack_service._ack_kind(getattr(comment, "body", "") or "") is not None:
155
+ continue
156
+ if comment.created_at > source.created_at:
157
+ return True
158
+ if comment.created_at < source.created_at:
159
+ continue
160
+ if _is_reply_in_thread_to(comment, source, by_id):
161
+ return True
162
+ source_seq = getattr(source, "comment_seq", None)
163
+ comment_seq = getattr(comment, "comment_seq", None)
164
+ if source_seq is not None and comment_seq is not None:
165
+ if comment_seq > source_seq:
166
+ return True
167
+ continue
168
+ if comment.id > source.id:
169
+ return True
170
+ return False
171
+
172
+
173
+ def replied_after(
174
+ db: Session,
175
+ *,
176
+ mention: Mention,
177
+ agent_id: uuid.UUID,
178
+ ) -> bool:
179
+ """Alias for ``comment_after`` (plan naming)."""
180
+ return comment_after(db, mention=mention, agent_id=agent_id)