Skip to content

[P-03] Delibera.Grpc — .proto schema, bi-directional streaming, C# client auto-generation #11

Description

@techbuzzz

Overview

Add a full gRPC transport layer to Delibera.Server as a new project Delibera.Grpc.
Parallel to the existing REST+SSE API, gRPC provides:

  • Strongly-typed contract (.proto as the single source of truth)
  • Bi-directional streaming for live debate rounds (replaces SSE for gRPC clients)
  • Auto-generated C# client (Delibera.Grpc.Client NuGet) for SDK consumers
  • Better performance for high-frequency integrations (enterprise BPM, CI pipelines)

Branch: feature/p03-grpc


Project Structure

src/
├── Delibera.Server/          # existing — add Grpc service registrations
├── Delibera.Grpc/            # NEW — proto files + generated stubs
│   ├── Protos/
│   │   └── delibera.proto
│   ├── Services/
│   │   ├── DebateGrpcService.cs
│   │   ├── ScenarioGrpcService.cs
│   │   └── CorpusGrpcService.cs
│   └── Delibera.Grpc.csproj
└── Delibera.Grpc.Client/     # NEW — client NuGet package
    ├── DeliberaGrpcClient.cs
    └── Delibera.Grpc.Client.csproj

Proto Schema — delibera.proto

Derived from existing contracts:

  • ScenarioRequest / ScenarioMember / ScenarioChairmansrc/Delibera.Server/Api/Contracts/ScenarioRequest.cs
  • DebateResponse / VerdictDto / VotingResultDto / TokenStatsDtoDebateResponse.cs, VerdictDto.cs
  • SSE events debate-round / debate-completedSseDebateStreamWriter.cs
syntax = "proto3";
option csharp_namespace = "Delibera.Grpc";
package delibera.v1;

import "google/protobuf/timestamp.proto";
import "google/protobuf/struct.proto";

// ─────────────────────────────────────────────────────────────────────────────
// SERVICE: DebateService
// ─────────────────────────────────────────────────────────────────────────────
service DebateService {
  // Unary: get debate by ID
  rpc GetDebate (GetDebateRequest) returns (DebateResponse);

  // Server streaming: stream live rounds (mirrors SSE /stream)
  rpc StreamDebate (StreamDebateRequest) returns (stream DebateEvent);

  // Bi-directional: client sends control signals, server streams rounds
  rpc RunDebate (stream DebateControl) returns (stream DebateEvent);

  // Unary: cancel a running debate
  rpc CancelDebate (CancelDebateRequest) returns (CancelDebateResponse);

  // Unary: list all debates
  rpc ListDebates (ListDebatesRequest) returns (ListDebatesResponse);
}

// ─────────────────────────────────────────────────────────────────────────────
// SERVICE: ScenarioService
// ─────────────────────────────────────────────────────────────────────────────
service ScenarioService {
  // Unary: run a fully self-describing scenario (mirrors POST /api/v1/scenarios)
  rpc RunScenario (ScenarioRequest) returns (DebateResponse);

  // Server streaming: run scenario and stream rounds live
  rpc RunScenarioStream (ScenarioRequest) returns (stream DebateEvent);
}

// ─────────────────────────────────────────────────────────────────────────────
// SERVICE: TemplateService
// ─────────────────────────────────────────────────────────────────────────────
service TemplateService {
  rpc ListTemplates (ListTemplatesRequest) returns (ListTemplatesResponse);
  rpc RunTemplate   (RunTemplateRequest)   returns (DebateResponse);
  rpc RunTemplateStream (RunTemplateRequest) returns (stream DebateEvent);
}

// ─────────────────────────────────────────────────────────────────────────────
// SERVICE: CorpusService
// ─────────────────────────────────────────────────────────────────────────────
service CorpusService {
  rpc CreateCorpus (CreateCorpusRequest) returns (CorpusResponse);
  rpc GetCorpus    (GetCorpusRequest)    returns (CorpusResponse);
  rpc DeleteCorpus (DeleteCorpusRequest) returns (DeleteCorpusResponse);
  rpc ListCorpora  (ListCorporaRequest)  returns (ListCorporaResponse);
}

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Scenario / Council composition
// ─────────────────────────────────────────────────────────────────────────────
message ScenarioRequest {
  string              question            = 1;  // required
  repeated Member     members             = 2;  // required, min 1
  string              strategy            = 3;  // Standard | Critique | Consensus
  Chairman            chairman            = 4;
  string              voting_strategy     = 5;  // Majority | BordaCount | Weighted | ""
  map<string, float>  member_weights      = 6;  // used when voting_strategy = Weighted
  int32               max_rounds          = 7;  // default 3
  float               temperature         = 8;  // default 0.7
  string              system_prompt       = 9;
  string              knowledge_text      = 10;
  repeated string     corpus_ids          = 11;
  string              output_schema       = 12; // JSON Schema string
  string              compression_strategy = 13; // Hybrid | Semantic | Dedup | Summarization | None
  google.protobuf.Struct input_data       = 14; // free-form JSON
  string              label               = 15;
  string              response_language   = 16;
  int32               max_degree_of_parallelism = 17;
}

