Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2b7c2f5
test(email-writing): define review evidence contracts
seonghobae Aug 12, 2026
c458e24
feat(email-writing): persist privacy-minimized review evidence
seonghobae Aug 12, 2026
001da50
fix(email-writing): register evidence metadata explicitly
seonghobae Aug 12, 2026
764f8e9
ci(email-writing): add one-shot evidence review repair
seonghobae Aug 12, 2026
9cec449
ci(email-writing): run one-shot evidence review repair
seonghobae Aug 12, 2026
3522691
ci(email-writing): remove feedback tenant identifiers
seonghobae Aug 12, 2026
2da5309
ci(email-writing): rerun corrected evidence repair
seonghobae Aug 12, 2026
2c91c44
fix(email-writing): harden evidence tests under safety settings
seonghobae Aug 12, 2026
7c28cc3
ci(email-writing): split backend repair from workflow mutation
seonghobae Aug 12, 2026
b688d0a
ci(email-writing): materialize backend review repair without workflow…
seonghobae Aug 12, 2026
96cd7f8
fix(email-writing): address review evidence findings
github-actions[bot] Aug 12, 2026
4a182c4
ci(email-writing): remove completed one-shot repair workflow
seonghobae Aug 12, 2026
9d08e14
ci(email-writing): refresh Task 3 on current contracts parent
seonghobae Aug 15, 2026
24b7644
Merge remote-tracking branch 'origin/feat/llm-email-writing-contracts…
Aug 15, 2026
6ab000e
ci(email-writing): remove Task 3 parent refresh workflow
Aug 15, 2026
51fb5e8
Merge remote-tracking branch 'origin/feat/llm-email-writing-contracts…
cursoragent Aug 17, 2026
f65974e
Merge current Task 2 into email-writing evidence Task 3
seonghobae Sep 1, 2026
7ae87ab
Merge current Task 2 into email-writing evidence Task 3
seonghobae Sep 1, 2026
ea61b9d
Merge current Task 2 into email-writing evidence Task 3
seonghobae Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/email-writing-evidence-tdd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Email Writing Evidence TDD

on:
push:
branches:
- feat/llm-email-writing-review-evidence-task3
workflow_dispatch:

permissions:
contents: read

concurrency:
group: email-writing-evidence-tdd-${{ github.ref }}
cancel-in-progress: true

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
review-evidence:
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 privacy-minimized model and migration tests
run: |
cd backend
python -m pytest -q \
tests/test_email_writing_models.py \
tests/test_email_writing_migration.py
- name: Verify migration statement and branch coverage
run: |
cd backend
python -m coverage erase
python -m coverage run --branch \
--include='alembic/versions/20260812_0001_add_email_writing_review_evidence.py' \
-m pytest -q tests/test_email_writing_migration.py
python -m coverage report --show-missing --fail-under=100 \
alembic/versions/20260812_0001_add_email_writing_review_evidence.py
Comment thread
seonghobae marked this conversation as resolved.
- name: Lint evidence implementation and tests
run: |
cd backend
python -m ruff check \
db/email_writing_evidence.py \
alembic/env.py \
alembic/versions/20260812_0001_add_email_writing_review_evidence.py \
tests/test_email_writing_models.py \
tests/test_email_writing_migration.py
4 changes: 2 additions & 2 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from sqlalchemy.ext.asyncio import async_engine_from_config

from core.config import settings
from db.models import Base
from db.email_writing_evidence import EmailReviewSession

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = Base.metadata
target_metadata = EmailReviewSession.__table__.metadata


