연례 EU DSA 준수 워크플로우가 어떻게 되나요?

여러분, 안녕하세요.

최근 한 클라이언트와 함께 EU DSA(디지털서비스법) 준수를 위한 연례 보고서를 준비하는 작업을 하고 있었는데, EU 내에서 Discourse 커뮤니티를 운영하는 많은 관리자나 모더레이터에게도 이 작업이 반복적으로 필요한 일이 아닐까 싶었습니다.

이 보고 과정은 반복적이고 세심한 주의를 요하는 작업이므로, 이곳의 다른 분들이 어떻게 접근하고 있는지 궁금합니다.

  • 정확한 연례 보고서를 작성하기 위해 어떤 워크플로우를 구축하셨나요?
  • 특정 플러그인, 커스텀 스크립트, 데이터 탐색기 쿼리 또는 외부 도구에 의존하고 계신가요?
  • 데이터 수집이나 보고 과정을 단순화하기 위한 자동화를 구현해 보셨나요?
  • 그리고 Discourse 자체에 DSA 보고 과정의 일부 부분을 의미 있게 단순화하거나 자동화하는 내장 기능이 있나요?

Discourse 자체가 잘 구조화된 DSA 투명성 보고서(예: Digital Services Act 2025 (DSA) Transparency Report | Discourse - Civilized Discussion)를 게시하고 있다는 점을 확인했는데, 참고 자료로 매우 유용합니다.

다양한 커뮤니티들이 이 문제를 어떻게 해결하고 있는지 듣고 싶습니다.

여기서 모인 집단적 경험을 통해 배우기를 기대합니다.

미리 감사드립니다!

저는 지난 주에 이 보고서를 처음 생성했습니다. data explorer를 사용하면 매우 빠르고 쉽게 만들 수 있었습니다. 제가 사용하는 쿼리는 다음과 같습니다 (@SaraDev 님이 작성해주셨습니다):

DSA 보고

-- [params]
-- date :start_date = 2020-01-01
-- date :end_date = 2026-01-01