message Member {
  string role         = 1; // required
  string model        = 2;
  string provider     = 3; // Ollama | OpenAI | AzureOpenAI | Anthropic
  string persona      = 4;
  float  weight       = 5; // default 1.0
  string capabilities = 6; // Text | Vision | Both
}

message Chairman {
  string model             = 1;
  string provider          = 2;
  string system_prompt     = 3;
  bool   opening_statement = 4;
}

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Debate responses
// ─────────────────────────────────────────────────────────────────────────────
message DebateResponse {
  string                    debate_id     = 1;
  string                    template_id   = 2;
  string                    tenant_id     = 3;
  DebateStatus              status        = 4;
  string                    final_verdict = 5;
  Verdict                   verdict       = 6;
  VotingResult              voting        = 7;
  TokenStats                token_stats   = 8;
  string                    error_message = 9;
  google.protobuf.Timestamp created_at    = 10;
  google.protobuf.Timestamp completed_at  = 11;
}

enum DebateStatus {
  DEBATE_STATUS_UNSPECIFIED = 0;
  PENDING   = 1;
  RUNNING   = 2;
  COMPLETED = 3;
  FAILED    = 4;
  CANCELLED = 5;
  PAUSED    = 6;
}

message Verdict {
  string         recommendation = 1;
  string         risk_level     = 2; // Low | Medium | High | Critical
  string         rationale      = 3;
  float          confidence     = 4; // 0.0 – 1.0
  repeated string conditions    = 5;
  repeated Risk  risks          = 6;
  string         raw_json       = 7;
}

message Risk {
  string category    = 1;
  string description = 2;
  string severity    = 3;
  string mitigation  = 4;
}

message VotingResult {
  string          strategy    = 1;
  string          winner      = 2;
  int32           total_votes = 3;
  repeated Tally  tally       = 4;
}

message Tally {
  string candidate = 1;
  int32  votes     = 2;
  float  score     = 3;
}

message TokenStats {
  int32 total_input_tokens   = 1;
  int32 total_output_tokens  = 2;
  int32 total_tokens         = 3;
  int32 saved_by_compression = 4;
}

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Streaming events (mirrors SSE debate-round / debate-completed)
// ─────────────────────────────────────────────────────────────────────────────
message DebateEvent {
  oneof payload {
    DebateRoundEvent    round     = 1;
    DebateCompletedEvent completed = 2;
    DebateErrorEvent    error     = 3;
    DebateStatusEvent   status    = 4;
  }
}

message DebateRoundEvent {
  string debate_id   = 1;
  int32  round_index = 2;
  string speaker     = 3;
  string role        = 4;
  string content     = 5;
  google.protobuf.Timestamp timestamp = 6;
}

message DebateCompletedEvent {
  string                    debate_id    = 1;
  DebateStatus              status       = 2;
  string                    verdict      = 3;
  google.protobuf.Timestamp completed_at = 4;
  string                    error        = 5;
}

message DebateErrorEvent {
  string debate_id = 1;
  string code      = 2;
  string message   = 3;
}

message DebateStatusEvent {
  string       debate_id = 1;
  DebateStatus status    = 2;
}

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Bi-directional control (client → server in RunDebate)
// ─────────────────────────────────────────────────────────────────────────────
message DebateControl {
  oneof action {
    ScenarioRequest start  = 1; // first message: start debate
    PauseAction     pause  = 2;
    ResumeAction    resume = 3;
    CancelAction    cancel = 4;
  }
}

message PauseAction  { string debate_id = 1; }
message ResumeAction { string debate_id = 1; }
message CancelAction { string debate_id = 1; string reason = 2; }

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Get / List / Cancel Debate
// ─────────────────────────────────────────────────────────────────────────────
message GetDebateRequest    { string debate_id = 1; }
message StreamDebateRequest { string debate_id = 1; }
message CancelDebateRequest { string debate_id = 1; string reason = 2; }
message CancelDebateResponse { bool success = 1; string message = 2; }

message ListDebatesRequest {
  string tenant_id  = 1;
  string label      = 2;
  int32  page       = 3;
  int32  page_size  = 4;
}
message ListDebatesResponse {
  repeated DebateResponse debates = 1;
  int32    total_count            = 2;
}

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Templates
// ─────────────────────────────────────────────────────────────────────────────
message ListTemplatesRequest  {}
message ListTemplatesResponse { repeated TemplateDto templates = 1; }
message TemplateDto {
  string id          = 1;
  string name        = 2;
  string description = 3;
  string vertical    = 4; // Engineering | Governance | Legal | Product
}
message RunTemplateRequest {
  string              template_id = 1;
  string              question    = 2;
  google.protobuf.Struct input_data = 3;
  repeated string     corpus_ids  = 4;
  string              label       = 5;
}

