mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(model-council): multi-model deliberation — parallel members + chair synthesis (#2890)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Claude Opus 4.7
Steven Enamakel
parent
d08882b219
commit
acb12adf80
@@ -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(<ModelCouncilTab />);
|
||||
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(<ModelCouncilTab />);
|
||||
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(<ModelCouncilTab />);
|
||||
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(<ModelCouncilTab />);
|
||||
// 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(<ModelCouncilTab />);
|
||||
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(<ModelCouncilTab />);
|
||||
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(<ModelCouncilTab />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<MemberRow[]>([
|
||||
{ id: 0, value: '' },
|
||||
{ id: 1, value: '' },
|
||||
]);
|
||||
const [chair, setChair] = useState('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<ModelCouncilResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="note"
|
||||
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
|
||||
<p className="font-medium mb-1">{t('modelCouncil.title')}</p>
|
||||
<p>{t('modelCouncil.intro')}</p>
|
||||
</div>
|
||||
|
||||
{/* Question */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="model-council-question"
|
||||
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('modelCouncil.questionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
id="model-council-question"
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('modelCouncil.questionPlaceholder')}
|
||||
aria-label={t('modelCouncil.questionLabel')}
|
||||
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Member models */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('modelCouncil.membersLabel')}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('modelCouncil.maxMembersNote').replace('{max}', String(MAX_MEMBERS))}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{members.map((member, index) => (
|
||||
<li key={member.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={member.value}
|
||||
onChange={e => updateMember(member.id, e.target.value)}
|
||||
placeholder={t('modelCouncil.memberPlaceholder')}
|
||||
aria-label={t('modelCouncil.memberAria').replace('{n}', String(index + 1))}
|
||||
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember(member.id)}
|
||||
disabled={members.length <= 1}
|
||||
aria-label={t('modelCouncil.removeMemberAria').replace('{n}', String(index + 1))}
|
||||
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1.5 text-xs text-stone-500 dark:text-neutral-400 hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMember}
|
||||
disabled={members.length >= MAX_MEMBERS}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
{t('modelCouncil.addMember')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chair model */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="model-council-chair"
|
||||
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
||||
{t('modelCouncil.chairLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="model-council-chair"
|
||||
type="text"
|
||||
value={chair}
|
||||
onChange={e => setChair(e.target.value)}
|
||||
placeholder={t('modelCouncil.chairPlaceholder')}
|
||||
aria-label={t('modelCouncil.chairLabel')}
|
||||
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
/>
|
||||
<p className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('modelCouncil.chairHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRun()}
|
||||
disabled={!canRun}
|
||||
className="rounded-lg bg-primary-500 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{running ? t('modelCouncil.running') : t('modelCouncil.run')}
|
||||
</button>
|
||||
{running && (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('modelCouncil.runningHint')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
|
||||
{t('modelCouncil.errorPrefix')} {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<section aria-labelledby="model-council-results-heading" className="space-y-3 pt-1">
|
||||
<h3
|
||||
id="model-council-results-heading"
|
||||
className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{t('modelCouncil.resultsHeading')}
|
||||
</h3>
|
||||
|
||||
{/* Member answers, side by side */}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{result.members.map((member, index) => (
|
||||
<div
|
||||
key={`${member.model}-${index}`}
|
||||
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono font-medium text-stone-700 dark:text-neutral-200 truncate">
|
||||
{member.model}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider shrink-0 ${
|
||||
member.error
|
||||
? 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
|
||||
: 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
|
||||
}`}>
|
||||
{member.error
|
||||
? t('modelCouncil.memberFailed')
|
||||
: t('modelCouncil.memberAnswered')}
|
||||
</span>
|
||||
</div>
|
||||
{member.error ? (
|
||||
<p className="text-xs text-coral-600 dark:text-coral-400">{member.error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-stone-600 dark:text-neutral-300 whitespace-pre-wrap break-words">
|
||||
{member.response}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chair synthesis */}
|
||||
<div className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{t('modelCouncil.synthesisHeading')}
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono text-stone-500 dark:text-neutral-400 truncate">
|
||||
{t('modelCouncil.synthesisBy').replace('{model}', result.chair_model)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-stone-700 dark:text-neutral-200 whitespace-pre-wrap break-words">
|
||||
{result.synthesis}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelCouncilTab;
|
||||
@@ -4309,6 +4309,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'مهلة الإدخال (مللي ثانية)',
|
||||
'autocomplete.maxChars': 'أقصى عدد لأحرف السياق',
|
||||
'autocomplete.overlayTtlMs': 'مهلة الطبقة (مللي ثانية)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'وسيط',
|
||||
'graphCohesion.brokerTitle':
|
||||
'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم — وهو الرابط الوحيد بينهم.',
|
||||
|
||||
@@ -4383,6 +4383,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'ডিবাউন্স (মিলিসেকেন্ড)',
|
||||
'autocomplete.maxChars': 'সর্বোচ্চ প্রসঙ্গ অক্ষর',
|
||||
'autocomplete.overlayTtlMs': 'ওভারলে টাইমআউট (মিলিসেকেন্ড)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'ব্রোকার',
|
||||
'graphCohesion.brokerTitle':
|
||||
'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয় — এটিই তাদের মধ্যে একমাত্র সংযোগ।',
|
||||
|
||||
@@ -4499,6 +4499,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Entprellung (ms)',
|
||||
'autocomplete.maxChars': 'Maximale Kontextzeichen',
|
||||
'autocomplete.overlayTtlMs': 'Overlay-Timeout (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'Broker',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Strukturelles Loch: Die Nachbarn dieser Entität sind nicht miteinander verbunden — sie ist die einzige Verknüpfung zwischen ihnen.',
|
||||
|
||||
@@ -307,6 +307,30 @@ const en: TranslationMap = {
|
||||
'memory.tab.timeline': 'Timeline',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
'memory.tab.settings': 'Settings',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'memory.analyzeNow': 'Analyze Now',
|
||||
'namespaceOverview.title': 'Namespace Overview',
|
||||
'namespaceOverview.intro':
|
||||
|
||||
@@ -4469,6 +4469,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Retardo (ms)',
|
||||
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
|
||||
'autocomplete.overlayTtlMs': 'Tiempo de espera de superposición (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'intermediario',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Hueco estructural: los vecinos de esta entidad no están conectados entre sí — ella es el único enlace entre ellos.',
|
||||
|
||||
@@ -4485,6 +4485,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Anti-rebond (ms)',
|
||||
'autocomplete.maxChars': 'Caractères de contexte maximum',
|
||||
'autocomplete.overlayTtlMs': "Délai d'affichage (ms)",
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'courtier',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Trou structurel : les voisins de cette entité ne sont pas connectés entre eux — elle est le seul lien entre eux.',
|
||||
|
||||
@@ -4391,6 +4391,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'डिबाउंस (ms)',
|
||||
'autocomplete.maxChars': 'अधिकतम संदर्भ वर्ण',
|
||||
'autocomplete.overlayTtlMs': 'ओवरले समय-समाप्ति (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'ब्रोकर',
|
||||
'graphCohesion.brokerTitle':
|
||||
'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े — यह उनके बीच एकमात्र कड़ी है।',
|
||||
|
||||
@@ -4400,6 +4400,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Debounce (md)',
|
||||
'autocomplete.maxChars': 'Karakter konteks maks',
|
||||
'autocomplete.overlayTtlMs': 'Batas waktu overlay (md)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'broker',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Lubang struktural: tetangga entitas ini tidak saling terhubung — entitas inilah satu-satunya tautan di antara mereka.',
|
||||
|
||||
@@ -4461,6 +4461,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Debounce (ms)',
|
||||
'autocomplete.maxChars': 'Caratteri massimi di contesto',
|
||||
'autocomplete.overlayTtlMs': 'Timeout overlay (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'broker',
|
||||
'graphCohesion.brokerTitle':
|
||||
"Buco strutturale: i vicini di questa entità non sono collegati tra loro — essa è l'unico collegamento tra loro.",
|
||||
|
||||
@@ -4350,6 +4350,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': '디바운스 (ms)',
|
||||
'autocomplete.maxChars': '최대 컨텍스트 문자 수',
|
||||
'autocomplete.overlayTtlMs': '오버레이 시간 초과 (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': '브로커',
|
||||
'graphCohesion.brokerTitle':
|
||||
'구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다 — 이들 사이의 유일한 연결고리입니다.',
|
||||
|
||||
@@ -4458,6 +4458,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Opóźnienie (ms)',
|
||||
'autocomplete.maxChars': 'Maksymalna liczba znaków kontekstu',
|
||||
'autocomplete.overlayTtlMs': 'Limit czasu nakładki (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'broker',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Dziura strukturalna: sąsiedzi tej encji nie są ze sobą połączeni — jest ona jedynym łącznikiem między nimi.',
|
||||
|
||||
@@ -4457,6 +4457,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Atraso (ms)',
|
||||
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
|
||||
'autocomplete.overlayTtlMs': 'Tempo limite da sobreposição (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'intermediador',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Buraco estrutural: os vizinhos desta entidade não estão conectados entre si — ela é a única ligação entre eles.',
|
||||
|
||||
@@ -4427,6 +4427,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': 'Задержка (мс)',
|
||||
'autocomplete.maxChars': 'Макс. символов контекста',
|
||||
'autocomplete.overlayTtlMs': 'Тайм-аут наложения (мс)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': 'брокер',
|
||||
'graphCohesion.brokerTitle':
|
||||
'Структурная дыра: соседи этой сущности не связаны друг с другом — она единственная связь между ними.',
|
||||
|
||||
@@ -4175,6 +4175,30 @@ const messages: TranslationMap = {
|
||||
'autocomplete.debounceMs': '防抖 (毫秒)',
|
||||
'autocomplete.maxChars': '最大上下文字符数',
|
||||
'autocomplete.overlayTtlMs': '覆盖层超时 (ms)',
|
||||
'memory.tab.council': 'Council',
|
||||
'modelCouncil.title': 'Model Council',
|
||||
'modelCouncil.intro':
|
||||
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
|
||||
'modelCouncil.questionLabel': 'Question',
|
||||
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
|
||||
'modelCouncil.membersLabel': 'Member models',
|
||||
'modelCouncil.maxMembersNote': 'up to {max}',
|
||||
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
|
||||
'modelCouncil.memberAria': 'Member model {n}',
|
||||
'modelCouncil.removeMemberAria': 'Remove member model {n}',
|
||||
'modelCouncil.addMember': '+ Add model',
|
||||
'modelCouncil.chairLabel': 'Chair model',
|
||||
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
|
||||
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
|
||||
'modelCouncil.run': 'Convene council',
|
||||
'modelCouncil.running': 'Convening…',
|
||||
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
|
||||
'modelCouncil.errorPrefix': 'Council failed:',
|
||||
'modelCouncil.resultsHeading': 'Council results',
|
||||
'modelCouncil.memberAnswered': 'Answered',
|
||||
'modelCouncil.memberFailed': 'Failed',
|
||||
'modelCouncil.synthesisHeading': 'Synthesis',
|
||||
'modelCouncil.synthesisBy': 'by {model}',
|
||||
'graphCohesion.brokerBadge': '经纪者',
|
||||
'graphCohesion.brokerTitle': '结构洞:该实体的邻居彼此之间没有连接——它是它们之间唯一的链接。',
|
||||
'graphCohesion.colCohesion': '凝聚度',
|
||||
|
||||
@@ -11,6 +11,7 @@ import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTa
|
||||
import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab';
|
||||
import MemoryTimelineTab from '../components/intelligence/MemoryTimelineTab';
|
||||
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
|
||||
import ModelCouncilTab from '../components/intelligence/ModelCouncilTab';
|
||||
import NamespaceOverviewTab from '../components/intelligence/NamespaceOverviewTab';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
@@ -38,7 +39,8 @@ type IntelligenceTab =
|
||||
| 'freshness'
|
||||
| 'timeline'
|
||||
| 'path'
|
||||
| 'namespaces';
|
||||
| 'namespaces'
|
||||
| 'council';
|
||||
|
||||
export default function Intelligence() {
|
||||
const { t } = useT();
|
||||
@@ -124,6 +126,7 @@ export default function Intelligence() {
|
||||
{ id: 'timeline', label: t('memory.tab.timeline') },
|
||||
{ id: 'path', label: t('memory.tab.path') },
|
||||
{ id: 'namespaces', label: t('memory.tab.namespaces') },
|
||||
{ id: 'council', label: t('memory.tab.council') },
|
||||
];
|
||||
const activeTabDef = tabs.find(tab => tab.id === activeTab);
|
||||
|
||||
@@ -226,6 +229,8 @@ export default function Intelligence() {
|
||||
{activeTab === 'path' && <ConnectionPathTab />}
|
||||
|
||||
{activeTab === 'namespaces' && <NamespaceOverviewTab />}
|
||||
|
||||
{activeTab === 'council' && <ModelCouncilTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { modelCouncilApi, type ModelCouncilResult, unwrapCouncilEnvelope } from './modelCouncilApi';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
vi.mock('../coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
|
||||
}));
|
||||
|
||||
const RESULT: ModelCouncilResult = {
|
||||
question: 'What is the capital of France?',
|
||||
members: [
|
||||
{ model: 'model-a', response: 'Paris.', error: null },
|
||||
{ model: 'model-b', response: null, error: 'rate limited' },
|
||||
],
|
||||
chair_model: 'chair-model',
|
||||
synthesis: 'Both agree the capital is Paris (one seat failed).',
|
||||
};
|
||||
|
||||
describe('unwrapCouncilEnvelope', () => {
|
||||
it('unwraps the { result, logs } CLI envelope', () => {
|
||||
expect(unwrapCouncilEnvelope({ result: RESULT, logs: ['done'] })).toEqual(RESULT);
|
||||
});
|
||||
|
||||
it('passes a bare result through unchanged', () => {
|
||||
expect(unwrapCouncilEnvelope(RESULT)).toEqual(RESULT);
|
||||
});
|
||||
|
||||
it('passes through an object with a result field but no logs (not a real envelope)', () => {
|
||||
// Only `{ result, logs }` (logs array present) is treated as the CLI
|
||||
// envelope. A bare `{ result }` is returned unchanged, not unwrapped.
|
||||
const notAnEnvelope = { result: RESULT } as unknown;
|
||||
expect(unwrapCouncilEnvelope(notAnEnvelope)).toEqual({ result: RESULT });
|
||||
});
|
||||
|
||||
it('does NOT unwrap a null result (guards against { result: null, logs } crashing the UI)', () => {
|
||||
// A partial-error envelope with a null result must not be unwrapped to
|
||||
// `null` — that would crash the component on `result.members`. The guard
|
||||
// returns the payload as-is so the caller surfaces it as a malformed shape.
|
||||
const withNull = { result: null, logs: ['boom'] } as unknown;
|
||||
expect(unwrapCouncilEnvelope(withNull)).toEqual({ result: null, logs: ['boom'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelCouncilApi.runCouncil', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
it('calls openhuman.model_council_run with the params + a long timeout', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['ok'] });
|
||||
const out = await modelCouncilApi.runCouncil({
|
||||
question: 'What is the capital of France?',
|
||||
member_models: ['model-a', 'model-b'],
|
||||
chair_model: 'chair-model',
|
||||
temperature: 0.4,
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.model_council_run',
|
||||
params: {
|
||||
question: 'What is the capital of France?',
|
||||
member_models: ['model-a', 'model-b'],
|
||||
chair_model: 'chair-model',
|
||||
temperature: 0.4,
|
||||
},
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
expect(out).toEqual(RESULT);
|
||||
});
|
||||
|
||||
it('returns the unwrapped result when the core wraps it in an envelope', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['done'] });
|
||||
const out = await modelCouncilApi.runCouncil({
|
||||
question: 'q',
|
||||
member_models: ['a'],
|
||||
chair_model: 'c',
|
||||
});
|
||||
expect(out.members).toHaveLength(2);
|
||||
expect(out.synthesis).toContain('Paris');
|
||||
});
|
||||
|
||||
it('returns a bare result unchanged when no envelope is present', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce(RESULT);
|
||||
const out = await modelCouncilApi.runCouncil({
|
||||
question: 'q',
|
||||
member_models: ['a'],
|
||||
chair_model: 'c',
|
||||
});
|
||||
expect(out).toEqual(RESULT);
|
||||
});
|
||||
|
||||
it('propagates errors from the RPC layer', async () => {
|
||||
mockCallCoreRpc.mockRejectedValueOnce(new Error('all member models failed'));
|
||||
await expect(
|
||||
modelCouncilApi.runCouncil({ question: 'q', member_models: ['a'], chair_model: 'c' })
|
||||
).rejects.toThrow('all member models failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Typed RPC wrapper for the Model Council domain.
|
||||
*
|
||||
* Calls `openhuman.model_council_run` — runs a question through several
|
||||
* "member" models in parallel, then a "chair" model synthesizes their
|
||||
* answers (surfacing agreement / disagreement / unique insight).
|
||||
*
|
||||
* The core returns the result inside a `{ result, logs }` CLI envelope
|
||||
* (the handler attaches a completion log), so `runCouncil` unwraps it
|
||||
* defensively and returns the bare `ModelCouncilResult`.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('model-council:api');
|
||||
|
||||
/** One member model's contribution. `response` and `error` are mutually exclusive. */
|
||||
export interface CouncilMemberResult {
|
||||
/** The model id this seat ran. */
|
||||
model: string;
|
||||
/** The model's answer, or `null` if the call failed. */
|
||||
response: string | null;
|
||||
/** The failure message, or `null` on success. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Full council result: every member's answer plus the chair synthesis. */
|
||||
export interface ModelCouncilResult {
|
||||
question: string;
|
||||
members: CouncilMemberResult[];
|
||||
chair_model: string;
|
||||
synthesis: string;
|
||||
}
|
||||
|
||||
export interface RunCouncilParams {
|
||||
question: string;
|
||||
/** Member model ids to consult (deduplicated + capped server-side). */
|
||||
member_models: string[];
|
||||
/** Model id that synthesizes the member answers. */
|
||||
chair_model: string;
|
||||
/** Optional sampling temperature applied to every call. */
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the core's `{ result, logs }` CLI envelope. Returns the inner
|
||||
* `result` when the envelope shape is present, otherwise passes the payload
|
||||
* through unchanged — so the caller is correct whether or not the handler
|
||||
* attached logs.
|
||||
*/
|
||||
export function unwrapCouncilEnvelope(payload: unknown): ModelCouncilResult {
|
||||
const record = asRecord(payload);
|
||||
if (
|
||||
record &&
|
||||
'result' in record &&
|
||||
record.result != null &&
|
||||
'logs' in record &&
|
||||
Array.isArray(record.logs)
|
||||
) {
|
||||
return record.result as ModelCouncilResult;
|
||||
}
|
||||
return payload as ModelCouncilResult;
|
||||
}
|
||||
|
||||
export const modelCouncilApi = {
|
||||
runCouncil: async (params: RunCouncilParams): Promise<ModelCouncilResult> => {
|
||||
log(
|
||||
'run question=%s members=%o chair=%s',
|
||||
params.question.slice(0, 40),
|
||||
params.member_models,
|
||||
params.chair_model
|
||||
);
|
||||
const payload = await callCoreRpc<unknown>({
|
||||
method: 'openhuman.model_council_run',
|
||||
params,
|
||||
// Member calls run in parallel but each is a full model round-trip plus
|
||||
// a synthesis pass — give it well beyond the default 30s before timing out.
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
const result = unwrapCouncilEnvelope(payload);
|
||||
log(
|
||||
'run done: %d members, synthesis %d chars',
|
||||
result.members?.length ?? 0,
|
||||
result.synthesis?.length ?? 0
|
||||
);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,10 @@ test.describe('Insights Dashboard', () => {
|
||||
await bootAuthenticatedPage(page, 'pw-insights-user', '/intelligence');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// The Intelligence page now defaults to the "Agent Tasks" tab (#2998), so
|
||||
// select the Memory tab before asserting its workspace renders.
|
||||
await page.getByRole('tab', { name: 'Memory', exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-testid="memory-workspace"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="memory-actions"]')).toBeVisible();
|
||||
|
||||
@@ -169,6 +169,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(crate::openhuman::service::all_service_registered_controllers());
|
||||
// Data migration utilities
|
||||
controllers.extend(crate::openhuman::migration::all_migration_registered_controllers());
|
||||
// Model Council: multi-model deliberation (parallel members + chair synthesis)
|
||||
controllers.extend(crate::openhuman::model_council::all_model_council_registered_controllers());
|
||||
// Unified inference domain: text / vision / local runtime / cloud providers.
|
||||
// (Formerly split across inference, local_ai, and providers namespaces.)
|
||||
controllers.extend(crate::openhuman::inference::all_inference_registered_controllers());
|
||||
@@ -327,6 +329,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas());
|
||||
schemas.extend(crate::openhuman::service::all_service_controller_schemas());
|
||||
schemas.extend(crate::openhuman::migration::all_migration_controller_schemas());
|
||||
schemas.extend(crate::openhuman::model_council::all_model_council_controller_schemas());
|
||||
schemas.extend(crate::openhuman::inference::all_inference_controller_schemas());
|
||||
schemas.extend(crate::openhuman::inference::all_local_ai_controller_schemas());
|
||||
schemas.extend(crate::openhuman::embeddings::all_embeddings_controller_schemas());
|
||||
|
||||
@@ -73,6 +73,7 @@ pub mod memory_tools;
|
||||
pub mod memory_tree;
|
||||
pub mod migration;
|
||||
pub mod migrations;
|
||||
pub mod model_council;
|
||||
pub mod notifications;
|
||||
pub mod overlay;
|
||||
pub mod people;
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Model Council — multi-model deliberation core.
|
||||
//!
|
||||
//! A "council" runs one user question against several **member** models
|
||||
//! concurrently (each a single-shot, tool-free completion via the existing
|
||||
//! [`agent_chat_simple`] primitive), then asks a single **chair** model to
|
||||
//! synthesize the member answers into one response that surfaces where the
|
||||
//! models **agree**, where they **disagree**, and what unique insight each
|
||||
//! contributes.
|
||||
//!
|
||||
//! ## Why single-shot (not the full agent loop)
|
||||
//!
|
||||
//! Council members are deliberately *plain* completions: no tools, no memory
|
||||
//! writes, no multi-round agent loop. The value of a council is independent
|
||||
//! perspectives on the same prompt, so each member must see exactly the same
|
||||
//! input and nothing else. Reusing [`agent_chat_simple`] (which itself does a
|
||||
//! single `chat_with_system(None, …)` call) keeps every member identical and
|
||||
//! avoids duplicating provider-resolution logic here.
|
||||
//!
|
||||
//! ## Partial failure is tolerated, total failure is not
|
||||
//!
|
||||
//! If one member errors (model unavailable, rate-limited, …) the council still
|
||||
//! proceeds: that seat is recorded as an error and the chair is told the seat
|
||||
//! was empty. Only when **every** member fails do we abort — synthesizing from
|
||||
//! zero answers would be meaningless.
|
||||
//!
|
||||
//! The pure helpers ([`validate_council_request`], [`normalize_member_models`],
|
||||
//! [`build_synthesis_prompt`], [`all_members_failed`]) are split out from the
|
||||
//! I/O orchestrator ([`run_council`]) so the deliberation logic is unit-tested
|
||||
//! without any network or provider.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::inference::local::rpc::agent_chat_simple;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
/// Upper bound on how many member models a single council run may fan out to.
|
||||
///
|
||||
/// Each member is a real model call, so an unbounded list would let one RPC
|
||||
/// spawn arbitrarily many concurrent completions. Five is generous for the
|
||||
/// "compare a few frontier models" use case while keeping cost bounded.
|
||||
pub const MAX_COUNCIL_MEMBERS: usize = 5;
|
||||
|
||||
/// One member model's contribution to a council run.
|
||||
///
|
||||
/// `response` and `error` are mutually exclusive: exactly one is `Some`.
|
||||
/// Both are serialized (as `null` when absent) so the importer/UI can render a
|
||||
/// stable shape without guessing which key exists.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct CouncilMemberResult {
|
||||
/// The model id this seat ran (the resolved override passed to the provider).
|
||||
pub model: String,
|
||||
/// The model's answer, or `None` if the call failed.
|
||||
pub response: Option<String>,
|
||||
/// The failure message, or `None` on success.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Full result of a council run: every member's answer plus the chair synthesis.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ModelCouncilResult {
|
||||
/// The original user question, echoed back for display / logging.
|
||||
pub question: String,
|
||||
/// One entry per (normalized) member model, in input order.
|
||||
pub members: Vec<CouncilMemberResult>,
|
||||
/// The model id that produced the synthesis.
|
||||
pub chair_model: String,
|
||||
/// The chair's synthesized answer over the member responses.
|
||||
pub synthesis: String,
|
||||
}
|
||||
|
||||
/// Normalize the requested member model list: trim each id, drop blanks, and
|
||||
/// de-duplicate while preserving first-seen order.
|
||||
///
|
||||
/// De-duplication matters because the result is keyed by model id in the UI;
|
||||
/// two identical seats would collide and also waste a model call. PURE.
|
||||
pub fn normalize_member_models(member_models: &[String]) -> Vec<String> {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for raw in member_models {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !seen.iter().any(|m| m.as_str() == trimmed) {
|
||||
seen.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Validate a council request against the *normalized* member list. PURE.
|
||||
///
|
||||
/// Returns a stable, human-readable error string on the first violation so the
|
||||
/// JSON-RPC layer can surface it directly.
|
||||
pub fn validate_council_request(
|
||||
question: &str,
|
||||
normalized_members: &[String],
|
||||
chair_model: &str,
|
||||
) -> Result<(), String> {
|
||||
if question.trim().is_empty() {
|
||||
return Err("model council: question must not be empty".to_string());
|
||||
}
|
||||
if normalized_members.is_empty() {
|
||||
return Err("model council: at least one member model is required".to_string());
|
||||
}
|
||||
if normalized_members.len() > MAX_COUNCIL_MEMBERS {
|
||||
return Err(format!(
|
||||
"model council: too many member models ({}, max {})",
|
||||
normalized_members.len(),
|
||||
MAX_COUNCIL_MEMBERS
|
||||
));
|
||||
}
|
||||
if chair_model.trim().is_empty() {
|
||||
return Err("model council: chair model must not be empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True when every member failed, so synthesis would have nothing to work with.
|
||||
///
|
||||
/// An empty slice is NOT "all failed" (there were no seats to fail); callers
|
||||
/// validate non-emptiness separately. PURE.
|
||||
pub fn all_members_failed(members: &[CouncilMemberResult]) -> bool {
|
||||
!members.is_empty() && members.iter().all(|m| m.response.is_none())
|
||||
}
|
||||
|
||||
/// Build the chair's synthesis prompt from the question + member answers. PURE.
|
||||
///
|
||||
/// Failed seats are surfaced explicitly (as "[no response: …]") so the chair
|
||||
/// knows a perspective is missing rather than silently synthesizing from fewer
|
||||
/// answers than the user asked for. Members are labeled "Model A/B/C…" rather
|
||||
/// than by raw id to keep the chair focused on the *content* of each answer.
|
||||
pub fn build_synthesis_prompt(question: &str, members: &[CouncilMemberResult]) -> String {
|
||||
let mut prompt = String::new();
|
||||
prompt.push_str(
|
||||
"You are the chair of a model council. Several AI models were each asked \
|
||||
the SAME question independently. Your job is to synthesize their answers \
|
||||
into one clear, balanced response for the user.\n\n",
|
||||
);
|
||||
prompt.push_str("Original question:\n");
|
||||
prompt.push_str(question.trim());
|
||||
prompt.push_str("\n\n");
|
||||
prompt.push_str("Member answers:\n");
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
let label = council_member_label(i);
|
||||
prompt.push_str(&format!("\n--- Model {label} ---\n"));
|
||||
match &member.response {
|
||||
Some(text) => prompt.push_str(text.trim()),
|
||||
None => {
|
||||
let reason = member.error.as_deref().unwrap_or("unknown error");
|
||||
prompt.push_str(&format!("[no response: {reason}]"));
|
||||
}
|
||||
}
|
||||
prompt.push('\n');
|
||||
}
|
||||
prompt.push_str(
|
||||
"\nNow write the synthesis. Explicitly cover:\n\
|
||||
1. Where the models AGREE (the consensus the user can rely on).\n\
|
||||
2. Where they DISAGREE or diverge (and, if you can tell, which view is \
|
||||
better supported).\n\
|
||||
3. Any unique insight a single model contributed that the others missed.\n\
|
||||
End with a concise bottom-line recommendation. Do not invent agreement \
|
||||
that is not present; if the answers genuinely conflict, say so plainly.",
|
||||
);
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Map a zero-based member index to a stable display label: A, B, …, Z, then
|
||||
/// AA, AB, … (spreadsheet-column style). Used only to label answers for the
|
||||
/// chair; never parsed back. PURE.
|
||||
fn council_member_label(index: usize) -> String {
|
||||
let mut n = index;
|
||||
let mut label = String::new();
|
||||
loop {
|
||||
label.insert(0, (b'A' + (n % 26) as u8) as char);
|
||||
if n < 26 {
|
||||
break;
|
||||
}
|
||||
n = n / 26 - 1;
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Run a full council: fan out to every member concurrently, then synthesize.
|
||||
///
|
||||
/// Reuses [`agent_chat_simple`] for both the member calls and the chair call so
|
||||
/// provider resolution, prompt-injection guarding, and temperature handling are
|
||||
/// all inherited unchanged. Member calls run concurrently via
|
||||
/// [`futures_util::future::join_all`]; wall-clock is the slowest single member,
|
||||
/// not their sum.
|
||||
pub async fn run_council(
|
||||
config: &Config,
|
||||
question: &str,
|
||||
member_models: &[String],
|
||||
chair_model: &str,
|
||||
temperature: Option<f64>,
|
||||
) -> Result<RpcOutcome<ModelCouncilResult>, String> {
|
||||
let models = normalize_member_models(member_models);
|
||||
validate_council_request(question, &models, chair_model)?;
|
||||
|
||||
log::debug!(
|
||||
"[model-council] run_council: question_len={}, members={}, chair={}, temp={:?}",
|
||||
question.len(),
|
||||
models.len(),
|
||||
chair_model,
|
||||
temperature
|
||||
);
|
||||
|
||||
// Fan out: each member answers the SAME question as an independent
|
||||
// single-shot completion. A per-member failure is captured in-band as an
|
||||
// error seat rather than aborting the whole council.
|
||||
let member_futures = models.iter().map(|model| {
|
||||
let model = model.clone();
|
||||
async move {
|
||||
match agent_chat_simple(config, question, Some(model.clone()), temperature).await {
|
||||
Ok(outcome) => CouncilMemberResult {
|
||||
model,
|
||||
response: Some(outcome.value),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => CouncilMemberResult {
|
||||
model,
|
||||
response: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
let members: Vec<CouncilMemberResult> = futures_util::future::join_all(member_futures).await;
|
||||
|
||||
let success_count = members.iter().filter(|m| m.response.is_some()).count();
|
||||
log::debug!(
|
||||
"[model-council] member results: success={}, failed={}",
|
||||
success_count,
|
||||
members.len() - success_count
|
||||
);
|
||||
|
||||
if all_members_failed(&members) {
|
||||
log::debug!("[model-council] all members failed; aborting before synthesis");
|
||||
return Err("model council: all member models failed to respond".to_string());
|
||||
}
|
||||
|
||||
let synthesis_prompt = build_synthesis_prompt(question, &members);
|
||||
log::debug!("[model-council] convening chair model: {chair_model}");
|
||||
let synthesis = agent_chat_simple(
|
||||
config,
|
||||
&synthesis_prompt,
|
||||
Some(chair_model.to_string()),
|
||||
temperature,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("model council: chair synthesis failed: {e}"))?
|
||||
.value;
|
||||
log::debug!(
|
||||
"[model-council] synthesis complete: {} chars",
|
||||
synthesis.len()
|
||||
);
|
||||
|
||||
let result = ModelCouncilResult {
|
||||
question: question.to_string(),
|
||||
members,
|
||||
chair_model: chair_model.to_string(),
|
||||
synthesis,
|
||||
};
|
||||
Ok(RpcOutcome::single_log(
|
||||
result,
|
||||
"model council synthesis completed",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn ok_member(model: &str, response: &str) -> CouncilMemberResult {
|
||||
CouncilMemberResult {
|
||||
model: model.to_string(),
|
||||
response: Some(response.to_string()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn err_member(model: &str, error: &str) -> CouncilMemberResult {
|
||||
CouncilMemberResult {
|
||||
model: model.to_string(),
|
||||
response: None,
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_trims_drops_blanks_and_dedups_preserving_order() {
|
||||
let input = vec![
|
||||
" gpt ".to_string(),
|
||||
"claude".to_string(),
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"gpt".to_string(), // dup of trimmed " gpt "
|
||||
"gemini".to_string(),
|
||||
"claude".to_string(), // dup
|
||||
];
|
||||
let out = normalize_member_models(&input);
|
||||
assert_eq!(out, vec!["gpt", "claude", "gemini"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_empty_question() {
|
||||
let members = vec!["a".to_string()];
|
||||
let err = validate_council_request(" ", &members, "chair").unwrap_err();
|
||||
assert!(err.contains("question"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_no_members() {
|
||||
let err = validate_council_request("q", &[], "chair").unwrap_err();
|
||||
assert!(err.contains("at least one member"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_too_many_members() {
|
||||
let members: Vec<String> = (0..(MAX_COUNCIL_MEMBERS + 1))
|
||||
.map(|i| format!("m{i}"))
|
||||
.collect();
|
||||
let err = validate_council_request("q", &members, "chair").unwrap_err();
|
||||
assert!(err.contains("too many"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_blank_chair() {
|
||||
let members = vec!["a".to_string()];
|
||||
let err = validate_council_request("q", &members, " ").unwrap_err();
|
||||
assert!(err.contains("chair"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_well_formed_request() {
|
||||
let members = vec!["a".to_string(), "b".to_string()];
|
||||
assert!(validate_council_request("q", &members, "chair").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_members_failed_is_false_when_any_succeeds() {
|
||||
let members = vec![err_member("a", "boom"), ok_member("b", "hi")];
|
||||
assert!(!all_members_failed(&members));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_members_failed_is_true_only_when_every_seat_failed() {
|
||||
let members = vec![err_member("a", "boom"), err_member("b", "nope")];
|
||||
assert!(all_members_failed(&members));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_members_failed_is_false_for_empty_slice() {
|
||||
assert!(!all_members_failed(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_prompt_includes_question_and_each_answer() {
|
||||
let members = vec![
|
||||
ok_member("gpt", "Paris is the capital."),
|
||||
ok_member("claude", "The capital is Paris."),
|
||||
];
|
||||
let prompt = build_synthesis_prompt("What is the capital of France?", &members);
|
||||
assert!(prompt.contains("What is the capital of France?"));
|
||||
assert!(prompt.contains("Paris is the capital."));
|
||||
assert!(prompt.contains("The capital is Paris."));
|
||||
assert!(prompt.contains("Model A"));
|
||||
assert!(prompt.contains("Model B"));
|
||||
// The chair must be instructed to surface agreement + disagreement.
|
||||
assert!(prompt.contains("AGREE"));
|
||||
assert!(prompt.contains("DISAGREE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesis_prompt_marks_failed_seats_with_their_error() {
|
||||
let members = vec![
|
||||
ok_member("gpt", "ok answer"),
|
||||
err_member("claude", "rate limited"),
|
||||
];
|
||||
let prompt = build_synthesis_prompt("q", &members);
|
||||
assert!(prompt.contains("[no response: rate limited]"));
|
||||
assert!(prompt.contains("ok answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_labels_are_spreadsheet_style() {
|
||||
assert_eq!(council_member_label(0), "A");
|
||||
assert_eq!(council_member_label(1), "B");
|
||||
assert_eq!(council_member_label(25), "Z");
|
||||
assert_eq!(council_member_label(26), "AA");
|
||||
assert_eq!(council_member_label(27), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_serializes_with_stable_keys_and_null_for_absent_fields() {
|
||||
let result = ModelCouncilResult {
|
||||
question: "q".to_string(),
|
||||
members: vec![ok_member("gpt", "answer"), err_member("claude", "boom")],
|
||||
chair_model: "chair-model".to_string(),
|
||||
synthesis: "the synthesis".to_string(),
|
||||
};
|
||||
let json: Value = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(json["question"], "q");
|
||||
assert_eq!(json["chair_model"], "chair-model");
|
||||
assert_eq!(json["synthesis"], "the synthesis");
|
||||
let members = json["members"].as_array().unwrap();
|
||||
assert_eq!(members.len(), 2);
|
||||
// Success seat: response set, error null.
|
||||
assert_eq!(members[0]["model"], "gpt");
|
||||
assert_eq!(members[0]["response"], "answer");
|
||||
assert!(members[0]["error"].is_null());
|
||||
// Failure seat: response null, error set.
|
||||
assert_eq!(members[1]["model"], "claude");
|
||||
assert!(members[1]["response"].is_null());
|
||||
assert_eq!(members[1]["error"], "boom");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Model Council — run one question through several models in parallel and
|
||||
//! synthesize their answers with a chair model.
|
||||
//!
|
||||
//! See [`council`] for the deliberation core (pure helpers + the
|
||||
//! [`council::run_council`] orchestrator) and [`schemas`] for the JSON-RPC
|
||||
//! controller surface (`openhuman.model_council_run`).
|
||||
|
||||
pub mod council;
|
||||
mod schemas;
|
||||
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_model_council_controller_schemas,
|
||||
all_registered_controllers as all_model_council_registered_controllers,
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
//! JSON-RPC controller surface for the Model Council.
|
||||
//!
|
||||
//! Exposes a single method, `openhuman.model_council_run`, which takes a
|
||||
//! question + a list of member model ids + a chair model id, runs the council
|
||||
//! (see [`crate::openhuman::model_council::council`]), and returns the
|
||||
//! aggregated result synchronously in the JSON-RPC response.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelCouncilParams {
|
||||
question: String,
|
||||
member_models: Vec<String>,
|
||||
chair_model: String,
|
||||
temperature: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![schemas("run")]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![RegisteredController {
|
||||
schema: schemas("run"),
|
||||
handler: handle_run,
|
||||
}]
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"run" => ControllerSchema {
|
||||
namespace: "model_council",
|
||||
function: "run",
|
||||
description: "Run a question through several member models concurrently, then \
|
||||
synthesize their answers with a chair model. Returns each member's \
|
||||
answer (or error) plus the chair's synthesis.",
|
||||
inputs: vec![
|
||||
required_string("question", "The question to put to the council."),
|
||||
required_string_array(
|
||||
"member_models",
|
||||
"Member model ids to consult (deduplicated; max 5).",
|
||||
),
|
||||
required_string(
|
||||
"chair_model",
|
||||
"Model id that synthesizes the member answers.",
|
||||
),
|
||||
optional_f64(
|
||||
"temperature",
|
||||
"Optional sampling temperature for all calls.",
|
||||
),
|
||||
],
|
||||
outputs: vec![json_output(
|
||||
"result",
|
||||
"Council result: per-member answers + chair synthesis.",
|
||||
)],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "model_council",
|
||||
function: "unknown",
|
||||
description: "Unknown model_council controller function.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Error message.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_run(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
log::debug!("[model-council] handle_run: received RPC request");
|
||||
let p = deserialize_params::<ModelCouncilParams>(params)?;
|
||||
// Log a sanitized summary only — never the full question text.
|
||||
log::debug!(
|
||||
"[model-council] handle_run: question_len={}, members={}, chair={}",
|
||||
p.question.len(),
|
||||
p.member_models.len(),
|
||||
p.chair_model
|
||||
);
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(
|
||||
crate::openhuman::model_council::council::run_council(
|
||||
&config,
|
||||
&p.question,
|
||||
&p.member_models,
|
||||
&p.chair_model,
|
||||
p.temperature,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::debug!("[model-council] handle_run: run_council failed: {e}");
|
||||
e
|
||||
})?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
|
||||
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
fn required_string(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::String,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string_array(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_f64(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::F64)),
|
||||
comment,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
|
||||
FieldSchema {
|
||||
name,
|
||||
ty: TypeSchema::Json,
|
||||
comment,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn controller_schema_inventory_is_stable() {
|
||||
let schemas = all_controller_schemas();
|
||||
let functions: Vec<_> = schemas.iter().map(|schema| schema.function).collect();
|
||||
assert_eq!(functions, vec!["run"]);
|
||||
assert_eq!(schemas.len(), all_registered_controllers().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_schema_exposes_expected_inputs_and_method_name() {
|
||||
let run = schemas("run");
|
||||
assert_eq!(run.namespace, "model_council");
|
||||
assert_eq!(run.function, "run");
|
||||
assert_eq!(
|
||||
crate::core::all::rpc_method_name(&run),
|
||||
"openhuman.model_council_run"
|
||||
);
|
||||
assert_eq!(run.inputs.len(), 4);
|
||||
assert!(run
|
||||
.inputs
|
||||
.iter()
|
||||
.any(|input| input.name == "question" && input.required));
|
||||
let members = run
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|input| input.name == "member_models")
|
||||
.expect("member_models input present");
|
||||
assert!(matches!(members.ty, TypeSchema::Array(_)));
|
||||
assert!(members.required);
|
||||
let temperature = run
|
||||
.inputs
|
||||
.iter()
|
||||
.find(|input| input.name == "temperature")
|
||||
.expect("temperature input present");
|
||||
assert!(!temperature.required);
|
||||
assert!(matches!(temperature.ty, TypeSchema::Option(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_function_falls_back_to_error_output() {
|
||||
let unknown = schemas("nope");
|
||||
assert_eq!(unknown.function, "unknown");
|
||||
assert_eq!(unknown.outputs[0].name, "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_parses_a_well_formed_payload() {
|
||||
let params = Map::from_iter([
|
||||
("question".to_string(), Value::from("What is 2+2?")),
|
||||
(
|
||||
"member_models".to_string(),
|
||||
Value::from(vec![Value::from("gpt"), Value::from("claude")]),
|
||||
),
|
||||
("chair_model".to_string(), Value::from("chair")),
|
||||
("temperature".to_string(), Value::from(0.5)),
|
||||
]);
|
||||
let parsed = deserialize_params::<ModelCouncilParams>(params).unwrap();
|
||||
assert_eq!(parsed.question, "What is 2+2?");
|
||||
assert_eq!(parsed.member_models, vec!["gpt", "claude"]);
|
||||
assert_eq!(parsed.chair_model, "chair");
|
||||
assert_eq!(parsed.temperature, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_allows_omitted_temperature() {
|
||||
let params = Map::from_iter([
|
||||
("question".to_string(), Value::from("q")),
|
||||
(
|
||||
"member_models".to_string(),
|
||||
Value::from(vec![Value::from("a")]),
|
||||
),
|
||||
("chair_model".to_string(), Value::from("chair")),
|
||||
]);
|
||||
let parsed = deserialize_params::<ModelCouncilParams>(params).unwrap();
|
||||
assert_eq!(parsed.temperature, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_rejects_missing_required_field() {
|
||||
let params = Map::from_iter([("question".to_string(), Value::from("q"))]);
|
||||
assert!(deserialize_params::<ModelCouncilParams>(params).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user