diff options
| author | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 21:43:34 +0000 |
|---|---|---|
| committer | YurenHao0426 <blackhao0426@gmail.com> | 2026-02-13 21:43:34 +0000 |
| commit | 77be59bc0a6353e98846b9c9bfa2d566efea8b1f (patch) | |
| tree | c0cc008b4705eb50616e6656f8fbc0e5b3475307 /backend/app/main.py | |
| parent | 30921396cb53f61eca90c85d692e0fc06d0f5ff4 (diff) | |
Add LLM Council mode for multi-model consensus
3-stage council orchestration: parallel model queries (Stage 1),
anonymous peer ranking (Stage 2), and streamed chairman synthesis
(Stage 3). Includes scope-aware file resolution for Google/Claude
providers so upstream file attachments are visible to all providers.
- Backend: council.py orchestrator, /api/run_council_stream endpoint,
query_model_full() non-streaming wrapper, resolve_provider() helper,
resolve_scoped_file_ids() for Google/Claude scope parity with OpenAI
- Frontend: council toggle UI, model checkbox selector, chairman picker,
SSE event parsing, tabbed Stage 1/2/3 response display
- Canvas: amber council node indicator with Users icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend/app/main.py')
| -rw-r--r-- | backend/app/main.py | 162 |
1 files changed, 158 insertions, 4 deletions
diff --git a/backend/app/main.py b/backend/app/main.py index d48ec89..9370a32 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,8 +8,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, FileResponse from fastapi import UploadFile, File, Form from pydantic import BaseModel -from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort -from app.services.llm import llm_streamer, generate_title, get_openai_client, get_anthropic_client +from app.schemas import NodeRunRequest, NodeRunResponse, MergeStrategy, Role, Message, Context, LLMConfig, ModelProvider, ReasoningEffort, CouncilRunRequest +from app.services.llm import llm_streamer, generate_title, get_openai_client, get_anthropic_client, resolve_provider +from app.services.council import council_event_stream from app.auth import auth_router, get_current_user, get_current_user_optional, init_db, User, get_db from app.auth.utils import get_password_hash from dotenv import load_dotenv @@ -421,17 +422,19 @@ async def run_node_stream( tools.append(tool_def) logger.debug("openai file_search: vs_ids=%s refs=%s filters=%s", vs_ids, debug_refs, filters) elif request.config.provider == ModelProvider.GOOGLE: + scoped_ids = resolve_scoped_file_ids(username, request.scopes, non_image_file_ids) attachments = await prepare_attachments( user=username, target_provider=request.config.provider, - attached_ids=non_image_file_ids, + attached_ids=scoped_ids, llm_config=request.config, ) elif request.config.provider == ModelProvider.CLAUDE: + scoped_ids = resolve_scoped_file_ids(username, request.scopes, non_image_file_ids) attachments = await prepare_attachments( user=username, target_provider=request.config.provider, - attached_ids=non_image_file_ids, + attached_ids=scoped_ids, llm_config=request.config, ) @@ -442,6 +445,127 @@ async def run_node_stream( media_type="text/event-stream" ) +@app.post("/api/run_council_stream") +async def run_council_stream( + request: CouncilRunRequest, + user: str = DEFAULT_USER, + current_user: User | None = Depends(get_current_user_optional), +): + """ + Run the 3-stage LLM Council and stream SSE events. + """ + resolved = resolve_user(current_user, user) + username = resolved.username if resolved else DEFAULT_USER + + # Merge incoming contexts (same logic as run_node_stream) + raw_messages = [] + for ctx in request.incoming_contexts: + raw_messages.extend(ctx.messages) + if request.merge_strategy == MergeStrategy.SMART: + final_messages = smart_merge_messages(raw_messages) + else: + final_messages = raw_messages + execution_context = Context(messages=final_messages) + + # Extract images from attached files + images, non_image_file_ids = extract_image_attachments(username, request.attached_file_ids) + + openrouter_key = get_user_api_key(resolved, "openrouter") + + # Build LLMConfig + attachments for each council member + member_configs: list[LLMConfig] = [] + attachments_per_model: list[list[dict] | None] = [] + tools_per_model: list[list[dict] | None] = [] + + all_model_names = [m.model_name for m in request.council_models] + [request.chairman_model] + + for member in request.council_models: + provider = resolve_provider(member.model_name) + provider_str = provider.value + api_key = get_user_api_key(resolved, provider_str) + + config = LLMConfig( + provider=provider, + model_name=member.model_name, + temperature=request.temperature, + system_prompt=request.system_prompt, + api_key=api_key, + reasoning_effort=request.reasoning_effort, + ) + member_configs.append(config) + + # Prepare provider-specific file attachments + tools: list[dict] = [] + attachments: list[dict] = [] + + # For Google/Claude: resolve scope-based files so upstream attachments are visible + scoped_file_ids = resolve_scoped_file_ids(username, request.scopes, non_image_file_ids) + + if provider == ModelProvider.OPENAI: + vs_ids, debug_refs, filters = await prepare_openai_vector_search( + user=username, + attached_ids=non_image_file_ids, + scopes=request.scopes, + llm_config=config, + ) + if not vs_ids: + try: + client = get_openai_client(config.api_key) + vs_id = await ensure_user_vector_store(username, client) + if vs_id: + vs_ids = [vs_id] + except Exception: + pass + if vs_ids: + tool_def = {"type": "file_search", "vector_store_ids": vs_ids} + if filters: + tool_def["filters"] = filters + tools.append(tool_def) + elif provider == ModelProvider.GOOGLE: + attachments = await prepare_attachments( + user=username, + target_provider=provider, + attached_ids=scoped_file_ids, + llm_config=config, + ) + elif provider == ModelProvider.CLAUDE: + attachments = await prepare_attachments( + user=username, + target_provider=provider, + attached_ids=scoped_file_ids, + llm_config=config, + ) + + attachments_per_model.append(attachments or None) + tools_per_model.append(tools or None) + + # Build chairman config + chairman_provider = resolve_provider(request.chairman_model) + chairman_api_key = get_user_api_key(resolved, chairman_provider.value) + chairman_config = LLMConfig( + provider=chairman_provider, + model_name=request.chairman_model, + temperature=request.temperature, + system_prompt=request.system_prompt, + api_key=chairman_api_key, + reasoning_effort=request.reasoning_effort, + ) + + return StreamingResponse( + council_event_stream( + user_prompt=request.user_prompt, + context=execution_context, + member_configs=member_configs, + chairman_config=chairman_config, + attachments_per_model=attachments_per_model, + tools_per_model=tools_per_model, + openrouter_api_key=openrouter_key, + images=images, + ), + media_type="text/event-stream", + ) + + class TitleRequest(BaseModel): user_prompt: str response: str @@ -832,6 +956,36 @@ def save_files_index(user: str, items: List[FileMeta]): json.dump([item.model_dump() for item in items], f, ensure_ascii=False, indent=2) +def resolve_scoped_file_ids(user: str, scopes: List[str], explicit_ids: List[str]) -> List[str]: + """ + Resolve file IDs that are relevant to the given scopes (upstream nodes). + Combines scope-matched files with explicitly attached files. + This gives Google/Claude the same scope awareness that OpenAI gets via file_search. + """ + if not scopes and not explicit_ids: + return [] + + items = load_files_index(user) + result_ids: dict[str, bool] = {} + + # Add explicitly attached files first + for fid in explicit_ids: + result_ids[fid] = True + + # Add files whose scopes intersect with requested scopes (skip images) + if scopes: + for item in items: + if item.id in result_ids: + continue + if item.mime in IMAGE_MIME_TYPES: + continue + if item.scopes and any(s in scopes for s in item.scopes): + result_ids[item.id] = True + logger.debug("resolve_scoped_file_ids: scope match %s -> %s", item.name, item.id) + + return list(result_ids.keys()) + + async def _check_google_file_active(uri_or_name: str, api_key: str = None) -> bool: """Check if a Google file reference is still ACTIVE (not expired).""" key = api_key or os.getenv("GOOGLE_API_KEY") |