def _database_url() -> str:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
"""Add privacy-minimized email-writing review evidence tables.

Revision ID: 20260812_email_writing_evidence
Revises: 0017_merge_newsdom_carddav_heads
Create Date: 2026-08-12 15:05:00.000000

The revision stores ownership, immutable revision identifiers, bounded runtime
buckets, diagnostic hashes, Judge outcomes, and idempotent feedback codes. It
deliberately excludes authored mail or draft content, model payloads, credentials,
and complete execution traces.
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa

revision = "20260812_email_writing_evidence"
down_revision = "0017_merge_newsdom_carddav_heads"
branch_labels = None
depends_on = None

_SESSION_TABLE = "email_review_session"
_DIAGNOSTIC_TABLE = "writing_diagnostic_record"
_FEEDBACK_TABLE = "diagnostic_feedback_event"
_NEW_TABLE_NAMES = (_SESSION_TABLE, _DIAGNOSTIC_TABLE, _FEEDBACK_TABLE)


def _review_evidence_metadata() -> sa.MetaData:
"""Build dialect-neutral table metadata for SQLite and PostgreSQL."""
metadata = sa.MetaData()
sa.Table(
"email_records",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
)
review_session = sa.Table(
_SESSION_TABLE,
metadata,
sa.Column("review_session_id", sa.String(length=36), nullable=False),
sa.Column("owner_user_id", sa.String(length=255), nullable=False),
sa.Column("owner_organization_id", sa.String(length=255), nullable=False),
sa.Column("source_email_id", sa.Integer(), nullable=False),
sa.Column("revision_algorithm", sa.String(length=16), nullable=False),
sa.Column("revision_digest", sa.String(length=64), nullable=False),
sa.Column("revision_entity_tag", sa.String(length=96), nullable=False),
sa.Column("projection_name", sa.String(length=128), nullable=False),
sa.Column("projection_version", sa.Integer(), nullable=False),
sa.Column("review_mode", sa.String(length=32), nullable=False),
sa.Column("language_profile", sa.String(length=64), nullable=False),
sa.Column("review_status", sa.String(length=32), nullable=False),
sa.Column("workflow_identifier", sa.String(length=128), nullable=False),
sa.Column("workflow_version", sa.String(length=128), nullable=False),
sa.Column("model_profile_id", sa.String(length=128), nullable=False),
sa.Column("rubric_version", sa.String(length=128), nullable=False),
sa.Column("judge_policy_version", sa.String(length=128), nullable=False),
sa.Column("orchestration_mode", sa.String(length=32), nullable=False),
sa.Column("prompt_hash", sa.String(length=71), nullable=False),
sa.Column("latency_bucket_ms", sa.Integer(), nullable=False),
sa.Column("cost_bucket_micro_usd", sa.Integer(), nullable=False),
sa.Column("prompt_token_bucket", sa.Integer(), nullable=False),
sa.Column("completion_token_bucket", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("evidence_expires_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint(
"review_session_id",
name="pk_email_review_session",
),
sa.ForeignKeyConstraint(
["source_email_id"],
["email_records.id"],
name="fk_email_review_session_source_email",
ondelete="CASCADE",
),
sa.CheckConstraint(
"revision_algorithm = 'SHA-256'",
name="ck_email_review_session_revision_algorithm",
),
sa.CheckConstraint(
"length(revision_digest) = 64",
name="ck_email_review_session_revision_digest",
),
sa.CheckConstraint(
"length(revision_entity_tag) >= 73",
name="ck_email_review_session_revision_entity_tag",
),
sa.CheckConstraint(
"projection_version > 0",
name="ck_email_review_session_projection_version",
),
sa.CheckConstraint(
"review_mode IN ('incremental', 'deep')",
name="ck_email_review_session_review_mode",
),
sa.CheckConstraint(
"review_status IN ("
"'pending', 'completed', 'abstained', 'unavailable', 'stale', "
"'rejected', 'context_insufficient', 'judge_disagreement'"
")",
name="ck_email_review_session_review_status",
),
sa.CheckConstraint(
"orchestration_mode IN ('route', 'conduct')",
name="ck_email_review_session_orchestration_mode",
),
sa.CheckConstraint(
"prompt_hash LIKE 'sha256:%' AND length(prompt_hash) = 71",
name="ck_email_review_session_prompt_hash",
),
sa.CheckConstraint(
"latency_bucket_ms >= 0 AND cost_bucket_micro_usd >= 0 "
"AND prompt_token_bucket >= 0 AND completion_token_bucket >= 0",
name="ck_email_review_session_nonnegative_buckets",
),
sa.CheckConstraint(
"evidence_expires_at > created_at",
name="ck_email_review_session_retention_window",
),
)
sa.Index(
"ix_email_review_session_owner_scope",
review_session.c.owner_user_id,
review_session.c.owner_organization_id,
review_session.c.created_at,
)
sa.Index(
"ix_email_review_session_expiry_status",
review_session.c.evidence_expires_at,
review_session.c.review_status,
)
sa.Index(
"ix_email_review_session_source_email",
review_session.c.source_email_id,
)

diagnostic_record = sa.Table(
_DIAGNOSTIC_TABLE,
metadata,
sa.Column("diagnostic_record_id", sa.String(length=36), nullable=False),
sa.Column("review_session_id", sa.String(length=36), nullable=False),
sa.Column("diagnostic_identifier", sa.String(length=128), nullable=False),
sa.Column("diagnostic_category", sa.String(length=128), nullable=False),
sa.Column("diagnostic_priority", sa.String(length=32), nullable=False),
sa.Column("selector_start", sa.Integer(), nullable=False),
sa.Column("selector_end", sa.Integer(), nullable=False),
sa.Column("candidate_hash", sa.String(length=71), nullable=False),
sa.Column("replacement_hash", sa.String(length=71), nullable=True),
sa.Column("explanation_hash", sa.String(length=71), nullable=False),
sa.Column("criterion_categories_json", sa.JSON(), nullable=False),
sa.Column("judge_score", sa.Float(), nullable=False),
sa.Column("admission_status", sa.String(length=32), nullable=False),
sa.Column("admission_reason_code", sa.String(length=128), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint(
"diagnostic_record_id",
name="pk_writing_diagnostic_record",
),
sa.ForeignKeyConstraint(
["review_session_id"],
["email_review_session.review_session_id"],
name="fk_writing_diagnostic_record_review_session",
ondelete="CASCADE",
),
sa.UniqueConstraint(
"review_session_id",
"diagnostic_identifier",
name="uq_writing_diagnostic_record_session_identifier",
),
sa.CheckConstraint(
"selector_start >= 0 AND selector_end > selector_start",
name="ck_writing_diagnostic_record_selector_order",
),
sa.CheckConstraint(
"diagnostic_priority IN ('critical', 'important', 'advisory')",
name="ck_writing_diagnostic_record_priority_code",
),
sa.CheckConstraint(
"judge_score >= 0 AND judge_score <= 1",
name="ck_writing_diagnostic_record_judge_score",
),
sa.CheckConstraint(
"admission_status IN ('admitted', 'rejected', 'abstained')",
name="ck_writing_diagnostic_record_admission_status",
),
sa.CheckConstraint(
"candidate_hash LIKE 'sha256:%' AND length(candidate_hash) = 71 "
"AND explanation_hash LIKE 'sha256:%' "
"AND length(explanation_hash) = 71 "
"AND (replacement_hash IS NULL OR ("
"replacement_hash LIKE 'sha256:%' "
"AND length(replacement_hash) = 71))",
name="ck_writing_diagnostic_record_hash_shapes",
),
)
sa.Index(
"ix_writing_diagnostic_record_session_status",
diagnostic_record.c.review_session_id,
diagnostic_record.c.admission_status,
)

feedback_event = sa.Table(
_FEEDBACK_TABLE,
metadata,
sa.Column("feedback_event_id", sa.String(length=36), nullable=False),
sa.Column("diagnostic_record_id", sa.String(length=36), nullable=False),
sa.Column("owner_user_id", sa.String(length=255), nullable=False),
sa.Column("owner_organization_id", sa.String(length=255), nullable=False),
sa.Column("feedback_action", sa.String(length=32), nullable=False),
sa.Column("reviewed_revision_digest", sa.String(length=64), nullable=False),
sa.Column("resulting_revision_digest", sa.String(length=64), nullable=True),
sa.Column("conflict_reason_code", sa.String(length=128), nullable=True),
sa.Column("stale_reason_code", sa.String(length=128), nullable=True),
sa.Column("event_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint(
"feedback_event_id",
name="pk_diagnostic_feedback_event",
),
sa.ForeignKeyConstraint(
["diagnostic_record_id"],
["writing_diagnostic_record.diagnostic_record_id"],
name="fk_diagnostic_feedback_event_diagnostic_record",
ondelete="CASCADE",
),
sa.UniqueConstraint(
"owner_user_id",
"owner_organization_id",
"idempotency_key",
name="uq_diagnostic_feedback_event_owner_idempotency",
),
sa.CheckConstraint(
"feedback_action IN ("
"'applied', 'ignored', 'dismissed', 'explanation_requested'"
")",
name="ck_diagnostic_feedback_event_action_code",
),
sa.CheckConstraint(
"feedback_action != 'applied' OR resulting_revision_digest IS NOT NULL",
name="ck_diagnostic_feedback_event_apply_revision",
),
sa.CheckConstraint(
"length(reviewed_revision_digest) = 64 "
"AND (resulting_revision_digest IS NULL "
"OR length(resulting_revision_digest) = 64)",
name="ck_diagnostic_feedback_event_revision_digests",
),
sa.CheckConstraint(
"length(idempotency_key) > 0",
name="ck_diagnostic_feedback_event_idempotency_key",
),
)
sa.Index(
"ix_diagnostic_feedback_event_owner_scope",
feedback_event.c.owner_user_id,
feedback_event.c.owner_organization_id,
feedback_event.c.event_time,
)
sa.Index(
"ix_diagnostic_feedback_event_diagnostic_record",
feedback_event.c.diagnostic_record_id,
feedback_event.c.event_time,
)
return metadata


def upgrade() -> None:
"""Create the evidence tables and indexes without touching raw mail data."""
connection = op.get_bind()
metadata = _review_evidence_metadata()
for table_name in _NEW_TABLE_NAMES:
metadata.tables[table_name].create(connection, checkfirst=True)


def downgrade() -> None:
"""Drop only this revision's evidence objects in dependency-safe order."""
connection = op.get_bind()
metadata = _review_evidence_metadata()
for table_name in reversed(_NEW_TABLE_NAMES):
metadata.tables[table_name].drop(connection, checkfirst=True)
Loading