WITH flag_data AS (
    SELECT
        r.id AS flag_id,
        p.id AS post_id,
        p.topic_id,
        p.raw AS flagged_item_text,
        p.user_id AS post_author_id,
        fu.username AS flagged_by_username,
        r.created_at AS flagged_date,
        r.type AS flag_type,
        r.reviewable_by_moderator AS flag_source,
        r.payload AS flag_reason,
        r.status AS review_status,
        rs.reviewed_by_id,
        rs.reviewed_at,
        rs.score AS review_score,
        ru.username AS reviewed_by_username,
        p.deleted_at AS post_deleted_at,
        u.silenced_till AS user_silenced_till,
        u.suspended_till AS user_suspended_till,
        p.hidden_at AS post_hidden_at,
        pa.post_action_type_id
    FROM
        reviewables r
    LEFT JOIN posts p ON r.target_id = p.id AND r.target_type = 'Post'
    LEFT JOIN users fu ON r.created_by_id = fu.id
    LEFT JOIN reviewable_scores rs ON rs.reviewable_id = r.id
    LEFT JOIN users ru ON rs.reviewed_by_id = ru.id
    LEFT JOIN users u ON p.user_id = u.id
    LEFT JOIN post_actions pa ON pa.post_id = p.id
    WHERE
        r.created_at BETWEEN :start_date AND :end_date
        AND r.status = 1 -- Only include flags that were agreed with and action was taken
),
flag_types AS (
    SELECT
        3 AS post_action_type_id, 'Off-topic' AS flag_type_name
    UNION ALL
    SELECT
        4 AS post_action_type_id, 'Inappropriate' AS flag_type_name
    UNION ALL
    SELECT
        6 AS post_action_type_id, 'Notify_user' AS flag_type_name
    UNION ALL
    SELECT
        7 AS post_action_type_id, 'Notify_moderators' AS flag_type_name
    UNION ALL
    SELECT
        8 AS post_action_type_id, 'Spam' AS flag_type_name
    UNION ALL
    SELECT
        10 AS post_action_type_id, 'Illegal' AS flag_type_name
),
median_time_to_act AS (
    SELECT
        CASE
            WHEN ft.flag_type_name IS NOT NULL THEN ft.flag_type_name
            WHEN fd.flag_type IN (
                'ReviewableAkismetPost',
                'ReviewableFlaggedPost',
                'ReviewableChatMessage',
                'ReviewablePost',
                'ReviewableQueuedPost'
            ) THEN 'something_else'
            ELSE fd.flag_type
        END AS flag_type,
        ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (fd.reviewed_at - fd.flagged_date))) / 60) AS median_time_minutes
    FROM
        flag_data fd
    LEFT JOIN flag_types ft 
        ON fd.post_action_type_id = ft.post_action_type_id
    WHERE
        fd.reviewed_at IS NOT NULL
        AND (
            ft.flag_type_name IS NOT NULL -- Include mapped flag types
            OR fd.flag_type IN (
                'ReviewableAkismetPost',
                'ReviewableUser',
                'ReviewableFlaggedPost',
                'ReviewableChatMessage',
                'ReviewablePost',
                'ReviewableQueuedPost'
            ) -- Include specific flag types for NULL results
        )
    GROUP BY
        CASE
            WHEN ft.flag_type_name IS NOT NULL THEN ft.flag_type_name
            WHEN fd.flag_type IN (
                'ReviewableAkismetPost',
                'ReviewableFlaggedPost',
                'ReviewableChatMessage',
                'ReviewablePost',
                'ReviewableQueuedPost'
            ) THEN 'something_else'
            ELSE fd.flag_type
        END
)
SELECT
    CASE
        WHEN ft.flag_type_name IS NOT NULL THEN ft.flag_type_name
        WHEN fd.flag_type IN (
            'ReviewableAkismetPost',
            'ReviewableFlaggedPost',
            'ReviewableChatMessage',
            'ReviewablePost',
            'ReviewableQueuedPost'
        ) THEN 'something_else'
        ELSE fd.flag_type
    END AS Type,
    COUNT(CASE WHEN fd.flagged_by_username NOT IN ('spam_scanner_bot', 'system') THEN 1 END) AS Reported,
    COUNT(CASE WHEN fd.flagged_by_username IN ('spam_scanner_bot', 'system') THEN 1 END) AS Automated,
    COUNT(*) AS Total,
    COALESCE(mta.median_time_minutes, 0) AS "Median time to act (minutes)",
    COUNT(CASE WHEN fd.user_silenced_till IS NOT NULL THEN 1 END) AS "User silenced",
    COUNT(CASE WHEN fd.user_suspended_till IS NOT NULL THEN 1 END) AS "User deleted",
    COUNT(CASE WHEN fd.post_deleted_at IS NOT NULL THEN 1 END) AS "Post deleted",
    COUNT(CASE WHEN fd.post_hidden_at IS NOT NULL THEN 1 END) AS "Post hidden"

FROM
    flag_data fd
LEFT JOIN flag_types ft 
    ON fd.post_action_type_id = ft.post_action_type_id
LEFT JOIN median_time_to_act mta 
    ON CASE
        WHEN ft.flag_type_name IS NOT NULL THEN ft.flag_type_name
        WHEN fd.flag_type IN (
            'ReviewableAkismetPost',
            'ReviewableFlaggedPost',
            'ReviewableChatMessage',
            'ReviewablePost',
            'ReviewableQueuedPost'
        ) THEN 'something_else'
        ELSE fd.flag_type
    END = mta.flag_type
WHERE
    ft.flag_type_name IS NOT NULL -- Include mapped flag types
    OR fd.flag_type IN (
        'ReviewableAkismetPost',
        'ReviewableUser',
        'ReviewableFlaggedPost',
        'ReviewableChatMessage',
        'ReviewablePost',
        'ReviewableQueuedPost'
    ) -- Include specific flag types for NULL results
GROUP BY
    CASE
        WHEN ft.flag_type_name IS NOT NULL THEN ft.flag_type_name
        WHEN fd.flag_type IN (
            'ReviewableAkismetPost',
            'ReviewableFlaggedPost',
            'ReviewableChatMessage',
            'ReviewablePost',
            'ReviewableQueuedPost'
        ) THEN 'something_else'
        ELSE fd.flag_type
    END,
    mta.median_time_minutes
ORDER BY
    Total DESC

수행된 모더레이션 조치

-- [params]
-- date :start_date = 2024-01-01
-- date :end_date = 2025-01-01
-- null int :action_type

