summaryrefslogtreecommitdiff
path: root/backend/app/main.py
diff options
context:
space:
mode:
authorYurenHao0426 <blackhao0426@gmail.com>2026-02-13 21:43:34 +0000
committerYurenHao0426 <blackhao0426@gmail.com>2026-02-13 21:43:34 +0000
commit77be59bc0a6353e98846b9c9bfa2d566efea8b1f (patch)
treec0cc008b4705eb50616e6656f8fbc0e5b3475307 /backend/app/main.py
parent30921396cb53f61eca90c85d692e0fc06d0f5ff4 (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.py162
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")