likwid/backend/src/plugins/builtin/structured_deliberation.rs

436 lines
13 KiB
Rust
Raw Normal View History

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use crate::plugins::{
2026-02-03 16:54:39 +00:00
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
};
pub struct StructuredDeliberationPlugin;
impl StructuredDeliberationPlugin {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Plugin for StructuredDeliberationPlugin {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
name: "structured_deliberation",
version: "1.0.0",
description: "Pro/con arguments and collaborative summaries",
is_core: false,
scope: PluginScope::Community,
default_enabled: true,
settings_schema: Some(json!({
"type": "object",
"properties": {
"require_reading": {"type": "boolean", "default": true},
"enable_summaries": {"type": "boolean", "default": true}
}
})),
}
}
fn register(&self, system: &mut PluginSystem) {
// Hook: Check reading requirement before comment
system.add_action(
"comment.pre_create",
"structured_deliberation".to_string(),
5,
Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move {
if let (Some(proposal_id), Some(user_id)) = (
2026-02-03 16:54:39 +00:00
payload
.get("proposal_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()),
ctx.actor_user_id,
) {
let can_comment = DeliberationService::check_can_participate(
2026-02-03 16:54:39 +00:00
&ctx.pool,
proposal_id,
user_id,
"comment",
)
.await?;
if !can_comment {
return Err(PluginError::Message(
2026-02-03 16:54:39 +00:00
"Please read the proposal before commenting".to_string(),
));
}
}
Ok(())
})
}),
);
// Hook: Calculate metrics periodically
system.add_action(
"cron.hourly",
"structured_deliberation".to_string(),
50,
Arc::new(|ctx: HookContext, _payload: Value| {
Box::pin(async move {
DeliberationService::update_all_metrics(&ctx.pool).await?;
Ok(())
})
}),
);
// Hook: Track argument creation
system.add_action(
"deliberation.argument.created",
"structured_deliberation".to_string(),
50,
Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move {
ctx.emit_public_event(
Some("structured_deliberation"),
"argument.created",
payload.clone(),
2026-02-03 16:54:39 +00:00
)
.await?;
Ok(())
})
}),
);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument {
pub id: Uuid,
pub proposal_id: Uuid,
pub stance: String,
pub title: String,
pub content: String,
pub author_id: Uuid,
pub upvotes: i32,
pub downvotes: i32,
pub quality_score: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Summary {
pub id: Uuid,
pub proposal_id: Uuid,
pub summary_type: String,
pub content: String,
pub version: i32,
pub is_approved: bool,
}
pub struct DeliberationService;
impl DeliberationService {
/// Check if user can participate based on reading requirements
pub async fn check_can_participate(
pool: &PgPool,
proposal_id: Uuid,
user_id: Uuid,
action: &str,
) -> Result<bool, PluginError> {
let reading = sqlx::query!(
r#"SELECT can_comment, can_vote FROM deliberation_reading_log
WHERE proposal_id = $1 AND user_id = $2"#,
proposal_id,
user_id
)
.fetch_optional(pool)
.await?;
match reading {
Some(r) => Ok(match action {
"comment" => r.can_comment.unwrap_or(false),
"vote" => r.can_vote.unwrap_or(false),
_ => true,
}),
None => Ok(false), // No reading record
}
}
/// Record that user has read content
pub async fn record_reading(
pool: &PgPool,
proposal_id: Uuid,
user_id: Uuid,
read_type: &str,
time_seconds: i32,
) -> Result<(), PluginError> {
sqlx::query!(
r#"INSERT INTO deliberation_reading_log (proposal_id, user_id, first_read_at, reading_time_seconds)
VALUES ($1, $2, NOW(), $3)
ON CONFLICT (proposal_id, user_id) DO UPDATE SET
read_proposal = CASE WHEN $4 = 'proposal' THEN true ELSE deliberation_reading_log.read_proposal END,
read_summaries = CASE WHEN $4 = 'summaries' THEN true ELSE deliberation_reading_log.read_summaries END,
read_top_arguments = CASE WHEN $4 = 'arguments' THEN true ELSE deliberation_reading_log.read_top_arguments END,
reading_time_seconds = deliberation_reading_log.reading_time_seconds + $3,
updated_at = NOW()"#,
proposal_id,
user_id,
time_seconds,
read_type
)
.execute(pool)
.await?;
Ok(())
}
/// Add an argument
pub async fn add_argument(
pool: &PgPool,
proposal_id: Uuid,
parent_id: Option<Uuid>,
stance: &str,
title: &str,
content: &str,
author_id: Uuid,
) -> Result<Uuid, PluginError> {
let argument_id: Uuid = sqlx::query_scalar(
2026-02-03 16:54:39 +00:00
"SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)",
)
.bind(proposal_id)
.bind(parent_id)
.bind(stance)
.bind(title)
.bind(content)
.bind(author_id)
.fetch_one(pool)
.await?;
Ok(argument_id)
}
/// Vote on an argument
pub async fn vote_argument(
pool: &PgPool,
argument_id: Uuid,
user_id: Uuid,
vote_type: &str,
) -> Result<(), PluginError> {
sqlx::query("SELECT vote_on_argument($1, $2, $3)")
.bind(argument_id)
.bind(user_id)
.bind(vote_type)
.execute(pool)
.await?;
Ok(())
}
/// Get arguments for a proposal
pub async fn get_arguments(
pool: &PgPool,
proposal_id: Uuid,
stance: Option<&str>,
limit: i64,
) -> Result<Vec<Argument>, PluginError> {
let arguments = if let Some(s) = stance {
sqlx::query_as!(
Argument,
r#"SELECT
id, proposal_id, stance::text AS "stance!",
title, content, author_id,
upvotes, downvotes, quality_score::float8 AS quality_score
FROM deliberation_arguments
WHERE proposal_id = $1 AND stance::text = $2 AND NOT is_hidden AND parent_id IS NULL
ORDER BY quality_score DESC NULLS LAST
LIMIT $3"#,
proposal_id,
s,
limit
)
.fetch_all(pool)
.await?
} else {
sqlx::query_as!(
Argument,
r#"SELECT
id, proposal_id, stance::text AS "stance!",
title, content, author_id,
upvotes, downvotes, quality_score::float8 AS quality_score
FROM deliberation_arguments
WHERE proposal_id = $1 AND NOT is_hidden AND parent_id IS NULL
ORDER BY quality_score DESC NULLS LAST
LIMIT $2"#,
proposal_id,
limit
)
.fetch_all(pool)
.await?
};
Ok(arguments)
}
/// Create or update a summary
pub async fn upsert_summary(
pool: &PgPool,
proposal_id: Uuid,
summary_type: &str,
content: &str,
key_points: Value,
editor_id: Uuid,
) -> Result<Uuid, PluginError> {
// Check if summary exists
let existing = sqlx::query_scalar!(
"SELECT id FROM deliberation_summaries WHERE proposal_id = $1 AND summary_type = $2::summary_type",
proposal_id,
summary_type as _
)
.fetch_optional(pool)
.await?;
if let Some(summary_id) = existing {
// Save to history
sqlx::query!(
r#"INSERT INTO summary_edit_history (summary_id, version, content, key_points, editor_id)
SELECT id, version, content, key_points, last_editor_id
FROM deliberation_summaries WHERE id = $1"#,
summary_id
)
.execute(pool)
.await?;
// Update
sqlx::query!(
r#"UPDATE deliberation_summaries SET
content = $2, key_points = $3, last_editor_id = $4,
version = version + 1, edit_count = edit_count + 1,
is_approved = false, updated_at = NOW()
WHERE id = $1"#,
summary_id,
content,
key_points,
editor_id
)
.execute(pool)
.await?;
Ok(summary_id)
} else {
// Create new
let summary_id = sqlx::query_scalar!(
r#"INSERT INTO deliberation_summaries (
proposal_id, summary_type, content, key_points, last_editor_id
) VALUES ($1, $2::summary_type, $3, $4, $5)
RETURNING id"#,
proposal_id,
summary_type as _,
content,
key_points,
editor_id
)
.fetch_one(pool)
.await?;
Ok(summary_id)
}
}
/// Get summaries for a proposal
pub async fn get_summaries(
pool: &PgPool,
proposal_id: Uuid,
) -> Result<Vec<Summary>, PluginError> {
let summaries = sqlx::query_as!(
Summary,
r#"SELECT
id, proposal_id, summary_type::text AS "summary_type!",
content, version, is_approved
FROM deliberation_summaries
WHERE proposal_id = $1
ORDER BY summary_type"#,
proposal_id
)
.fetch_all(pool)
.await?;
Ok(summaries)
}
/// Approve a summary
pub async fn approve_summary(
pool: &PgPool,
summary_id: Uuid,
approver_id: Uuid,
) -> Result<(), PluginError> {
sqlx::query!(
r#"UPDATE deliberation_summaries SET
is_approved = true, approved_by = $2, approved_at = NOW()
WHERE id = $1"#,
summary_id,
approver_id
)
.execute(pool)
.await?;
Ok(())
}
/// Update metrics for all active proposals
pub async fn update_all_metrics(pool: &PgPool) -> Result<i32, PluginError> {
let proposals = sqlx::query_scalar!(
r#"SELECT DISTINCT proposal_id FROM deliberation_arguments
WHERE created_at > NOW() - INTERVAL '1 day'"#
)
.fetch_all(pool)
.await?;
let mut count = 0;
for proposal_id in proposals {
sqlx::query("SELECT calculate_deliberation_metrics($1)")
.bind(proposal_id)
.execute(pool)
.await?;
count += 1;
}
Ok(count)
}
/// Get deliberation overview
pub async fn get_overview(pool: &PgPool, proposal_id: Uuid) -> Result<Value, PluginError> {
let metrics = sqlx::query!(
r#"SELECT
total_arguments, pro_arguments, con_arguments, neutral_arguments,
unique_participants, substantive_ratio::float8 AS substantive_ratio,
balance_score::float8 AS balance_score
FROM deliberation_metrics
WHERE proposal_id = $1
ORDER BY calculated_at DESC
LIMIT 1"#,
proposal_id
)
.fetch_optional(pool)
.await?;
let top_pro = Self::get_arguments(pool, proposal_id, Some("pro"), 3).await?;
let top_con = Self::get_arguments(pool, proposal_id, Some("con"), 3).await?;
let summaries = Self::get_summaries(pool, proposal_id).await?;
Ok(json!({
"metrics": metrics.map(|m| json!({
"total_arguments": m.total_arguments,
"pro": m.pro_arguments,
"con": m.con_arguments,
"neutral": m.neutral_arguments,
"participants": m.unique_participants,
"substantive_ratio": m.substantive_ratio,
"balance_score": m.balance_score
})),
"top_pro_arguments": top_pro,
"top_con_arguments": top_con,
"summaries": summaries
}))
}
}