SELECT 
    uh.acting_user_id,
    uh.action AS action_type,
    CASE uh.action
        WHEN 1 THEN 'delete_user'
        WHEN 10 THEN 'suspend_user'
        WHEN 11 THEN 'unsuspend_user'
        WHEN 17 THEN 'delete_post'
        WHEN 18 THEN 'delete_topic'
        WHEN 25 THEN 'reviewed_post'
        WHEN 30 THEN 'silence_user'
        WHEN 31 THEN 'unsilence_user'
        WHEN 39 THEN 'deactivate_user'
        WHEN 41 THEN 'lock_trust_level'
        WHEN 42 THEN 'unlock_trust_level'
        WHEN 47 THEN 'notified_about_get_a_room'
        WHEN 49 THEN 'post_locked'
        WHEN 50 THEN 'post_unlocked'
        WHEN 56 THEN 'post_approved'
        WHEN 60 THEN 'removed_silence_user'
        WHEN 61 THEN 'removed_suspend_user'
        WHEN 62 THEN 'removed_unsilence_user'
        WHEN 63 THEN 'removed_unsuspend_user'
        WHEN 64 THEN 'post_rejected'
        WHEN 69 THEN 'approve_user'
        WHEN 95 THEN 'post_staff_note_create'
        WHEN 96 THEN 'post_staff_note_destroy'
        ELSE 'unknown_action'
    END AS action_name,
    uh.target_user_id AS user_id,
    uh.subject AS "Subject",     -- Subject of the action
    uh.created_at AS "When",     -- Timestamp of the action
    uh.details AS "Details",     -- Additional details about the action
    uh.context AS "Context",     -- Context of the action
    uh.previous_value,
    uh.new_value,
    u.suspended_till,
    u.silenced_till,
    uh.topic_id AS topic_id,
    uh.post_id AS post_id,
    uh.category_id AS category_id,
    uh.custom_type
FROM 
    user_histories uh
LEFT JOIN 
    users u ON uh.target_user_id = u.id
WHERE 
    uh.created_at BETWEEN :start_date AND :end_date
    AND uh.action IN (
        1,   -- delete_user
        10,  -- suspend_user
        11,  -- unsuspend_user
        17,  -- delete_post
        18,  -- delete_topic
        25,  -- reviewed_post
        30,  -- silence_user
        31,  -- unsilence_user
        39,  -- deactivate_user
        41,  -- lock_trust_level
        42,  -- unlock_trust_level
        47,  -- notified_about_get_a_room
        49,  -- post_locked
        50,  -- post_unlocked
        56,  -- post_approved
        60,  -- removed_silence_user
        61,  -- removed_suspend_user
        62,  -- removed_unsilence_user
        63,  -- removed_unsuspend_user
        64,  -- post_rejected
        69,  -- approve_user
        95,  -- post_staff_note_create
        96   -- post_staff_note_destroy
    )
    AND (:action_type IS NULL OR uh.action = :action_type)
ORDER BY 
    uh.created_at DESC

그리고 경고 발행에 대해서는: https://meta.discourse.org/admin/reports/moderator_warning_private_messages?end_date=2024-12-31&mode=table&start_date=2024-01-01

HAWK님 안녕하세요

정말 유용한 정보네요. 공유해 주셔서 감사합니다.

여기에 후속 질문이 몇 가지 있습니다.
  1. DSA 보고 쿼리에서 시작 날짜가 2020년으로 되어 있는데, 공개된 DSA 보고서에는 2025년 1월 1일부터 12월 31일까지의 기간이 명시되어 있습니다. 이것이 쿼리나 Discourse의 작동 방식과 관련이 있나요?
  1. 모더레이션 조치 쿼리에도 비공개(unlisted) 토픽이 포함되나요? 저희 인스턴스에서는 Discourse AI - AI triage가 활성화되어 있으며, 그 중 하나의 조치로 범위 밖 토픽을 자동으로 비공개 처리합니다.

DE에서 실행하면 매개변수를 설정할 수 있는 필드가 표시됩니다. 이는 기본적으로 설정된 값들입니다.

네, @system 사용자가 수행한 조치도 기록됩니다.

좋아요. 제목에 DSA가 명확히 드러나도록 기존 쿼리에 추가해야 해요.