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,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)
|