From acb12adf801f260778ff30fc48e28638b0198331 Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Sat, 30 May 2026 22:59:31 +0500 Subject: [PATCH] =?UTF-8?q?feat(model-council):=20multi-model=20deliberati?= =?UTF-8?q?on=20=E2=80=94=20parallel=20members=20+=20chair=20synthesis=20(?= =?UTF-8?q?#2890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 Co-authored-by: Steven Enamakel --- .../intelligence/ModelCouncilTab.test.tsx | 134 ++++++ .../intelligence/ModelCouncilTab.tsx | 260 +++++++++++ app/src/lib/i18n/ar.ts | 24 + app/src/lib/i18n/bn.ts | 24 + app/src/lib/i18n/de.ts | 24 + app/src/lib/i18n/en.ts | 24 + app/src/lib/i18n/es.ts | 24 + app/src/lib/i18n/fr.ts | 24 + app/src/lib/i18n/hi.ts | 24 + app/src/lib/i18n/id.ts | 24 + app/src/lib/i18n/it.ts | 24 + app/src/lib/i18n/ko.ts | 24 + app/src/lib/i18n/pl.ts | 24 + app/src/lib/i18n/pt.ts | 24 + app/src/lib/i18n/ru.ts | 24 + app/src/lib/i18n/zh-CN.ts | 24 + app/src/pages/Intelligence.tsx | 7 +- app/src/services/api/modelCouncilApi.test.ts | 98 ++++ app/src/services/api/modelCouncilApi.ts | 96 ++++ .../specs/insights-dashboard.spec.ts | 4 + src/core/all.rs | 3 + src/openhuman/mod.rs | 1 + src/openhuman/model_council/council.rs | 419 ++++++++++++++++++ src/openhuman/model_council/mod.rs | 14 + src/openhuman/model_council/schemas.rs | 238 ++++++++++ 25 files changed, 1609 insertions(+), 1 deletion(-) create mode 100644 app/src/components/intelligence/ModelCouncilTab.test.tsx create mode 100644 app/src/components/intelligence/ModelCouncilTab.tsx create mode 100644 app/src/services/api/modelCouncilApi.test.ts create mode 100644 app/src/services/api/modelCouncilApi.ts create mode 100644 src/openhuman/model_council/council.rs create mode 100644 src/openhuman/model_council/mod.rs create mode 100644 src/openhuman/model_council/schemas.rs diff --git a/app/src/components/intelligence/ModelCouncilTab.test.tsx b/app/src/components/intelligence/ModelCouncilTab.test.tsx new file mode 100644 index 000000000..9fe7442b4 --- /dev/null +++ b/app/src/components/intelligence/ModelCouncilTab.test.tsx @@ -0,0 +1,134 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ModelCouncilResult } from '../../services/api/modelCouncilApi'; +import ModelCouncilTab from './ModelCouncilTab'; + +const mockRunCouncil = vi.fn(); +vi.mock('../../services/api/modelCouncilApi', () => ({ + modelCouncilApi: { runCouncil: (...args: unknown[]) => mockRunCouncil(...args) }, +})); + +const RESULT: ModelCouncilResult = { + question: 'What is the capital of France?', + members: [ + { model: 'model-a', response: 'Paris is the capital.', error: null }, + { model: 'model-b', response: null, error: 'rate limited' }, + ], + chair_model: 'chair-model', + synthesis: 'Both that answered agree: Paris. One seat failed.', +}; + +const fillValidForm = () => { + fireEvent.change(screen.getByLabelText('Question'), { + target: { value: 'What is the capital of France?' }, + }); + fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: 'model-a' } }); + fireEvent.change(screen.getByLabelText('Member model 2'), { target: { value: 'model-b' } }); + fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: 'chair-model' } }); +}; + +describe('ModelCouncilTab', () => { + beforeEach(() => { + mockRunCouncil.mockReset(); + }); + + it('renders the compose surface with two member rows by default', () => { + render(); + expect(screen.getByText('Model Council')).toBeInTheDocument(); + expect(screen.getByLabelText('Question')).toBeInTheDocument(); + expect(screen.getByLabelText('Member model 1')).toBeInTheDocument(); + expect(screen.getByLabelText('Member model 2')).toBeInTheDocument(); + expect(screen.getByLabelText('Chair model')).toBeInTheDocument(); + }); + + it('disables Convene until question + a member + chair are all filled', () => { + render(); + const run = screen.getByRole('button', { name: 'Convene council' }); + expect(run).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Question'), { target: { value: 'q' } }); + expect(run).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: 'm' } }); + expect(run).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: 'c' } }); + expect(run).not.toBeDisabled(); + }); + + it('adds member rows up to the max of 5 and stops', () => { + render(); + const add = screen.getByRole('button', { name: '+ Add model' }); + fireEvent.click(add); // 3 + fireEvent.click(add); // 4 + fireEvent.click(add); // 5 + expect(screen.getByLabelText('Member model 5')).toBeInTheDocument(); + expect(add).toBeDisabled(); + expect(screen.queryByLabelText('Member model 6')).not.toBeInTheDocument(); + }); + + it('removes member rows but never below one', () => { + render(); + // Two rows initially; remove one → one left, remove button then disabled. + fireEvent.click(screen.getByRole('button', { name: 'Remove member model 2' })); + expect(screen.queryByLabelText('Member model 2')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove member model 1' })).toBeDisabled(); + }); + + it('runs the council and renders member answers side-by-side + the synthesis', async () => { + mockRunCouncil.mockResolvedValueOnce(RESULT); + render(); + fillValidForm(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + expect(mockRunCouncil).toHaveBeenCalledWith({ + question: 'What is the capital of France?', + member_models: ['model-a', 'model-b'], + chair_model: 'chair-model', + }); + await waitFor(() => { + expect(screen.getByText('Council results')).toBeInTheDocument(); + }); + // Member A answered; Member B failed. + expect(screen.getByText('Paris is the capital.')).toBeInTheDocument(); + expect(screen.getByText('rate limited')).toBeInTheDocument(); + expect(screen.getByText('Answered')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + // Synthesis from the chair. + expect( + screen.getByText('Both that answered agree: Paris. One seat failed.') + ).toBeInTheDocument(); + expect(screen.getByText('by chair-model')).toBeInTheDocument(); + }); + + it('trims whitespace and drops blank member rows before calling the API', async () => { + mockRunCouncil.mockResolvedValueOnce(RESULT); + render(); + fireEvent.change(screen.getByLabelText('Question'), { target: { value: ' hi ' } }); + fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: ' model-a ' } }); + // leave member 2 blank + fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: ' chair ' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + expect(mockRunCouncil).toHaveBeenCalledWith({ + question: 'hi', + member_models: ['model-a'], + chair_model: 'chair', + }); + }); + + it('surfaces an error alert when the council run fails', async () => { + mockRunCouncil.mockRejectedValueOnce(new Error('all member models failed to respond')); + render(); + fillValidForm(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Convene council' })); + }); + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert.textContent).toMatch(/all member models failed to respond/); + }); + // No results section on failure. + expect(screen.queryByText('Council results')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/ModelCouncilTab.tsx b/app/src/components/intelligence/ModelCouncilTab.tsx new file mode 100644 index 000000000..28ba84fdb --- /dev/null +++ b/app/src/components/intelligence/ModelCouncilTab.tsx @@ -0,0 +1,260 @@ +/** + * Model Council tab — ask one question, get independent answers from several + * models in parallel, then a chair model's synthesis of where they agree, + * disagree, and what unique insight each added. + * + * The orchestration (parallel member calls + chair synthesis) lives in the + * Rust core behind `openhuman.model_council_run`; this tab is the compose + + * compare surface. Model ids are entered as free text because the available + * set is provider-specific (local Ollama + any configured cloud providers) + * and the council accepts arbitrary ids. + */ +import { useCallback, useMemo, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { modelCouncilApi, type ModelCouncilResult } from '../../services/api/modelCouncilApi'; + +/** Matches the server-side MAX_COUNCIL_MEMBERS cap. */ +const MAX_MEMBERS = 5; + +/** A member row carries a stable id so React keys survive mid-list removal. */ +interface MemberRow { + id: number; + value: string; +} + +/** Next id = max existing + 1: unique among current rows, no ref/StrictMode hazard. */ +const nextMemberId = (rows: MemberRow[]): number => + rows.reduce((max, r) => Math.max(max, r.id), -1) + 1; + +const ModelCouncilTab = () => { + const { t } = useT(); + const [question, setQuestion] = useState(''); + const [members, setMembers] = useState([ + { id: 0, value: '' }, + { id: 1, value: '' }, + ]); + const [chair, setChair] = useState(''); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const filledMembers = useMemo( + () => members.map(m => m.value.trim()).filter(v => v.length > 0), + [members] + ); + + const canRun = + !running && question.trim().length > 0 && filledMembers.length > 0 && chair.trim().length > 0; + + const updateMember = useCallback((id: number, value: string) => { + setMembers(prev => prev.map(m => (m.id === id ? { ...m, value } : m))); + }, []); + + const addMember = useCallback(() => { + setMembers(prev => + prev.length >= MAX_MEMBERS ? prev : [...prev, { id: nextMemberId(prev), value: '' }] + ); + }, []); + + const removeMember = useCallback((id: number) => { + setMembers(prev => (prev.length <= 1 ? prev : prev.filter(m => m.id !== id))); + }, []); + + const handleRun = useCallback(async () => { + if (running) return; + const trimmedMembers = members.map(m => m.value.trim()).filter(v => v.length > 0); + if (question.trim().length === 0 || trimmedMembers.length === 0 || chair.trim().length === 0) { + return; + } + setRunning(true); + setError(null); + setResult(null); + try { + const res = await modelCouncilApi.runCouncil({ + question: question.trim(), + member_models: trimmedMembers, + chair_model: chair.trim(), + }); + setResult(res); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRunning(false); + } + }, [running, members, question, chair]); + + return ( +
+
+

{t('modelCouncil.title')}

+

{t('modelCouncil.intro')}

+
+ + {/* Question */} +
+ +