From e5cdce69e5660c7859ab7c79bf79d9f519c14698 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:25:33 +0900 Subject: [PATCH 1/5] test(email-writing): define authorized context contracts --- .../workflows/email-writing-context-tdd.yml | 71 ++++ .../test_email_writing_context_service.py | 370 ++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 .github/workflows/email-writing-context-tdd.yml create mode 100644 backend/tests/test_email_writing_context_service.py diff --git a/.github/workflows/email-writing-context-tdd.yml b/.github/workflows/email-writing-context-tdd.yml new file mode 100644 index 000000000..ca0a4dba4 --- /dev/null +++ b/.github/workflows/email-writing-context-tdd.yml @@ -0,0 +1,71 @@ +name: Email Writing Context TDD + +on: + push: + branches: + - feat/llm-email-writing-context-task4 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: email-writing-context-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + context-service: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Install hash-verified coverage tool + run: | + set -euo pipefail + mkdir -p /tmp/coverage-wheel + cat >/tmp/coverage-lock.txt <<'EOF' + coverage==7.15.2 --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c + EOF + python -m pip download \ + --disable-pip-version-check \ + --require-hashes \ + --no-deps \ + --only-binary=:all: \ + --platform any \ + --python-version 3.14 \ + --implementation py \ + --abi none \ + --dest /tmp/coverage-wheel \ + -r /tmp/coverage-lock.txt + python -m pip install --disable-pip-version-check --no-deps /tmp/coverage-wheel/coverage-7.15.2-py3-none-any.whl + - name: Run authorized email and thread context tests + run: | + cd backend + python -m pytest -q tests/test_email_writing_context_service.py + - name: Verify context service statement and branch coverage + run: | + cd backend + python -m coverage erase + python -m coverage run --branch \ + --include='services/email_writing_context_service.py' \ + -m pytest -q tests/test_email_writing_context_service.py + python -m coverage report --show-missing --fail-under=100 \ + services/email_writing_context_service.py + - name: Lint context service and tests + run: | + cd backend + python -m ruff check \ + services/email_writing_context_service.py \ + tests/test_email_writing_context_service.py diff --git a/backend/tests/test_email_writing_context_service.py b/backend/tests/test_email_writing_context_service.py new file mode 100644 index 000000000..60a2787e4 --- /dev/null +++ b/backend/tests/test_email_writing_context_service.py @@ -0,0 +1,370 @@ +"""Server-authoritative context construction for LLM email-writing review.""" + +from __future__ import annotations + +import datetime +from pathlib import Path +from typing import Any + +import pytest +from pydantic import ValidationError + +from api.auth import AuthContext +from db.models import Email +from services.email_writing_context_service import ( + MAX_CONTEXT_JSON_BYTES, + MAX_SELECTED_BODY_GRAPHEMES, + EmailWritingContextError, + build_email_writing_context, +) +from services.email_writing_contracts import EmailWritingReviewRequest + +UTC = datetime.timezone.utc +DIGEST_HEX = "7c" * 32 + + +def _auth( + *, + user_id: str = "user_alpha", + organization_id: str | None = "organization_alpha", +) -> AuthContext: + return AuthContext( + user_id=user_id, + role="member", + organization_id=organization_id, + group_ids=(), + workspace_id="workspace_alpha", + ) + + +def _request(**overrides: Any) -> EmailWritingReviewRequest: + payload: dict[str, Any] = { + "source_email_id": 10, + "document_revision": { + "algorithm": "SHA-256", + "digest_hex": DIGEST_HEX, + "strong_entity_tag": f'"sha256-{DIGEST_HEX}"', + }, + "projection_name": "inkspan-prosemirror-text", + "projection_version": 1, + "draft_plain_text": "안녕하세요. 요청하신 검토 결과를 회신드립니다.", + "language_tag": "ko-KR", + "review_mode": "deep", + "reply_objective": "검토 결과와 다음 조치를 명확히 전달한다.", + } + payload.update(overrides) + return EmailWritingReviewRequest.model_validate(payload) + + +def _email( + email_id: int, + *, + message_id: str | None = None, + thread_id: str | None = "thread-alpha@example.test", + user_id: str = "user_alpha", + organization_id: str = "organization_alpha", + sender: str = "Alice ", + reply_to: str | None = "Review Desk ", + recipients: str | None = "Bob , Team ", + subject: str | None = "검토 요청", + body: str = "본문입니다.", + minute: int = 0, +) -> Email: + return Email( + id=email_id, + user_id=user_id, + organization_id=organization_id, + message_id=message_id or f"message-{email_id}@example.test", + thread_id=thread_id, + fingerprint=None, + sender=sender, + reply_to=reply_to, + recipients=recipients, + subject=subject, + in_reply_to=None, + references=None, + date=datetime.datetime(2026, 8, 12, 12, minute, tzinfo=UTC), + body=body, + is_read=True, + ) + + +class _Result: + def __init__(self, rows: list[Email]): + self._rows = rows + + def scalar_one_or_none(self) -> Email | None: + if len(self._rows) > 1: + raise AssertionError("selected-email query returned multiple rows") + return self._rows[0] if self._rows else None + + def scalars(self) -> "_Result": + return self + + def all(self) -> list[Email]: + return list(self._rows) + + +class _RecordingSession: + def __init__( + self, + *, + selected: Email | None, + thread_rows: list[Email] | None = None, + ) -> None: + self.selected = selected + self.thread_rows = list(thread_rows or []) + self.queries: list[Any] = [] + + async def execute(self, query: Any) -> _Result: + self.queries.append(query) + if len(self.queries) == 1: + return _Result([] if self.selected is None else [self.selected]) + return _Result(self.thread_rows) + + +def _query_values(query: Any) -> list[Any]: + return list(query.compile().params.values()) + + +@pytest.mark.asyncio +async def test_builds_immutable_server_authoritative_context_in_chronological_order() -> None: + selected = _email( + 10, + body=( + "Alice wrote:\n> Please retain this quoted requirement.\n\n" + "Regards,\nAlice" + ), + minute=20, + ) + older = _email(8, body="Earlier context", minute=5) + newer = _email(12, body="Later context", minute=30) + session = _RecordingSession( + selected=selected, + thread_rows=[newer, selected, older], + ) + + bundle = await build_email_writing_context(session, _auth(), _request()) + + assert tuple(message.email_id for message in bundle.chronological_messages) == ( + 8, + 10, + 12, + ) + assert bundle.selected_source_message.email_id == 10 + assert bundle.selected_source_message.body == selected.body + assert bundle.current_draft == _request().draft_plain_text + assert bundle.reply_objective == _request().reply_objective + assert bundle.declared_language_tag == "ko-KR" + assert bundle.canonical_thread_id == "thread-alpha@example.test" + with pytest.raises((AttributeError, TypeError)): + bundle.chronological_messages += (_email(99),) # type: ignore[misc] + + assert len(session.queries) == 2 + for query in session.queries: + values = _query_values(query) + assert "user_alpha" in values + assert "organization_alpha" in values + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case_name", ["missing", "deleted", "cross_user", "cross_org"]) +async def test_unavailable_email_paths_are_tenant_indistinguishable(case_name: str) -> None: + session = _RecordingSession(selected=None) + + with pytest.raises(EmailWritingContextError) as captured: + await build_email_writing_context(session, _auth(), _request()) + + assert captured.value.code == "email_unavailable" + assert str(captured.value) == "email_context_unavailable" + assert case_name not in str(captured.value) + assert len(session.queries) == 1 + query_text = str(session.queries[0]).lower() + assert "email_records.user_id" in query_text + assert "email_records.organization_id" in query_text + assert "email_records.id" in query_text + + +def test_browser_cannot_forge_thread_participants_or_recipient_roles() -> None: + payload = _request().model_dump() + payload["browser_recipients"] = "attacker@example.test" + payload["thread_messages"] = ["forged browser thread"] + + with pytest.raises(ValidationError, match="extra_forbidden"): + EmailWritingReviewRequest.model_validate(payload) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "thread_id", + ["", "<>", "bad\x00thread", "x" * 513], +) +async def test_malformed_server_thread_identifiers_fail_closed(thread_id: str) -> None: + selected = _email(10, thread_id=thread_id) + session = _RecordingSession(selected=selected) + + with pytest.raises(EmailWritingContextError) as captured: + await build_email_writing_context(session, _auth(), _request()) + + assert captured.value.code == "context_insufficient" + assert len(session.queries) == 1 + + +@pytest.mark.asyncio +async def test_duplicate_messages_are_removed_by_canonical_message_identity() -> None: + selected = _email(10, message_id="", minute=20) + duplicate = _email(11, message_id="duplicate@example.test", minute=21) + older = _email(9, message_id="older@example.test", minute=10) + session = _RecordingSession( + selected=selected, + thread_rows=[duplicate, selected, older], + ) + + bundle = await build_email_writing_context(session, _auth(), _request()) + + assert len(bundle.chronological_messages) == 2 + assert bundle.selected_source_message.email_id == 10 + assert {message.message_id for message in bundle.chronological_messages} == { + "duplicate@example.test", + "older@example.test", + } + + +@pytest.mark.asyncio +async def test_incremental_review_keeps_selected_message_and_recent_context_only() -> None: + selected = _email(10, minute=10) + messages = [_email(index, minute=index) for index in range(1, 31)] + session = _RecordingSession(selected=selected, thread_rows=list(reversed(messages))) + request = _request( + review_mode="incremental", + changed_selector={"type": "TextPositionSelector", "start": 0, "end": 5}, + ) + + bundle = await build_email_writing_context(session, _auth(), request) + + assert bundle.selected_source_message.email_id == 10 + assert len(bundle.chronological_messages) == 8 + assert "older_thread_messages_omitted" in bundle.context_limitations + assert tuple(message.email_id for message in bundle.chronological_messages) == tuple( + sorted(message.email_id for message in bundle.chronological_messages) + ) + + +@pytest.mark.asyncio +async def test_context_selection_uses_chronology_not_lexical_keyword_matching() -> None: + selected = _email(10, body="Neutral source", minute=10) + older_keyword = _email(1, body="urgent critical important", minute=1) + recent_neutral = [_email(index, body="ordinary context", minute=index) for index in range(20, 29)] + session = _RecordingSession( + selected=selected, + thread_rows=list(reversed([older_keyword, selected, *recent_neutral])), + ) + request = _request( + review_mode="incremental", + changed_selector={"type": "TextPositionSelector", "start": 0, "end": 5}, + ) + + bundle = await build_email_writing_context(session, _auth(), request) + + assert 1 not in {message.email_id for message in bundle.chronological_messages} + assert 10 in {message.email_id for message in bundle.chronological_messages} + assert 28 in {message.email_id for message in bundle.chronological_messages} + + +@pytest.mark.asyncio +async def test_recipient_roles_come_only_from_persisted_headers() -> None: + selected = _email( + 10, + sender='"Alice A." ', + reply_to="Support Queue ", + recipients="Bob ; Alice A. ", + ) + session = _RecordingSession(selected=selected, thread_rows=[selected]) + + bundle = await build_email_writing_context(session, _auth(), _request()) + + role_pairs = { + (participant.role_code, participant.address) + for participant in bundle.participant_roles + } + assert ("sender", "alice@example.test") in role_pairs + assert ("reply_to", "support@example.test") in role_pairs + assert ("recipient", "bob@example.test") in role_pairs + assert ("reply_target", "support@example.test") in role_pairs + assert all(participant.trust_class == "untrusted_email_content" for participant in bundle.participant_roles) + + +@pytest.mark.asyncio +async def test_prompt_payload_marks_authored_and_email_text_as_untrusted() -> None: + selected = _email(10, body="본문과 서명", minute=10) + session = _RecordingSession(selected=selected, thread_rows=[selected]) + + bundle = await build_email_writing_context(session, _auth(), _request()) + payload = bundle.to_prompt_payload() + + assert payload["selected_source_message"]["trust_class"] == ( + "untrusted_email_content" + ) + assert payload["selected_source_message"]["body"]["value"] == "본문과 서명" + assert payload["selected_source_message"]["body"]["trust_class"] == ( + "untrusted_email_content" + ) + assert payload["current_draft"]["trust_class"] == "untrusted_authored_content" + assert payload["reply_objective"]["trust_class"] == ( + "untrusted_authored_content" + ) + assert payload["system_boundary"] == "email_writing_context_v1" + + +@pytest.mark.asyncio +async def test_oversized_selected_source_fails_without_cutting_graphemes_or_json() -> None: + selected = _email(10, body="가\u0301" * (MAX_SELECTED_BODY_GRAPHEMES + 1)) + session = _RecordingSession(selected=selected) + + with pytest.raises(EmailWritingContextError) as captured: + await build_email_writing_context(session, _auth(), _request()) + + assert captured.value.code == "context_insufficient" + assert captured.value.reason_code == "selected_source_too_large" + + +@pytest.mark.asyncio +async def test_json_budget_omits_whole_old_messages_and_records_limitation() -> None: + selected = _email(10, body="Selected source", minute=10) + large_messages = [ + _email(index, body="문단 전체 " + ("x" * 15_000), minute=index) + for index in range(11, 23) + ] + session = _RecordingSession( + selected=selected, + thread_rows=list(reversed([selected, *large_messages])), + ) + + bundle = await build_email_writing_context(session, _auth(), _request()) + serialized = bundle.to_prompt_json().encode("utf-8") + + assert len(serialized) <= MAX_CONTEXT_JSON_BYTES + assert "context_budget_omitted_messages" in bundle.context_limitations + assert bundle.selected_source_message.body == "Selected source" + assert all( + message.body == "Selected source" or message.body.endswith("x" * 15_000) + for message in bundle.chronological_messages + ) + + +def test_context_service_source_has_no_semantic_keyword_selector() -> None: + """Static regression: deterministic context selection remains metadata-only.""" + source = ( + Path(__file__).resolve().parents[1] + / "services" + / "email_writing_context_service.py" + ).read_text(encoding="utf-8") + forbidden_fragments = ( + "KEYWORD_LIST", + "IMPORTANT_WORDS", + "re.search(", + "sender_domain", + "recipient_count", + "nearest_text", + ) + assert all(fragment not in source for fragment in forbidden_fragments) From 430393324c0419e73ed8e9beb068465990b28459 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:29:03 +0900 Subject: [PATCH 2/5] feat(email-writing): build authorized thread context --- .../services/email_writing_context_service.py | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 backend/services/email_writing_context_service.py diff --git a/backend/services/email_writing_context_service.py b/backend/services/email_writing_context_service.py new file mode 100644 index 000000000..05045ab72 --- /dev/null +++ b/backend/services/email_writing_context_service.py @@ -0,0 +1,630 @@ +"""Build server-authoritative, bounded context for LLM email-writing review. + +This module owns only authorization, canonical thread membership, chronology, +recipient-role derivation, deterministic size bounds, and trust labeling. It does +not classify prose, select context by keywords, infer communication quality, or +accept browser-supplied mail/thread participants. +""" + +from __future__ import annotations + +import datetime +import json +from dataclasses import dataclass +from email.utils import getaddresses +from typing import Any, Literal, Protocol, cast + +import regex +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.auth import AuthContext +from db.models import Email +from services.email_writing_contracts import EmailWritingReviewRequest +from services.threading_service import normalize_message_id + +EmailTrustClass = Literal[ + "untrusted_email_content", + "untrusted_authored_content", +] +ParticipantRole = Literal["sender", "reply_to", "recipient", "reply_target"] + +MAX_THREAD_IDENTIFIER_CHARS = 512 +MAX_SUBJECT_GRAPHEMES = 2_048 +MAX_SELECTED_BODY_GRAPHEMES = 40_000 +MAX_RELATED_BODY_GRAPHEMES = 30_000 +MAX_INCREMENTAL_THREAD_MESSAGES = 8 +MAX_DEEP_THREAD_MESSAGES = 24 +MAX_THREAD_CANDIDATES = 96 +MAX_CONTEXT_JSON_BYTES = 120_000 + +_GRAPHEME_PATTERN = regex.compile(r"\X") + + +class _EmailQueryResult(Protocol): + """Minimal SQLAlchemy result surface used by the context builder.""" + + def scalar_one_or_none(self) -> Email | None: + """Return the selected row or ``None``.""" + + def scalars(self) -> "_EmailQueryResult": + """Return a scalar result view.""" + + def all(self) -> list[Email]: + """Return materialized email rows.""" + + +class _EmailContextSession(Protocol): + """Async execution surface accepted by production and deterministic tests.""" + + async def execute(self, query: Any) -> _EmailQueryResult: + """Execute one server-owned SQLAlchemy query.""" + + +class EmailWritingContextError(RuntimeError): + """Redacted typed failure for unavailable or insufficient email context.""" + + def __init__( + self, + code: Literal["email_unavailable", "context_insufficient"], + *, + reason_code: str, + ) -> None: + """Create a stable failure without exposing tenant or message identity.""" + message = ( + "email_context_unavailable" + if code == "email_unavailable" + else "email_context_insufficient" + ) + super().__init__(message) + self.code = code + self.reason_code = reason_code + + +@dataclass(frozen=True, slots=True) +class EmailWritingParticipant: + """One participant role derived from persisted server-side headers.""" + + source_email_id: int + role_code: ParticipantRole + address: str + display_name: str | None + trust_class: Literal["untrusted_email_content"] = "untrusted_email_content" + + def to_prompt_payload(self) -> dict[str, Any]: + """Return a trust-labeled prompt representation of the participant.""" + return { + "source_email_id": self.source_email_id, + "role_code": self.role_code, + "address": _tagged_text(self.address, self.trust_class), + "display_name": _tagged_text(self.display_name, self.trust_class), + "trust_class": self.trust_class, + } + + +@dataclass(frozen=True, slots=True) +class EmailWritingMessageContext: + """One immutable chronological message admitted by server thread policy.""" + + email_id: int + message_id: str + sent_at: datetime.datetime + subject: str + sender_header: str + reply_to_header: str | None + recipient_header: str | None + body: str + selected_source: bool + trust_class: Literal["untrusted_email_content"] = "untrusted_email_content" + + def to_prompt_payload(self) -> dict[str, Any]: + """Return the complete message with every authored field trust-labeled.""" + return { + "email_id": self.email_id, + "message_id": self.message_id, + "sent_at": self.sent_at.isoformat(), + "subject": _tagged_text(self.subject, self.trust_class), + "sender_header": _tagged_text(self.sender_header, self.trust_class), + "reply_to_header": _tagged_text( + self.reply_to_header, + self.trust_class, + ), + "recipient_header": _tagged_text( + self.recipient_header, + self.trust_class, + ), + "body": _tagged_text(self.body, self.trust_class), + "selected_source": self.selected_source, + "trust_class": self.trust_class, + } + + +@dataclass(frozen=True, slots=True) +class EmailWritingContextBundle: + """Immutable authorized context consumed by the candidate-review prompt layer.""" + + selected_email_id: int + canonical_thread_id: str + subject: str + selected_source_message: EmailWritingMessageContext + chronological_messages: tuple[EmailWritingMessageContext, ...] + participant_roles: tuple[EmailWritingParticipant, ...] + reply_objective: str | None + current_draft: str + declared_language_tag: str + review_mode: Literal["incremental", "deep"] + document_revision_digest: str + projection_name: str + projection_version: int + context_limitations: tuple[str, ...] + + def to_prompt_payload(self) -> dict[str, Any]: + """Return canonical prompt data with explicit untrusted-content boundaries.""" + return { + "system_boundary": "email_writing_context_v1", + "selected_email_id": self.selected_email_id, + "canonical_thread_id": self.canonical_thread_id, + "subject": _tagged_text(self.subject, "untrusted_email_content"), + "selected_source_message": self.selected_source_message.to_prompt_payload(), + "chronological_messages": [ + message.to_prompt_payload() + for message in self.chronological_messages + ], + "participant_roles": [ + participant.to_prompt_payload() + for participant in self.participant_roles + ], + "reply_objective": _tagged_text( + self.reply_objective, + "untrusted_authored_content", + ), + "current_draft": _tagged_text( + self.current_draft, + "untrusted_authored_content", + ), + "declared_language_tag": _tagged_text( + self.declared_language_tag, + "untrusted_authored_content", + ), + "review_mode": self.review_mode, + "document_revision_digest": self.document_revision_digest, + "projection_name": self.projection_name, + "projection_version": self.projection_version, + "context_limitations": list(self.context_limitations), + } + + def to_prompt_json(self) -> str: + """Serialize the complete bundle without truncating any JSON string.""" + return json.dumps( + self.to_prompt_payload(), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def _tagged_text(value: str | None, trust_class: EmailTrustClass) -> dict[str, Any]: + """Wrap one complete text value with its prompt trust classification.""" + return {"value": value, "trust_class": trust_class} + + +def _grapheme_count(value: str) -> int: + """Count extended grapheme clusters without splitting Unicode text.""" + return sum(1 for _ in _GRAPHEME_PATTERN.finditer(value)) + + +def _contains_non_scalar_unicode(value: str) -> bool: + """Return whether persisted text contains an invalid surrogate code point.""" + return any(0xD800 <= ord(character) <= 0xDFFF for character in value) + + +def _safe_server_text(value: Any, *, nullable: bool) -> str | None: + """Validate persisted text as complete Unicode without browser coercion.""" + if value is None: + if nullable: + return None + raise EmailWritingContextError( + "context_insufficient", + reason_code="required_server_text_missing", + ) + text = str(value) + if _contains_non_scalar_unicode(text): + raise EmailWritingContextError( + "context_insufficient", + reason_code="invalid_server_unicode", + ) + return text + + +def _canonical_selected_thread_id(email: Email) -> str: + """Return a bounded canonical thread key or fail closed on malformed storage.""" + raw_identifier = email.thread_id if email.thread_id is not None else email.message_id + if raw_identifier is None: + raise EmailWritingContextError( + "context_insufficient", + reason_code="thread_identifier_missing", + ) + raw_text = str(raw_identifier) + if ( + not raw_text + or len(raw_text) > MAX_THREAD_IDENTIFIER_CHARS + or _contains_non_scalar_unicode(raw_text) + or any(ord(character) < 0x20 for character in raw_text) + ): + raise EmailWritingContextError( + "context_insufficient", + reason_code="thread_identifier_invalid", + ) + normalized = normalize_message_id(raw_text) + if not normalized or len(normalized) > MAX_THREAD_IDENTIFIER_CHARS: + raise EmailWritingContextError( + "context_insufficient", + reason_code="thread_identifier_invalid", + ) + return normalized + + +def _thread_lookup_values(canonical_thread_id: str) -> tuple[str, ...]: + """Return deterministic persisted forms used by the thread membership query.""" + return (canonical_thread_id, f"<{canonical_thread_id}>") + + +def _row_matches_owner(email: Email, auth_context: AuthContext) -> bool: + """Defensively confirm a row matches the signed-session tenant scope.""" + return ( + email.user_id == auth_context.user_id + and email.organization_id == auth_context.organization_id + ) + + +def _row_belongs_to_thread(email: Email, canonical_thread_id: str) -> bool: + """Check canonical persisted thread/message identifiers without text semantics.""" + candidate_values = (email.thread_id, email.message_id) + for candidate in candidate_values: + if candidate is None: + continue + normalized = normalize_message_id(str(candidate)) + if normalized == canonical_thread_id: + return True + return False + + +def _normalized_message_id(email: Email) -> str: + """Return a valid canonical message identity for de-duplication and prompts.""" + normalized = normalize_message_id(email.message_id) + if ( + normalized is None + or len(normalized) > MAX_THREAD_IDENTIFIER_CHARS + or _contains_non_scalar_unicode(normalized) + or any(ord(character) < 0x20 for character in normalized) + ): + raise EmailWritingContextError( + "context_insufficient", + reason_code="message_identifier_invalid", + ) + return normalized + + +def _normalized_timestamp(value: datetime.datetime | None) -> datetime.datetime: + """Normalize a persisted timestamp to timezone-aware UTC chronology.""" + if value is None: + raise EmailWritingContextError( + "context_insufficient", + reason_code="message_timestamp_missing", + ) + if value.tzinfo is None: + return value.replace(tzinfo=datetime.timezone.utc) + return value.astimezone(datetime.timezone.utc) + + +def _message_context(email: Email, *, selected_source: bool) -> EmailWritingMessageContext: + """Validate one complete persisted message without truncating authored fields.""" + subject = cast(str, _safe_server_text(email.subject or "", nullable=False)) + body = cast(str, _safe_server_text(email.body, nullable=False)) + sender = cast(str, _safe_server_text(email.sender, nullable=False)) + reply_to = _safe_server_text(email.reply_to, nullable=True) + recipients = _safe_server_text(email.recipients, nullable=True) + if _grapheme_count(subject) > MAX_SUBJECT_GRAPHEMES: + raise EmailWritingContextError( + "context_insufficient", + reason_code=( + "selected_subject_too_large" + if selected_source + else "related_subject_too_large" + ), + ) + body_limit = ( + MAX_SELECTED_BODY_GRAPHEMES + if selected_source + else MAX_RELATED_BODY_GRAPHEMES + ) + if _grapheme_count(body) > body_limit: + raise EmailWritingContextError( + "context_insufficient", + reason_code=( + "selected_source_too_large" + if selected_source + else "related_message_too_large" + ), + ) + return EmailWritingMessageContext( + email_id=email.id, + message_id=_normalized_message_id(email), + sent_at=_normalized_timestamp(email.date), + subject=subject, + sender_header=sender, + reply_to_header=reply_to, + recipient_header=recipients, + body=body, + selected_source=selected_source, + ) + + +def _parsed_addresses(value: str | None) -> tuple[tuple[str | None, str], ...]: + """Parse complete persisted address headers and preserve stable header order.""" + if not value: + return () + parsed: list[tuple[str | None, str]] = [] + seen: set[tuple[str | None, str]] = set() + for display_name, address in getaddresses([value.replace(";", ",")]): + normalized_address = address.strip().lower() + if ( + not normalized_address + or _contains_non_scalar_unicode(normalized_address) + or any(ord(character) < 0x20 for character in normalized_address) + ): + continue + normalized_display = display_name.strip() or None + if normalized_display is not None and _contains_non_scalar_unicode( + normalized_display + ): + continue + candidate = (normalized_display, normalized_address) + if candidate not in seen: + seen.add(candidate) + parsed.append(candidate) + return tuple(parsed) + + +def _participant_roles( + messages: tuple[EmailWritingMessageContext, ...], + selected_message: EmailWritingMessageContext, +) -> tuple[EmailWritingParticipant, ...]: + """Derive sender/reply/recipient roles only from admitted persisted headers.""" + participants: list[EmailWritingParticipant] = [] + seen: set[tuple[int, ParticipantRole, str]] = set() + + def add_roles( + message: EmailWritingMessageContext, + role_code: ParticipantRole, + header_value: str | None, + ) -> None: + for display_name, address in _parsed_addresses(header_value): + identity = (message.email_id, role_code, address) + if identity in seen: + continue + seen.add(identity) + participants.append( + EmailWritingParticipant( + source_email_id=message.email_id, + role_code=role_code, + address=address, + display_name=display_name, + ) + ) + + for message in messages: + add_roles(message, "sender", message.sender_header) + add_roles(message, "reply_to", message.reply_to_header) + add_roles(message, "recipient", message.recipient_header) + + reply_targets = _parsed_addresses(selected_message.reply_to_header) + if not reply_targets: + reply_targets = _parsed_addresses(selected_message.sender_header) + for display_name, address in reply_targets: + identity = (selected_message.email_id, "reply_target", address) + if identity in seen: + continue + seen.add(identity) + participants.append( + EmailWritingParticipant( + source_email_id=selected_message.email_id, + role_code="reply_target", + address=address, + display_name=display_name, + ) + ) + return tuple(participants) + + +def _append_limitation(limitations: list[str], code: str) -> None: + """Append one stable limitation code at most once.""" + if code not in limitations: + limitations.append(code) + + +def _message_cap(review_mode: Literal["incremental", "deep"]) -> int: + """Return the documented chronology cap for the requested review mode.""" + if review_mode == "incremental": + return MAX_INCREMENTAL_THREAD_MESSAGES + return MAX_DEEP_THREAD_MESSAGES + + +def _cap_chronological_messages( + messages: list[EmailWritingMessageContext], + *, + selected_email_id: int, + review_mode: Literal["incremental", "deep"], + limitations: list[str], +) -> list[EmailWritingMessageContext]: + """Retain the selected source and most recent complete messages by chronology.""" + cap = _message_cap(review_mode) + if len(messages) <= cap: + return messages + recent = list(messages[-cap:]) + if not any(message.email_id == selected_email_id for message in recent): + selected = next( + message for message in messages if message.email_id == selected_email_id + ) + recent = [selected, *recent[-(cap - 1) :]] + recent.sort(key=lambda message: (message.sent_at, message.email_id)) + _append_limitation(limitations, "older_thread_messages_omitted") + return recent + + +def _build_bundle( + *, + request: EmailWritingReviewRequest, + canonical_thread_id: str, + messages: list[EmailWritingMessageContext], + limitations: list[str], +) -> EmailWritingContextBundle: + """Create one immutable bundle from admitted complete message objects.""" + selected = next( + message for message in messages if message.email_id == request.source_email_id + ) + message_tuple = tuple(messages) + return EmailWritingContextBundle( + selected_email_id=request.source_email_id, + canonical_thread_id=canonical_thread_id, + subject=selected.subject, + selected_source_message=selected, + chronological_messages=message_tuple, + participant_roles=_participant_roles(message_tuple, selected), + reply_objective=request.reply_objective, + current_draft=request.draft_plain_text, + declared_language_tag=request.language_tag, + review_mode=request.review_mode, + document_revision_digest=request.document_revision.digest_hex, + projection_name=request.projection_name, + projection_version=request.projection_version, + context_limitations=tuple(limitations), + ) + + +def _apply_json_budget( + *, + request: EmailWritingReviewRequest, + canonical_thread_id: str, + messages: list[EmailWritingMessageContext], + limitations: list[str], +) -> EmailWritingContextBundle: + """Omit whole older messages until canonical JSON fits the byte budget.""" + retained = list(messages) + while True: + bundle = _build_bundle( + request=request, + canonical_thread_id=canonical_thread_id, + messages=retained, + limitations=limitations, + ) + try: + payload_size = len(bundle.to_prompt_json().encode("utf-8")) + except UnicodeEncodeError as exc: + raise EmailWritingContextError( + "context_insufficient", + reason_code="prompt_unicode_invalid", + ) from exc + if payload_size <= MAX_CONTEXT_JSON_BYTES: + return bundle + removable_index = next( + ( + index + for index, message in enumerate(retained) + if message.email_id != request.source_email_id + ), + None, + ) + if removable_index is None: + raise EmailWritingContextError( + "context_insufficient", + reason_code="selected_context_budget_exceeded", + ) + retained.pop(removable_index) + _append_limitation(limitations, "context_budget_omitted_messages") + + +async def build_email_writing_context( + session: AsyncSession | _EmailContextSession, + auth_context: AuthContext, + request: EmailWritingReviewRequest, +) -> EmailWritingContextBundle: + """Build an authorized context bundle from persisted email and thread rows.""" + owner_filters = Email.owner_filters( + auth_context.user_id, + auth_context.organization_id, + ) + selected_result = await session.execute( + select(Email) + .where( + Email.id == request.source_email_id, + *owner_filters, + ) + .limit(1) + ) + selected_email = selected_result.scalar_one_or_none() + if selected_email is None or not _row_matches_owner(selected_email, auth_context): + raise EmailWritingContextError( + "email_unavailable", + reason_code="email_not_available_in_owner_scope", + ) + + canonical_thread_id = _canonical_selected_thread_id(selected_email) + lookup_values = _thread_lookup_values(canonical_thread_id) + thread_result = await session.execute( + select(Email) + .where( + *owner_filters, + or_( + Email.thread_id.in_(lookup_values), + Email.message_id.in_(lookup_values), + ), + ) + .order_by(Email.date.desc(), Email.id.desc()) + .limit(MAX_THREAD_CANDIDATES + 1) + ) + raw_thread_rows = list(thread_result.scalars().all()) + limitations: list[str] = [] + if len(raw_thread_rows) > MAX_THREAD_CANDIDATES: + raw_thread_rows = raw_thread_rows[:MAX_THREAD_CANDIDATES] + _append_limitation(limitations, "thread_candidate_limit_applied") + + admitted_rows = [selected_email] + admitted_rows.extend( + email + for email in raw_thread_rows + if email.id != selected_email.id + and _row_matches_owner(email, auth_context) + and _row_belongs_to_thread(email, canonical_thread_id) + ) + + unique_messages: list[EmailWritingMessageContext] = [] + seen_message_ids: set[str] = set() + for email in admitted_rows: + selected_source = email.id == selected_email.id + try: + message = _message_context(email, selected_source=selected_source) + except EmailWritingContextError: + if selected_source: + raise + _append_limitation(limitations, "invalid_thread_message_omitted") + continue + if message.message_id in seen_message_ids: + _append_limitation(limitations, "duplicate_thread_messages_removed") + continue + seen_message_ids.add(message.message_id) + unique_messages.append(message) + + unique_messages.sort(key=lambda message: (message.sent_at, message.email_id)) + capped_messages = _cap_chronological_messages( + unique_messages, + selected_email_id=selected_email.id, + review_mode=request.review_mode, + limitations=limitations, + ) + return _apply_json_budget( + request=request, + canonical_thread_id=canonical_thread_id, + messages=capped_messages, + limitations=limitations, + ) From 8fda12de151d8803ecb98eec2b13d4a5a639613d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 00:46:12 +0900 Subject: [PATCH 3/5] test(email-writing): close context coverage gaps --- .../workflows/email-writing-context-tdd.yml | 11 +- ..._email_writing_context_service_coverage.py | 457 ++++++++++++++++++ 2 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_email_writing_context_service_coverage.py diff --git a/.github/workflows/email-writing-context-tdd.yml b/.github/workflows/email-writing-context-tdd.yml index ca0a4dba4..3bfa4d598 100644 --- a/.github/workflows/email-writing-context-tdd.yml +++ b/.github/workflows/email-writing-context-tdd.yml @@ -53,14 +53,18 @@ jobs: - name: Run authorized email and thread context tests run: | cd backend - python -m pytest -q tests/test_email_writing_context_service.py + python -m pytest -q \ + tests/test_email_writing_context_service.py \ + tests/test_email_writing_context_service_coverage.py - name: Verify context service statement and branch coverage run: | cd backend python -m coverage erase python -m coverage run --branch \ --include='services/email_writing_context_service.py' \ - -m pytest -q tests/test_email_writing_context_service.py + -m pytest -q \ + tests/test_email_writing_context_service.py \ + tests/test_email_writing_context_service_coverage.py python -m coverage report --show-missing --fail-under=100 \ services/email_writing_context_service.py - name: Lint context service and tests @@ -68,4 +72,5 @@ jobs: cd backend python -m ruff check \ services/email_writing_context_service.py \ - tests/test_email_writing_context_service.py + tests/test_email_writing_context_service.py \ + tests/test_email_writing_context_service_coverage.py diff --git a/backend/tests/test_email_writing_context_service_coverage.py b/backend/tests/test_email_writing_context_service_coverage.py new file mode 100644 index 000000000..b36b7db74 --- /dev/null +++ b/backend/tests/test_email_writing_context_service_coverage.py @@ -0,0 +1,457 @@ +"""Terminal branch coverage for the server-authoritative email context service.""" + +from __future__ import annotations + +import datetime +from typing import Any + +import pytest + +from api.auth import AuthContext +from db.models import Email +from services import email_writing_context_service as context_service +from services.email_writing_contracts import EmailWritingReviewRequest + +UTC = datetime.timezone.utc +DIGEST_HEX = "7c" * 32 + + +def _auth( + *, + user_id: str = "user_alpha", + organization_id: str | None = "organization_alpha", +) -> AuthContext: + return AuthContext( + user_id=user_id, + role="member", + organization_id=organization_id, + group_ids=(), + workspace_id="workspace_alpha", + ) + + +def _request(**overrides: Any) -> EmailWritingReviewRequest: + payload: dict[str, Any] = { + "source_email_id": 10, + "document_revision": { + "algorithm": "SHA-256", + "digest_hex": DIGEST_HEX, + "strong_entity_tag": f'"sha256-{DIGEST_HEX}"', + }, + "projection_name": "inkspan-prosemirror-text", + "projection_version": 1, + "draft_plain_text": "Reply draft", + "language_tag": "en-US", + "review_mode": "deep", + "reply_objective": "State the verified outcome.", + } + payload.update(overrides) + return EmailWritingReviewRequest.model_validate(payload) + + +def _email( + email_id: int, + *, + message_id: str | None = None, + thread_id: str | None = "thread-alpha@example.test", + user_id: str = "user_alpha", + organization_id: str = "organization_alpha", + sender: str | None = "Alice ", + reply_to: str | None = None, + recipients: str | None = "Bob ", + subject: str | None = "Subject", + body: str | None = "Body", + sent_at: datetime.datetime | None = None, +) -> Email: + return Email( + id=email_id, + user_id=user_id, + organization_id=organization_id, + message_id=message_id, + thread_id=thread_id, + fingerprint=None, + sender=sender, + reply_to=reply_to, + recipients=recipients, + subject=subject, + in_reply_to=None, + references=None, + date=sent_at + if sent_at is not None + else datetime.datetime(2026, 8, 12, 12, tzinfo=UTC) + + datetime.timedelta(seconds=email_id), + body=body, + is_read=True, + ) + + +class _Result: + def __init__(self, rows: list[Email]): + self.rows = rows + + def scalar_one_or_none(self) -> Email | None: + return self.rows[0] if self.rows else None + + def scalars(self) -> "_Result": + return self + + def all(self) -> list[Email]: + return list(self.rows) + + +class _Session: + def __init__(self, selected: Email | None, thread_rows: list[Email]) -> None: + self.selected = selected + self.thread_rows = thread_rows + self.call_count = 0 + + async def execute(self, _query: Any) -> _Result: + self.call_count += 1 + if self.call_count == 1: + return _Result([] if self.selected is None else [self.selected]) + return _Result(self.thread_rows) + + +def _message( + email_id: int, + *, + message_id: str | None = None, + sender_header: str = "Alice ", + reply_to_header: str | None = None, + recipient_header: str | None = None, + selected_source: bool = False, + second: int | None = None, +) -> context_service.EmailWritingMessageContext: + return context_service.EmailWritingMessageContext( + email_id=email_id, + message_id=message_id or f"message-{email_id}@example.test", + sent_at=datetime.datetime( + 2026, + 8, + 12, + 12, + 0, + email_id if second is None else second, + tzinfo=UTC, + ), + subject="Subject", + sender_header=sender_header, + reply_to_header=reply_to_header, + recipient_header=recipient_header, + body="Body", + selected_source=selected_source, + ) + + +def test_safe_server_text_missing_and_invalid_unicode_fail_closed() -> None: + with pytest.raises(context_service.EmailWritingContextError) as missing: + context_service._safe_server_text(None, nullable=False) + assert missing.value.reason_code == "required_server_text_missing" + assert context_service._safe_server_text(None, nullable=True) is None + + with pytest.raises(context_service.EmailWritingContextError) as invalid: + context_service._safe_server_text("bad\ud800text", nullable=False) + assert invalid.value.reason_code == "invalid_server_unicode" + + +def test_thread_identifier_missing_and_normalization_failure_fail_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + missing = _email(10, message_id=None, thread_id=None) + with pytest.raises(context_service.EmailWritingContextError) as captured: + context_service._canonical_selected_thread_id(missing) + assert captured.value.reason_code == "thread_identifier_missing" + + malformed = _email(10, message_id="message@example.test", thread_id="value") + monkeypatch.setattr(context_service, "normalize_message_id", lambda _value: None) + with pytest.raises(context_service.EmailWritingContextError) as normalized: + context_service._canonical_selected_thread_id(malformed) + assert normalized.value.reason_code == "thread_identifier_invalid" + + +def test_thread_membership_handles_missing_matching_and_nonmatching_values() -> None: + matching = _email( + 10, + message_id="", + thread_id=None, + ) + assert context_service._row_belongs_to_thread( + matching, + "thread-alpha@example.test", + ) + + nonmatching = _email( + 11, + message_id="other-message@example.test", + thread_id="other-thread@example.test", + ) + assert not context_service._row_belongs_to_thread( + nonmatching, + "thread-alpha@example.test", + ) + + +def test_message_identifier_and_timestamp_terminal_validation() -> None: + missing_identifier = _email(10, message_id=None) + with pytest.raises(context_service.EmailWritingContextError) as identifier_error: + context_service._normalized_message_id(missing_identifier) + assert identifier_error.value.reason_code == "message_identifier_invalid" + + with pytest.raises(context_service.EmailWritingContextError) as timestamp_error: + context_service._normalized_timestamp(None) + assert timestamp_error.value.reason_code == "message_timestamp_missing" + + naive = datetime.datetime(2026, 8, 12, 12, 0) + assert context_service._normalized_timestamp(naive).tzinfo == UTC + aware = datetime.datetime( + 2026, + 8, + 12, + 21, + 0, + tzinfo=datetime.timezone(datetime.timedelta(hours=9)), + ) + assert context_service._normalized_timestamp(aware) == datetime.datetime( + 2026, + 8, + 12, + 12, + 0, + tzinfo=UTC, + ) + + +@pytest.mark.parametrize("selected_source", [True, False]) +def test_subject_bounds_distinguish_selected_and_related_messages( + selected_source: bool, +) -> None: + email = _email( + 10, + message_id="message@example.test", + subject="x" * (context_service.MAX_SUBJECT_GRAPHEMES + 1), + ) + with pytest.raises(context_service.EmailWritingContextError) as captured: + context_service._message_context(email, selected_source=selected_source) + expected = ( + "selected_subject_too_large" + if selected_source + else "related_subject_too_large" + ) + assert captured.value.reason_code == expected + + +def test_related_body_bound_uses_complete_message_omission_policy() -> None: + email = _email( + 11, + message_id="related@example.test", + body="x" * (context_service.MAX_RELATED_BODY_GRAPHEMES + 1), + ) + with pytest.raises(context_service.EmailWritingContextError) as captured: + context_service._message_context(email, selected_source=False) + assert captured.value.reason_code == "related_message_too_large" + + +def test_address_parser_rejects_invalid_and_duplicate_candidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert context_service._parsed_addresses(None) == () + monkeypatch.setattr( + context_service, + "getaddresses", + lambda _headers: [ + ("", ""), + ("", "bad\ud800@example.test"), + ("bad\ud800", "display@example.test"), + ("Alice", "ALICE@example.test"), + ("Alice", "ALICE@example.test"), + ], + ) + assert context_service._parsed_addresses("ignored") == ( + ("Alice", "alice@example.test"), + ) + + +def test_participant_role_duplicate_and_reply_target_fallback_branches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = _message( + 10, + sender_header="Alice ", + reply_to_header=None, + selected_source=True, + ) + duplicate = _message( + 10, + sender_header="Alice ", + ) + participants = context_service._participant_roles((selected, duplicate), selected) + assert sum(participant.role_code == "sender" for participant in participants) == 1 + assert any(participant.role_code == "reply_target" for participant in participants) + + original = context_service._parsed_addresses + + def repeated(value: str | None): + if value == "Reply ": + return ( + ("Reply", "reply@example.test"), + ("Reply", "reply@example.test"), + ) + return original(value) + + monkeypatch.setattr(context_service, "_parsed_addresses", repeated) + selected_with_reply = _message( + 20, + sender_header="Alice ", + reply_to_header="Reply ", + selected_source=True, + ) + reply_participants = context_service._participant_roles( + (selected_with_reply,), + selected_with_reply, + ) + assert ( + sum( + participant.role_code == "reply_target" + for participant in reply_participants + ) + == 1 + ) + + +def test_limitation_deduplication_and_message_cap_branches() -> None: + limitations: list[str] = [] + context_service._append_limitation(limitations, "one") + context_service._append_limitation(limitations, "one") + assert limitations == ["one"] + assert ( + context_service._message_cap("incremental") + == context_service.MAX_INCREMENTAL_THREAD_MESSAGES + ) + assert context_service._message_cap("deep") == context_service.MAX_DEEP_THREAD_MESSAGES + + messages = [_message(index, second=index) for index in range(1, 31)] + retained_limitations: list[str] = [] + retained = context_service._cap_chronological_messages( + messages, + selected_email_id=1, + review_mode="incremental", + limitations=retained_limitations, + ) + assert retained[0].email_id == 1 + assert len(retained) == context_service.MAX_INCREMENTAL_THREAD_MESSAGES + assert retained_limitations == ["older_thread_messages_omitted"] + + recent_limitations: list[str] = [] + recent = context_service._cap_chronological_messages( + messages, + selected_email_id=30, + review_mode="incremental", + limitations=recent_limitations, + ) + assert recent[-1].email_id == 30 + + +def test_json_budget_unicode_and_selected_only_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request = _request() + selected = _message(10, selected_source=True) + + def invalid_json(_self: context_service.EmailWritingContextBundle) -> str: + raise UnicodeEncodeError("utf-8", "x", 0, 1, "invalid") + + monkeypatch.setattr( + context_service.EmailWritingContextBundle, + "to_prompt_json", + invalid_json, + ) + with pytest.raises(context_service.EmailWritingContextError) as unicode_error: + context_service._apply_json_budget( + request=request, + canonical_thread_id="thread-alpha@example.test", + messages=[selected], + limitations=[], + ) + assert unicode_error.value.reason_code == "prompt_unicode_invalid" + + monkeypatch.undo() + monkeypatch.setattr(context_service, "MAX_CONTEXT_JSON_BYTES", 0) + with pytest.raises(context_service.EmailWritingContextError) as budget_error: + context_service._apply_json_budget( + request=request, + canonical_thread_id="thread-alpha@example.test", + messages=[selected], + limitations=[], + ) + assert budget_error.value.reason_code == "selected_context_budget_exceeded" + + +@pytest.mark.asyncio +async def test_defensive_owner_mismatch_is_indistinguishable_from_missing() -> None: + selected = _email( + 10, + message_id="message@example.test", + user_id="other_user", + ) + session = _Session(selected, []) + with pytest.raises(context_service.EmailWritingContextError) as captured: + await context_service.build_email_writing_context( + session, + _auth(), + _request(), + ) + assert captured.value.code == "email_unavailable" + + +@pytest.mark.asyncio +async def test_candidate_limit_and_invalid_related_message_are_recorded() -> None: + selected = _email(10, message_id="selected@example.test") + valid_rows = [ + _email( + index, + message_id=f"message-{index}@example.test", + thread_id="thread-alpha@example.test", + ) + for index in range(100, 198) + ] + invalid_related = _email( + 198, + message_id="invalid-related@example.test", + thread_id="thread-alpha@example.test", + body="x" * (context_service.MAX_RELATED_BODY_GRAPHEMES + 1), + ) + session = _Session(selected, [invalid_related, *valid_rows]) + + bundle = await context_service.build_email_writing_context( + session, + _auth(), + _request(), + ) + + assert "thread_candidate_limit_applied" in bundle.context_limitations + assert "invalid_thread_message_omitted" in bundle.context_limitations + + +@pytest.mark.asyncio +async def test_thread_query_filters_same_id_wrong_owner_and_wrong_thread_rows() -> None: + selected = _email(10, message_id="selected@example.test") + same_id = _email(10, message_id="same-id@example.test") + wrong_owner = _email( + 11, + message_id="wrong-owner@example.test", + user_id="other_user", + ) + wrong_thread = _email( + 12, + message_id="wrong-thread@example.test", + thread_id="other-thread@example.test", + ) + valid = _email(13, message_id="valid@example.test") + session = _Session(selected, [same_id, wrong_owner, wrong_thread, valid]) + + bundle = await context_service.build_email_writing_context( + session, + _auth(), + _request(), + ) + assert {message.email_id for message in bundle.chronological_messages} == {10, 13} From a61c9602911abc9bf615fd4df9bf75038c874c24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:54:05 +0900 Subject: [PATCH 4/5] ci(email-writing): refresh Task 4 on current evidence parent --- .../refresh-email-writing-context-parent.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/refresh-email-writing-context-parent.yml diff --git a/.github/workflows/refresh-email-writing-context-parent.yml b/.github/workflows/refresh-email-writing-context-parent.yml new file mode 100644 index 000000000..b4d4cc119 --- /dev/null +++ b/.github/workflows/refresh-email-writing-context-parent.yml @@ -0,0 +1,63 @@ +name: Refresh Email Writing Context Parent + +on: + push: + branches: + - feat/llm-email-writing-context-task4 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: refresh-email-writing-context-parent + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + refresh: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/llm-email-writing-context-task4 + persist-credentials: true + fetch-depth: 0 + - name: Merge the current Task 3 parent + run: | + set -euo pipefail + git fetch origin feat/llm-email-writing-review-evidence-task3 + git config user.name "CWL Email Writing Task 4 Refresh" + git config user.email "actions@users.noreply.github.com" + git merge --no-edit --no-ff origin/feat/llm-email-writing-review-evidence-task3 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked backend dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Verify server-authoritative context contracts + run: | + cd backend + python -m pytest -q \ + tests/test_email_writing_context_service.py \ + tests/test_email_writing_context_service_coverage.py + python -m ruff check \ + services/email_writing_context_service.py \ + tests/test_email_writing_context_service.py \ + tests/test_email_writing_context_service_coverage.py + python -m compileall -q services/email_writing_context_service.py + - name: Remove one-shot workflow and publish refresh + run: | + set -euo pipefail + rm .github/workflows/refresh-email-writing-context-parent.yml + git diff --check + git add .github/workflows/refresh-email-writing-context-parent.yml + git commit -m "ci(email-writing): remove Task 4 parent refresh workflow" + git push origin HEAD:feat/llm-email-writing-context-task4 From 0376d0ac896f6ea86183234495184694b27d7896 Mon Sep 17 00:00:00 2001 From: CWL Email Writing Task 4 Refresh Date: Sat, 15 Aug 2026 11:55:00 +0000 Subject: [PATCH 5/5] ci(email-writing): remove Task 4 parent refresh workflow --- .../refresh-email-writing-context-parent.yml | 63 ------------------- 1 file changed, 63 deletions(-) delete mode 100644 .github/workflows/refresh-email-writing-context-parent.yml diff --git a/.github/workflows/refresh-email-writing-context-parent.yml b/.github/workflows/refresh-email-writing-context-parent.yml deleted file mode 100644 index b4d4cc119..000000000 --- a/.github/workflows/refresh-email-writing-context-parent.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Refresh Email Writing Context Parent - -on: - push: - branches: - - feat/llm-email-writing-context-task4 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: refresh-email-writing-context-parent - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - -jobs: - refresh: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/llm-email-writing-context-task4 - persist-credentials: true - fetch-depth: 0 - - name: Merge the current Task 3 parent - run: | - set -euo pipefail - git fetch origin feat/llm-email-writing-review-evidence-task3 - git config user.name "CWL Email Writing Task 4 Refresh" - git config user.email "actions@users.noreply.github.com" - git merge --no-edit --no-ff origin/feat/llm-email-writing-review-evidence-task3 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked backend dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Verify server-authoritative context contracts - run: | - cd backend - python -m pytest -q \ - tests/test_email_writing_context_service.py \ - tests/test_email_writing_context_service_coverage.py - python -m ruff check \ - services/email_writing_context_service.py \ - tests/test_email_writing_context_service.py \ - tests/test_email_writing_context_service_coverage.py - python -m compileall -q services/email_writing_context_service.py - - name: Remove one-shot workflow and publish refresh - run: | - set -euo pipefail - rm .github/workflows/refresh-email-writing-context-parent.yml - git diff --check - git add .github/workflows/refresh-email-writing-context-parent.yml - git commit -m "ci(email-writing): remove Task 4 parent refresh workflow" - git push origin HEAD:feat/llm-email-writing-context-task4