feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -69,9 +71,10 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea
|
||||
html_content,
|
||||
markdown_content,
|
||||
word_count,
|
||||
source_label
|
||||
source_label,
|
||||
plaintext_snapshot
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
input.ArticleID,
|
||||
@@ -81,6 +84,7 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea
|
||||
pgText(storedMarkdown),
|
||||
input.WordCount,
|
||||
pgText(&input.SourceLabel),
|
||||
strings.TrimSpace(input.Title+"\n"+ExtractArticlePlaintext(input.MarkdownContent)),
|
||||
).Scan(&versionID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -88,6 +92,60 @@ func (r *articleRepository) CreateArticleVersion(ctx context.Context, input Crea
|
||||
return versionID, nil
|
||||
}
|
||||
|
||||
func ExtractArticlePlaintext(markdown string) string {
|
||||
markdown = strings.TrimSpace(markdown)
|
||||
if markdown == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(markdown))
|
||||
inFence := false
|
||||
lines := strings.Split(markdown, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
builder.WriteString(trimmed)
|
||||
builder.WriteByte('\n')
|
||||
continue
|
||||
}
|
||||
line = stripMarkdownLine(line)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(line)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func stripMarkdownLine(line string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"#", " ",
|
||||
"*", " ",
|
||||
"_", " ",
|
||||
"`", " ",
|
||||
">", " ",
|
||||
"[", " ",
|
||||
"]", " ",
|
||||
"(", " ",
|
||||
")", " ",
|
||||
"!", " ",
|
||||
"|", " ",
|
||||
)
|
||||
line = replacer.Replace(line)
|
||||
line = strings.TrimLeft(line, "-+0123456789. \t")
|
||||
return strings.Join(strings.Fields(line), " ")
|
||||
}
|
||||
|
||||
func CountArticlePlaintextWords(markdown string) int {
|
||||
return contentstats.CountWords(ExtractArticlePlaintext(markdown))
|
||||
}
|
||||
|
||||
func (r *articleRepository) UpdateArticleCurrentVersion(ctx context.Context, articleID, tenantID, versionID int64) error {
|
||||
tag, err := r.db.Exec(ctx, `
|
||||
UPDATE articles
|
||||
|
||||
@@ -10,25 +10,30 @@ import (
|
||||
)
|
||||
|
||||
type DesktopPublishJob struct {
|
||||
DesktopID uuid.UUID
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
CreatedByUserID int64
|
||||
Title string
|
||||
ContentRef []byte
|
||||
ScheduledAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DesktopID uuid.UUID
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
CreatedByUserID int64
|
||||
Title string
|
||||
ContentRef []byte
|
||||
ScheduledAt *time.Time
|
||||
ArticleID *int64
|
||||
ArticleVersionID *int64
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CreateDesktopPublishJobParams struct {
|
||||
DesktopID uuid.UUID
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
CreatedByUserID int64
|
||||
Title string
|
||||
ContentRef []byte
|
||||
ScheduledAt *time.Time
|
||||
DesktopID uuid.UUID
|
||||
TenantID int64
|
||||
WorkspaceID int64
|
||||
CreatedByUserID int64
|
||||
Title string
|
||||
ContentRef []byte
|
||||
ScheduledAt *time.Time
|
||||
ArticleID *int64
|
||||
ArticleVersionID *int64
|
||||
}
|
||||
|
||||
type DesktopTask struct {
|
||||
@@ -136,13 +141,15 @@ func NewDesktopTaskRepository(db generated.DBTX) DesktopTaskRepository {
|
||||
|
||||
func (r *desktopTaskRepository) CreatePublishJob(ctx context.Context, params CreateDesktopPublishJobParams) (*DesktopPublishJob, error) {
|
||||
row, err := r.q.CreateDesktopPublishJob(ctx, generated.CreateDesktopPublishJobParams{
|
||||
DesktopID: pgUUID(params.DesktopID),
|
||||
TenantID: params.TenantID,
|
||||
WorkspaceID: params.WorkspaceID,
|
||||
CreatedByUserID: params.CreatedByUserID,
|
||||
Title: params.Title,
|
||||
ContentRef: params.ContentRef,
|
||||
ScheduledAt: pgTimestamp(params.ScheduledAt),
|
||||
DesktopID: pgUUID(params.DesktopID),
|
||||
TenantID: params.TenantID,
|
||||
WorkspaceID: params.WorkspaceID,
|
||||
CreatedByUserID: params.CreatedByUserID,
|
||||
Title: params.Title,
|
||||
ContentRef: params.ContentRef,
|
||||
ScheduledAt: pgTimestamp(params.ScheduledAt),
|
||||
ArticleID: pgInt8(params.ArticleID),
|
||||
ArticleVersionID: pgInt8(params.ArticleVersionID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -314,15 +321,18 @@ func (r *desktopTaskRepository) FinishAttempt(ctx context.Context, params Finish
|
||||
|
||||
func desktopPublishJobFromGenerated(row generated.DesktopPublishJob) *DesktopPublishJob {
|
||||
return &DesktopPublishJob{
|
||||
DesktopID: uuidFromPG(row.DesktopID),
|
||||
TenantID: row.TenantID,
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
CreatedByUserID: row.CreatedByUserID,
|
||||
Title: row.Title,
|
||||
ContentRef: row.ContentRef,
|
||||
ScheduledAt: optionalTime(row.ScheduledAt),
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
|
||||
DesktopID: uuidFromPG(row.DesktopID),
|
||||
TenantID: row.TenantID,
|
||||
WorkspaceID: row.WorkspaceID,
|
||||
CreatedByUserID: row.CreatedByUserID,
|
||||
Title: row.Title,
|
||||
ContentRef: row.ContentRef,
|
||||
ScheduledAt: optionalTime(row.ScheduledAt),
|
||||
ArticleID: nullableInt64(row.ArticleID),
|
||||
ArticleVersionID: nullableInt64(row.ArticleVersionID),
|
||||
Status: row.Status,
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +211,9 @@ INSERT INTO desktop_publish_jobs (
|
||||
created_by_user_id,
|
||||
title,
|
||||
content_ref,
|
||||
scheduled_at
|
||||
scheduled_at,
|
||||
article_id,
|
||||
article_version_id
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
@@ -220,19 +222,23 @@ VALUES (
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
)
|
||||
RETURNING id, desktop_id, tenant_id, workspace_id, created_by_user_id, title, content_ref, scheduled_at, created_at, updated_at
|
||||
RETURNING id, desktop_id, tenant_id, workspace_id, created_by_user_id, title, content_ref, scheduled_at, created_at, updated_at, article_id, article_version_id, status, compliance_blocked_record_id, compliance_blocked_at, compliance_blocked_reason
|
||||
`
|
||||
|
||||
type CreateDesktopPublishJobParams struct {
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
CreatedByUserID int64 `json:"created_by_user_id"`
|
||||
Title string `json:"title"`
|
||||
ContentRef []byte `json:"content_ref"`
|
||||
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
CreatedByUserID int64 `json:"created_by_user_id"`
|
||||
Title string `json:"title"`
|
||||
ContentRef []byte `json:"content_ref"`
|
||||
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
|
||||
ArticleID pgtype.Int8 `json:"article_id"`
|
||||
ArticleVersionID pgtype.Int8 `json:"article_version_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktopPublishJobParams) (DesktopPublishJob, error) {
|
||||
@@ -244,6 +250,8 @@ func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktop
|
||||
arg.Title,
|
||||
arg.ContentRef,
|
||||
arg.ScheduledAt,
|
||||
arg.ArticleID,
|
||||
arg.ArticleVersionID,
|
||||
)
|
||||
var i DesktopPublishJob
|
||||
err := row.Scan(
|
||||
@@ -257,6 +265,12 @@ func (q *Queries) CreateDesktopPublishJob(ctx context.Context, arg CreateDesktop
|
||||
&i.ScheduledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ArticleID,
|
||||
&i.ArticleVersionID,
|
||||
&i.Status,
|
||||
&i.ComplianceBlockedRecordID,
|
||||
&i.ComplianceBlockedAt,
|
||||
&i.ComplianceBlockedReason,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -544,12 +558,18 @@ const leaseNextQueuedDesktopTask = `-- name: LeaseNextQueuedDesktopTask :one
|
||||
WITH candidate AS (
|
||||
SELECT dt.desktop_id
|
||||
FROM desktop_tasks AS dt
|
||||
LEFT JOIN desktop_publish_jobs AS j ON j.desktop_id = dt.job_id
|
||||
WHERE dt.target_client_id = $3
|
||||
AND dt.status = 'queued'
|
||||
AND (
|
||||
$4::text IS NULL
|
||||
OR dt.kind = $4::text
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR j.desktop_id IS NULL
|
||||
OR j.status = 'queued'
|
||||
)
|
||||
ORDER BY dt.lane_weight DESC,
|
||||
dt.priority DESC,
|
||||
COALESCE(dt.enqueued_at, dt.created_at) ASC,
|
||||
|
||||
@@ -87,6 +87,12 @@ type ArticleVersion struct {
|
||||
WordCount int32 `json:"word_count"`
|
||||
SourceLabel pgtype.Text `json:"source_label"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
// Plaintext extracted at save time, used by compliance gate to skip diff reconstruction.
|
||||
PlaintextSnapshot pgtype.Text `json:"plaintext_snapshot"`
|
||||
// Hot-path manual review stamp: none|pending|approved|rejected.
|
||||
ManualReviewStatus string `json:"manual_review_status"`
|
||||
ManualReviewID pgtype.Int8 `json:"manual_review_id"`
|
||||
ManualReviewUpdatedAt pgtype.Timestamptz `json:"manual_review_updated_at"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
@@ -153,6 +159,105 @@ type Competitor struct {
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
}
|
||||
|
||||
type ComplianceAckRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
ArticleID int64 `json:"article_id"`
|
||||
ArticleVersionID int64 `json:"article_version_id"`
|
||||
CheckRecordID int64 `json:"check_record_id"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
PolicyFingerprint string `json:"policy_fingerprint"`
|
||||
AcknowledgedBy int64 `json:"acknowledged_by"`
|
||||
AckViolationIds []int64 `json:"ack_violation_ids"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
ConsumedAt pgtype.Timestamptz `json:"consumed_at"`
|
||||
ConsumedByJobID pgtype.UUID `json:"consumed_by_job_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type ComplianceCheckRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
ArticleID pgtype.Int8 `json:"article_id"`
|
||||
ArticleVersionID pgtype.Int8 `json:"article_version_id"`
|
||||
TargetPlatforms []string `json:"target_platforms"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
PolicyFingerprint string `json:"policy_fingerprint"`
|
||||
TriggerSource string `json:"trigger_source"`
|
||||
EnforcementMode string `json:"enforcement_mode"`
|
||||
HighestLevel pgtype.Text `json:"highest_level"`
|
||||
Passed bool `json:"passed"`
|
||||
HitCount int32 `json:"hit_count"`
|
||||
StoredHitCount int32 `json:"stored_hit_count"`
|
||||
Truncated bool `json:"truncated"`
|
||||
DictVersions []byte `json:"dict_versions"`
|
||||
LlmUsed bool `json:"llm_used"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
CheckedBy pgtype.Int8 `json:"checked_by"`
|
||||
CheckedAt pgtype.Timestamptz `json:"checked_at"`
|
||||
ManualReviewStatus string `json:"manual_review_status"`
|
||||
ManualReviewID pgtype.Int8 `json:"manual_review_id"`
|
||||
}
|
||||
|
||||
type ComplianceManualReview struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
ArticleID int64 `json:"article_id"`
|
||||
ArticleVersionID int64 `json:"article_version_id"`
|
||||
OriginalCheckRecordID pgtype.Int8 `json:"original_check_record_id"`
|
||||
OriginalContentHash string `json:"original_content_hash"`
|
||||
OriginalPolicyFingerprint string `json:"original_policy_fingerprint"`
|
||||
OriginalTargetPlatforms []string `json:"original_target_platforms"`
|
||||
OriginalViolationSummary []byte `json:"original_violation_summary"`
|
||||
RequestedBy int64 `json:"requested_by"`
|
||||
RequestedAt pgtype.Timestamptz `json:"requested_at"`
|
||||
RequestNote pgtype.Text `json:"request_note"`
|
||||
Status string `json:"status"`
|
||||
CancelledBy pgtype.Int8 `json:"cancelled_by"`
|
||||
CancelledAt pgtype.Timestamptz `json:"cancelled_at"`
|
||||
CancelReason pgtype.Text `json:"cancel_reason"`
|
||||
ReviewedBy pgtype.Int8 `json:"reviewed_by"`
|
||||
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
|
||||
DecisionReason pgtype.Text `json:"decision_reason"`
|
||||
}
|
||||
|
||||
type ComplianceReviewJob struct {
|
||||
ID int64 `json:"id"`
|
||||
MessageID pgtype.UUID `json:"message_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
ArticleID int64 `json:"article_id"`
|
||||
ArticleVersionID int64 `json:"article_version_id"`
|
||||
CheckRecordID int64 `json:"check_record_id"`
|
||||
Status string `json:"status"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
AvailableAt pgtype.Timestamptz `json:"available_at"`
|
||||
LockedAt pgtype.Timestamptz `json:"locked_at"`
|
||||
LockedBy pgtype.Text `json:"locked_by"`
|
||||
LastError pgtype.Text `json:"last_error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ComplianceViolation struct {
|
||||
ID int64 `json:"id"`
|
||||
RecordID int64 `json:"record_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
PlatformCode pgtype.Text `json:"platform_code"`
|
||||
Source string `json:"source"`
|
||||
DictionaryCode pgtype.Text `json:"dictionary_code"`
|
||||
DictionaryName pgtype.Text `json:"dictionary_name"`
|
||||
TermPattern pgtype.Text `json:"term_pattern"`
|
||||
MatchedText string `json:"matched_text"`
|
||||
StartOffset int32 `json:"start_offset"`
|
||||
EndOffset int32 `json:"end_offset"`
|
||||
DomPath pgtype.Text `json:"dom_path"`
|
||||
Level string `json:"level"`
|
||||
Hint pgtype.Text `json:"hint"`
|
||||
ReferenceLaw pgtype.Text `json:"reference_law"`
|
||||
AcknowledgedInAckID pgtype.Int8 `json:"acknowledged_in_ack_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type DesktopClient struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
@@ -180,16 +285,22 @@ type DesktopClientPrimaryLease struct {
|
||||
}
|
||||
|
||||
type DesktopPublishJob struct {
|
||||
ID int64 `json:"id"`
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
CreatedByUserID int64 `json:"created_by_user_id"`
|
||||
Title string `json:"title"`
|
||||
ContentRef []byte `json:"content_ref"`
|
||||
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
CreatedByUserID int64 `json:"created_by_user_id"`
|
||||
Title string `json:"title"`
|
||||
ContentRef []byte `json:"content_ref"`
|
||||
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ArticleID pgtype.Int8 `json:"article_id"`
|
||||
ArticleVersionID pgtype.Int8 `json:"article_version_id"`
|
||||
Status string `json:"status"`
|
||||
ComplianceBlockedRecordID pgtype.Int8 `json:"compliance_blocked_record_id"`
|
||||
ComplianceBlockedAt pgtype.Timestamptz `json:"compliance_blocked_at"`
|
||||
ComplianceBlockedReason pgtype.Text `json:"compliance_blocked_reason"`
|
||||
}
|
||||
|
||||
type DesktopTask struct {
|
||||
|
||||
@@ -6,7 +6,9 @@ INSERT INTO desktop_publish_jobs (
|
||||
created_by_user_id,
|
||||
title,
|
||||
content_ref,
|
||||
scheduled_at
|
||||
scheduled_at,
|
||||
article_id,
|
||||
article_version_id
|
||||
)
|
||||
VALUES (
|
||||
sqlc.arg(desktop_id),
|
||||
@@ -15,7 +17,9 @@ VALUES (
|
||||
sqlc.arg(created_by_user_id),
|
||||
sqlc.arg(title),
|
||||
sqlc.arg(content_ref),
|
||||
sqlc.narg(scheduled_at)
|
||||
sqlc.narg(scheduled_at),
|
||||
sqlc.narg(article_id),
|
||||
sqlc.narg(article_version_id)
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
@@ -59,12 +63,18 @@ LIMIT 1;
|
||||
WITH candidate AS (
|
||||
SELECT dt.desktop_id
|
||||
FROM desktop_tasks AS dt
|
||||
LEFT JOIN desktop_publish_jobs AS j ON j.desktop_id = dt.job_id
|
||||
WHERE dt.target_client_id = sqlc.arg(client_id)
|
||||
AND dt.status = 'queued'
|
||||
AND (
|
||||
sqlc.narg(kind)::text IS NULL
|
||||
OR dt.kind = sqlc.narg(kind)::text
|
||||
)
|
||||
AND (
|
||||
dt.kind <> 'publish'
|
||||
OR j.desktop_id IS NULL
|
||||
OR j.status = 'queued'
|
||||
)
|
||||
ORDER BY dt.lane_weight DESC,
|
||||
dt.priority DESC,
|
||||
COALESCE(dt.enqueued_at, dt.created_at) ASC,
|
||||
|
||||
Reference in New Issue
Block a user