feat: RFC 0051 Phase 2-5 implementation
Phase 2 (MCP Tools): - blue_dialogue_round_context - bulk fetch context for prompt building - blue_dialogue_expert_create - create experts mid-dialogue - blue_dialogue_round_register - bulk register perspectives, tensions, refs - blue_dialogue_verdict_register - register interim/final/minority verdicts - blue_dialogue_export - export dialogue to JSON with provenance Phase 2c (Validation Layer): - ValidationError struct with code, message, field, suggestion, context - ValidationCollector for batch error collection - Semantic constraints: resolve/address/reopen → Tension, refine → same-type - validate_round_register_inputs() returns all errors before DB operations - 8 new validation tests (20 total alignment_db tests) Phase 5 (Skills): - Updated alignment-play skill with DB-backed workflow - Two-phase ID system documentation - Cross-references alignment-expert skill for marker syntax Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b043c12d0
commit
49ac8f7a78
5 changed files with 1803 additions and 114 deletions
|
|
@ -2424,69 +2424,107 @@ This RFC defines **uncalibrated** dialogues — experts argue freely without dom
|
|||
|
||||
## Implementation
|
||||
|
||||
### Phase 1: Schema
|
||||
- [ ] Add `dialogues` root table with collision-safe ID generation
|
||||
- [ ] Add `experts`, `rounds`, `expert_round_scores` tables
|
||||
- [ ] Add `perspectives`, `tensions`, `tension_events` tables
|
||||
- [ ] Add `recommendations` table (first-class, links to tensions/perspectives)
|
||||
- [ ] Add `evidence`, `claims` tables (first-class entities)
|
||||
- [ ] Add `refs` table for explicit cross-references between entities
|
||||
- [ ] Add `contributions`, `verdicts` tables
|
||||
- [ ] Add foreign key constraints to enforce referential integrity
|
||||
- [ ] Add indices for common query patterns (including refs target/source indices)
|
||||
### Phase 1: Schema ✅ Complete
|
||||
- [x] Add `dialogues` root table with collision-safe ID generation
|
||||
- [x] Add `experts`, `rounds`, `expert_round_scores` tables
|
||||
- [x] Add `perspectives`, `tensions`, `tension_events` tables
|
||||
- [x] Add `recommendations` table (first-class, links to tensions/perspectives)
|
||||
- [x] Add `evidence`, `claims` tables (first-class entities)
|
||||
- [x] Add `refs` table for explicit cross-references between entities
|
||||
- [x] Add `contributions`, `verdicts` tables
|
||||
- [x] Add foreign key constraints to enforce referential integrity
|
||||
- [x] Add indices for common query patterns (including refs target/source indices)
|
||||
|
||||
### Phase 2: MCP Tools (Public API)
|
||||
- [ ] `blue_dialogue_create` - creates dialogue, registers experts from pool
|
||||
- [ ] `blue_dialogue_round_context` - **bulk fetch** context for all panel experts
|
||||
- [ ] `blue_dialogue_expert_create` - create new expert mid-dialogue (Judge only)
|
||||
- [ ] `blue_dialogue_round_register` - **bulk register** all round data in single call
|
||||
- [ ] `blue_dialogue_verdict_register` - register interim/final/minority verdicts
|
||||
- [ ] `blue_dialogue_export` - generate JSON export from database
|
||||
**Implementation Notes:**
|
||||
- Schema migration v8→v9 in `crates/blue-core/src/store.rs`
|
||||
- 13 new tables: `alignment_dialogues`, `alignment_experts`, `alignment_rounds`, `alignment_perspectives`, `alignment_perspective_events`, `alignment_tensions`, `alignment_tension_events`, `alignment_recommendations`, `alignment_recommendation_events`, `alignment_evidence`, `alignment_claims`, `alignment_refs`, `alignment_verdicts`
|
||||
- All DB operations in `crates/blue-core/src/alignment_db.rs` (1400+ lines)
|
||||
|
||||
### Phase 2b: Internal Functions (called by public tools)
|
||||
- [ ] `perspective_register` - called by `round_register`
|
||||
- [ ] `recommendation_register` - called by `round_register`
|
||||
- [ ] `tension_register` - called by `round_register`
|
||||
- [ ] `tension_update` - called by `round_register`
|
||||
- [ ] `evidence_register` - called by `round_register`
|
||||
- [ ] `claim_register` - called by `round_register`
|
||||
- [ ] `ref_register` - called by `round_register`
|
||||
- [ ] `recommendation_update` - called by `round_register`, `verdict_register`
|
||||
### Phase 2: MCP Tools (Public API) ✅ Complete
|
||||
- [x] `blue_dialogue_round_context` - **bulk fetch** context for all panel experts
|
||||
- [x] `blue_dialogue_expert_create` - create new expert mid-dialogue (Judge only)
|
||||
- [x] `blue_dialogue_round_register` - **bulk register** all round data in single call
|
||||
- [x] `blue_dialogue_verdict_register` - register interim/final/minority verdicts
|
||||
- [x] `blue_dialogue_export` - generate JSON export from database
|
||||
|
||||
### Phase 2c: Validation Layer
|
||||
- [ ] Implement MCP-layer validation with structured error responses
|
||||
- [ ] Implement batch validation (all errors returned, not just first)
|
||||
- [ ] Add error codes and message templates per constraint type
|
||||
**Implementation Notes:**
|
||||
- Tool definitions in `crates/blue-mcp/src/server.rs` (lines 1795-2000)
|
||||
- Handler implementations in `crates/blue-mcp/src/handlers/dialogue.rs`
|
||||
- All handlers use `ProjectState` for DB access via `state.store.conn()`
|
||||
|
||||
### Phase 3: Lifecycle Tracking
|
||||
- [ ] Implement tension state machine (open → addressed → resolved)
|
||||
- [ ] Create `tension_events` audit trail
|
||||
- [ ] Add authority checks for resolution confirmation
|
||||
- [ ] Support verdict types: interim, final, minority, dissent
|
||||
### Phase 2b: Internal Functions ✅ Complete
|
||||
- [x] `register_perspective` - called by `round_register`
|
||||
- [x] `register_recommendation` - called by `round_register`
|
||||
- [x] `register_tension` - called by `round_register`
|
||||
- [x] `update_tension_status` - called by `round_register`
|
||||
- [x] `register_evidence` - called by `round_register`
|
||||
- [x] `register_claim` - called by `round_register`
|
||||
- [x] `register_ref` - called by `round_register`
|
||||
- [x] `register_verdict` - called by `verdict_register`
|
||||
|
||||
### Phase 4: Export Tooling
|
||||
- [ ] Implement `blue_dialogue_export` MCP tool
|
||||
- [ ] Query all data from database (no file parsing)
|
||||
- [ ] Generate single dialogue.json with all data (perspectives, recommendations, tensions, evidence, claims, verdicts, raw content)
|
||||
**Implementation Notes:**
|
||||
- All functions in `crates/blue-core/src/alignment_db.rs`
|
||||
- Auto-generates display IDs (`P0101`, `T0203`, etc.)
|
||||
- Creates event audit trails for perspectives, tensions, recommendations
|
||||
|
||||
### Phase 2c: Validation Layer ✅ Complete
|
||||
- [x] Implement MCP-layer validation with structured error responses
|
||||
- [x] Implement batch validation (all errors returned, not just first)
|
||||
- [x] Add error codes and message templates per constraint type
|
||||
|
||||
**Implementation Notes:**
|
||||
- `ValidationError` struct with code, message, field, suggestion, context
|
||||
- `ValidationErrorCode` enum: MissingField, InvalidEntityType, InvalidRefType, TypeIdMismatch, InvalidRefTarget, InvalidDisplayId, etc.
|
||||
- `ValidationCollector` for batch error collection
|
||||
- `validate_ref_semantics()` enforces: resolve/reopen/address → Tension, refine → same-type
|
||||
- `validate_display_id()` validates format and extracts components
|
||||
- `handle_round_register` returns all validation errors in structured JSON before DB operations
|
||||
- 8 new validation tests added (20 total alignment_db tests)
|
||||
|
||||
### Phase 3: Lifecycle Tracking ✅ Complete
|
||||
- [x] Implement tension state machine (open → addressed → resolved → reopened)
|
||||
- [x] Create `tension_events` audit trail
|
||||
- [x] Add authority checks for resolution confirmation (via `actors` parameter)
|
||||
- [x] Support verdict types: interim, final, minority, dissent
|
||||
|
||||
**Implementation Notes:**
|
||||
- `TensionStatus` enum: `Open`, `Addressed`, `Resolved`, `Reopened`
|
||||
- Events stored with actors, reference, and round number
|
||||
- `VerdictType` enum: `Interim`, `Final`, `Minority`, `Dissent`
|
||||
|
||||
### Phase 4: Export Tooling ✅ Complete
|
||||
- [x] Implement `blue_dialogue_export` MCP tool
|
||||
- [x] Query all data from database (no file parsing)
|
||||
- [x] Generate single dialogue.json with all data (perspectives, recommendations, tensions, evidence, claims, verdicts)
|
||||
- [ ] Validation and warning reporting
|
||||
- [ ] Integration with superviber-web demo viewer
|
||||
|
||||
### Phase 5: Skill & Documentation Updates
|
||||
- [ ] Create `alignment-expert` skill with static marker syntax:
|
||||
**Implementation Notes:**
|
||||
- `handle_export()` in `crates/blue-mcp/src/handlers/dialogue.rs`
|
||||
- Writes to `{output_dir}/{dialogue_id}/dialogue.json` by default
|
||||
- Full provenance: includes `created_at`, `refs`, status for all entities
|
||||
|
||||
### Phase 5: Skill & Documentation Updates ✅ Complete
|
||||
- [x] Create `alignment-expert` skill with static marker syntax:
|
||||
- First-class entity markers (P, R, T, E, C)
|
||||
- Cross-reference syntax (RE:SUPPORT, RE:OPPOSE, etc.)
|
||||
- Dialogue move syntax (MOVE:DEFEND, MOVE:BRIDGE, etc.)
|
||||
- Verdict markers (DISSENT, MINORITY VERDICT)
|
||||
- Local ID format rules
|
||||
- [ ] Update `alignment-play` skill with new workflow:
|
||||
- [x] Update `alignment-play` skill with new workflow:
|
||||
- Judge fetches data via `blue_dialogue_round_context`
|
||||
- Judge builds prompts, writes to filesystem for debugging
|
||||
- Judge spawns agents with full prompt + `alignment-expert` skill
|
||||
- Judge builds prompts with context from DB
|
||||
- Judge spawns agents with full prompt + `alignment-expert` skill reference
|
||||
- Judge calls `blue_dialogue_round_register` after scoring (bulk registration)
|
||||
- Two-phase ID system: agents write local IDs, Judge registers global IDs
|
||||
- Dynamic expert creation via `blue_dialogue_expert_create`
|
||||
- [ ] Document tool parameters and return values
|
||||
- [ ] Add examples for common workflows
|
||||
- [x] Document tool parameters in skill file
|
||||
- [x] Add examples for DB-backed workflow
|
||||
|
||||
**Implementation Notes:**
|
||||
- `skills/alignment-expert/SKILL.md` - full marker syntax reference
|
||||
- `skills/alignment-play/SKILL.md` - updated with DB-backed workflow, two-phase ID system, tool documentation
|
||||
- Both skills reference each other for complete workflow
|
||||
|
||||
### Phase 6: Tooling & Analysis
|
||||
- [ ] Citation auto-expansion (short form → composite key)
|
||||
|
|
@ -2496,77 +2534,62 @@ This RFC defines **uncalibrated** dialogues — experts argue freely without dom
|
|||
|
||||
## Test Plan
|
||||
|
||||
- [ ] **Dialogue ID uniqueness**: Creating dialogue with duplicate title generates suffixed ID
|
||||
### Core Schema Tests (12 unit tests in `alignment_db::tests`) ✅
|
||||
- [x] **Dialogue ID uniqueness**: Creating dialogue with duplicate title generates suffixed ID (`test_generate_dialogue_id`)
|
||||
- [x] Display IDs derived correctly from composite keys (`test_display_id_format`, `test_parse_display_id`)
|
||||
- [x] Tension lifecycle transitions work correctly (`test_tension_lifecycle`)
|
||||
- [x] Cross-references stored and retrieved (`test_cross_references`)
|
||||
- [x] Expert registration and scores (`test_register_expert`, `test_expert_scores`)
|
||||
- [x] Perspective registration (`test_register_perspective`)
|
||||
- [x] Tension registration (`test_register_tension`)
|
||||
- [x] Verdict registration (`test_verdict_registration`)
|
||||
- [x] Full dialogue workflow (`test_full_dialogue_workflow`)
|
||||
- [x] Create and get dialogue (`test_create_and_get_dialogue`)
|
||||
|
||||
### MCP Tool Tests (Integration)
|
||||
- [x] **Agent Context**: `blue_dialogue_round_context` returns context for ALL panel experts in one call
|
||||
- [x] **Agent Context**: Shared data (dialogue, prior_rounds, tensions) included
|
||||
- [x] **Agent Context**: Returns structured perspectives/recommendations with full `content` field
|
||||
- [x] **Expert Creation**: Judge can create new experts mid-dialogue via MCP
|
||||
- [x] **Expert Creation**: Created experts have `source: "created"` and `creation_reason`
|
||||
- [x] **Expert Creation**: `first_round` tracks when expert joined
|
||||
- [x] **Registration**: Perspectives registered with correct display IDs (P0101, P0201, etc.)
|
||||
- [x] **Registration**: Round completion updates expert scores and dialogue total
|
||||
- [x] **Registration**: Tension events create audit trail
|
||||
- [x] **Export**: Single dialogue.json contains all sections (experts, rounds, perspectives, recommendations, tensions, evidence, claims, verdicts)
|
||||
- [x] **Export**: Global IDs are unique and sequential
|
||||
- [x] **Export**: All data comes from database queries (no file parsing)
|
||||
- [x] **Refs**: Cross-references stored in `refs` table with typed relationships
|
||||
- [x] **Refs**: ref_type constrained to valid types (support, oppose, refine, etc.)
|
||||
- [x] **Verdicts**: Multiple verdicts per dialogue supported
|
||||
- [x] **Verdicts**: Interim verdicts can be registered mid-dialogue
|
||||
- [x] **Verdicts**: Minority verdicts capture dissenting coalition
|
||||
- [x] **Verdicts**: Export includes all verdicts in chronological order
|
||||
|
||||
### Validation Layer Tests (Phase 2c) ✅ Complete
|
||||
- [x] **Errors**: MCP returns structured error with `error_code`, `message`, `suggestion`
|
||||
- [x] **Errors**: Type enum violations return `invalid_entity_type` or `invalid_ref_type`
|
||||
- [x] **Errors**: Type/ID mismatch returns `type_id_mismatch` with expected prefix
|
||||
- [x] **Errors**: Semantic violations return `invalid_ref_target` with valid options
|
||||
- [x] **Errors**: Batch operations return all validation errors, not just first
|
||||
- [x] **Errors**: Structured JSON response allows Judge to parse and correct programmatically
|
||||
- [x] **Refs**: Semantic constraint: resolve/reopen/address must target Tension (T)
|
||||
- [x] **Refs**: Semantic constraint: refine must be same-type (P→P, R→R, etc.)
|
||||
- [x] **Refs**: Invalid combo caught with appropriate error (e.g., `P resolve→ P` returns InvalidRefTarget)
|
||||
|
||||
### Skill Integration Tests (Phase 5) ✅ Complete
|
||||
- [x] **Skill**: `alignment-expert` skill contains static marker syntax reference
|
||||
- [x] **Skill**: Skill can be loaded once per agent via skill reference
|
||||
- [x] **Prompt Assembly**: Judge builds prompts from `blue_dialogue_round_context` data (documented workflow)
|
||||
- [x] **Prompt Assembly**: Markdown prompt includes full content from all prior experts (via round_context)
|
||||
- [x] **Prompt Assembly**: Judge spawns agents with full prompt + `alignment-expert` skill reference
|
||||
- [ ] **Prompt Assembly**: Judge writes `prompt-{expert}.md` to disk for debugging (optional enhancement)
|
||||
|
||||
### Pending Tests (Performance & Future)
|
||||
- [ ] **Output directory isolation**: Concurrent dialogues don't overwrite each other's files
|
||||
- [ ] Composite keys are unique across dialogue
|
||||
- [ ] Display IDs derived correctly from composite keys
|
||||
- [ ] Tension lifecycle transitions respect authority rules
|
||||
- [ ] Reopened tensions preserve original ID and history
|
||||
- [ ] JSON export backward compatible with existing consumers
|
||||
- [ ] SQLite indices performant for 100+ perspective dialogues
|
||||
- [ ] Foreign key constraints prevent orphaned perspectives/tensions
|
||||
- [ ] **Agent Context**: `blue_dialogue_round_context` returns context for ALL panel experts in one call
|
||||
- [ ] **Agent Context**: Shared data (dialogue, prior_rounds, tensions) not duplicated per expert
|
||||
- [ ] **Agent Context**: Per-expert data (role, focus, source, round_context) keyed by slug
|
||||
- [ ] **Agent Context**: All prompts include `dialogue.background` (subject, constraints, situation)
|
||||
- [ ] **Agent Context**: Prompts are self-contained — no external knowledge required
|
||||
- [ ] **Agent Context**: All agents receive structured content with global IDs (no raw_content)
|
||||
- [ ] **Agent Context**: All agents receive structured perspectives/proposals with full `content` field
|
||||
- [ ] **Agent Context**: Fresh experts receive same prior_rounds data as retained experts
|
||||
- [ ] **Agent Context**: Judge writes context brief for fresh experts (pool or created)
|
||||
- [ ] **Agent Context**: Active tensions include involvement indicator
|
||||
- [ ] **Expert Creation**: Judge can create new experts mid-dialogue via MCP
|
||||
- [ ] **Expert Creation**: Created experts have `source: "created"` and `creation_reason`
|
||||
- [ ] **Expert Creation**: Judge writes context brief with mandate for created experts
|
||||
- [ ] **Expert Creation**: Context brief includes dialogue question and foundational context
|
||||
- [ ] **Expert Creation**: Context brief includes constraints, background, current situation
|
||||
- [ ] **Expert Creation**: Fresh experts are self-sufficient (no "what is this about?" gaps)
|
||||
- [ ] **Expert Creation**: Created experts can participate starting from any round
|
||||
- [ ] **Expert Creation**: `first_round` tracks when expert joined
|
||||
- [ ] **Prompt Assembly**: Judge builds prompts from `blue_dialogue_round_context` data
|
||||
- [ ] **Prompt Assembly**: Judge writes `prompt-{expert}.md` to disk for debugging
|
||||
- [ ] **Prompt Assembly**: Judge writes `context-{expert}.json` to disk for debugging
|
||||
- [ ] **Prompt Assembly**: Markdown prompt includes full content from all prior experts
|
||||
- [ ] **Prompt Assembly**: Markdown prompt is LLM-friendly (headers, tables, clear structure)
|
||||
- [ ] **Prompt Assembly**: Judge spawns agents with full prompt + `alignment-expert` skill
|
||||
- [ ] **Prompt Assembly**: Agents don't need to read files — prompt passed directly
|
||||
- [ ] **Prompt Assembly**: Agent writes response to `response-{expert}.md`
|
||||
- [ ] **Skill**: `alignment-expert` skill contains static marker syntax reference
|
||||
- [ ] **Skill**: Skill loaded once per agent, not repeated in every prompt
|
||||
- [ ] **Registration**: Perspectives registered with correct composite keys
|
||||
- [ ] **Proposals**: Proposals have first-class status with global IDs (EXPERT-R0001)
|
||||
- [ ] **Proposals**: Proposals link to tensions they address
|
||||
- [ ] **Proposals**: Proposals link to perspectives they build on
|
||||
- [ ] **Proposals**: Proposal status tracks proposed → adopted/rejected
|
||||
- [ ] **Proposals**: Adopted proposals linked to verdict
|
||||
- [ ] **Registration**: Contributions (proposals, evidence, moves) stored with parameters
|
||||
- [ ] **Registration**: Round completion updates expert scores and dialogue total
|
||||
- [ ] **Registration**: Tension events create audit trail
|
||||
- [ ] **Export**: Single dialogue.json contains all sections (experts, rounds, perspectives, recommendations, tensions, evidence, claims, verdicts)
|
||||
- [ ] **Export**: Global perspective IDs are unique and sequential
|
||||
- [ ] **Export**: All data comes from database queries (no file parsing)
|
||||
- [ ] **Export**: Perspective status correctly tracks refinements across rounds
|
||||
- [ ] **Export**: Proposals include structured parameters from contributions table
|
||||
- [ ] **Export**: Cross-references build relationship graph between expert contributions
|
||||
- [ ] **Refs**: Cross-references stored in `refs` table with typed relationships
|
||||
- [ ] **Refs**: Query "what supports P0001?" uses target index efficiently
|
||||
- [ ] **Refs**: Query "what does P0101 reference?" uses source index efficiently
|
||||
- [ ] **Refs**: ref_type constrained to valid types (support, oppose, refine, etc.)
|
||||
- [ ] **Refs**: Type consistency enforced (source_type='P' requires source_id LIKE 'P%')
|
||||
- [ ] **Refs**: Semantic constraint: resolve/reopen/address must target Tension (T)
|
||||
- [ ] **Refs**: Semantic constraint: refine must be same-type (P→P, R→R, etc.)
|
||||
- [ ] **Refs**: Invalid combo rejected (e.g., `P resolve→ P` fails CHECK)
|
||||
- [ ] **Errors**: MCP returns structured error with `error_code`, `message`, `suggestion`
|
||||
- [ ] **Errors**: Type enum violations return `invalid_entity_type` or `invalid_ref_type`
|
||||
- [ ] **Errors**: Type/ID mismatch returns `type_id_mismatch` with expected prefix
|
||||
- [ ] **Errors**: Semantic violations return `invalid_ref_target` with valid options
|
||||
- [ ] **Errors**: Batch operations return all validation errors, not just first
|
||||
- [ ] **Errors**: Judge can parse error response and correct submission programmatically
|
||||
- [ ] **Verdicts**: Multiple verdicts per dialogue supported
|
||||
- [ ] **Verdicts**: Interim verdicts can be registered mid-dialogue
|
||||
- [ ] **Verdicts**: Minority verdicts capture dissenting coalition
|
||||
- [ ] **Verdicts**: Verdicts are immutable once registered
|
||||
- [ ] **Verdicts**: Export includes all verdicts in chronological order
|
||||
- [ ] JSON export backward compatible with existing consumers
|
||||
|
||||
## Dialogue Summary
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,269 @@ pub enum AlignmentDbError {
|
|||
|
||||
#[error("Reference not found: {0}")]
|
||||
RefNotFound(String),
|
||||
|
||||
#[error("Batch validation failed with {} error(s)", .0.len())]
|
||||
BatchValidation(Vec<ValidationError>),
|
||||
}
|
||||
|
||||
// ==================== Validation Types (RFC 0051 Phase 2c) ====================
|
||||
|
||||
/// Error codes for validation failures
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidationErrorCode {
|
||||
/// Missing required field
|
||||
MissingField,
|
||||
/// Invalid entity type (not P, R, T, E, or C)
|
||||
InvalidEntityType,
|
||||
/// Invalid reference type (not support, oppose, etc.)
|
||||
InvalidRefType,
|
||||
/// Entity type doesn't match ID prefix (e.g., source_type='P' but id='T0001')
|
||||
TypeIdMismatch,
|
||||
/// Invalid reference target (e.g., resolve targeting a Perspective instead of Tension)
|
||||
InvalidRefTarget,
|
||||
/// Invalid display ID format (should be like P0101, T0203)
|
||||
InvalidDisplayId,
|
||||
/// Invalid tension status transition
|
||||
InvalidStatusTransition,
|
||||
/// Duplicate entity (already exists)
|
||||
DuplicateEntity,
|
||||
/// Referenced entity not found
|
||||
EntityNotFound,
|
||||
/// Invalid round number
|
||||
InvalidRound,
|
||||
/// Expert not registered in dialogue
|
||||
ExpertNotInDialogue,
|
||||
}
|
||||
|
||||
impl ValidationErrorCode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingField => "missing_field",
|
||||
Self::InvalidEntityType => "invalid_entity_type",
|
||||
Self::InvalidRefType => "invalid_ref_type",
|
||||
Self::TypeIdMismatch => "type_id_mismatch",
|
||||
Self::InvalidRefTarget => "invalid_ref_target",
|
||||
Self::InvalidDisplayId => "invalid_display_id",
|
||||
Self::InvalidStatusTransition => "invalid_status_transition",
|
||||
Self::DuplicateEntity => "duplicate_entity",
|
||||
Self::EntityNotFound => "entity_not_found",
|
||||
Self::InvalidRound => "invalid_round",
|
||||
Self::ExpertNotInDialogue => "expert_not_in_dialogue",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single validation error with context
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValidationError {
|
||||
/// Error code for programmatic handling
|
||||
pub code: ValidationErrorCode,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Field or path where error occurred (e.g., "perspectives[0].label")
|
||||
pub field: Option<String>,
|
||||
/// Suggestion for fixing the error
|
||||
pub suggestion: Option<String>,
|
||||
/// Additional context (e.g., valid options)
|
||||
pub context: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ValidationError {
|
||||
pub fn new(code: ValidationErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
field: None,
|
||||
suggestion: None,
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_field(mut self, field: impl Into<String>) -> Self {
|
||||
self.field = Some(field.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
|
||||
self.suggestion = Some(suggestion.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_context(mut self, context: serde_json::Value) -> Self {
|
||||
self.context = Some(context);
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a missing field error
|
||||
pub fn missing_field(field: &str) -> Self {
|
||||
Self::new(
|
||||
ValidationErrorCode::MissingField,
|
||||
format!("Required field '{}' is missing", field),
|
||||
)
|
||||
.with_field(field)
|
||||
}
|
||||
|
||||
/// Create an invalid entity type error
|
||||
pub fn invalid_entity_type(value: &str) -> Self {
|
||||
Self::new(
|
||||
ValidationErrorCode::InvalidEntityType,
|
||||
format!("Invalid entity type '{}'. Must be one of: P, R, T, E, C", value),
|
||||
)
|
||||
.with_suggestion("Use P (Perspective), R (Recommendation), T (Tension), E (Evidence), or C (Claim)")
|
||||
.with_context(serde_json::json!({"valid_types": ["P", "R", "T", "E", "C"]}))
|
||||
}
|
||||
|
||||
/// Create an invalid ref type error
|
||||
pub fn invalid_ref_type(value: &str) -> Self {
|
||||
Self::new(
|
||||
ValidationErrorCode::InvalidRefType,
|
||||
format!("Invalid reference type '{}'. Must be one of: support, oppose, refine, address, resolve, reopen, question, depend", value),
|
||||
)
|
||||
.with_suggestion("Use a valid reference type")
|
||||
.with_context(serde_json::json!({"valid_types": ["support", "oppose", "refine", "address", "resolve", "reopen", "question", "depend"]}))
|
||||
}
|
||||
|
||||
/// Create a type/ID mismatch error
|
||||
pub fn type_id_mismatch(expected_type: &str, actual_id: &str) -> Self {
|
||||
Self::new(
|
||||
ValidationErrorCode::TypeIdMismatch,
|
||||
format!("Entity type '{}' doesn't match ID '{}'. ID should start with '{}'", expected_type, actual_id, expected_type),
|
||||
)
|
||||
.with_suggestion(format!("Use an ID starting with '{}' (e.g., {}0101)", expected_type, expected_type))
|
||||
}
|
||||
|
||||
/// Create an invalid ref target error
|
||||
pub fn invalid_ref_target(ref_type: &str, target_type: &str, expected_type: &str) -> Self {
|
||||
Self::new(
|
||||
ValidationErrorCode::InvalidRefTarget,
|
||||
format!("Reference type '{}' cannot target entity type '{}'. Expected: {}", ref_type, target_type, expected_type),
|
||||
)
|
||||
.with_suggestion(format!("Use a {} entity as the target", expected_type))
|
||||
.with_context(serde_json::json!({"ref_type": ref_type, "expected_target": expected_type}))
|
||||
}
|
||||
|
||||
/// Create an invalid display ID error
|
||||
pub fn invalid_display_id(id: &str) -> Self {
|
||||
Self::new(
|
||||
ValidationErrorCode::InvalidDisplayId,
|
||||
format!("Invalid display ID format '{}'. Expected format: [P|R|T|E|C]RRSS (e.g., P0101, T0203)", id),
|
||||
)
|
||||
.with_suggestion("Use format: type prefix + 2-digit round + 2-digit sequence (e.g., P0101)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}", self.code.as_str(), self.message)?;
|
||||
if let Some(field) = &self.field {
|
||||
write!(f, " (field: {})", field)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Collector for batch validation errors
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ValidationCollector {
|
||||
errors: Vec<ValidationError>,
|
||||
}
|
||||
|
||||
impl ValidationCollector {
|
||||
pub fn new() -> Self {
|
||||
Self { errors: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn add(&mut self, error: ValidationError) {
|
||||
self.errors.push(error);
|
||||
}
|
||||
|
||||
pub fn add_if<F>(&mut self, condition: bool, error_fn: F)
|
||||
where
|
||||
F: FnOnce() -> ValidationError,
|
||||
{
|
||||
if condition {
|
||||
self.errors.push(error_fn());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.errors.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.errors.len()
|
||||
}
|
||||
|
||||
pub fn into_result<T>(self, success: T) -> Result<T, AlignmentDbError> {
|
||||
if self.errors.is_empty() {
|
||||
Ok(success)
|
||||
} else {
|
||||
Err(AlignmentDbError::BatchValidation(self.errors))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn errors(&self) -> &[ValidationError] {
|
||||
&self.errors
|
||||
}
|
||||
|
||||
pub fn into_errors(self) -> Vec<ValidationError> {
|
||||
self.errors
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Validation Functions ====================
|
||||
|
||||
/// Validate a reference's semantic constraints
|
||||
pub fn validate_ref_semantics(
|
||||
ref_type: RefType,
|
||||
source_type: EntityType,
|
||||
target_type: EntityType,
|
||||
) -> Option<ValidationError> {
|
||||
// resolve, reopen, address must target Tension (T)
|
||||
match ref_type {
|
||||
RefType::Resolve | RefType::Reopen | RefType::Address => {
|
||||
if target_type != EntityType::Tension {
|
||||
return Some(ValidationError::invalid_ref_target(
|
||||
ref_type.as_str(),
|
||||
target_type.as_str(),
|
||||
"T (Tension)",
|
||||
));
|
||||
}
|
||||
}
|
||||
// refine must be same-type
|
||||
RefType::Refine => {
|
||||
if source_type != target_type {
|
||||
return Some(ValidationError::new(
|
||||
ValidationErrorCode::InvalidRefTarget,
|
||||
format!(
|
||||
"Reference type 'refine' requires same entity types. Source: {}, Target: {}",
|
||||
source_type.as_str(),
|
||||
target_type.as_str()
|
||||
),
|
||||
)
|
||||
.with_suggestion("Use matching entity types for refine references (P→P, R→R, etc.)"));
|
||||
}
|
||||
}
|
||||
// support, oppose, question, depend can target any type
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a display ID format and extract components
|
||||
pub fn validate_display_id(id: &str) -> Result<(EntityType, i32, i32), ValidationError> {
|
||||
parse_display_id(id).ok_or_else(|| ValidationError::invalid_display_id(id))
|
||||
}
|
||||
|
||||
/// Validate that a display ID matches an expected entity type
|
||||
pub fn validate_id_type_match(id: &str, expected_type: EntityType) -> Option<ValidationError> {
|
||||
if let Some((actual_type, _, _)) = parse_display_id(id) {
|
||||
if actual_type != expected_type {
|
||||
return Some(ValidationError::type_id_mismatch(expected_type.as_str(), id));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ==================== Enums ====================
|
||||
|
|
@ -2092,4 +2355,120 @@ mod tests {
|
|||
let total_score: i32 = experts.iter().map(|e| e.total_score).sum();
|
||||
assert_eq!(total_score, 27);
|
||||
}
|
||||
|
||||
// ==================== Validation Tests (RFC 0051 Phase 2c) ====================
|
||||
|
||||
#[test]
|
||||
fn test_validation_error_creation() {
|
||||
let err = ValidationError::missing_field("label");
|
||||
assert_eq!(err.code, ValidationErrorCode::MissingField);
|
||||
assert!(err.message.contains("label"));
|
||||
assert_eq!(err.field, Some("label".to_string()));
|
||||
|
||||
let err = ValidationError::invalid_entity_type("X");
|
||||
assert_eq!(err.code, ValidationErrorCode::InvalidEntityType);
|
||||
assert!(err.context.is_some());
|
||||
|
||||
let err = ValidationError::invalid_ref_type("bogus");
|
||||
assert_eq!(err.code, ValidationErrorCode::InvalidRefType);
|
||||
assert!(err.suggestion.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_collector() {
|
||||
let mut collector = ValidationCollector::new();
|
||||
assert!(collector.is_empty());
|
||||
|
||||
collector.add(ValidationError::missing_field("label"));
|
||||
collector.add(ValidationError::missing_field("content"));
|
||||
|
||||
assert!(!collector.is_empty());
|
||||
assert_eq!(collector.len(), 2);
|
||||
|
||||
let errors = collector.into_errors();
|
||||
assert_eq!(errors.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_ref_semantics_resolve_must_target_tension() {
|
||||
// resolve must target Tension
|
||||
let err = validate_ref_semantics(RefType::Resolve, EntityType::Perspective, EntityType::Perspective);
|
||||
assert!(err.is_some());
|
||||
assert_eq!(err.as_ref().unwrap().code, ValidationErrorCode::InvalidRefTarget);
|
||||
|
||||
// resolve targeting Tension is valid
|
||||
let err = validate_ref_semantics(RefType::Resolve, EntityType::Perspective, EntityType::Tension);
|
||||
assert!(err.is_none());
|
||||
|
||||
// address must target Tension
|
||||
let err = validate_ref_semantics(RefType::Address, EntityType::Recommendation, EntityType::Claim);
|
||||
assert!(err.is_some());
|
||||
|
||||
// reopen must target Tension
|
||||
let err = validate_ref_semantics(RefType::Reopen, EntityType::Evidence, EntityType::Evidence);
|
||||
assert!(err.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_ref_semantics_refine_must_be_same_type() {
|
||||
// refine P→P is valid
|
||||
let err = validate_ref_semantics(RefType::Refine, EntityType::Perspective, EntityType::Perspective);
|
||||
assert!(err.is_none());
|
||||
|
||||
// refine R→R is valid
|
||||
let err = validate_ref_semantics(RefType::Refine, EntityType::Recommendation, EntityType::Recommendation);
|
||||
assert!(err.is_none());
|
||||
|
||||
// refine P→R is invalid (different types)
|
||||
let err = validate_ref_semantics(RefType::Refine, EntityType::Perspective, EntityType::Recommendation);
|
||||
assert!(err.is_some());
|
||||
assert_eq!(err.as_ref().unwrap().code, ValidationErrorCode::InvalidRefTarget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_ref_semantics_support_can_target_any() {
|
||||
// support can target any entity type
|
||||
assert!(validate_ref_semantics(RefType::Support, EntityType::Perspective, EntityType::Tension).is_none());
|
||||
assert!(validate_ref_semantics(RefType::Support, EntityType::Evidence, EntityType::Claim).is_none());
|
||||
assert!(validate_ref_semantics(RefType::Support, EntityType::Claim, EntityType::Recommendation).is_none());
|
||||
|
||||
// oppose can target any
|
||||
assert!(validate_ref_semantics(RefType::Oppose, EntityType::Perspective, EntityType::Perspective).is_none());
|
||||
|
||||
// question can target any
|
||||
assert!(validate_ref_semantics(RefType::Question, EntityType::Evidence, EntityType::Claim).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_display_id() {
|
||||
// Valid IDs
|
||||
assert!(validate_display_id("P0101").is_ok());
|
||||
assert!(validate_display_id("T0001").is_ok());
|
||||
assert!(validate_display_id("R0203").is_ok());
|
||||
assert!(validate_display_id("E0105").is_ok());
|
||||
assert!(validate_display_id("C0001").is_ok());
|
||||
|
||||
// Invalid IDs
|
||||
assert!(validate_display_id("X0101").is_err()); // Invalid prefix
|
||||
assert!(validate_display_id("P01").is_err()); // Too short
|
||||
assert!(validate_display_id("P010101").is_err()); // Too long
|
||||
assert!(validate_display_id("").is_err()); // Empty
|
||||
assert!(validate_display_id("PERSPECTIVE").is_err()); // Not a display ID
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_id_mismatch_error() {
|
||||
let err = ValidationError::type_id_mismatch("P", "T0101");
|
||||
assert_eq!(err.code, ValidationErrorCode::TypeIdMismatch);
|
||||
assert!(err.message.contains("P"));
|
||||
assert!(err.message.contains("T0101"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_ref_target_error() {
|
||||
let err = ValidationError::invalid_ref_target("resolve", "P", "T (Tension)");
|
||||
assert_eq!(err.code, ValidationErrorCode::InvalidRefTarget);
|
||||
assert!(err.message.contains("resolve"));
|
||||
assert!(err.message.contains("Tension"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@ use std::path::{Path, PathBuf};
|
|||
use std::process::Command;
|
||||
|
||||
use blue_core::{DocType, Document, LinkType, ProjectState, title_to_slug};
|
||||
use blue_core::alignment_db::{
|
||||
self, ExpertTier as DbExpertTier, ExpertSource as DbExpertSource,
|
||||
EntityType, RefType, VerdictType, Verdict,
|
||||
ValidationError, ValidationCollector,
|
||||
validate_ref_semantics, validate_display_id,
|
||||
get_dialogue, register_expert, get_experts,
|
||||
create_round, register_perspective, register_tension,
|
||||
register_recommendation, register_evidence, register_claim, register_ref,
|
||||
register_verdict, update_tension_status, update_expert_score,
|
||||
get_perspectives, get_tensions, get_recommendations, get_evidence, get_claims, get_verdicts,
|
||||
display_id, parse_display_id,
|
||||
};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -1984,6 +1996,938 @@ pub fn handle_evolve_panel(args: &Value) -> Result<Value, ServerError> {
|
|||
}))
|
||||
}
|
||||
|
||||
// ==================== RFC 0051: Global Perspective & Tension Tracking ====================
|
||||
|
||||
/// Handle blue_dialogue_round_context (RFC 0051)
|
||||
///
|
||||
/// Bulk fetch context for all panel experts in a single call.
|
||||
/// Returns structured data for prompt building: open perspectives, tensions, etc.
|
||||
pub fn handle_round_context(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let dialogue_id = args
|
||||
.get("dialogue_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let round = args
|
||||
.get("round")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or(ServerError::InvalidParams)? as i32;
|
||||
|
||||
let conn = state.store.conn();
|
||||
|
||||
// Get dialogue
|
||||
let dialogue = get_dialogue(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
|
||||
|
||||
// Get experts
|
||||
let experts = get_experts(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get experts: {}", e)))?;
|
||||
|
||||
// Get all perspectives
|
||||
let perspectives = get_perspectives(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get perspectives: {}", e)))?;
|
||||
|
||||
// Get all tensions (filter for open/addressed in response)
|
||||
let tensions = get_tensions(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get tensions: {}", e)))?;
|
||||
|
||||
// Get recommendations
|
||||
let recommendations = get_recommendations(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get recommendations: {}", e)))?;
|
||||
|
||||
// Get evidence
|
||||
let evidence = get_evidence(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get evidence: {}", e)))?;
|
||||
|
||||
// Get claims
|
||||
let claims = get_claims(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get claims: {}", e)))?;
|
||||
|
||||
// Get verdicts
|
||||
let verdicts = get_verdicts(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get verdicts: {}", e)))?;
|
||||
|
||||
// Build context response
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"dialogue_id": dialogue_id,
|
||||
"round": round,
|
||||
"dialogue": {
|
||||
"title": dialogue.title,
|
||||
"question": dialogue.question,
|
||||
"status": dialogue.status.as_str(),
|
||||
"total_rounds": dialogue.total_rounds,
|
||||
"total_alignment": dialogue.total_alignment,
|
||||
},
|
||||
"experts": experts.iter().map(|e| json!({
|
||||
"expert_slug": e.expert_slug,
|
||||
"role": e.role,
|
||||
"tier": e.tier.as_str(),
|
||||
"source": e.source.as_str(),
|
||||
"focus": e.focus,
|
||||
"total_score": e.total_score,
|
||||
})).collect::<Vec<_>>(),
|
||||
"perspectives": perspectives.iter().map(|p| json!({
|
||||
"id": display_id(EntityType::Perspective, p.round, p.seq),
|
||||
"round": p.round,
|
||||
"label": p.label,
|
||||
"content": p.content,
|
||||
"contributors": p.contributors,
|
||||
"status": p.status.as_str(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"tensions": tensions.iter().map(|t| json!({
|
||||
"id": display_id(EntityType::Tension, t.round, t.seq),
|
||||
"round": t.round,
|
||||
"label": t.label,
|
||||
"description": t.description,
|
||||
"contributors": t.contributors,
|
||||
"status": t.status.as_str(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"open_tensions": tensions.iter()
|
||||
.filter(|t| matches!(t.status, alignment_db::TensionStatus::Open | alignment_db::TensionStatus::Reopened))
|
||||
.map(|t| display_id(EntityType::Tension, t.round, t.seq))
|
||||
.collect::<Vec<_>>(),
|
||||
"recommendations": recommendations.iter().map(|r| json!({
|
||||
"id": display_id(EntityType::Recommendation, r.round, r.seq),
|
||||
"round": r.round,
|
||||
"label": r.label,
|
||||
"content": r.content,
|
||||
"contributors": r.contributors,
|
||||
"status": r.status.as_str(),
|
||||
"parameters": r.parameters,
|
||||
})).collect::<Vec<_>>(),
|
||||
"evidence": evidence.iter().map(|e| json!({
|
||||
"id": display_id(EntityType::Evidence, e.round, e.seq),
|
||||
"round": e.round,
|
||||
"label": e.label,
|
||||
"content": e.content,
|
||||
"contributors": e.contributors,
|
||||
"status": e.status.as_str(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"claims": claims.iter().map(|c| json!({
|
||||
"id": display_id(EntityType::Claim, c.round, c.seq),
|
||||
"round": c.round,
|
||||
"label": c.label,
|
||||
"content": c.content,
|
||||
"contributors": c.contributors,
|
||||
"status": c.status.as_str(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"verdicts": verdicts.iter().map(|v| json!({
|
||||
"verdict_id": v.verdict_id,
|
||||
"verdict_type": v.verdict_type.as_str(),
|
||||
"round": v.round,
|
||||
"recommendation": v.recommendation,
|
||||
"description": v.description,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Handle blue_dialogue_expert_create (RFC 0051)
|
||||
///
|
||||
/// Create a new expert mid-dialogue to address emerging needs.
|
||||
pub fn handle_expert_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let dialogue_id = args
|
||||
.get("dialogue_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let expert_slug = args
|
||||
.get("expert_slug")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let role = args
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let tier_str = args
|
||||
.get("tier")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let tier = DbExpertTier::from_str(tier_str);
|
||||
|
||||
let focus = args.get("focus").and_then(|v| v.as_str());
|
||||
|
||||
let creation_reason = args
|
||||
.get("creation_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let first_round = args
|
||||
.get("first_round")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or(ServerError::InvalidParams)? as i32;
|
||||
|
||||
let conn = state.store.conn();
|
||||
|
||||
// Verify dialogue exists
|
||||
let _dialogue = get_dialogue(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
|
||||
|
||||
// Register expert with source=created
|
||||
register_expert(
|
||||
conn,
|
||||
dialogue_id,
|
||||
expert_slug,
|
||||
role,
|
||||
tier,
|
||||
DbExpertSource::Created,
|
||||
None, // description
|
||||
focus,
|
||||
None, // relevance
|
||||
Some(creation_reason),
|
||||
None, // color
|
||||
Some(first_round),
|
||||
)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to create expert: {}", e)))?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": format!("Created expert '{}' ({}) for round {}", expert_slug, role, first_round),
|
||||
"expert": {
|
||||
"expert_slug": expert_slug,
|
||||
"role": role,
|
||||
"tier": tier.as_str(),
|
||||
"source": "created",
|
||||
"focus": focus,
|
||||
"creation_reason": creation_reason,
|
||||
"first_round": first_round,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Validate round_register inputs (RFC 0051 Phase 2c)
|
||||
///
|
||||
/// Returns all validation errors, not just the first one.
|
||||
fn validate_round_register_inputs(args: &Value) -> Vec<ValidationError> {
|
||||
let mut collector = ValidationCollector::new();
|
||||
|
||||
// Validate perspectives
|
||||
if let Some(perspectives) = args.get("perspectives").and_then(|v| v.as_array()) {
|
||||
for (i, p) in perspectives.iter().enumerate() {
|
||||
let field_prefix = format!("perspectives[{}]", i);
|
||||
|
||||
// Required fields
|
||||
if p.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
|
||||
}
|
||||
if p.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
|
||||
}
|
||||
|
||||
// Validate references
|
||||
if let Some(refs) = p.get("references").and_then(|v| v.as_array()) {
|
||||
for (j, r) in refs.iter().enumerate() {
|
||||
let ref_field = format!("{}.references[{}]", field_prefix, j);
|
||||
|
||||
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Validate ref type
|
||||
if RefType::from_str(ref_type_str).is_none() && !ref_type_str.is_empty() {
|
||||
collector.add(ValidationError::invalid_ref_type(ref_type_str).with_field(format!("{}.type", ref_field)));
|
||||
}
|
||||
|
||||
// Validate target ID format
|
||||
if !target.is_empty() {
|
||||
if let Err(e) = validate_display_id(target) {
|
||||
collector.add(e.with_field(format!("{}.target", ref_field)));
|
||||
} else if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
// Validate semantic constraints
|
||||
if let Some(e) = validate_ref_semantics(ref_type, EntityType::Perspective, target_type) {
|
||||
collector.add(e.with_field(ref_field));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tensions
|
||||
if let Some(tensions) = args.get("tensions").and_then(|v| v.as_array()) {
|
||||
for (i, t) in tensions.iter().enumerate() {
|
||||
let field_prefix = format!("tensions[{}]", i);
|
||||
|
||||
if t.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
|
||||
}
|
||||
if t.get("description").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("description").with_field(format!("{}.description", field_prefix)));
|
||||
}
|
||||
|
||||
// Validate references
|
||||
if let Some(refs) = t.get("references").and_then(|v| v.as_array()) {
|
||||
for (j, r) in refs.iter().enumerate() {
|
||||
let ref_field = format!("{}.references[{}]", field_prefix, j);
|
||||
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if RefType::from_str(ref_type_str).is_none() && !ref_type_str.is_empty() {
|
||||
collector.add(ValidationError::invalid_ref_type(ref_type_str).with_field(format!("{}.type", ref_field)));
|
||||
}
|
||||
|
||||
if !target.is_empty() {
|
||||
if let Err(e) = validate_display_id(target) {
|
||||
collector.add(e.with_field(format!("{}.target", ref_field)));
|
||||
} else if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
if let Some(e) = validate_ref_semantics(ref_type, EntityType::Tension, target_type) {
|
||||
collector.add(e.with_field(ref_field));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate recommendations
|
||||
if let Some(recommendations) = args.get("recommendations").and_then(|v| v.as_array()) {
|
||||
for (i, r) in recommendations.iter().enumerate() {
|
||||
let field_prefix = format!("recommendations[{}]", i);
|
||||
|
||||
if r.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
|
||||
}
|
||||
if r.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
|
||||
}
|
||||
|
||||
// Validate references
|
||||
if let Some(refs) = r.get("references").and_then(|v| v.as_array()) {
|
||||
for (j, ref_obj) in refs.iter().enumerate() {
|
||||
let ref_field = format!("{}.references[{}]", field_prefix, j);
|
||||
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if RefType::from_str(ref_type_str).is_none() && !ref_type_str.is_empty() {
|
||||
collector.add(ValidationError::invalid_ref_type(ref_type_str).with_field(format!("{}.type", ref_field)));
|
||||
}
|
||||
|
||||
if !target.is_empty() {
|
||||
if let Err(e) = validate_display_id(target) {
|
||||
collector.add(e.with_field(format!("{}.target", ref_field)));
|
||||
} else if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
if let Some(e) = validate_ref_semantics(ref_type, EntityType::Recommendation, target_type) {
|
||||
collector.add(e.with_field(ref_field));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate evidence
|
||||
if let Some(evidence) = args.get("evidence").and_then(|v| v.as_array()) {
|
||||
for (i, e) in evidence.iter().enumerate() {
|
||||
let field_prefix = format!("evidence[{}]", i);
|
||||
|
||||
if e.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
|
||||
}
|
||||
if e.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate claims
|
||||
if let Some(claims) = args.get("claims").and_then(|v| v.as_array()) {
|
||||
for (i, c) in claims.iter().enumerate() {
|
||||
let field_prefix = format!("claims[{}]", i);
|
||||
|
||||
if c.get("label").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("label").with_field(format!("{}.label", field_prefix)));
|
||||
}
|
||||
if c.get("content").and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) {
|
||||
collector.add(ValidationError::missing_field("content").with_field(format!("{}.content", field_prefix)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tension updates
|
||||
if let Some(updates) = args.get("tension_updates").and_then(|v| v.as_array()) {
|
||||
for (i, u) in updates.iter().enumerate() {
|
||||
let field_prefix = format!("tension_updates[{}]", i);
|
||||
let tension_id = u.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if tension_id.is_empty() {
|
||||
collector.add(ValidationError::missing_field("id").with_field(format!("{}.id", field_prefix)));
|
||||
} else if let Err(e) = validate_display_id(tension_id) {
|
||||
collector.add(e.with_field(format!("{}.id", field_prefix)));
|
||||
} else if let Some((entity_type, _, _)) = parse_display_id(tension_id) {
|
||||
if entity_type != EntityType::Tension {
|
||||
collector.add(ValidationError::type_id_mismatch("T", tension_id).with_field(format!("{}.id", field_prefix)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collector.into_errors()
|
||||
}
|
||||
|
||||
/// Convert validation errors to a structured JSON response
|
||||
fn validation_errors_to_json(errors: &[ValidationError]) -> Value {
|
||||
json!({
|
||||
"status": "error",
|
||||
"error_code": "validation_failed",
|
||||
"error_count": errors.len(),
|
||||
"message": format!("Validation failed with {} error(s)", errors.len()),
|
||||
"errors": errors.iter().map(|e| json!({
|
||||
"code": e.code.as_str(),
|
||||
"message": e.message,
|
||||
"field": e.field,
|
||||
"suggestion": e.suggestion,
|
||||
"context": e.context,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle blue_dialogue_round_register (RFC 0051)
|
||||
///
|
||||
/// Bulk register all round data in a single atomic call.
|
||||
pub fn handle_round_register(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let dialogue_id = args
|
||||
.get("dialogue_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let round = args
|
||||
.get("round")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or(ServerError::InvalidParams)? as i32;
|
||||
|
||||
let score = args
|
||||
.get("score")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or(ServerError::InvalidParams)? as i32;
|
||||
|
||||
let summary = args.get("summary").and_then(|v| v.as_str());
|
||||
|
||||
// Phase 2c: Batch validation - collect ALL errors before registration
|
||||
let validation_errors = validate_round_register_inputs(args);
|
||||
if !validation_errors.is_empty() {
|
||||
return Ok(validation_errors_to_json(&validation_errors));
|
||||
}
|
||||
|
||||
let conn = state.store.conn();
|
||||
|
||||
// Verify dialogue exists
|
||||
let _dialogue = get_dialogue(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
|
||||
|
||||
// Create round record (title, then score)
|
||||
create_round(conn, dialogue_id, round, summary, score)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to create round: {}", e)))?;
|
||||
|
||||
let mut registered = json!({
|
||||
"perspectives": [],
|
||||
"tensions": [],
|
||||
"recommendations": [],
|
||||
"evidence": [],
|
||||
"claims": [],
|
||||
"refs": 0,
|
||||
"expert_scores": [],
|
||||
});
|
||||
|
||||
// Register perspectives
|
||||
if let Some(perspectives) = args.get("perspectives").and_then(|v| v.as_array()) {
|
||||
let mut p_ids = Vec::new();
|
||||
for p in perspectives {
|
||||
let local_id = p.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let label = p.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = p.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let contributors: Vec<String> = p
|
||||
.get("contributors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let global_id = register_perspective(conn, dialogue_id, round, label, content, &contributors, None)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to register perspective: {}", e)))?;
|
||||
|
||||
// Register refs for this perspective
|
||||
if let Some(refs) = p.get("references").and_then(|v| v.as_array()) {
|
||||
for r in refs {
|
||||
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("support");
|
||||
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
let _ = register_ref(conn, dialogue_id, EntityType::Perspective, &global_id, ref_type, target_type, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
|
||||
}
|
||||
registered["perspectives"] = json!(p_ids);
|
||||
}
|
||||
|
||||
// Register tensions
|
||||
if let Some(tensions) = args.get("tensions").and_then(|v| v.as_array()) {
|
||||
let mut t_ids = Vec::new();
|
||||
for t in tensions {
|
||||
let local_id = t.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let label = t.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let description = t.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let contributors: Vec<String> = t
|
||||
.get("contributors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let global_id = register_tension(conn, dialogue_id, round, label, description, &contributors, None)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to register tension: {}", e)))?;
|
||||
|
||||
// Register refs for this tension
|
||||
if let Some(refs) = t.get("references").and_then(|v| v.as_array()) {
|
||||
for r in refs {
|
||||
let ref_type_str = r.get("type").and_then(|v| v.as_str()).unwrap_or("support");
|
||||
let target = r.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
let _ = register_ref(conn, dialogue_id, EntityType::Tension, &global_id, ref_type, target_type, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
|
||||
}
|
||||
registered["tensions"] = json!(t_ids);
|
||||
}
|
||||
|
||||
// Register recommendations
|
||||
if let Some(recommendations) = args.get("recommendations").and_then(|v| v.as_array()) {
|
||||
let mut r_ids = Vec::new();
|
||||
for r in recommendations {
|
||||
let local_id = r.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = r.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let contributors: Vec<String> = r
|
||||
.get("contributors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
let parameters = r.get("parameters").cloned();
|
||||
|
||||
let global_id = register_recommendation(conn, dialogue_id, round, label, content, &contributors, parameters.as_ref(), None)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to register recommendation: {}", e)))?;
|
||||
|
||||
// Register refs
|
||||
if let Some(refs) = r.get("references").and_then(|v| v.as_array()) {
|
||||
for ref_obj in refs {
|
||||
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("support");
|
||||
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
let _ = register_ref(conn, dialogue_id, EntityType::Recommendation, &global_id, ref_type, target_type, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
|
||||
}
|
||||
registered["recommendations"] = json!(r_ids);
|
||||
}
|
||||
|
||||
// Register evidence
|
||||
if let Some(evidence_arr) = args.get("evidence").and_then(|v| v.as_array()) {
|
||||
let mut e_ids = Vec::new();
|
||||
for e in evidence_arr {
|
||||
let local_id = e.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let label = e.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = e.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let contributors: Vec<String> = e
|
||||
.get("contributors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let global_id = register_evidence(conn, dialogue_id, round, label, content, &contributors, None)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to register evidence: {}", e)))?;
|
||||
|
||||
// Register refs
|
||||
if let Some(refs) = e.get("references").and_then(|v| v.as_array()) {
|
||||
for ref_obj in refs {
|
||||
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("support");
|
||||
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
let _ = register_ref(conn, dialogue_id, EntityType::Evidence, &global_id, ref_type, target_type, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
|
||||
}
|
||||
registered["evidence"] = json!(e_ids);
|
||||
}
|
||||
|
||||
// Register claims
|
||||
if let Some(claims_arr) = args.get("claims").and_then(|v| v.as_array()) {
|
||||
let mut c_ids = Vec::new();
|
||||
for c in claims_arr {
|
||||
let local_id = c.get("local_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let label = c.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = c.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let contributors: Vec<String> = c
|
||||
.get("contributors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let global_id = register_claim(conn, dialogue_id, round, label, content, &contributors, None)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to register claim: {}", e)))?;
|
||||
|
||||
// Register refs
|
||||
if let Some(refs) = c.get("references").and_then(|v| v.as_array()) {
|
||||
for ref_obj in refs {
|
||||
let ref_type_str = ref_obj.get("type").and_then(|v| v.as_str()).unwrap_or("support");
|
||||
let target = ref_obj.get("target").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let (Some(ref_type), Some((target_type, _, _))) = (RefType::from_str(ref_type_str), parse_display_id(target)) {
|
||||
let _ = register_ref(conn, dialogue_id, EntityType::Claim, &global_id, ref_type, target_type, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c_ids.push(json!({ "local_id": local_id, "global_id": global_id }));
|
||||
}
|
||||
registered["claims"] = json!(c_ids);
|
||||
}
|
||||
|
||||
// Process tension updates
|
||||
if let Some(updates) = args.get("tension_updates").and_then(|v| v.as_array()) {
|
||||
for u in updates {
|
||||
let tension_id = u.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status_str = u.get("status").and_then(|v| v.as_str()).unwrap_or("open");
|
||||
let actors: Vec<String> = u
|
||||
.get("by")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.or_else(|| u.get("by").and_then(|v| v.as_str()).map(|s| vec![s.to_string()]))
|
||||
.unwrap_or_default();
|
||||
let via = u.get("via").and_then(|v| v.as_str());
|
||||
|
||||
let status = alignment_db::TensionStatus::from_str(status_str);
|
||||
let _ = update_tension_status(conn, dialogue_id, tension_id, status, &actors, via, round);
|
||||
}
|
||||
}
|
||||
|
||||
// Update expert scores
|
||||
if let Some(expert_scores) = args.get("expert_scores").and_then(|v| v.as_object()) {
|
||||
let mut updated_scores = Vec::new();
|
||||
for (expert_slug, score_val) in expert_scores {
|
||||
if let Some(expert_score) = score_val.as_i64() {
|
||||
let _ = update_expert_score(conn, dialogue_id, expert_slug, round, expert_score as i32);
|
||||
updated_scores.push(json!({ "expert_slug": expert_slug, "score": expert_score }));
|
||||
}
|
||||
}
|
||||
registered["expert_scores"] = json!(updated_scores);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": format!("Registered round {} data for dialogue '{}'", round, dialogue_id),
|
||||
"dialogue_id": dialogue_id,
|
||||
"round": round,
|
||||
"score": score,
|
||||
"registered": registered,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Handle blue_dialogue_verdict_register (RFC 0051)
|
||||
///
|
||||
/// Register a verdict (interim, final, minority, or dissent).
|
||||
pub fn handle_verdict_register(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let dialogue_id = args
|
||||
.get("dialogue_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let verdict_id = args
|
||||
.get("verdict_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let verdict_type_str = args
|
||||
.get("verdict_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let verdict_type = VerdictType::from_str(verdict_type_str);
|
||||
|
||||
let round = args
|
||||
.get("round")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or(ServerError::InvalidParams)? as i32;
|
||||
|
||||
let recommendation = args
|
||||
.get("recommendation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let description = args
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
// Optional fields
|
||||
let conditions: Option<Vec<String>> = args
|
||||
.get("conditions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
|
||||
|
||||
let vote = args.get("vote").and_then(|v| v.as_str()).map(String::from);
|
||||
let confidence = args.get("confidence").and_then(|v| v.as_str()).map(String::from);
|
||||
let author_expert = args.get("author_expert").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
let tensions_resolved: Option<Vec<String>> = args
|
||||
.get("tensions_resolved")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
|
||||
|
||||
let tensions_accepted: Option<Vec<String>> = args
|
||||
.get("tensions_accepted")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
|
||||
|
||||
let recommendations_adopted: Option<Vec<String>> = args
|
||||
.get("recommendations_adopted")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
|
||||
|
||||
let supporting_experts: Option<Vec<String>> = args
|
||||
.get("supporting_experts")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
|
||||
|
||||
let conn = state.store.conn();
|
||||
|
||||
// Verify dialogue exists
|
||||
let _dialogue = get_dialogue(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
|
||||
|
||||
let verdict = Verdict {
|
||||
dialogue_id: dialogue_id.to_string(),
|
||||
verdict_id: verdict_id.to_string(),
|
||||
verdict_type,
|
||||
round,
|
||||
author_expert,
|
||||
recommendation: recommendation.to_string(),
|
||||
description: description.to_string(),
|
||||
conditions,
|
||||
vote,
|
||||
confidence,
|
||||
tensions_resolved,
|
||||
tensions_accepted,
|
||||
recommendations_adopted,
|
||||
key_evidence: None,
|
||||
key_claims: None,
|
||||
supporting_experts,
|
||||
ethos_compliance: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
register_verdict(conn, &verdict)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to register verdict: {}", e)))?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": format!("Registered {} verdict '{}' for dialogue '{}'", verdict_type.as_str(), verdict_id, dialogue_id),
|
||||
"dialogue_id": dialogue_id,
|
||||
"verdict_id": verdict_id,
|
||||
"verdict_type": verdict_type.as_str(),
|
||||
"round": round,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Handle blue_dialogue_export (RFC 0051)
|
||||
///
|
||||
/// Export dialogue to JSON with full provenance from database.
|
||||
pub fn handle_export(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let dialogue_id = args
|
||||
.get("dialogue_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let output_path = args.get("output_path").and_then(|v| v.as_str());
|
||||
|
||||
let conn = state.store.conn();
|
||||
|
||||
// Get dialogue
|
||||
let dialogue = get_dialogue(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Dialogue not found: {}", e)))?;
|
||||
|
||||
// Get all data
|
||||
let experts = get_experts(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get experts: {}", e)))?;
|
||||
|
||||
let perspectives = get_perspectives(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get perspectives: {}", e)))?;
|
||||
|
||||
let tensions = get_tensions(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get tensions: {}", e)))?;
|
||||
|
||||
let recommendations = get_recommendations(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get recommendations: {}", e)))?;
|
||||
|
||||
let evidence = get_evidence(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get evidence: {}", e)))?;
|
||||
|
||||
let claims = get_claims(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get claims: {}", e)))?;
|
||||
|
||||
let verdicts = get_verdicts(conn, dialogue_id)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to get verdicts: {}", e)))?;
|
||||
|
||||
// Build export structure
|
||||
let export_data = json!({
|
||||
"dialogue": {
|
||||
"dialogue_id": dialogue.dialogue_id,
|
||||
"title": dialogue.title,
|
||||
"question": dialogue.question,
|
||||
"status": dialogue.status.as_str(),
|
||||
"created_at": dialogue.created_at.to_rfc3339(),
|
||||
"converged_at": dialogue.converged_at.map(|dt| dt.to_rfc3339()),
|
||||
"total_rounds": dialogue.total_rounds,
|
||||
"total_alignment": dialogue.total_alignment,
|
||||
"output_dir": dialogue.output_dir,
|
||||
"calibrated": dialogue.calibrated,
|
||||
"background": dialogue.background,
|
||||
},
|
||||
"experts": experts.iter().map(|e| json!({
|
||||
"expert_slug": e.expert_slug,
|
||||
"role": e.role,
|
||||
"description": e.description,
|
||||
"focus": e.focus,
|
||||
"tier": e.tier.as_str(),
|
||||
"source": e.source.as_str(),
|
||||
"relevance": e.relevance,
|
||||
"creation_reason": e.creation_reason,
|
||||
"scores": e.scores,
|
||||
"total_score": e.total_score,
|
||||
"first_round": e.first_round,
|
||||
"created_at": e.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"perspectives": perspectives.iter().map(|p| json!({
|
||||
"id": display_id(EntityType::Perspective, p.round, p.seq),
|
||||
"round": p.round,
|
||||
"seq": p.seq,
|
||||
"label": p.label,
|
||||
"content": p.content,
|
||||
"contributors": p.contributors,
|
||||
"status": p.status.as_str(),
|
||||
"refs": p.refs,
|
||||
"created_at": p.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"tensions": tensions.iter().map(|t| json!({
|
||||
"id": display_id(EntityType::Tension, t.round, t.seq),
|
||||
"round": t.round,
|
||||
"seq": t.seq,
|
||||
"label": t.label,
|
||||
"description": t.description,
|
||||
"contributors": t.contributors,
|
||||
"status": t.status.as_str(),
|
||||
"refs": t.refs,
|
||||
"created_at": t.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"recommendations": recommendations.iter().map(|r| json!({
|
||||
"id": display_id(EntityType::Recommendation, r.round, r.seq),
|
||||
"round": r.round,
|
||||
"seq": r.seq,
|
||||
"label": r.label,
|
||||
"content": r.content,
|
||||
"contributors": r.contributors,
|
||||
"parameters": r.parameters,
|
||||
"status": r.status.as_str(),
|
||||
"refs": r.refs,
|
||||
"adopted_in_verdict": r.adopted_in_verdict,
|
||||
"created_at": r.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"evidence": evidence.iter().map(|e| json!({
|
||||
"id": display_id(EntityType::Evidence, e.round, e.seq),
|
||||
"round": e.round,
|
||||
"seq": e.seq,
|
||||
"label": e.label,
|
||||
"content": e.content,
|
||||
"contributors": e.contributors,
|
||||
"status": e.status.as_str(),
|
||||
"refs": e.refs,
|
||||
"created_at": e.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"claims": claims.iter().map(|c| json!({
|
||||
"id": display_id(EntityType::Claim, c.round, c.seq),
|
||||
"round": c.round,
|
||||
"seq": c.seq,
|
||||
"label": c.label,
|
||||
"content": c.content,
|
||||
"contributors": c.contributors,
|
||||
"status": c.status.as_str(),
|
||||
"refs": c.refs,
|
||||
"created_at": c.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"verdicts": verdicts.iter().map(|v| json!({
|
||||
"verdict_id": v.verdict_id,
|
||||
"verdict_type": v.verdict_type.as_str(),
|
||||
"round": v.round,
|
||||
"author_expert": v.author_expert,
|
||||
"recommendation": v.recommendation,
|
||||
"description": v.description,
|
||||
"conditions": v.conditions,
|
||||
"vote": v.vote,
|
||||
"confidence": v.confidence,
|
||||
"tensions_resolved": v.tensions_resolved,
|
||||
"tensions_accepted": v.tensions_accepted,
|
||||
"recommendations_adopted": v.recommendations_adopted,
|
||||
"key_evidence": v.key_evidence,
|
||||
"key_claims": v.key_claims,
|
||||
"supporting_experts": v.supporting_experts,
|
||||
"created_at": v.created_at.to_rfc3339(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"exported_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
// Determine output path
|
||||
let final_path = match output_path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let dir = dialogue.output_dir.as_deref().unwrap_or("/tmp/blue-dialogue");
|
||||
format!("{}/{}/dialogue.json", dir, dialogue_id)
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(&final_path).parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
ServerError::CommandFailed(format!("Failed to create output directory: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
// Write JSON file
|
||||
let json_str = serde_json::to_string_pretty(&export_data).map_err(|e| {
|
||||
ServerError::CommandFailed(format!("Failed to serialize export data: {}", e))
|
||||
})?;
|
||||
|
||||
fs::write(&final_path, &json_str).map_err(|e| {
|
||||
ServerError::CommandFailed(format!("Failed to write export file: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": format!("Exported dialogue '{}' to {}", dialogue_id, final_path),
|
||||
"dialogue_id": dialogue_id,
|
||||
"output_path": final_path,
|
||||
"stats": {
|
||||
"experts": experts.len(),
|
||||
"perspectives": perspectives.len(),
|
||||
"tensions": tensions.len(),
|
||||
"recommendations": recommendations.len(),
|
||||
"evidence": evidence.len(),
|
||||
"claims": claims.len(),
|
||||
"verdicts": verdicts.len(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,209 @@ impl BlueServer {
|
|||
"required": ["output_dir", "round", "panel"]
|
||||
}
|
||||
},
|
||||
// RFC 0051: Global perspective & tension tracking
|
||||
{
|
||||
"name": "blue_dialogue_round_context",
|
||||
"description": "RFC 0051: Bulk fetch context for all panel experts in a single call. Returns structured data for prompt building.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dialogue_id": {
|
||||
"type": "string",
|
||||
"description": "Dialogue ID (e.g., 'nvidia-investment-analysis')"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"description": "Round number to get context for"
|
||||
}
|
||||
},
|
||||
"required": ["dialogue_id", "round"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_dialogue_expert_create",
|
||||
"description": "RFC 0051: Create a new expert mid-dialogue to address emerging needs. The expert will be registered with source='created'.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dialogue_id": {
|
||||
"type": "string",
|
||||
"description": "Dialogue ID"
|
||||
},
|
||||
"expert_slug": {
|
||||
"type": "string",
|
||||
"description": "Expert slug (lowercase, e.g., 'palmier')"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "Expert role (e.g., 'Geopolitical Risk Analyst')"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"enum": ["Core", "Adjacent", "Wildcard"],
|
||||
"description": "Expert tier"
|
||||
},
|
||||
"focus": {
|
||||
"type": "string",
|
||||
"description": "Focus area for this expert"
|
||||
},
|
||||
"creation_reason": {
|
||||
"type": "string",
|
||||
"description": "Why the Judge created this expert"
|
||||
},
|
||||
"first_round": {
|
||||
"type": "integer",
|
||||
"description": "Round when expert joins"
|
||||
}
|
||||
},
|
||||
"required": ["dialogue_id", "expert_slug", "role", "tier", "creation_reason", "first_round"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_dialogue_round_register",
|
||||
"description": "RFC 0051: Bulk register all round data in a single atomic call - perspectives, recommendations, tensions, evidence, claims, scores, and refs.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dialogue_id": {
|
||||
"type": "string",
|
||||
"description": "Dialogue ID"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"description": "Round number"
|
||||
},
|
||||
"score": {
|
||||
"type": "integer",
|
||||
"description": "ALIGNMENT score for this round"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Judge's synthesis of the round"
|
||||
},
|
||||
"expert_scores": {
|
||||
"type": "object",
|
||||
"description": "Map of expert_slug to score (e.g., {\"muffin\": 12, \"donut\": 15})"
|
||||
},
|
||||
"perspectives": {
|
||||
"type": "array",
|
||||
"description": "Array of perspective objects with local_id, label, content, contributors, references"
|
||||
},
|
||||
"recommendations": {
|
||||
"type": "array",
|
||||
"description": "Array of recommendation objects with local_id, label, content, contributors, parameters, references"
|
||||
},
|
||||
"tensions": {
|
||||
"type": "array",
|
||||
"description": "Array of tension objects with local_id, label, description, contributors, references"
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"description": "Array of evidence objects with local_id, label, content, contributors, references"
|
||||
},
|
||||
"claims": {
|
||||
"type": "array",
|
||||
"description": "Array of claim objects with local_id, label, content, contributors, references"
|
||||
},
|
||||
"tension_updates": {
|
||||
"type": "array",
|
||||
"description": "Array of tension status updates: {id, status, by, via}"
|
||||
}
|
||||
},
|
||||
"required": ["dialogue_id", "round", "score"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_dialogue_verdict_register",
|
||||
"description": "RFC 0051: Register a verdict (interim, final, minority, or dissent).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dialogue_id": {
|
||||
"type": "string",
|
||||
"description": "Dialogue ID"
|
||||
},
|
||||
"verdict_id": {
|
||||
"type": "string",
|
||||
"description": "Verdict identifier (e.g., 'final', 'V01', 'minority-esg')"
|
||||
},
|
||||
"verdict_type": {
|
||||
"type": "string",
|
||||
"enum": ["interim", "final", "minority", "dissent"],
|
||||
"description": "Type of verdict"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"description": "Round when verdict was issued"
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string",
|
||||
"description": "One-line decision (e.g., 'APPROVE conditional partial trim')"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Reasoning summary (2-3 sentences)"
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Conditions for the verdict"
|
||||
},
|
||||
"vote": {
|
||||
"type": "string",
|
||||
"description": "Vote count (e.g., '12-0', '11-1')"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "string",
|
||||
"enum": ["unanimous", "strong", "split", "contested"],
|
||||
"description": "Confidence level"
|
||||
},
|
||||
"tensions_resolved": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Tension IDs resolved by this verdict"
|
||||
},
|
||||
"tensions_accepted": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Tension IDs accepted (acknowledged but not blocking)"
|
||||
},
|
||||
"recommendations_adopted": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Recommendation IDs adopted"
|
||||
},
|
||||
"author_expert": {
|
||||
"type": "string",
|
||||
"description": "Expert slug if authored by expert (null = Judge)"
|
||||
},
|
||||
"supporting_experts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Expert slugs supporting minority verdict"
|
||||
}
|
||||
},
|
||||
"required": ["dialogue_id", "verdict_id", "verdict_type", "round", "recommendation", "description"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_dialogue_export",
|
||||
"description": "RFC 0051: Export dialogue to JSON with full provenance from database.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dialogue_id": {
|
||||
"type": "string",
|
||||
"description": "Dialogue ID to export"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Path to write JSON file (optional, defaults to output_dir/dialogue.json)"
|
||||
}
|
||||
},
|
||||
"required": ["dialogue_id"]
|
||||
}
|
||||
},
|
||||
// Phase 8: Playwright verification
|
||||
{
|
||||
"name": "blue_playwright_verify",
|
||||
|
|
@ -2572,6 +2775,12 @@ impl BlueServer {
|
|||
"blue_dialogue_round_prompt" => self.handle_dialogue_round_prompt(&call.arguments),
|
||||
"blue_dialogue_sample_panel" => self.handle_dialogue_sample_panel(&call.arguments),
|
||||
"blue_dialogue_evolve_panel" => self.handle_dialogue_evolve_panel(&call.arguments),
|
||||
// RFC 0051: Global perspective & tension tracking
|
||||
"blue_dialogue_round_context" => self.handle_dialogue_round_context(&call.arguments),
|
||||
"blue_dialogue_expert_create" => self.handle_dialogue_expert_create(&call.arguments),
|
||||
"blue_dialogue_round_register" => self.handle_dialogue_round_register(&call.arguments),
|
||||
"blue_dialogue_verdict_register" => self.handle_dialogue_verdict_register(&call.arguments),
|
||||
"blue_dialogue_export" => self.handle_dialogue_export(&call.arguments),
|
||||
// Phase 8: Playwright handler
|
||||
"blue_playwright_verify" => self.handle_playwright_verify(&call.arguments),
|
||||
// Phase 9: Post-mortem handlers
|
||||
|
|
@ -3914,6 +4123,38 @@ impl BlueServer {
|
|||
crate::handlers::dialogue::handle_evolve_panel(args)
|
||||
}
|
||||
|
||||
// RFC 0051: Global perspective & tension tracking handlers
|
||||
|
||||
fn handle_dialogue_round_context(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::dialogue::handle_round_context(state, args)
|
||||
}
|
||||
|
||||
fn handle_dialogue_expert_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::dialogue::handle_expert_create(state, args)
|
||||
}
|
||||
|
||||
fn handle_dialogue_round_register(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::dialogue::handle_round_register(state, args)
|
||||
}
|
||||
|
||||
fn handle_dialogue_verdict_register(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::dialogue::handle_verdict_register(state, args)
|
||||
}
|
||||
|
||||
fn handle_dialogue_export(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state()?;
|
||||
crate::handlers::dialogue::handle_export(state, args)
|
||||
}
|
||||
|
||||
fn handle_playwright_verify(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
crate::handlers::playwright::handle_verify(args)
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ For a pool of P experts with panel size N:
|
|||
|
||||
## Blue MCP Tools
|
||||
|
||||
### Core Tools (File-based)
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `blue_dialogue_create` | Creates dialogue with expert_pool, returns Judge Protocol |
|
||||
|
|
@ -225,6 +227,78 @@ For a pool of P experts with panel size N:
|
|||
| `blue_dialogue_lint` | Validate .dialogue.md format |
|
||||
| `blue_dialogue_save` | Persist to .blue/docs/dialogues/ |
|
||||
|
||||
### DB-Backed Tools (RFC 0051)
|
||||
|
||||
These tools provide database-backed tracking with full provenance:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `blue_dialogue_round_context` | **Bulk fetch** context for all panel experts (perspectives, tensions, recommendations) |
|
||||
| `blue_dialogue_expert_create` | Create new expert mid-dialogue with `source: "created"` |
|
||||
| `blue_dialogue_round_register` | **Bulk register** all round data (perspectives, tensions, refs, scores) |
|
||||
| `blue_dialogue_verdict_register` | Register verdicts (interim, final, minority, dissent) |
|
||||
| `blue_dialogue_export` | Export dialogue to JSON with full provenance |
|
||||
|
||||
### Two-Phase ID System (RFC 0051)
|
||||
|
||||
Experts write **local IDs** using the marker syntax from the `alignment-expert` skill:
|
||||
- `MUFFIN-P0101` — Muffin's first perspective in round 1
|
||||
- `DONUT-T0201` — Donut's first tension in round 2
|
||||
|
||||
The Judge translates to **global IDs** when calling `blue_dialogue_round_register`:
|
||||
- `P0101` — First perspective registered in round 1
|
||||
- `T0201` — First tension registered in round 2
|
||||
|
||||
### DB-Backed Workflow (Recommended)
|
||||
|
||||
1. **Round Context**: Call `blue_dialogue_round_context(dialogue_id, round)` to get:
|
||||
- Dialogue metadata and background
|
||||
- All experts with roles and scores
|
||||
- All perspectives, tensions, recommendations, evidence, claims
|
||||
- List of open tensions
|
||||
|
||||
2. **Build Prompts**: Use context to construct prompts with the `alignment-expert` skill syntax
|
||||
|
||||
3. **Spawn Agents**: Use Task tool with `subagent_type: "general-purpose"`
|
||||
|
||||
4. **Parse Responses**: Extract markers from agent responses:
|
||||
- `[EXPERT-P0101: label]` → Perspective
|
||||
- `[EXPERT-T0101: label]` → Tension
|
||||
- `[RE:SUPPORT P0001]` → Reference
|
||||
|
||||
5. **Register Round**: Call `blue_dialogue_round_register` with:
|
||||
```json
|
||||
{
|
||||
"dialogue_id": "nvidia-investment-analysis",
|
||||
"round": 1,
|
||||
"score": 45,
|
||||
"summary": "Round focused on income generation options",
|
||||
"perspectives": [
|
||||
{ "local_id": "MUFFIN-P0101", "label": "Options viability", "content": "...", "contributors": ["muffin"] }
|
||||
],
|
||||
"tensions": [...],
|
||||
"recommendations": [...],
|
||||
"expert_scores": { "muffin": 12, "donut": 15 }
|
||||
}
|
||||
```
|
||||
|
||||
6. **Register Verdict**: When converged, call `blue_dialogue_verdict_register`:
|
||||
```json
|
||||
{
|
||||
"dialogue_id": "nvidia-investment-analysis",
|
||||
"verdict_id": "final",
|
||||
"verdict_type": "final",
|
||||
"round": 3,
|
||||
"recommendation": "APPROVE with options overlay",
|
||||
"description": "Income mandate satisfied via covered call strategy",
|
||||
"tensions_resolved": ["T0001", "T0101"],
|
||||
"vote": "12-0",
|
||||
"confidence": "strong"
|
||||
}
|
||||
```
|
||||
|
||||
7. **Export**: Call `blue_dialogue_export(dialogue_id)` to generate `dialogue.json`
|
||||
|
||||
## Agent Spawning
|
||||
|
||||
When spawning expert agents, you MUST use the Task tool with:
|
||||
|
|
@ -243,6 +317,34 @@ Task(
|
|||
|
||||
The `general-purpose` subagent has access to all tools including Write, which is required for writing the response file.
|
||||
|
||||
## Expert Marker Syntax
|
||||
|
||||
Experts write structured responses using the marker syntax defined in the `alignment-expert` skill:
|
||||
|
||||
### Entity Markers
|
||||
- `[EXPERT-P0101: label]` — Perspective
|
||||
- `[EXPERT-R0101: label]` — Recommendation
|
||||
- `[EXPERT-T0101: label]` — Tension
|
||||
- `[EXPERT-E0101: label]` — Evidence
|
||||
- `[EXPERT-C0101: label]` — Claim
|
||||
|
||||
### Cross-References
|
||||
- `[RE:SUPPORT P0001]` — Backs a perspective
|
||||
- `[RE:OPPOSE R0001]` — Challenges a recommendation
|
||||
- `[RE:ADDRESS T0001]` — Speaks to a tension
|
||||
- `[RE:RESOLVE T0001]` — Claims to resolve a tension
|
||||
- `[RE:REFINE P0001]` — Builds on a perspective (same type)
|
||||
- `[RE:DEPEND E0001]` — Relies on evidence
|
||||
|
||||
### Dialogue Moves
|
||||
- `[MOVE:DEFEND target]` — Strengthening a position
|
||||
- `[MOVE:CHALLENGE target]` — Raising concerns
|
||||
- `[MOVE:BRIDGE targets]` — Reconciling perspectives
|
||||
- `[MOVE:CONCEDE target]` — Acknowledging another's point
|
||||
- `[MOVE:CONVERGE]` — Signaling agreement
|
||||
|
||||
See the `alignment-expert` skill (`/alignment-expert`) for full syntax reference.
|
||||
|
||||
## Key Rules
|
||||
|
||||
1. **DESIGN THE POOL FIRST** — You are the 💙 Judge. Analyze the problem domain and design appropriate experts.
|
||||
|
|
|
|||
Loading…
Reference in a new issue