// ─────────────────────────────────────────────────────────────────────────────
// MESSAGES: Corpus
// ─────────────────────────────────────────────────────────────────────────────
message CreateCorpusRequest {
  string tenant_id   = 1;
  string name        = 2;
  string description = 3;
  string content     = 4;
}
message GetCorpusRequest    { string corpus_id = 1; }
message DeleteCorpusRequest { string corpus_id = 1; }
message DeleteCorpusResponse { bool success = 1; }
message ListCorporaRequest  { string tenant_id = 1; }
message CorpusResponse {
  string corpus_id   = 1;
  string name        = 2;
  string description = 3;
  int32  chunk_count = 4;
  google.protobuf.Timestamp created_at = 5;
}
message ListCorporaResponse { repeated CorpusResponse corpora = 1; }

Delibera.Grpc.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Grpc.AspNetCore"              Version="2.67.*" />
    <PackageReference Include="Google.Protobuf"               Version="3.29.*" />
    <PackageReference Include="Grpc.Tools"                   Version="2.67.*" PrivateAssets="All" />
    <ProjectReference Include="../Delibera.Server/Delibera.Server.csproj" />
  </ItemGroup>
  <ItemGroup>
    <Protobuf Include="Protos/delibera.proto" GrpcServices="Server" />
  </ItemGroup>
</Project>

Delibera.Grpc.Client.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PackageId>Delibera.Grpc.Client</PackageId>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Grpc.Net.Client"  Version="2.67.*" />
    <PackageReference Include="Google.Protobuf"  Version="3.29.*" />
    <PackageReference Include="Grpc.Tools"       Version="2.67.*" PrivateAssets="All" />
  </ItemGroup>
  <ItemGroup>
    <Protobuf Include="../Delibera.Grpc/Protos/delibera.proto" GrpcServices="Client" Link="Protos/delibera.proto" />
  </ItemGroup>
</Project>

Program.cs — регистрация gRPC

Добавить в Delibera.Server/Program.cs:

// ── gRPC ──────────────────────────────────────────────────────────────────────
builder.Services.AddGrpc(o =>
{
    o.EnableDetailedErrors = builder.Environment.IsDevelopment();
    o.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16 MB
});
builder.Services.AddGrpcReflection(); // grpcurl support

// ... после app.Build():
app.MapGrpcService<DebateGrpcService>();
app.MapGrpcService<ScenarioGrpcService>();
app.MapGrpcService<TemplateGrpcService>();
app.MapGrpcService<CorpusGrpcService>();

if (app.Environment.IsDevelopment())
    app.MapGrpcReflectionService();

Bi-Directional Streaming — RunDebate

RunDebate — главный метод. Клиент отправляет первым сообщением ScenarioRequest (обёрнутый в DebateControl.start), сервер запускает CouncilExecutor и стримит DebateEvent по мере выполнения раундов. Клиент может в любой момент послать PauseAction / ResumeAction / CancelAction.

// Services/DebateGrpcService.cs — скелет
public override async Task RunDebate(
    IAsyncStreamReader<DebateControl> requestStream,
    IServerStreamWriter<DebateEvent> responseStream,
    ServerCallContext context)
{
    // 1. Read first message — must be ScenarioRequest (start)
    await requestStream.MoveNext(context.CancellationToken);
    var start = requestStream.Current.Start
        ?? throw new RpcException(new Status(StatusCode.InvalidArgument, "First message must be 'start'"));

    // 2. Build council from ScenarioRequest
    var record = await _debateService.StartAsync(start.ToScenarioRequest(), context.CancellationToken);

    // 3. Stream rounds + listen for control signals in parallel
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);

    var streamTask = StreamRoundsAsync(record, responseStream, cts.Token);
    var controlTask = ListenControlAsync(requestStream, record, cts);

    await Task.WhenAll(streamTask, controlTask);
}

Acceptance Criteria

  • src/Delibera.Grpc/Protos/delibera.proto создан и валиден (protoc --version без ошибок)
  • dotnet build src/Delibera.Grpc → 0 errors (C# stubs сгенерированы Grpc.Tools)
  • dotnet build src/Delibera.Grpc.Client → 0 errors (C# клиент сгенерирован)
  • DebateGrpcService, ScenarioGrpcService, TemplateGrpcService, CorpusGrpcService реализованы
  • RunDebate bi-directional stream работает: start → rounds → completed
  • RunScenarioStream server stream работает end-to-end
  • gRPC Reflection включён в Development (grpcurl возвращает список сервисов)
  • Delibera.Server/Program.cs регистрирует все gRPC сервисы
  • Delibera.slnx обновлён — добавлены Delibera.Grpc и Delibera.Grpc.Client
  • README обновлён: раздел gRPC с примером grpcurl и C# клиента

Branch

feature/p03-grpc

Labels

enhancement, grpc, server

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions