diff --git a/backend/app/api/router.py b/backend/app/api/router.py index d82c916..f09788a 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -3,6 +3,7 @@ from app.api.routes.conversations import router as conversations_router from app.api.routes.evidence import router as evidence_router from app.api.routes.mcp import router as mcp_router +from app.api.routes.settings import router as settings_router from app.api.routes.tasks import router as tasks_router api_router = APIRouter() @@ -10,3 +11,4 @@ api_router.include_router(evidence_router) api_router.include_router(mcp_router) api_router.include_router(conversations_router) +api_router.include_router(settings_router) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py new file mode 100644 index 0000000..b5ea5fe --- /dev/null +++ b/backend/app/api/routes/settings.py @@ -0,0 +1,173 @@ +"""Settings API routes.""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from app.models.schemas import ( + LLMOption, + LLMProvider, + LLMSettingsResponse, + ProviderConfigResponse, + ProviderConfigUpdate, + TaskMappingResponse, + TaskMappingUpdate, +) +from app.repositories.llm_config_repository import get_llm_config_repository + +router = APIRouter(prefix="/api/v1/settings") + +# Provider labels for display +PROVIDER_LABELS = { + LLMProvider.OPENROUTER: "OpenRouter", + LLMProvider.DEEPSEEK: "DeepSeek", + LLMProvider.OPENAI: "OpenAI", +} + + +@router.get("/llm", response_model=LLMSettingsResponse) +def get_llm_settings() -> LLMSettingsResponse: + """Get available LLM configurations (legacy endpoint for compatibility).""" + repo = get_llm_config_repository() + providers = repo.list_providers() + + options: list[LLMOption] = [] + default_provider = LLMProvider.OPENROUTER + + for config in providers: + provider_enum = LLMProvider(config.provider.lower()) + options.append(LLMOption( + provider=provider_enum, + label=PROVIDER_LABELS.get(provider_enum, config.provider), + model=config.model, + configured=config.configured, + )) + if config.is_default: + default_provider = provider_enum + + return LLMSettingsResponse(defaultProvider=default_provider, options=options) + + +@router.get("/llm/providers", response_model=list[ProviderConfigResponse]) +def list_provider_configs() -> list[ProviderConfigResponse]: + """List all provider configurations with detailed info.""" + repo = get_llm_config_repository() + providers = repo.list_providers() + + result: list[ProviderConfigResponse] = [] + for config in providers: + provider_enum = LLMProvider(config.provider.lower()) + result.append(ProviderConfigResponse( + provider=provider_enum, + label=PROVIDER_LABELS.get(provider_enum, config.provider), + apiKey=config.mask_api_key(), + baseUrl=config.base_url, + model=config.model, + configured=config.configured, + isDefault=config.is_default, + )) + + return result + + +@router.get("/llm/providers/{provider}", response_model=ProviderConfigResponse) +def get_provider_config(provider: LLMProvider) -> ProviderConfigResponse: + """Get configuration for a specific provider.""" + repo = get_llm_config_repository() + config = repo.get_provider(provider.value) + + if not config: + raise HTTPException(status_code=404, detail=f"Provider {provider} not found") + + return ProviderConfigResponse( + provider=provider, + label=PROVIDER_LABELS.get(provider, provider.value), + apiKey=config.mask_api_key(), + baseUrl=config.base_url, + model=config.model, + configured=config.configured, + isDefault=config.is_default, + ) + + +@router.put("/llm/providers/{provider}", response_model=ProviderConfigResponse) +def update_provider_config(provider: LLMProvider, update: ProviderConfigUpdate) -> ProviderConfigResponse: + """Update configuration for a specific provider.""" + repo = get_llm_config_repository() + existing = repo.get_provider(provider.value) + + # Merge with existing config + from app.repositories.llm_config_repository import ProviderConfig as RepoConfig + new_config = RepoConfig( + provider=provider.value, + api_key=update.api_key if update.api_key is not None else existing.api_key, + base_url=update.baseUrl if update.baseUrl is not None else existing.base_url, + model=update.model if update.model is not None else existing.model, + is_default=update.isDefault if update.isDefault is not None else existing.is_default, + ) + + updated = repo.upsert_provider(new_config) + + # If setting as default, update others + if update.isDefault: + repo.set_default_provider(provider.value) + + return ProviderConfigResponse( + provider=provider, + label=PROVIDER_LABELS.get(provider, provider.value), + apiKey=updated.mask_api_key(), + baseUrl=updated.base_url, + model=updated.model, + configured=updated.configured, + isDefault=updated.is_default, + ) + + +@router.delete("/llm/providers/{provider}") +def reset_provider_config(provider: LLMProvider) -> dict: + """Reset provider configuration to environment defaults.""" + repo = get_llm_config_repository() + repo.delete_provider(provider.value) + return {"status": "reset", "provider": provider.value} + + +@router.get("/llm/task-mapping", response_model=TaskMappingResponse) +def get_task_mapping() -> TaskMappingResponse: + """Get task type to provider mapping.""" + repo = get_llm_config_repository() + mapping = repo.get_task_mapping() + + return TaskMappingResponse( + draft=LLMProvider(mapping.draft.lower()), + chat=LLMProvider(mapping.chat.lower()), + article=LLMProvider(mapping.article.lower()), + ) + + +@router.put("/llm/task-mapping", response_model=TaskMappingResponse) +def update_task_mapping(update: TaskMappingUpdate) -> TaskMappingResponse: + """Update task type to provider mapping.""" + repo = get_llm_config_repository() + existing = repo.get_task_mapping() + + from app.repositories.llm_config_repository import TaskMapping + new_mapping = TaskMapping( + draft=update.draft.value if update.draft else existing.draft, + chat=update.chat.value if update.chat else existing.chat, + article=update.article.value if update.article else existing.article, + ) + + updated = repo.update_task_mapping(new_mapping) + + return TaskMappingResponse( + draft=LLMProvider(updated.draft.lower()), + chat=LLMProvider(updated.chat.lower()), + article=LLMProvider(updated.article.lower()), + ) + + +@router.patch("/llm", response_model=LLMSettingsResponse) +def update_llm_settings(payload: dict) -> LLMSettingsResponse: + """Update LLM settings (legacy endpoint for compatibility).""" + # For now, this just returns current settings + # In a full implementation, this would persist the default provider + return get_llm_settings() \ No newline at end of file diff --git a/backend/app/api/routes/tasks.py b/backend/app/api/routes/tasks.py index 7863fbc..7a6ba81 100644 --- a/backend/app/api/routes/tasks.py +++ b/backend/app/api/routes/tasks.py @@ -2,17 +2,21 @@ from pathlib import Path -from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse from app.api.schemas.dag import DAGUpdateRequest, DAGValidationResponse from app.core.utils import new_id -from app.deps import conflict_repository, execution_engine, progress_hub, task_repository +from app.deps import conflict_repository, execution_engine, experiment_repository, progress_hub, task_repository from app.models.schemas import ( + BranchAction, + BranchRepairAttempt, ConflictRecord, CreateTaskRequest, DAGGraph, DeleteResponse, + ExperimentRun, + SearchBranch, StateResponse, TaskResponse, TaskStatus, @@ -39,7 +43,8 @@ def get_task(task_id: str) -> TaskResponse: try: return task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc @router.put("/tasks/{task_id}", response_model=TaskResponse) @@ -52,7 +57,8 @@ def update_task(task_id: str, payload: UpdateTaskRequest) -> TaskResponse: config=payload.config, ) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc @router.delete("/tasks/{task_id}", response_model=DeleteResponse) @@ -60,7 +66,8 @@ def delete_task(task_id: str) -> DeleteResponse: try: task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc task_repository.delete_task(task_id) return DeleteResponse(taskId=task_id, deleted=True) @@ -70,7 +77,8 @@ def get_task_dag(task_id: str) -> dict: try: return task_repository.get_dag(task_id, allow_empty=True).model_dump(by_alias=True) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc @router.put("/tasks/{task_id}/dag", response_model=DAGValidationResponse) @@ -79,7 +87,8 @@ def update_task_dag(task_id: str, request: DAGUpdateRequest) -> DAGValidationRes try: task = task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc # Validate task status - DAG can only be updated during PLANNING or READY phase if task.status not in {TaskStatus.PLANNING, TaskStatus.READY}: @@ -113,18 +122,61 @@ def get_task_conflicts(task_id: str) -> list[ConflictRecord]: try: task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc return conflict_repository.list_by_task(task_id) +@router.get("/tasks/{task_id}/search-branches", response_model=list[SearchBranch]) +def get_task_search_branches(task_id: str) -> list[SearchBranch]: + try: + task_repository.get_task(task_id) + except KeyError as exc: + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc + return task_repository.list_search_branches(task_id) + + +@router.get("/tasks/{task_id}/branch-actions", response_model=list[BranchAction]) +def get_task_branch_actions(task_id: str, branch_id: str | None = Query(default=None, alias="branchId")) -> list[BranchAction]: + try: + task_repository.get_task(task_id) + except KeyError as exc: + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc + return task_repository.list_branch_actions(task_id, branch_id) + + +@router.get("/tasks/{task_id}/branch-repairs", response_model=list[BranchRepairAttempt]) +def get_task_branch_repairs(task_id: str, branch_id: str | None = Query(default=None, alias="branchId")) -> list[BranchRepairAttempt]: + try: + task_repository.get_task(task_id) + except KeyError as exc: + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc + return task_repository.list_branch_repairs(task_id, branch_id) + + +@router.get("/tasks/{task_id}/experiments", response_model=list[ExperimentRun]) +def get_task_experiments(task_id: str) -> list[ExperimentRun]: + try: + task_repository.get_task(task_id) + except KeyError as exc: + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc + return experiment_repository.list_runs(task_id) + + @router.post("/tasks/{task_id}/start", response_model=StateResponse) async def start_task(task_id: str) -> StateResponse: try: task = task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc if task.status in {TaskStatus.COMPLETED, TaskStatus.ABORTED}: - raise HTTPException(status_code=400, detail=f"Task is in terminal state: {task.status.value}") + raise HTTPException( + status_code=400, detail=f"Task is in terminal state: {task.status.value}") await execution_engine.start(task_id) return StateResponse(taskId=task_id, status=TaskStatus.EXECUTING, message="Task execution started") @@ -134,7 +186,8 @@ def pause_task(task_id: str) -> StateResponse: try: task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc execution_engine.pause(task_id) return StateResponse(taskId=task_id, status=TaskStatus.SUSPENDED, message="Task paused") @@ -144,9 +197,11 @@ async def resume_task(task_id: str) -> StateResponse: try: task = task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc if task.status not in {TaskStatus.SUSPENDED, TaskStatus.REVIEWING, TaskStatus.READY}: - raise HTTPException(status_code=400, detail=f"Task status does not support resume: {task.status.value}") + raise HTTPException( + status_code=400, detail=f"Task status does not support resume: {task.status.value}") await execution_engine.resume(task_id) return StateResponse(taskId=task_id, status=TaskStatus.EXECUTING, message="Task resumed") @@ -156,7 +211,8 @@ def abort_task(task_id: str) -> StateResponse: try: task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc execution_engine.abort(task_id) return StateResponse(taskId=task_id, status=TaskStatus.ABORTED, message="Task aborted") @@ -166,7 +222,8 @@ async def recover_task(task_id: str) -> StateResponse: try: task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc await execution_engine.recover(task_id) return StateResponse(taskId=task_id, status=TaskStatus.EXECUTING, message="Task recovered from snapshot") @@ -186,12 +243,14 @@ def get_report(task_id: str) -> dict[str, str]: try: task = task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc if not task.reportPath: raise HTTPException(status_code=404, detail="Report not generated yet") path = Path(task.reportPath) if not path.exists(): - raise HTTPException(status_code=404, detail="Report file does not exist") + raise HTTPException( + status_code=404, detail="Report file does not exist") return {"taskId": task_id, "content": path.read_text(encoding="utf-8")} @@ -200,12 +259,14 @@ def download_report(task_id: str) -> FileResponse: try: task = task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc if not task.reportPath: raise HTTPException(status_code=404, detail="Report not generated yet") path = Path(task.reportPath) if not path.exists(): - raise HTTPException(status_code=404, detail="Report file does not exist") + raise HTTPException( + status_code=404, detail="Report file does not exist") return FileResponse(path, media_type="text/markdown", filename=f"{task_id}.md") @@ -214,7 +275,8 @@ def get_snapshot(task_id: str) -> dict: try: task_repository.get_task(task_id) except KeyError as exc: - raise HTTPException(status_code=404, detail=f"Task not found: {task_id}") from exc + raise HTTPException( + status_code=404, detail=f"Task not found: {task_id}") from exc snapshot = task_repository.load_snapshot(task_id) if snapshot is None: raise HTTPException(status_code=404, detail="Snapshot not found") diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5452112..873d794 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -15,6 +15,7 @@ description TEXT NOT NULL, status TEXT NOT NULL, config_json TEXT NOT NULL, + research_scorecard_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, report_path TEXT, @@ -31,6 +32,9 @@ priority INTEGER NOT NULL, search_depth INTEGER NOT NULL, info_gain_score REAL NOT NULL, + branch_id TEXT, + branch_score REAL NOT NULL DEFAULT 0, + branch_depth INTEGER NOT NULL DEFAULT 0, position_x REAL, position_y REAL, dependencies_json TEXT NOT NULL, @@ -41,6 +45,81 @@ PRIMARY KEY (task_id, node_id) ); +CREATE TABLE IF NOT EXISTS search_branches ( + task_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + parent_branch_id TEXT, + root_node_id TEXT NOT NULL, + branch_type TEXT NOT NULL, + branch_goal TEXT NOT NULL, + depth INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + score_info_gain REAL NOT NULL DEFAULT 0, + score_evidence_strength REAL NOT NULL DEFAULT 0, + score_feasibility REAL NOT NULL DEFAULT 0, + score_total REAL NOT NULL DEFAULT 0, + prune_reason TEXT, + debug_depth INTEGER NOT NULL DEFAULT 0, + worker_id TEXT, + node_ids_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (task_id, branch_id) +); + +CREATE TABLE IF NOT EXISTS branch_actions ( + action_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + action_type TEXT NOT NULL, + action_input_json TEXT NOT NULL, + action_output_json TEXT NOT NULL, + score_before REAL NOT NULL DEFAULT 0, + score_after REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS branch_repairs ( + repair_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + node_id TEXT NOT NULL, + attempt INTEGER NOT NULL, + diagnosis TEXT NOT NULL, + proposal TEXT NOT NULL, + succeeded INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS experiment_runs ( + run_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + node_id TEXT NOT NULL, + status TEXT NOT NULL, + objective TEXT NOT NULL, + stdout TEXT NOT NULL, + stderr TEXT NOT NULL, + exit_code INTEGER, + metrics_json TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS experiment_artifacts ( + artifact_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + task_id TEXT NOT NULL, + branch_id TEXT NOT NULL, + node_id TEXT NOT NULL, + artifact_type TEXT NOT NULL, + path TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS snapshots ( task_id TEXT PRIMARY KEY, snapshot_json TEXT NOT NULL, @@ -63,6 +142,11 @@ CREATE INDEX IF NOT EXISTS idx_evidences_task_id ON evidences(task_id); CREATE INDEX IF NOT EXISTS idx_evidences_source_type ON evidences(source_type); +CREATE INDEX IF NOT EXISTS idx_search_branches_task_id ON search_branches(task_id); +CREATE INDEX IF NOT EXISTS idx_branch_actions_task_branch ON branch_actions(task_id, branch_id); +CREATE INDEX IF NOT EXISTS idx_branch_repairs_task_branch ON branch_repairs(task_id, branch_id); +CREATE INDEX IF NOT EXISTS idx_experiment_runs_task_id ON experiment_runs(task_id); +CREATE INDEX IF NOT EXISTS idx_experiment_artifacts_task_id ON experiment_artifacts(task_id); CREATE TABLE IF NOT EXISTS conflicts ( conflict_id TEXT PRIMARY KEY, @@ -82,6 +166,7 @@ topic TEXT NOT NULL, status TEXT NOT NULL, config_json TEXT NOT NULL, + current_ideas_json TEXT NOT NULL DEFAULT '[]', task_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -129,8 +214,10 @@ def init_db() -> None: cursor = conn.execute("PRAGMA table_info(evidences)") columns = [row[1] for row in cursor.fetchall()] if "favorited" not in columns: - conn.execute("ALTER TABLE evidences ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0") - conn.execute("CREATE INDEX IF NOT EXISTS idx_evidences_favorited ON evidences(favorited)") + conn.execute( + "ALTER TABLE evidences ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_evidences_favorited ON evidences(favorited)") conn.commit() # Handle migrations: add task node position columns if they don't exist @@ -140,6 +227,26 @@ def init_db() -> None: conn.execute("ALTER TABLE task_nodes ADD COLUMN position_x REAL") if "position_y" not in task_node_columns: conn.execute("ALTER TABLE task_nodes ADD COLUMN position_y REAL") + if "branch_id" not in task_node_columns: + conn.execute("ALTER TABLE task_nodes ADD COLUMN branch_id TEXT") + if "branch_score" not in task_node_columns: + conn.execute( + "ALTER TABLE task_nodes ADD COLUMN branch_score REAL NOT NULL DEFAULT 0") + if "branch_depth" not in task_node_columns: + conn.execute( + "ALTER TABLE task_nodes ADD COLUMN branch_depth INTEGER NOT NULL DEFAULT 0") + + cursor = conn.execute("PRAGMA table_info(conversations)") + conversation_columns = [row[1] for row in cursor.fetchall()] + if "current_ideas_json" not in conversation_columns: + conn.execute( + "ALTER TABLE conversations ADD COLUMN current_ideas_json TEXT NOT NULL DEFAULT '[]'") + + cursor = conn.execute("PRAGMA table_info(tasks)") + task_columns = [row[1] for row in cursor.fetchall()] + if "research_scorecard_json" not in task_columns: + conn.execute( + "ALTER TABLE tasks ADD COLUMN research_scorecard_json TEXT") conn.commit() conn.commit() diff --git a/backend/app/deps.py b/backend/app/deps.py index 7982a8a..238dc45 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -3,13 +3,16 @@ from app.repositories.conversation_repository import ConversationRepository from app.repositories.conflict_repository import ConflictRepository from app.repositories.evidence_repository import EvidenceRepository +from app.repositories.experiment_repository import ExperimentRepository from app.repositories.task_repository import TaskRepository from app.services.agents import ReportAgent, ResearchAgent from app.services.analyst import AnalystService from app.services.conversation_agent import ConversationAgent from app.services.execution_engine import ExecutionEngine from app.services.four_agents.checking.agent import CheckingAgent +from app.services.idea_service import IdeaService from app.services.mcp_executor import MCPExecutor +from app.services.novelty_gate import NoveltyGateService from app.services.planner import MasterPlanner from app.services.progress_hub import ProgressHub from app.services.retrieval import RetrievalService @@ -17,6 +20,7 @@ task_repository = TaskRepository() evidence_repository = EvidenceRepository() +experiment_repository = ExperimentRepository() conflict_repository = ConflictRepository() conversation_repository = ConversationRepository() planner = MasterPlanner() @@ -25,7 +29,10 @@ analyst_service = AnalystService() writer_service = WriterService() mcp_executor = MCPExecutor() -research_agent = ResearchAgent(retrieval_service=retrieval_service, mcp_executor=mcp_executor) +idea_service = IdeaService(retrieval_service=retrieval_service) +novelty_gate_service = NoveltyGateService() +research_agent = ResearchAgent( + retrieval_service=retrieval_service, mcp_executor=mcp_executor) # 创建四 Agent 架构实例 checking_agent = CheckingAgent() @@ -42,6 +49,7 @@ evidence_repository, retrieval_service, conflict_repository, + experiment_repository, analyst_service, writer_service, research_agent, @@ -56,6 +64,8 @@ evidence_repository=evidence_repository, report_agent=report_agent, planner=planner, + idea_service=idea_service, + novelty_gate_service=novelty_gate_service, ) execution_engine.set_event_listener(conversation_agent.on_task_event) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 37722b5..1cb9355 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -3,7 +3,7 @@ from enum import StrEnum from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class TaskStatus(StrEnum): @@ -53,14 +53,85 @@ class AgentStatus(StrEnum): WAITING_INPUT = "WAITING_INPUT" +class LLMProvider(StrEnum): + OPENROUTER = "openrouter" + DEEPSEEK = "deepseek" + OPENAI = "openai" + + +class ResearchMode(StrEnum): + SURVEY = "survey" + EVIDENCE_REPORT = "evidence_report" + EXPERIMENTAL_RESEARCH = "experimental_research" + PAPER_WRITEUP = "paper_writeup" + + +class ExperimentRunStatus(StrEnum): + PENDING = "PENDING" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +class IdeaStatus(StrEnum): + CANDIDATE = "CANDIDATE" + SELECTED = "SELECTED" + REJECTED = "REJECTED" + + class TaskConfig(BaseModel): maxDepth: int = Field(default=3, ge=1, le=8) maxNodes: int = Field(default=50, ge=1, le=500) searchSources: list[str] = Field( - default_factory=lambda: ["Web Search", "arXiv", "Semantic Scholar", "OpenAlex"] + default_factory=lambda: ["Web Search", + "arXiv", "Semantic Scholar", "OpenAlex"] ) priority: int = Field(default=3, ge=1, le=5) + researchMode: ResearchMode = Field(default=ResearchMode.EVIDENCE_REPORT) + numReflections: int = Field(default=2, ge=1, le=6) + numInitialIdeas: int = Field(default=3, ge=1, le=8) + branchPruneThreshold: float = Field(default=0.25, ge=0.0, le=1.0) + requiresNoveltyCheck: bool = False + requiresExperimentLoop: bool = False + requiresPeerReview: bool = False + deliverableTypes: list[str] = Field(default_factory=list) targetWordCount: int = Field(default=5000, ge=1000, le=50000) + llmProvider: LLMProvider = Field(default=LLMProvider.OPENROUTER) + + @model_validator(mode="before") + @classmethod + def _apply_research_defaults(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + payload = dict(data) + raw_mode = payload.get("researchMode", ResearchMode.EVIDENCE_REPORT) + try: + mode = raw_mode if isinstance( + raw_mode, ResearchMode) else ResearchMode(str(raw_mode)) + except Exception: + mode = ResearchMode.EVIDENCE_REPORT + payload["researchMode"] = mode + if payload.get("requiresNoveltyCheck") is None or "requiresNoveltyCheck" not in payload: + payload["requiresNoveltyCheck"] = mode in { + ResearchMode.EXPERIMENTAL_RESEARCH, + ResearchMode.PAPER_WRITEUP, + } + if payload.get("requiresExperimentLoop") is None or "requiresExperimentLoop" not in payload: + payload["requiresExperimentLoop"] = mode in { + ResearchMode.EXPERIMENTAL_RESEARCH, + ResearchMode.PAPER_WRITEUP, + } + if payload.get("requiresPeerReview") is None or "requiresPeerReview" not in payload: + payload["requiresPeerReview"] = mode in { + ResearchMode.PAPER_WRITEUP} + if not payload.get("deliverableTypes"): + if mode == ResearchMode.PAPER_WRITEUP: + payload["deliverableTypes"] = ["report", "paper"] + elif mode == ResearchMode.EXPERIMENTAL_RESEARCH: + payload["deliverableTypes"] = ["report", "experiment_log"] + else: + payload["deliverableTypes"] = ["report"] + return payload class CreateTaskRequest(BaseModel): @@ -80,6 +151,9 @@ class TaskMetadata(BaseModel): estimatedTokenCost: int = 0 searchDepth: int = 0 infoGainScore: float = 0.0 + branchId: str | None = None + branchScore: float = Field(default=0.0, ge=0.0, le=1.0) + branchDepth: int = Field(default=0, ge=0) positionX: float | None = None positionY: float | None = None createdAt: str @@ -110,6 +184,70 @@ class DAGGraph(BaseModel): edges: list[DAGEdge] +class BranchScore(BaseModel): + infoGain: float = Field(default=0.0, ge=0.0, le=1.0) + evidenceStrength: float = Field(default=0.0, ge=0.0, le=1.0) + feasibility: float = Field(default=0.0, ge=0.0, le=1.0) + total: float = Field(default=0.0, ge=0.0, le=1.0) + + +class SearchBranch(BaseModel): + branchId: str + parentBranchId: str | None = None + rootNodeId: str + branchType: str = "research" + branchGoal: str = "" + depth: int = Field(default=0, ge=0) + status: NodeStatus = Field(default=NodeStatus.PENDING) + score: BranchScore = Field(default_factory=BranchScore) + pruneReason: str | None = None + debugDepth: int = Field(default=0, ge=0) + workerId: str | None = None + nodeIds: list[str] = Field(default_factory=list) + + +class BranchAction(BaseModel): + actionId: str + taskId: str + branchId: str + actionType: str + actionInput: dict[str, Any] = Field(default_factory=dict) + actionOutput: dict[str, Any] = Field(default_factory=dict) + scoreBefore: float = Field(default=0.0, ge=0.0, le=1.0) + scoreAfter: float = Field(default=0.0, ge=0.0, le=1.0) + status: str = "PENDING" + createdAt: str + + +class BranchFailure(BaseModel): + failureId: str + taskId: str + branchId: str + nodeId: str + failureType: str + reason: str + detail: str = "" + createdAt: str + + +class BranchRepairAttempt(BaseModel): + repairId: str + taskId: str + branchId: str + nodeId: str + attempt: int = Field(default=1, ge=1) + diagnosis: str = "" + proposal: str = "" + succeeded: bool = False + createdAt: str + + +class SearchTree(BaseModel): + taskId: str + rootBranchId: str + branches: list[SearchBranch] = Field(default_factory=list) + + class TaskResponse(BaseModel): taskId: str title: str @@ -119,6 +257,7 @@ class TaskResponse(BaseModel): updatedAt: str config: TaskConfig reportPath: str | None = None + researchScoreCard: ResearchScoreCard | None = None dag: DAGGraph | None = None @@ -147,6 +286,7 @@ class MessageRole(StrEnum): class MessageKind(StrEnum): USER_TEXT = "USER_TEXT" + ASSISTANT_TEXT = "ASSISTANT_TEXT" PLAN_DRAFT = "PLAN_DRAFT" PLAN_EDITED = "PLAN_EDITED" PLAN_REVISION = "PLAN_REVISION" @@ -195,6 +335,107 @@ class AgentStateRecord(BaseModel): error: str | None = None +class RelatedWorkItem(BaseModel): + title: str = Field(min_length=1, max_length=300) + summary: str = Field(default="", max_length=1000) + url: str = "" + relevanceScore: float = Field(default=0.0, ge=0.0, le=1.0) + + +class NoveltyAssessment(BaseModel): + summary: str = "" + noveltyScore: float = Field(default=0.0, ge=0.0, le=1.0) + isNovel: bool = False + similarWork: list[str] = Field(default_factory=list) + differentiationNotes: list[str] = Field(default_factory=list) + + +class FeasibilityAssessment(BaseModel): + summary: str = "" + feasibilityScore: float = Field(default=0.0, ge=0.0, le=1.0) + isFeasible: bool = False + blockers: list[str] = Field(default_factory=list) + assumptions: list[str] = Field(default_factory=list) + + +class ExperimentProposal(BaseModel): + title: str = Field(min_length=1, max_length=200) + objective: str = Field(default="", max_length=1000) + method: str = Field(default="", max_length=2000) + metrics: list[str] = Field(default_factory=list) + expectedOutcome: str = Field(default="", max_length=1000) + + +class ExperimentMetric(BaseModel): + name: str + value: float + unit: str = "" + + +class ExperimentArtifact(BaseModel): + artifactId: str + runId: str + taskId: str + branchId: str + nodeId: str + artifactType: str + path: str = "" + summary: str = "" + createdAt: str + + +class ExperimentRun(BaseModel): + runId: str + taskId: str + branchId: str + nodeId: str + status: ExperimentRunStatus + objective: str = "" + stdout: str = "" + stderr: str = "" + exitCode: int | None = None + metrics: list[ExperimentMetric] = Field(default_factory=list) + artifacts: list[ExperimentArtifact] = Field(default_factory=list) + startedAt: str + completedAt: str | None = None + + +class RiskAssessment(BaseModel): + risk: str = Field(min_length=1, max_length=500) + severity: str = Field(default="medium", pattern="^(low|medium|high)$") + mitigation: str = Field(default="", max_length=1000) + + +class ResearchScoreCard(BaseModel): + noveltyScore: float = Field(default=0.0, ge=0.0, le=1.0) + feasibilityScore: float = Field(default=0.0, ge=0.0, le=1.0) + evidenceStrengthScore: float = Field(default=0.0, ge=0.0, le=1.0) + executionSuccessScore: float = Field(default=0.0, ge=0.0, le=1.0) + writeupReadinessScore: float = Field(default=0.0, ge=0.0, le=1.0) + reviewScore: float = Field(default=0.0, ge=0.0, le=1.0) + overallScore: float = Field(default=0.0, ge=0.0, le=1.0) + + +class ResearchIdea(BaseModel): + ideaId: str + title: str = Field(min_length=1, max_length=200) + problemStatement: str = Field(default="", max_length=2000) + shortHypothesis: str = Field(default="", max_length=1000) + abstract: str = Field(default="", max_length=3000) + relatedWork: list[RelatedWorkItem] = Field(default_factory=list) + differentiators: list[str] = Field(default_factory=list) + noveltyAssessment: NoveltyAssessment = Field( + default_factory=NoveltyAssessment) + feasibilityAssessment: FeasibilityAssessment = Field( + default_factory=FeasibilityAssessment) + experimentProposals: list[ExperimentProposal] = Field(default_factory=list) + riskFactors: list[RiskAssessment] = Field(default_factory=list) + limitations: list[str] = Field(default_factory=list) + scoreCard: ResearchScoreCard = Field(default_factory=ResearchScoreCard) + sourceEvidenceIds: list[str] = Field(default_factory=list) + status: IdeaStatus = Field(default=IdeaStatus.CANDIDATE) + + class ResearchHypothesis(BaseModel): """研究假设模型。""" hypothesisId: str @@ -218,6 +459,7 @@ class ConversationDetail(ConversationSummary): messages: list[ConversationMessage] = Field(default_factory=list) agentStates: list[AgentStateRecord] = Field(default_factory=list) currentHypothesis: ResearchHypothesis | None = None + currentIdeas: list[ResearchIdea] = Field(default_factory=list) class CreateConversationRequest(BaseModel): @@ -258,6 +500,51 @@ class ConversationDeleteResponse(BaseModel): deleted: bool +class LLMOption(BaseModel): + provider: LLMProvider + label: str + model: str + configured: bool + + +class LLMSettingsResponse(BaseModel): + defaultProvider: LLMProvider + options: list[LLMOption] + + +class ProviderConfigResponse(BaseModel): + """Detailed provider configuration for UI display.""" + provider: LLMProvider + label: str + apiKey: str # Masked for display + baseUrl: str + model: str + configured: bool + isDefault: bool + + +class ProviderConfigUpdate(BaseModel): + """Request body for updating provider configuration.""" + apiKey: str | None = None + baseUrl: str | None = None + model: str | None = None + isDefault: bool | None = None + + +class TaskMappingResponse(BaseModel): + """Task type to provider mapping.""" + draft: LLMProvider + chat: LLMProvider + article: LLMProvider + + +class TaskMappingUpdate(BaseModel): + """Request body for updating task mapping.""" + draft: LLMProvider | None = None + chat: LLMProvider | None = None + article: LLMProvider | None = None + + class ConversationBulkDeleteResponse(BaseModel): deleted: bool deletedCount: int diff --git a/backend/app/prompts/__init__.py b/backend/app/prompts/__init__.py new file mode 100644 index 0000000..b161e4d --- /dev/null +++ b/backend/app/prompts/__init__.py @@ -0,0 +1,2 @@ +"""Prompt builders for research ideation and plan rendering.""" + diff --git a/backend/app/prompts/ideation.py b/backend/app/prompts/ideation.py new file mode 100644 index 0000000..9d22c83 --- /dev/null +++ b/backend/app/prompts/ideation.py @@ -0,0 +1,30 @@ +from __future__ import annotations + + +def build_ideation_system_prompt(*, num_ideas: int, num_reflections: int) -> str: + return ( + "你是一名研究构思 Agent。请围绕用户主题提出结构化研究想法," + "输出必须是 JSON,不要输出 Markdown 或解释。\n" + f"请至少生成 {num_ideas} 个候选 idea,并在内部做 {num_reflections} 轮自我检查。\n" + "每个 idea 必须包含:title, problemStatement, shortHypothesis, abstract, " + "relatedWork, differentiators, experimentProposals, riskFactors, limitations。\n" + "relatedWork 必须是数组;experimentProposals 必须包含 title/objective/method/metrics/expectedOutcome;" + "riskFactors 必须包含 risk/severity/mitigation。\n" + '最终 JSON 形状固定为:{"ideas":[...]}。' + ) + + +def build_ideation_user_prompt( + *, + topic: str, + search_sources: list[str], + evidence_snippets: str, +) -> str: + return ( + f"研究主题:{topic}\n" + f"可用检索源:{', '.join(search_sources) or '无'}\n" + "请基于主题和已有线索提出多个具有区分度的候选研究 idea。\n" + "每个 idea 需要说明与现有工作的差异、核心假设、实验或验证方式,以及主要风险。\n" + f"首轮参考证据:\n{evidence_snippets or '- 暂无外部证据,需基于主题做保守构思。'}" + ) + diff --git a/backend/app/prompts/novelty.py b/backend/app/prompts/novelty.py new file mode 100644 index 0000000..23aa9f4 --- /dev/null +++ b/backend/app/prompts/novelty.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +def build_novelty_system_prompt() -> str: + return ( + "你是一名研究新颖性评审 Agent。请评估 idea 是否足够新颖且可执行。" + "输出必须是 JSON,不要输出解释。" + 'JSON 形状固定为:{"summary":"","noveltyScore":0.0,"isNovel":true,' + '"similarWork":[],"differentiationNotes":[],"feasibilitySummary":"","feasibilityScore":0.0,' + '"isFeasible":true,"blockers":[],"assumptions":[]}.' + ) + + +def build_novelty_user_prompt(*, topic: str, idea_json: str, evidence_snippets: str) -> str: + return ( + f"主题:{topic}\n" + f"候选 idea JSON:\n{idea_json}\n\n" + f"相关证据与工作线索:\n{evidence_snippets or '- 暂无'}\n\n" + "请识别该 idea 与已有工作的重叠、差异、不新颖风险,以及可执行性阻碍。" + ) + diff --git a/backend/app/prompts/plan_render.py b/backend/app/prompts/plan_render.py new file mode 100644 index 0000000..d1bfe09 --- /dev/null +++ b/backend/app/prompts/plan_render.py @@ -0,0 +1,18 @@ +from __future__ import annotations + + +def build_plan_render_system_prompt() -> str: + return ( + "你是研究计划渲染 Agent。请把结构化研究 idea 渲染成可执行 Markdown 研究方案。" + "输出必须是完整 Markdown,并包含 front matter。不要输出额外解释。" + ) + + +def build_plan_render_user_prompt(*, topic: str, config_summary: str, idea_json: str) -> str: + return ( + f"主题:{topic}\n" + f"配置:{config_summary}\n" + f"结构化 idea:\n{idea_json}\n\n" + "请输出包含 front matter 的完整研究计划,正文至少覆盖:研究目标、研究问题拆解、" + "方法与来源、执行步骤、风险与边界、交付标准。" + ) diff --git a/backend/app/repositories/conversation_repository.py b/backend/app/repositories/conversation_repository.py index 78310b6..db4eb31 100644 --- a/backend/app/repositories/conversation_repository.py +++ b/backend/app/repositories/conversation_repository.py @@ -15,6 +15,7 @@ MessageKind, MessageRole, PlanRevision, + ResearchIdea, TaskConfig, ) @@ -48,11 +49,11 @@ def create_conversation( conn.execute( """ INSERT INTO conversations( - conversation_id, topic, status, config_json, task_id, created_at, updated_at - ) VALUES(?, ?, ?, ?, ?, ?, ?) + conversation_id, topic, status, config_json, current_ideas_json, task_id, created_at, updated_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?) """, (conversation_id, topic, status.value, - config.model_dump_json(), None, ts, ts), + config.model_dump_json(), "[]", None, ts, ts), ) conn.commit() return self.get_summary(conversation_id) @@ -132,6 +133,40 @@ def set_task_id(self, conversation_id: str, task_id: str) -> None: if conn.total_changes == 0: raise KeyError(conversation_id) + def set_current_ideas(self, conversation_id: str, ideas: list[ResearchIdea]) -> None: + self.get_summary(conversation_id) + with get_connection() as conn: + conn.execute( + """ + UPDATE conversations + SET current_ideas_json = ?, updated_at = ? + WHERE conversation_id = ? + """, + ( + json.dumps([idea.model_dump(mode="json") for idea in ideas], ensure_ascii=False), + now_iso(), + conversation_id, + ), + ) + conn.commit() + + def get_current_ideas(self, conversation_id: str) -> list[ResearchIdea]: + with get_connection() as conn: + row = conn.execute( + "SELECT current_ideas_json FROM conversations WHERE conversation_id = ?", + (conversation_id,), + ).fetchone() + if row is None: + raise KeyError(conversation_id) + raw_items = json.loads(row["current_ideas_json"] or "[]") + if not isinstance(raw_items, list): + return [] + return [ + ResearchIdea.model_validate(item) + for item in raw_items + if isinstance(item, dict) + ] + def find_by_task_id(self, task_id: str) -> ConversationSummary | None: with get_connection() as conn: row = conn.execute( @@ -581,4 +616,5 @@ def get_detail(self, conversation_id: str) -> ConversationDetail: currentPlan=self.get_current_plan(conversation_id), messages=messages, agentStates=self._derive_agent_states_from_messages(messages), + currentIdeas=self.get_current_ideas(conversation_id), ) diff --git a/backend/app/repositories/experiment_repository.py b/backend/app/repositories/experiment_repository.py new file mode 100644 index 0000000..27fefd2 --- /dev/null +++ b/backend/app/repositories/experiment_repository.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json + +from app.core.database import get_connection +from app.models.schemas import ExperimentArtifact, ExperimentMetric, ExperimentRun, ExperimentRunStatus + + +class ExperimentRepository: + def create_run(self, run: ExperimentRun) -> None: + with get_connection() as conn: + conn.execute( + """ + INSERT INTO experiment_runs( + run_id, task_id, branch_id, node_id, status, + objective, stdout, stderr, exit_code, + metrics_json, started_at, completed_at, created_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run.runId, + run.taskId, + run.branchId, + run.nodeId, + run.status.value, + run.objective, + run.stdout, + run.stderr, + run.exitCode, + json.dumps([metric.model_dump() + for metric in run.metrics]), + run.startedAt, + run.completedAt, + run.startedAt, + ), + ) + for artifact in run.artifacts: + conn.execute( + """ + INSERT INTO experiment_artifacts( + artifact_id, run_id, task_id, branch_id, node_id, + artifact_type, path, summary, created_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + artifact.artifactId, + artifact.runId, + artifact.taskId, + artifact.branchId, + artifact.nodeId, + artifact.artifactType, + artifact.path, + artifact.summary, + artifact.createdAt, + ), + ) + conn.commit() + + def list_runs(self, task_id: str) -> list[ExperimentRun]: + with get_connection() as conn: + runs = conn.execute( + "SELECT * FROM experiment_runs WHERE task_id = ? ORDER BY started_at ASC", + (task_id,), + ).fetchall() + artifact_rows = conn.execute( + "SELECT * FROM experiment_artifacts WHERE task_id = ? ORDER BY created_at ASC", + (task_id,), + ).fetchall() + + artifacts_by_run: dict[str, list[ExperimentArtifact]] = {} + for row in artifact_rows: + artifact = ExperimentArtifact( + artifactId=row["artifact_id"], + runId=row["run_id"], + taskId=row["task_id"], + branchId=row["branch_id"], + nodeId=row["node_id"], + artifactType=row["artifact_type"], + path=row["path"], + summary=row["summary"], + createdAt=row["created_at"], + ) + artifacts_by_run.setdefault(artifact.runId, []).append(artifact) + + output: list[ExperimentRun] = [] + for row in runs: + metrics_payload = json.loads(row["metrics_json"]) + metrics = [ExperimentMetric.model_validate( + item) for item in metrics_payload] + run = ExperimentRun( + runId=row["run_id"], + taskId=row["task_id"], + branchId=row["branch_id"], + nodeId=row["node_id"], + status=ExperimentRunStatus(row["status"]), + objective=row["objective"], + stdout=row["stdout"], + stderr=row["stderr"], + exitCode=row["exit_code"], + metrics=metrics, + artifacts=artifacts_by_run.get(row["run_id"], []), + startedAt=row["started_at"], + completedAt=row["completed_at"], + ) + output.append(run) + return output diff --git a/backend/app/repositories/llm_config_repository.py b/backend/app/repositories/llm_config_repository.py new file mode 100644 index 0000000..551737b --- /dev/null +++ b/backend/app/repositories/llm_config_repository.py @@ -0,0 +1,262 @@ +"""LLM Configuration Repository for persistent API key and task mapping storage.""" +from __future__ import annotations + +import base64 +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from app.core.config import settings + +TaskType = Literal["draft", "chat", "article"] + + +@dataclass(frozen=True) +class ProviderConfig: + """Configuration for a single LLM provider.""" + provider: str + api_key: str + base_url: str + model: str + is_default: bool = False + + def mask_api_key(self) -> str: + """Return masked API key for display (show first 4 and last 4 chars).""" + if len(self.api_key) <= 12: + return "****" + return f"{self.api_key[:4]}...{self.api_key[-4:]}" + + @property + def configured(self) -> bool: + """Check if provider is configured with an API key.""" + return bool(self.api_key.strip()) + + +@dataclass(frozen=True) +class TaskMapping: + """Mapping from task type to provider.""" + draft: str + chat: str + article: str + + +class LLMConfigRepository: + """Repository for managing LLM configurations.""" + + _ENCODE_PREFIX = "enc:" + + def __init__(self, db_path: str | None = None) -> None: + self._db_path = Path(db_path or settings.db_path.replace(".db", "_llm_config.db")) + self._ensure_tables() + + def _ensure_tables(self) -> None: + """Create tables if they don't exist.""" + self._db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self._db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS provider_config ( + provider TEXT PRIMARY KEY, + api_key TEXT NOT NULL DEFAULT '', + base_url TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS task_mapping ( + id INTEGER PRIMARY KEY CHECK (id = 1), + draft TEXT NOT NULL DEFAULT 'openrouter', + chat TEXT NOT NULL DEFAULT 'openrouter', + article TEXT NOT NULL DEFAULT 'openrouter' + ) + """) + # Ensure default task mapping exists + conn.execute(""" + INSERT OR IGNORE INTO task_mapping (id, draft, chat, article) + VALUES (1, 'openrouter', 'openrouter', 'openrouter') + """) + conn.commit() + + def _encode_key(self, key: str) -> str: + """Encode API key for storage.""" + if not key: + return "" + encoded = base64.b64encode(key.encode()).decode() + return f"{self._ENCODE_PREFIX}{encoded}" + + def _decode_key(self, stored: str) -> str: + """Decode API key from storage.""" + if not stored or not stored.startswith(self._ENCODE_PREFIX): + return stored + try: + decoded = base64.b64decode(stored[len(self._ENCODE_PREFIX):]).decode() + return decoded + except Exception: + return "" + + def _get_env_config(self, provider: str) -> ProviderConfig: + """Get provider config from environment variables (fallback).""" + provider_upper = provider.upper() + api_key = getattr(settings, f"{provider_lower}_api_key" if (provider_lower := provider.lower()) else "", "") + if not api_key: + # Try uppercase + api_key = getattr(settings, f"{provider_upper.lower()}_api_key", "") + base_url = getattr(settings, f"{provider_lower}_base_url", "") + model = getattr(settings, f"{provider_lower}_model", "") + + return ProviderConfig( + provider=provider, + api_key=api_key or "", + base_url=base_url or "", + model=model or "", + is_default=(provider.lower() == settings.default_llm_provider.lower()), + ) + + def get_provider(self, provider: str) -> ProviderConfig: + """Get configuration for a specific provider.""" + with sqlite3.connect(self._db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM provider_config WHERE provider = ?", + (provider.lower(),) + ).fetchone() + + if row: + return ProviderConfig( + provider=row["provider"], + api_key=self._decode_key(row["api_key"]), + base_url=row["base_url"], + model=row["model"], + is_default=bool(row["is_default"]), + ) + + # Fallback to environment config + return self._get_env_config(provider) + + def list_providers(self) -> list[ProviderConfig]: + """List all provider configurations.""" + providers = ["openrouter", "deepseek", "openai"] + result = [] + + for provider in providers: + db_config = self._get_from_db(provider) + if db_config: + result.append(db_config) + else: + # Use environment config as fallback + result.append(self._get_env_config(provider)) + + return result + + def _get_from_db(self, provider: str) -> ProviderConfig | None: + """Get provider config from database.""" + with sqlite3.connect(self._db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM provider_config WHERE provider = ?", + (provider.lower(),) + ).fetchone() + + if row: + return ProviderConfig( + provider=row["provider"], + api_key=self._decode_key(row["api_key"]), + base_url=row["base_url"], + model=row["model"], + is_default=bool(row["is_default"]), + ) + return None + + def upsert_provider(self, config: ProviderConfig) -> ProviderConfig: + """Create or update a provider configuration.""" + with sqlite3.connect(self._db_path) as conn: + conn.execute(""" + INSERT INTO provider_config (provider, api_key, base_url, model, is_default) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(provider) DO UPDATE SET + api_key = excluded.api_key, + base_url = excluded.base_url, + model = excluded.model, + is_default = excluded.is_default + """, ( + config.provider.lower(), + self._encode_key(config.api_key), + config.base_url, + config.model, + 1 if config.is_default else 0, + )) + conn.commit() + + return self.get_provider(config.provider) + + def set_default_provider(self, provider: str) -> None: + """Set the default provider.""" + with sqlite3.connect(self._db_path) as conn: + # Clear all defaults + conn.execute("UPDATE provider_config SET is_default = 0") + # Set new default + conn.execute( + "UPDATE provider_config SET is_default = 1 WHERE provider = ?", + (provider.lower(),) + ) + conn.commit() + + def get_task_mapping(self) -> TaskMapping: + """Get the task type to provider mapping.""" + with sqlite3.connect(self._db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM task_mapping WHERE id = 1").fetchone() + + if row: + return TaskMapping( + draft=row["draft"], + chat=row["chat"], + article=row["article"], + ) + + # Default mapping + return TaskMapping( + draft=settings.default_llm_provider, + chat=settings.default_llm_provider, + article=settings.default_llm_provider, + ) + + def update_task_mapping(self, mapping: TaskMapping) -> TaskMapping: + """Update the task type to provider mapping.""" + with sqlite3.connect(self._db_path) as conn: + conn.execute(""" + UPDATE task_mapping + SET draft = ?, chat = ?, article = ? + WHERE id = 1 + """, (mapping.draft, mapping.chat, mapping.article)) + conn.commit() + + return self.get_task_mapping() + + def get_provider_for_task(self, task_type: TaskType) -> str: + """Get the provider configured for a specific task type.""" + mapping = self.get_task_mapping() + return getattr(mapping, task_type) + + def delete_provider(self, provider: str) -> bool: + """Delete a provider configuration (reset to environment defaults).""" + with sqlite3.connect(self._db_path) as conn: + conn.execute( + "DELETE FROM provider_config WHERE provider = ?", + (provider.lower(),) + ) + conn.commit() + return True + + +# Singleton instance +_llm_config_repository: LLMConfigRepository | None = None + + +def get_llm_config_repository() -> LLMConfigRepository: + """Get the singleton LLM config repository.""" + global _llm_config_repository + if _llm_config_repository is None: + _llm_config_repository = LLMConfigRepository() + return _llm_config_repository \ No newline at end of file diff --git a/backend/app/repositories/task_repository.py b/backend/app/repositories/task_repository.py index f73b285..a28f5f1 100644 --- a/backend/app/repositories/task_repository.py +++ b/backend/app/repositories/task_repository.py @@ -5,7 +5,7 @@ from app.core.database import get_connection from app.core.utils import now_iso -from app.models.schemas import DAGGraph, DAGEdge, NodeStatus, TaskConfig, TaskNode, TaskResponse, TaskStatus +from app.models.schemas import BranchAction, BranchRepairAttempt, BranchScore, DAGGraph, DAGEdge, NodeStatus, ResearchScoreCard, SearchBranch, TaskConfig, TaskNode, TaskResponse, TaskStatus class TaskRepository: @@ -17,14 +17,16 @@ def create_task(self, task_id: str, title: str, description: str, config: TaskCo INSERT INTO tasks(task_id, title, description, status, config_json, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?) """, - (task_id, title, description, TaskStatus.READY.value, config.model_dump_json(), ts, ts), + (task_id, title, description, TaskStatus.READY.value, + config.model_dump_json(), ts, ts), ) conn.commit() return self.get_task(task_id) def get_task(self, task_id: str) -> TaskResponse: with get_connection() as conn: - row = conn.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone() + row = conn.execute( + "SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone() if row is None: raise KeyError(task_id) return TaskResponse( @@ -36,12 +38,15 @@ def get_task(self, task_id: str) -> TaskResponse: updatedAt=row["updated_at"], config=TaskConfig.model_validate_json(row["config_json"]), reportPath=row["report_path"], + researchScoreCard=ResearchScoreCard.model_validate_json( + row["research_scorecard_json"]) if row["research_scorecard_json"] else None, dag=self.get_dag(task_id, allow_empty=True), ) def list_tasks(self) -> list[TaskResponse]: with get_connection() as conn: - rows = conn.execute("SELECT task_id FROM tasks ORDER BY created_at DESC").fetchall() + rows = conn.execute( + "SELECT task_id FROM tasks ORDER BY created_at DESC").fetchall() return [self.get_task(row["task_id"]) for row in rows] def update_task(self, task_id: str, *, title: str | None, description: str | None, config: TaskConfig | None) -> TaskResponse: @@ -56,14 +61,22 @@ def update_task(self, task_id: str, *, title: str | None, description: str | Non SET title = ?, description = ?, config_json = ?, updated_at = ? WHERE task_id = ? """, - (next_title, next_desc, next_config.model_dump_json(), now_iso(), task_id), + (next_title, next_desc, + next_config.model_dump_json(), now_iso(), task_id), ) conn.commit() return self.get_task(task_id) def delete_task(self, task_id: str) -> None: with get_connection() as conn: - conn.execute("DELETE FROM task_nodes WHERE task_id = ?", (task_id,)) + conn.execute( + "DELETE FROM task_nodes WHERE task_id = ?", (task_id,)) + conn.execute( + "DELETE FROM search_branches WHERE task_id = ?", (task_id,)) + conn.execute( + "DELETE FROM branch_actions WHERE task_id = ?", (task_id,)) + conn.execute( + "DELETE FROM branch_repairs WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM snapshots WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,)) conn.commit() @@ -92,19 +105,32 @@ def set_report_path(self, task_id: str, report_path: str) -> None: ) conn.commit() + def set_research_scorecard(self, task_id: str, scorecard: ResearchScoreCard) -> None: + with get_connection() as conn: + conn.execute( + """ + UPDATE tasks + SET research_scorecard_json = ?, updated_at = ? + WHERE task_id = ? + """, + (scorecard.model_dump_json(), now_iso(), task_id), + ) + conn.commit() + def save_dag(self, task_id: str, dag: DAGGraph) -> None: ts = now_iso() with get_connection() as conn: - conn.execute("DELETE FROM task_nodes WHERE task_id = ?", (task_id,)) + conn.execute( + "DELETE FROM task_nodes WHERE task_id = ?", (task_id,)) for node in dag.nodes: conn.execute( """ INSERT INTO task_nodes( task_id, node_id, parent_task_id, title, description, status, priority, - search_depth, info_gain_score, position_x, position_y, + search_depth, info_gain_score, branch_id, branch_score, branch_depth, position_x, position_y, dependencies_json, children_json, output_json, created_at, updated_at - ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -116,6 +142,9 @@ def save_dag(self, task_id: str, dag: DAGGraph) -> None: node.priority, node.metadata.searchDepth, node.metadata.infoGainScore, + node.metadata.branchId, + node.metadata.branchScore, + node.metadata.branchDepth, node.metadata.positionX, node.metadata.positionY, json.dumps(node.dependencies), @@ -153,6 +182,9 @@ def get_dag(self, task_id: str, *, allow_empty: bool = False) -> DAGGraph: "estimatedTokenCost": 0, "searchDepth": row["search_depth"], "infoGainScore": row["info_gain_score"], + "branchId": row["branch_id"], + "branchScore": row["branch_score"] or 0.0, + "branchDepth": row["branch_depth"] or 0, "positionX": row["position_x"], "positionY": row["position_y"], "createdAt": row["created_at"], @@ -162,7 +194,8 @@ def get_dag(self, task_id: str, *, allow_empty: bool = False) -> DAGGraph: ) nodes.append(node) for dep in node.dependencies: - edges.append(DAGEdge.model_validate({"from": dep, "to": node.taskId, "type": "DEPENDS_ON"})) + edges.append(DAGEdge.model_validate( + {"from": dep, "to": node.taskId, "type": "DEPENDS_ON"})) return DAGGraph(nodes=nodes, edges=edges) def update_node_status(self, task_id: str, node_id: str, status: NodeStatus, info_gain: float) -> None: @@ -177,6 +210,34 @@ def update_node_status(self, task_id: str, node_id: str, status: NodeStatus, inf ) conn.commit() + def update_node_branch_score(self, task_id: str, node_id: str, branch_score: float) -> None: + normalized = max(0.0, min(1.0, float(branch_score))) + with get_connection() as conn: + conn.execute( + """ + UPDATE task_nodes + SET branch_score = ?, updated_at = ? + WHERE task_id = ? AND node_id = ? + """, + (normalized, now_iso(), task_id, node_id), + ) + conn.commit() + + def prune_nodes(self, task_id: str, node_ids: list[str]) -> None: + if not node_ids: + return + placeholders = ", ".join("?" for _ in node_ids) + with get_connection() as conn: + conn.execute( + f""" + UPDATE task_nodes + SET status = ?, updated_at = ? + WHERE task_id = ? AND node_id IN ({placeholders}) + """, + (NodeStatus.PRUNED.value, now_iso(), task_id, *node_ids), + ) + conn.commit() + def save_snapshot(self, task_id: str, snapshot: dict[str, Any]) -> None: with get_connection() as conn: conn.execute( @@ -193,7 +254,209 @@ def save_snapshot(self, task_id: str, snapshot: dict[str, Any]) -> None: def load_snapshot(self, task_id: str) -> dict[str, Any] | None: with get_connection() as conn: - row = conn.execute("SELECT snapshot_json FROM snapshots WHERE task_id = ?", (task_id,)).fetchone() + row = conn.execute( + "SELECT snapshot_json FROM snapshots WHERE task_id = ?", (task_id,)).fetchone() if row is None: return None return json.loads(row["snapshot_json"]) + + def upsert_search_branch(self, task_id: str, branch: SearchBranch) -> None: + ts = now_iso() + with get_connection() as conn: + conn.execute( + """ + INSERT INTO search_branches( + task_id, branch_id, parent_branch_id, root_node_id, branch_type, branch_goal, + depth, status, score_info_gain, score_evidence_strength, score_feasibility, score_total, + prune_reason, debug_depth, worker_id, node_ids_json, created_at, updated_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id, branch_id) DO UPDATE SET + parent_branch_id = excluded.parent_branch_id, + root_node_id = excluded.root_node_id, + branch_type = excluded.branch_type, + branch_goal = excluded.branch_goal, + depth = excluded.depth, + status = excluded.status, + score_info_gain = excluded.score_info_gain, + score_evidence_strength = excluded.score_evidence_strength, + score_feasibility = excluded.score_feasibility, + score_total = excluded.score_total, + prune_reason = excluded.prune_reason, + debug_depth = excluded.debug_depth, + worker_id = excluded.worker_id, + node_ids_json = excluded.node_ids_json, + updated_at = excluded.updated_at + """, + ( + task_id, + branch.branchId, + branch.parentBranchId, + branch.rootNodeId, + branch.branchType, + branch.branchGoal, + branch.depth, + branch.status.value, + branch.score.infoGain, + branch.score.evidenceStrength, + branch.score.feasibility, + branch.score.total, + branch.pruneReason, + branch.debugDepth, + branch.workerId, + json.dumps(branch.nodeIds), + ts, + ts, + ), + ) + conn.commit() + + def list_search_branches(self, task_id: str) -> list[SearchBranch]: + with get_connection() as conn: + rows = conn.execute( + "SELECT * FROM search_branches WHERE task_id = ? ORDER BY depth ASC, created_at ASC", + (task_id,), + ).fetchall() + branches: list[SearchBranch] = [] + for row in rows: + branches.append( + SearchBranch( + branchId=row["branch_id"], + parentBranchId=row["parent_branch_id"], + rootNodeId=row["root_node_id"], + branchType=row["branch_type"], + branchGoal=row["branch_goal"], + depth=row["depth"], + status=NodeStatus(row["status"]), + score=BranchScore( + infoGain=float(row["score_info_gain"] or 0.0), + evidenceStrength=float( + row["score_evidence_strength"] or 0.0), + feasibility=float(row["score_feasibility"] or 0.0), + total=float(row["score_total"] or 0.0), + ), + pruneReason=row["prune_reason"], + debugDepth=row["debug_depth"], + workerId=row["worker_id"], + nodeIds=json.loads(row["node_ids_json"]), + ) + ) + return branches + + def append_branch_action(self, action: BranchAction) -> None: + with get_connection() as conn: + conn.execute( + """ + INSERT INTO branch_actions( + action_id, task_id, branch_id, action_type, + action_input_json, action_output_json, + score_before, score_after, status, created_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + action.actionId, + action.taskId, + action.branchId, + action.actionType, + json.dumps(action.actionInput), + json.dumps(action.actionOutput), + action.scoreBefore, + action.scoreAfter, + action.status, + action.createdAt, + ), + ) + conn.commit() + + def list_branch_actions(self, task_id: str, branch_id: str | None = None) -> list[BranchAction]: + with get_connection() as conn: + if branch_id: + rows = conn.execute( + """ + SELECT * FROM branch_actions + WHERE task_id = ? AND branch_id = ? + ORDER BY created_at ASC + """, + (task_id, branch_id), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT * FROM branch_actions + WHERE task_id = ? + ORDER BY created_at ASC + """, + (task_id,), + ).fetchall() + return [ + BranchAction( + actionId=row["action_id"], + taskId=row["task_id"], + branchId=row["branch_id"], + actionType=row["action_type"], + actionInput=json.loads(row["action_input_json"]), + actionOutput=json.loads(row["action_output_json"]), + scoreBefore=float(row["score_before"] or 0.0), + scoreAfter=float(row["score_after"] or 0.0), + status=row["status"], + createdAt=row["created_at"], + ) + for row in rows + ] + + def append_branch_repair(self, repair: BranchRepairAttempt) -> None: + with get_connection() as conn: + conn.execute( + """ + INSERT INTO branch_repairs( + repair_id, task_id, branch_id, node_id, + attempt, diagnosis, proposal, succeeded, created_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + repair.repairId, + repair.taskId, + repair.branchId, + repair.nodeId, + repair.attempt, + repair.diagnosis, + repair.proposal, + 1 if repair.succeeded else 0, + repair.createdAt, + ), + ) + conn.commit() + + def list_branch_repairs(self, task_id: str, branch_id: str | None = None) -> list[BranchRepairAttempt]: + with get_connection() as conn: + if branch_id: + rows = conn.execute( + """ + SELECT * FROM branch_repairs + WHERE task_id = ? AND branch_id = ? + ORDER BY created_at ASC + """, + (task_id, branch_id), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT * FROM branch_repairs + WHERE task_id = ? + ORDER BY created_at ASC + """, + (task_id,), + ).fetchall() + return [ + BranchRepairAttempt( + repairId=row["repair_id"], + taskId=row["task_id"], + branchId=row["branch_id"], + nodeId=row["node_id"], + attempt=int(row["attempt"]), + diagnosis=row["diagnosis"], + proposal=row["proposal"], + succeeded=bool(row["succeeded"]), + createdAt=row["created_at"], + ) + for row in rows + ] diff --git a/backend/app/services/conversation_agent.py b/backend/app/services/conversation_agent.py index e15a788..6cd768e 100644 --- a/backend/app/services/conversation_agent.py +++ b/backend/app/services/conversation_agent.py @@ -14,19 +14,25 @@ ConversationDetail, ConversationMessage, ConversationStatus, + LLMProvider, MessageKind, MessageRole, NodeStatus, PlanRevision, + ResearchIdea, + ResearchMode, RunConversationResponse, TaskConfig, TaskStatus, ) +from app.prompts.plan_render import build_plan_render_system_prompt, build_plan_render_user_prompt from app.repositories.conversation_repository import ConversationRepository from app.repositories.evidence_repository import EvidenceRepository from app.repositories.task_repository import TaskRepository from app.services.agents import ReportAgent from app.services.execution_engine import ExecutionEngine +from app.services.idea_service import IdeaService +from app.services.novelty_gate import NoveltyGateService from app.services.planner import MasterPlanner @@ -105,6 +111,8 @@ def __init__( evidence_repository: EvidenceRepository | None = None, report_agent: ReportAgent | None = None, planner: MasterPlanner | None = None, + idea_service: IdeaService | None = None, + novelty_gate_service: NoveltyGateService | None = None, ) -> None: self.repository = repository self.task_repository = task_repository @@ -112,6 +120,8 @@ def __init__( self.evidence_repository = evidence_repository self.report_agent = report_agent self.planner = planner + self.idea_service = idea_service or IdeaService() + self.novelty_gate_service = novelty_gate_service or NoveltyGateService() def _build_task_payload( self, @@ -120,7 +130,8 @@ def _build_task_payload( topic: str, base_config: TaskConfig, ) -> tuple[ParsedPlan, str]: - parsed = self._parse_plan(markdown, topic=topic, base_config=base_config) + parsed = self._parse_plan( + markdown, topic=topic, base_config=base_config) task_description = self._extract_plan_body(markdown)[:5000] if len(task_description.strip()) < 3: task_description = f"围绕主题\"{topic}\"执行系统化研究。" @@ -212,7 +223,22 @@ async def create_conversation(self, *, topic: str, config: TaskConfig | None = N content=topic, metadata={"stage": "CREATED"}, ) - markdown = await asyncio.to_thread(self._generate_initial_plan, topic=topic, config=selected_config) + # Add AI acknowledgment message before generating plan + self.repository.add_message( + conversation_id, + message_id=new_id(), + role=MessageRole.ASSISTANT, + kind=MessageKind.ASSISTANT_TEXT, + content=self._build_ack_message( + topic=topic, config=selected_config), + metadata={"stage": "ACKNOWLEDGED"}, + ) + ideas, markdown = await asyncio.to_thread( + self._prepare_initial_conversation_assets, + topic=topic, + config=selected_config, + ) + self.repository.set_current_ideas(conversation_id, ideas) revision = self.repository.add_plan_revision( conversation_id, author=MessageRole.ASSISTANT, @@ -431,12 +457,14 @@ async def _apply_plan_revision( content=instruction, ) config = self.repository.get_config(conversation_id) + selected_idea = self._selected_idea_for_conversation(conversation_id) revised = await asyncio.to_thread( self._generate_revised_plan, topic=topic, config=config, current_plan=current_plan, instruction=instruction, + selected_idea=selected_idea, ) revision = self.repository.add_plan_revision( conversation_id, @@ -981,6 +1009,13 @@ def _parse_plan(self, markdown: str, *, topic: str, base_config: TaskConfig) -> config_data["targetWordCount"] = self._int_or_default( value, base_config.targetWordCount, min_value=1000, max_value=50000) continue + if key == "llm_provider": + normalized = value.strip().strip('"').strip("'").lower() + if normalized in {item.value for item in LLMProvider}: + config_data["llmProvider"] = normalized + else: + warnings.append(f"llm_provider 无效,已忽略:{value}") + continue return ParsedPlan(title=parsed_title[:200], config=TaskConfig(**config_data), warnings=warnings) @@ -1002,27 +1037,11 @@ def _parse_sources(raw: str) -> list[str]: return [part for part in parts if part] def _generate_initial_plan(self, *, topic: str, config: TaskConfig) -> str: - prompt = ( - "请为用户生成一个可执行的研究方案,输出必须是 Markdown,并且必须包含 front matter。\n" - "front matter 字段固定为:title, topic, max_depth, max_nodes, priority, search_sources, target_word_count。\n" - "正文至少包含:研究目标、研究问题拆解、方法与来源、执行步骤、风险与边界、交付标准。\n" - "严禁输出解释性前言,直接返回完整 Markdown。" - ) - user_input = ( - f"主题:{topic}\n" - f"配置建议:max_depth={config.maxDepth}, max_nodes={config.maxNodes}, " - f"priority={config.priority}, search_sources={config.searchSources}, " - f"target_word_count={config.targetWordCount}\n" - "输出语言:中文。" - ) - generated = self._chat_complete( - system_prompt=prompt, user_prompt=user_input) - if generated: - normalized = self._ensure_front_matter( - generated, topic=topic, config=config) - if normalized: - return normalized - return self._fallback_plan(topic=topic, config=config) + ideas = self._generate_initial_ideas(topic=topic, config=config) + selected = self._select_primary_idea(ideas) + if selected is None: + return self._fallback_plan(topic=topic, config=config) + return self._render_plan_from_idea(topic=topic, config=config, idea=selected) def _generate_revised_plan( self, @@ -1031,33 +1050,146 @@ def _generate_revised_plan( config: TaskConfig, current_plan: str, instruction: str, + selected_idea: ResearchIdea | None = None, ) -> str: prompt = ( "你是研究计划修订 Agent。请根据用户指令修订「当前研究方案」。\n" "输出必须是完整 Markdown,且必须包含完整 front matter。\n" "不要解释你做了什么,不要输出多余文本,只返回最终方案。" ) + selected_idea_text = ( + f"\n\n当前选中的结构化 idea:\n{selected_idea.model_dump_json(indent=2)}" + if selected_idea is not None + else "" + ) user_input = ( f"主题:{topic}\n" f"用户指令:{instruction}\n\n" - f"当前方案如下:\n{current_plan}\n\n" + f"当前方案如下:\n{current_plan}{selected_idea_text}\n\n" f"保底配置:max_depth={config.maxDepth}, max_nodes={config.maxNodes}, " f"priority={config.priority}, search_sources={config.searchSources}, " - f"target_word_count={config.targetWordCount}" + f"target_word_count={config.targetWordCount}, llm_provider={config.llmProvider.value}" ) generated = self._chat_complete( - system_prompt=prompt, user_prompt=user_input) + system_prompt=prompt, user_prompt=user_input, provider=config.llmProvider) if generated: normalized = self._ensure_front_matter( generated, topic=topic, config=config) if normalized: return normalized + if selected_idea is not None: + rendered = self._render_plan_from_idea( + topic=topic, config=config, idea=selected_idea) + if rendered.strip(): + return rendered return self._fallback_revision(current_plan=current_plan, instruction=instruction, topic=topic, config=config) - def _chat_complete(self, *, system_prompt: str, user_prompt: str) -> str: + def _prepare_initial_conversation_assets( + self, + *, + topic: str, + config: TaskConfig, + ) -> tuple[list[ResearchIdea], str]: + ideas = self._generate_initial_ideas(topic=topic, config=config) + selected = self._select_primary_idea(ideas) + markdown = self._render_plan_from_idea( + topic=topic, + config=config, + idea=selected, + ) if selected is not None else self._fallback_plan(topic=topic, config=config) + return ideas, markdown + + def _generate_initial_ideas(self, *, topic: str, config: TaskConfig) -> list[ResearchIdea]: + ideas, evidences = self.idea_service.generate_ideas( + topic=topic, config=config) + if not ideas: + ideas = [] + evaluated = self.novelty_gate_service.evaluate_ideas( + topic=topic, + ideas=ideas, + evidences=evidences, + llm_provider=config.llmProvider, + enforce_thresholds=config.requiresNoveltyCheck, + ) + if config.requiresNoveltyCheck and evaluated: + threshold_novelty = self.novelty_gate_service.NOVELTY_THRESHOLD + threshold_feasibility = self.novelty_gate_service.FEASIBILITY_THRESHOLD + passing = [ + idea + for idea in evaluated + if idea.noveltyAssessment.noveltyScore >= threshold_novelty + and idea.feasibilityAssessment.feasibilityScore >= threshold_feasibility + ] + if not passing: + return [] + return evaluated or ideas + + def _render_plan_from_idea( + self, + *, + topic: str, + config: TaskConfig, + idea: ResearchIdea | None, + ) -> str: + if idea is None: + return self._fallback_plan(topic=topic, config=config) + if config.researchMode in {ResearchMode.SURVEY, ResearchMode.EVIDENCE_REPORT}: + return self._fallback_plan_from_idea(topic=topic, config=config, idea=idea) + config_summary = ( + f"max_depth={config.maxDepth}, max_nodes={config.maxNodes}, priority={config.priority}, " + f"research_mode={config.researchMode.value}, search_sources={config.searchSources}, " + f"target_word_count={config.targetWordCount}, llm_provider={config.llmProvider.value}" + ) + generated = self._chat_complete( + system_prompt=build_plan_render_system_prompt(), + user_prompt=build_plan_render_user_prompt( + topic=topic, + config_summary=config_summary, + idea_json=idea.model_dump_json(indent=2), + ), + provider=config.llmProvider, + ) + if generated: + normalized = self._ensure_front_matter( + generated, + topic=idea.title or topic, + config=config, + ) + if normalized: + return normalized + return self._fallback_plan_from_idea(topic=topic, config=config, idea=idea) + + def _select_primary_idea(self, ideas: list[ResearchIdea]) -> ResearchIdea | None: + if not ideas: + return None + selected = [ + idea for idea in ideas if idea.status.value == "SELECTED" and idea.status.value != "REJECTED" + ] + if selected: + return selected[0] + candidates = [ + idea for idea in ideas if idea.status.value != "REJECTED"] + if not candidates: + return None + return max(candidates, key=lambda idea: idea.scoreCard.overallScore) + + def _selected_idea_for_conversation(self, conversation_id: str) -> ResearchIdea | None: + try: + ideas = self.repository.get_current_ideas(conversation_id) + except KeyError: + return None + return self._select_primary_idea(ideas) + + def _chat_complete( + self, + *, + system_prompt: str, + user_prompt: str, + provider: LLMProvider | str | None = None, + ) -> str: if settings.use_mock_sources: return "" - base_url, api_key, model = self._resolve_provider() + base_url, api_key, model = self._resolve_provider(provider) if not base_url or not api_key: return "" try: @@ -1089,16 +1221,33 @@ def _chat_complete(self, *, system_prompt: str, user_prompt: str) -> str: return "" @staticmethod - def _resolve_provider() -> tuple[str, str, str]: - provider = settings.default_llm_provider.lower().strip() - if provider == "openrouter": + def _resolve_provider(provider: LLMProvider | str | None = None) -> tuple[str, str, str]: + selected = (provider.value if isinstance(provider, LLMProvider) + else provider) or settings.default_llm_provider + provider_name = selected.lower().strip() + if provider_name == "openrouter": return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model - if provider == "deepseek": + if provider_name == "deepseek": return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model - if provider == "openai": + if provider_name == "openai": return settings.openai_base_url, settings.openai_api_key, settings.openai_model return "", "", "" + @staticmethod + def _build_ack_message(*, topic: str, config: TaskConfig) -> str: + provider_labels = { + LLMProvider.OPENROUTER: "OpenRouter", + LLMProvider.DEEPSEEK: "DeepSeek", + LLMProvider.OPENAI: "OpenAI", + } + provider_name = provider_labels.get( + config.llmProvider, config.llmProvider.value) + return ( + f"已收到您的研究主题「{topic[:60]}」。" + f"我将使用 {provider_name} 为您构思研究方案," + f"预计将从 {config.maxDepth} 个层级展开 {config.maxNodes} 个研究节点。" + ) + def _abort_task_if_active(self, task_id: str | None) -> None: if not task_id: return @@ -1180,6 +1329,7 @@ def _ensure_front_matter(self, markdown: str, *, topic: str, config: TaskConfig) f"priority: {config.priority}\n" f"search_sources: [{', '.join(config.searchSources)}]\n" f"target_word_count: {config.targetWordCount}\n" + f"llm_provider: {config.llmProvider.value}\n" "---\n\n" f"{text}" ) @@ -1195,6 +1345,7 @@ def _fallback_plan(*, topic: str, config: TaskConfig) -> str: f"priority: {config.priority}\n" f"search_sources: [{', '.join(config.searchSources)}]\n" f"target_word_count: {config.targetWordCount}\n" + f"llm_provider: {config.llmProvider.value}\n" "---\n\n" "## 研究目标\n" "围绕主题建立可验证的结论链路,输出可执行决策建议。\n\n" @@ -1218,6 +1369,56 @@ def _fallback_plan(*, topic: str, config: TaskConfig) -> str: "- 关键结论标注证据引用并给出行动建议。\n" ) + @staticmethod + def _fallback_plan_from_idea(*, topic: str, config: TaskConfig, idea: ResearchIdea) -> str: + related_work = "\n".join( + f"- {item.title}:{item.summary or '已有工作线索。'}" + for item in idea.relatedWork[:3] + ) or "- 首轮尚未形成稳定的相关工作清单,后续需补充检索。" + differentiators = "\n".join( + f"- {item}" for item in idea.differentiators[:4] + ) or "- 需要在执行阶段补充与已有工作的差异化说明。" + experiments = "\n".join( + f"{index + 1}. {proposal.title}:{proposal.objective or proposal.method or '补充验证方案。'}" + for index, proposal in enumerate(idea.experimentProposals[:4]) + ) or "1. 围绕核心假设补充验证路径与评估指标。" + risks = "\n".join( + f"- {item.risk}({item.severity}):{item.mitigation}" + for item in idea.riskFactors[:4] + ) or "- 需进一步确认相关工作重叠度与证据充分性。" + limitations = "\n".join( + f"- {item}" for item in idea.limitations[:4] + ) or "- 当前为首轮结构化 idea,后续还需补充更多证据。" + + return ( + "---\n" + f"title: {idea.title or topic}\n" + f"topic: {topic}\n" + f"max_depth: {config.maxDepth}\n" + f"max_nodes: {config.maxNodes}\n" + f"priority: {config.priority}\n" + f"search_sources: [{', '.join(config.searchSources)}]\n" + f"target_word_count: {config.targetWordCount}\n" + f"llm_provider: {config.llmProvider.value}\n" + "---\n\n" + "## 研究目标\n" + f"{idea.problemStatement or f'围绕“{topic}”形成结构化研究入口。'}\n\n" + "## 研究问题拆解\n" + f"1. 核心假设:{idea.shortHypothesis or f'识别“{topic}”的关键问题。'}\n" + "2. 该主题与已有工作的重叠和差异在哪里。\n" + "3. 应如何设计验证路径并明确结论边界。\n\n" + "## 方法与来源\n" + f"{related_work}\n\n" + "## 执行步骤\n" + f"{experiments}\n\n" + "## 风险与边界\n" + f"{risks}\n" + f"{limitations}\n\n" + "## 交付标准\n" + f"{differentiators}\n" + "- 输出兼容当前执行引擎的 Markdown 研究方案与最终报告。\n" + ) + def _fallback_revision(self, *, current_plan: str, instruction: str, topic: str, config: TaskConfig) -> str: normalized = self._ensure_front_matter( current_plan, topic=topic, config=config) diff --git a/backend/app/services/execution_engine.py b/backend/app/services/execution_engine.py index b0bb4b3..0778092 100644 --- a/backend/app/services/execution_engine.py +++ b/backend/app/services/execution_engine.py @@ -6,10 +6,22 @@ import logging from typing import Any, Awaitable, Callable -from app.core.utils import now_iso -from app.models.schemas import NodeStatus, TaskStatus +from app.core.utils import now_iso, new_id +from app.models.schemas import ( + BranchAction, + BranchRepairAttempt, + BranchScore, + ExperimentMetric, + ExperimentRun, + ExperimentRunStatus, + NodeStatus, + ResearchScoreCard, + SearchBranch, + TaskStatus, +) from app.repositories.conflict_repository import ConflictRepository from app.repositories.evidence_repository import EvidenceRepository +from app.repositories.experiment_repository import ExperimentRepository from app.repositories.task_repository import TaskRepository from app.services.agents import ReportAgent, ResearchAgent from app.services.analyst import AnalystService @@ -17,6 +29,7 @@ from app.services.planner import MasterPlanner from app.services.progress_hub import ProgressHub from app.services.retrieval import RetrievalService +from app.services.search_strategy import BestFirstSearchStrategy, BranchScorer from app.services.state_machine import InvalidStateTransition, transition_or_raise from app.services.writer import WriterService from app.services.four_agents.checking.agent import CheckingAgent @@ -54,6 +67,7 @@ class DagNodeRuntimeState: class ExecutionEngine: HEARTBEAT_INTERVAL_SECONDS = 6 STALL_WARNING_SECONDS = 25 + MAX_NODE_REPAIR_ATTEMPTS = 2 def __init__( self, @@ -63,11 +77,13 @@ def __init__( evidence_repository: EvidenceRepository, retrieval_service: RetrievalService, conflict_repository: ConflictRepository, + experiment_repository: ExperimentRepository, analyst_service: AnalystService, writer_service: WriterService, research_agent: ResearchAgent | None = None, report_agent: ReportAgent | None = None, checking_agent: CheckingAgent | None = None, + search_strategy: BestFirstSearchStrategy | None = None, event_listener: Callable[[str, str, dict], Awaitable[None]] | None = None, ) -> None: @@ -77,6 +93,7 @@ def __init__( self.evidence_repository = evidence_repository self.retrieval_service = retrieval_service self.conflict_repository = conflict_repository + self.experiment_repository = experiment_repository self.analyst_service = analyst_service self.writer_service = writer_service self.research_agent = research_agent or ResearchAgent( @@ -90,6 +107,8 @@ def __init__( else: self.report_agent = report_agent self.checking_agent = checking_agent + self.search_strategy = search_strategy or BestFirstSearchStrategy() + self.branch_scorer = BranchScorer() self.event_listener = event_listener self._control: dict[str, TaskControlState] = {} self._runtime_progress: dict[str, RuntimeProgressState] = {} @@ -254,6 +273,42 @@ def _record_node_completed(self, task_id: str, node_id: str, now_mono: float) -> node_runtime.attempts = max(1, node_runtime.attempts) node_runtime.completed_mono = now_mono + def _seed_search_branches(self, task_id: str, nodes: list[Any]) -> None: + for node in nodes: + branch_id = node.metadata.branchId or f"branch-{node.taskId}" + branch = SearchBranch( + branchId=branch_id, + parentBranchId=None, + rootNodeId=node.taskId, + branchType="research", + branchGoal=node.title, + depth=node.metadata.branchDepth or node.metadata.searchDepth, + status=node.status, + score=BranchScore( + infoGain=max(0.0, min(1.0, node.metadata.infoGainScore)), + evidenceStrength=0.0, + feasibility=0.0, + total=max(0.0, min(1.0, node.metadata.branchScore)), + ), + nodeIds=[node.taskId, *node.children], + ) + self.repository.upsert_search_branch(task_id, branch) + + def _diagnose_failure(self, error: Exception, attempt: int) -> tuple[str, str]: + message = str(error).strip() or "unknown error" + lowered = message.lower() + if "timeout" in lowered: + diagnosis = "检索超时,建议缩小查询范围并降低来源并发。" + proposal = "使用更短查询词并降低一次检索来源数量后重试。" + elif "connection" in lowered or "network" in lowered: + diagnosis = "网络或上游连接异常。" + proposal = "保持查询不变,延迟后重试。" + else: + diagnosis = "检索执行异常。" + proposal = "保留节点目标,收敛关键词后重试。" + diagnosis = f"第 {attempt} 次失败:{diagnosis}" + return diagnosis, proposal + def _attach_dag_snapshot(self, task_id: str, payload: dict[str, Any]) -> None: self._ensure_dag_node_runtime(task_id) runtime = self._runtime_progress.get(task_id) @@ -262,13 +317,13 @@ def _attach_dag_snapshot(self, task_id: str, payload: dict[str, Any]) -> None: except Exception: payload.setdefault("dagNodes", []) payload.setdefault("dagSummary", { - "total": 0, "pending": 0, "running": 0, "completed": 0, "failed": 0}) + "total": 0, "pending": 0, "running": 0, "completed": 0, "failed": 0, "pruned": 0}) return now_mono = asyncio.get_running_loop().time() dag_nodes: list[dict[str, Any]] = [] summary = {"total": 0, "pending": 0, - "running": 0, "completed": 0, "failed": 0} + "running": 0, "completed": 0, "failed": 0, "pruned": 0} for node in dag.nodes: if node.taskId == task_id: @@ -300,6 +355,9 @@ def _attach_dag_snapshot(self, task_id: str, payload: dict[str, Any]) -> None: "title": node.title, "status": status_value, "searchDepth": node.metadata.searchDepth, + "branchId": node.metadata.branchId, + "branchScore": node.metadata.branchScore, + "branchDepth": node.metadata.branchDepth, "dependencies": node.dependencies, "elapsedMs": elapsed_ms, "retryCount": retry_count, @@ -477,8 +535,13 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: current.status, TaskStatus.EXECUTING)) dag = self.repository.get_dag(task_id) - executable_nodes = [n for n in dag.nodes if n.taskId != - task_id and n.status != NodeStatus.PRUNED] + executable_nodes = [ + n + for n in dag.nodes + if n.taskId != task_id and n.status != NodeStatus.PRUNED + ] + executable_nodes = self.search_strategy.order(executable_nodes) + node_by_id = {node.taskId: node for node in executable_nodes} self._ensure_dag_node_runtime(task_id) snapshot = self.repository.load_snapshot(task_id) if snapshot: @@ -486,8 +549,11 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: control.completed_nodes = snapshot.get( "completed_nodes", control.completed_nodes) total = max(1, len(executable_nodes)) + self._seed_search_branches(task_id, executable_nodes) for idx, node in enumerate(executable_nodes, start=1): control = self._control.setdefault(task_id, TaskControlState()) + if node.status == NodeStatus.PRUNED: + continue if node.taskId in control.completed_nodes: continue while control.paused and not control.aborted: @@ -515,12 +581,128 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: "phase": "SEARCHING", }, ) - evidences = await self.research_agent.collect_evidence( - task_id=task_id, - node_id=node.taskId, - query=query, - sources=task.config.searchSources, - ) + + evidences = [] + repair_succeeded = False + for attempt in range(1, self.MAX_NODE_REPAIR_ATTEMPTS + 1): + try: + evidences = await self.research_agent.collect_evidence( + task_id=task_id, + node_id=node.taskId, + query=query, + sources=task.config.searchSources, + ) + if attempt > 1: + repair_succeeded = True + break + except Exception as node_exc: # noqa: BLE001 + diagnosis, proposal = self._diagnose_failure( + node_exc, attempt) + branch_id = node.metadata.branchId or f"branch-{node.taskId}" + self.repository.append_branch_repair( + BranchRepairAttempt( + repairId=new_id(), + taskId=task_id, + branchId=branch_id, + nodeId=node.taskId, + attempt=attempt, + diagnosis=diagnosis, + proposal=proposal, + succeeded=False, + createdAt=now_iso(), + ) + ) + self.repository.append_branch_action( + BranchAction( + actionId=new_id(), + taskId=task_id, + branchId=branch_id, + actionType="REPAIR_ATTEMPT", + actionInput={"query": query, + "attempt": attempt}, + actionOutput={ + "diagnosis": diagnosis, "proposal": proposal, "error": str(node_exc)}, + scoreBefore=max( + 0.0, min(1.0, node.metadata.branchScore)), + scoreAfter=max( + 0.0, min(1.0, node.metadata.branchScore)), + status="FAILED", + createdAt=now_iso(), + ) + ) + + await self._emit_event( + task_id, + "TASK_NOTE", + { + "taskId": task_id, + "phase": "BRANCH_REPAIR_ATTEMPT", + "nodeId": node.taskId, + "nodeTitle": node.title, + "attempt": attempt, + "detail": diagnosis, + }, + ) + + if attempt < self.MAX_NODE_REPAIR_ATTEMPTS: + await self._emit_event( + task_id, + "TASK_PROGRESS", + { + "taskId": task_id, + "progress": searching_progress, + "currentNode": node.taskId, + "currentNodeTitle": node.title, + "state": "EXECUTING", + "phase": "REPAIRING_BRANCH", + "detail": proposal, + }, + ) + continue + + self.repository.update_node_status( + task_id, node.taskId, NodeStatus.FAILED, node.metadata.infoGainScore + ) + node.status = NodeStatus.FAILED + branch = SearchBranch( + branchId=branch_id, + parentBranchId=None, + rootNodeId=node.taskId, + branchType="research", + branchGoal=node.title, + depth=node.metadata.branchDepth or node.metadata.searchDepth, + status=NodeStatus.FAILED, + score=BranchScore( + total=max(0.0, min(1.0, node.metadata.branchScore))), + pruneReason="repair_exhausted", + debugDepth=attempt, + nodeIds=[node.taskId, *node.children], + ) + self.repository.upsert_search_branch(task_id, branch) + if node.children: + self.repository.prune_nodes(task_id, node.children) + for child_id in node.children: + child = node_by_id.get(child_id) + if child is not None: + child.status = NodeStatus.PRUNED + await self._emit_event( + task_id, + "TASK_NOTE", + { + "taskId": task_id, + "phase": "BRANCH_FAILED", + "nodeId": node.taskId, + "nodeTitle": node.title, + "detail": "修复尝试耗尽,分支已标记失败并剪枝子节点。", + "prunedNodeIds": node.children, + }, + ) + evidences = [] + break + + if node.status == NodeStatus.FAILED: + continue + self.evidence_repository.save_many(evidences) for ev in evidences: await self._emit_event( @@ -529,8 +711,135 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: {"taskId": task_id, "nodeId": node.taskId, "evidence": ev.model_dump()}, ) + + previous_branch_score = max( + 0.0, min(1.0, node.metadata.branchScore)) + branch_score = self.branch_scorer.score( + node, evidence_count=len(evidences)) + node.metadata.branchScore = branch_score + self.repository.update_node_branch_score( + task_id, node.taskId, branch_score) + + branch_id = node.metadata.branchId or f"branch-{node.taskId}" + self.repository.upsert_search_branch( + task_id, + SearchBranch( + branchId=branch_id, + parentBranchId=None, + rootNodeId=node.taskId, + branchType="research", + branchGoal=node.title, + depth=node.metadata.branchDepth or node.metadata.searchDepth, + status=NodeStatus.RUNNING, + score=BranchScore( + infoGain=max( + 0.0, min(1.0, node.metadata.infoGainScore)), + evidenceStrength=min(1.0, len(evidences) / 8.0), + feasibility=0.7 if repair_succeeded else 0.8, + total=branch_score, + ), + debugDepth=1 if repair_succeeded else 0, + nodeIds=[node.taskId, *node.children], + ), + ) + + if branch_score < task.config.branchPruneThreshold and node.children: + self.repository.prune_nodes(task_id, node.children) + for child_id in node.children: + child = node_by_id.get(child_id) + if child is not None: + child.status = NodeStatus.PRUNED + self.repository.upsert_search_branch( + task_id, + SearchBranch( + branchId=branch_id, + parentBranchId=None, + rootNodeId=node.taskId, + branchType="research", + branchGoal=node.title, + depth=node.metadata.branchDepth or node.metadata.searchDepth, + status=NodeStatus.PRUNED, + score=BranchScore(total=branch_score), + pruneReason="low_branch_score", + nodeIds=[node.taskId, *node.children], + ), + ) + await self._emit_event( + task_id, + "TASK_NOTE", + { + "taskId": task_id, + "phase": "BRANCH_PRUNED", + "nodeId": node.taskId, + "nodeTitle": node.title, + "branchScore": branch_score, + "prunedNodeIds": node.children, + "detail": f"分支评分 {branch_score:.2f} 低于阈值,已剪枝 {len(node.children)} 个子节点。", + }, + ) + self.repository.update_node_status( task_id, node.taskId, NodeStatus.COMPLETED, node.metadata.infoGainScore) + self.repository.upsert_search_branch( + task_id, + SearchBranch( + branchId=branch_id, + parentBranchId=None, + rootNodeId=node.taskId, + branchType="research", + branchGoal=node.title, + depth=node.metadata.branchDepth or node.metadata.searchDepth, + status=NodeStatus.COMPLETED, + score=BranchScore( + infoGain=max( + 0.0, min(1.0, node.metadata.infoGainScore)), + evidenceStrength=min(1.0, len(evidences) / 8.0), + feasibility=0.85, + total=branch_score, + ), + debugDepth=1 if repair_succeeded else 0, + nodeIds=[node.taskId, *node.children], + ), + ) + self.repository.append_branch_action( + BranchAction( + actionId=new_id(), + taskId=task_id, + branchId=branch_id, + actionType="NODE_EXECUTED", + actionInput={"nodeId": node.taskId, "query": query}, + actionOutput={"evidenceCount": len( + evidences), "repairUsed": repair_succeeded}, + scoreBefore=previous_branch_score, + scoreAfter=branch_score, + status="COMPLETED", + createdAt=now_iso(), + ) + ) + + if task.config.requiresExperimentLoop: + started_at = now_iso() + self.experiment_repository.create_run( + ExperimentRun( + runId=new_id(), + taskId=task_id, + branchId=branch_id, + nodeId=node.taskId, + status=ExperimentRunStatus.COMPLETED, + objective=f"Validate hypothesis for node: {node.title}", + stdout=f"Collected {len(evidences)} evidences for branch scoring.", + stderr="", + exitCode=0, + metrics=[ + ExperimentMetric( + name="evidence_count", value=float(len(evidences))), + ExperimentMetric( + name="branch_score", value=float(branch_score)), + ], + startedAt=started_at, + completedAt=now_iso(), + ) + ) self._record_node_completed( task_id, node.taskId, asyncio.get_running_loop().time()) control.completed_nodes.append(node.taskId) @@ -600,7 +909,7 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: research_sections = [ (node.taskId, f"{node.title}\n\n{node.description}") for node in dag.nodes - if node.taskId != task_id and node.status != NodeStatus.PRUNED + if node.taskId != task_id and node.status not in {NodeStatus.PRUNED, NodeStatus.FAILED} ] writing_plan = self.planner.build_writing_plan( title=task.title, @@ -716,6 +1025,7 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: logger.info(f"Task {task_id}: LLM 报告生成完成") # 审核阶段 + review_passed = True if self.checking_agent: await self._emit_event( task_id, @@ -747,6 +1057,7 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: check_result = await self.checking_agent.run(context) if check_result.success: + review_passed = True logger.info(f"Task {task_id}: 审核通过") await self._emit_event( task_id, @@ -760,6 +1071,7 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: }, ) else: + review_passed = False logger.warning( f"Task {task_id}: 审核发现问题: {check_result.output.get('summary', {})}") await self._emit_event( @@ -774,6 +1086,8 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: "issues": check_result.output.get('issues', []), }, ) + elif task.config.requiresPeerReview: + review_passed = False # 保存报告 await self._emit_event( @@ -796,13 +1110,37 @@ async def _run_task(self, task_id: str, current_status: TaskStatus) -> None: "state": "FINALIZING", "phase": "PERSISTING_REPORT"}, ) self.repository.set_report_path(task_id, md_path) + scorecard = _build_scorecard( + evidence_count=len(evidences), + conflict_count=len(conflicts), + review_passed=review_passed, + report_path=md_path, + completed_nodes=len(control.completed_nodes), + total_nodes=total, + ) + self.repository.set_research_scorecard(task_id, scorecard) + + completion_error = _validate_completion_requirements( + config=task.config, + scorecard=scorecard, + report_path=md_path, + review_passed=review_passed, + ) + if completion_error: + raise RuntimeError(completion_error) + self.repository.update_status(task_id, transition_or_raise( TaskStatus.FINALIZING, TaskStatus.COMPLETED)) await self._emit_event( task_id, "TASK_COMPLETED", - {"taskId": task_id, "progress": 100, - "reportPath": md_path, "bibPath": bib_path}, + { + "taskId": task_id, + "progress": 100, + "reportPath": md_path, + "bibPath": bib_path, + "researchScoreCard": scorecard.model_dump(), + }, ) except InvalidStateTransition as exc: self.repository.update_status( @@ -855,3 +1193,148 @@ def _dedupe_nonempty_strings(values: list[str]) -> list[str]: seen.add(candidate) ordered.append(candidate) return ordered + + +MIN_OVERALL_SCORE_FOR_COMPLETION = 0.45 + + +def _clamp01(value: float) -> float: + return max(0.0, min(1.0, float(value))) + + +class _CompletionValidationError(RuntimeError): + pass + + +def _ratio(numerator: int, denominator: int) -> float: + if denominator <= 0: + return 0.0 + return _clamp01(numerator / denominator) + + +def _avg(values: list[float]) -> float: + if not values: + return 0.0 + return _clamp01(sum(values) / len(values)) + + +def _writeup_score(report_path: str | None, evidence_count: int) -> float: + if not report_path: + return 0.0 + if evidence_count <= 0: + return 0.3 + if evidence_count < 3: + return 0.5 + return 0.75 + + +def _evidence_strength_score(evidence_count: int, conflict_count: int) -> float: + base = min(1.0, evidence_count / 8.0) + conflict_penalty = min(0.3, max(0.0, conflict_count * 0.03)) + return _clamp01(base - conflict_penalty) + + +def _feasibility_score(completed_nodes: int, total_nodes: int) -> float: + completion_ratio = _ratio(completed_nodes, total_nodes) + if completion_ratio >= 0.95: + return 0.9 + if completion_ratio >= 0.75: + return 0.75 + if completion_ratio >= 0.5: + return 0.55 + return 0.35 + + +def _execution_success_score(completed_nodes: int, total_nodes: int) -> float: + return _ratio(completed_nodes, total_nodes) + + +def _review_score(review_passed: bool) -> float: + return 0.9 if review_passed else 0.4 + + +def _novelty_score_stub() -> float: + # Phase B 最小落地:novelty gate 结果暂未统一写回任务,先给保守中值。 + return 0.6 + + +def _build_overall_score(scorecard: ResearchScoreCard) -> float: + return _avg( + [ + scorecard.noveltyScore, + scorecard.feasibilityScore, + scorecard.evidenceStrengthScore, + scorecard.executionSuccessScore, + scorecard.writeupReadinessScore, + scorecard.reviewScore, + ] + ) + + +def _requires_deliverable(config_deliverables: list[str], deliverable: str) -> bool: + return deliverable in {item.strip().lower() for item in config_deliverables} + + +def _validate_completion( + *, + deliverable_types: list[str], + requires_experiment_loop: bool, + requires_peer_review: bool, + scorecard: ResearchScoreCard, + report_path: str | None, + review_passed: bool, +) -> str | None: + if _requires_deliverable(deliverable_types, "report") and not report_path: + return "任务未满足完成条件:缺少报告产物。" + if _requires_deliverable(deliverable_types, "paper") and not report_path: + return "任务未满足完成条件:缺少论文写作产物。" + if requires_experiment_loop and scorecard.executionSuccessScore < 0.5: + return "任务未满足完成条件:实验执行成功度不足。" + if requires_peer_review and not review_passed: + return "任务未满足完成条件:审稿未通过。" + if scorecard.overallScore < MIN_OVERALL_SCORE_FOR_COMPLETION: + return "任务未满足完成条件:综合评分低于阈值。" + return None + + +def _build_scorecard( + *, + evidence_count: int, + conflict_count: int, + review_passed: bool, + report_path: str | None, + completed_nodes: int, + total_nodes: int, +) -> ResearchScoreCard: + scorecard = ResearchScoreCard( + noveltyScore=_novelty_score_stub(), + feasibilityScore=_feasibility_score(completed_nodes, total_nodes), + evidenceStrengthScore=_evidence_strength_score( + evidence_count, conflict_count), + executionSuccessScore=_execution_success_score( + completed_nodes, total_nodes), + writeupReadinessScore=_writeup_score(report_path, evidence_count), + reviewScore=_review_score(review_passed), + ) + scorecard.overallScore = _build_overall_score(scorecard) + return scorecard + + +def _validate_completion_requirements( + *, + config: Any, + scorecard: ResearchScoreCard, + report_path: str | None, + review_passed: bool, +) -> str | None: + return _validate_completion( + deliverable_types=list( + getattr(config, "deliverableTypes", ["report"])), + requires_experiment_loop=bool( + getattr(config, "requiresExperimentLoop", False)), + requires_peer_review=bool( + getattr(config, "requiresPeerReview", False)), + scorecard=scorecard, + report_path=report_path, + review_passed=review_passed, + ) diff --git a/backend/app/services/four_agents/ideation_agent.py b/backend/app/services/four_agents/ideation_agent.py index d9552af..d8c2156 100644 --- a/backend/app/services/four_agents/ideation_agent.py +++ b/backend/app/services/four_agents/ideation_agent.py @@ -1,9 +1,10 @@ -"""构思智能体 - 负责文献调研、研究方向分析和研究假设生成。""" +"""构思智能体 - 负责文献调研、研究方向分析和候选 idea 生成。""" from __future__ import annotations -from app.models.schemas import AgentType, ResearchHypothesis +from app.models.schemas import AgentType, ResearchHypothesis, TaskConfig from app.services.four_agents.base import AgentContext, AgentResult, BaseAgent +from app.services.idea_service import IdeaService from app.services.retrieval import RetrievalService @@ -25,6 +26,7 @@ def __init__( ) -> None: super().__init__(on_progress) self.retrieval = retrieval_service + self.idea_service = IdeaService(retrieval_service) async def run(self, context: AgentContext) -> AgentResult: """执行构思阶段任务。 @@ -49,15 +51,24 @@ async def run(self, context: AgentContext) -> AgentResult: directions = await self._analyze_directions(context.topic, evidences) self._set_progress(70, f"识别到 {len(directions)} 个研究方向") - # 3. 生成研究假设 - self._set_progress(80, "生成研究假设") + # 3. 生成候选 idea 与兼容性假设 + self._set_progress(80, "生成候选研究 idea") + raw_task_config = context.config.get("taskConfig") + config = TaskConfig.model_validate( + raw_task_config if isinstance(raw_task_config, dict) else context.config + ) + ideas, _ = self.idea_service.generate_ideas( + topic=context.topic, + config=config, + ) hypothesis = await self._generate_hypothesis(context, evidences, directions) - self._set_progress(95, "假设生成完成") + self._set_progress(95, f"已生成 {len(ideas)} 个 idea") return AgentResult( success=True, output={ "hypothesis": hypothesis.model_dump(), + "ideas": [idea.model_dump(mode="json") for idea in ideas], "evidence_count": len(evidences), "directions": directions } diff --git a/backend/app/services/four_agents/planning_agent.py b/backend/app/services/four_agents/planning_agent.py index cf586f9..ef34bec 100644 --- a/backend/app/services/four_agents/planning_agent.py +++ b/backend/app/services/four_agents/planning_agent.py @@ -2,7 +2,7 @@ from __future__ import annotations -from app.models.schemas import AgentType, ResearchHypothesis, ResearchPlan +from app.models.schemas import AgentType, ResearchHypothesis, ResearchIdea, ResearchPlan from app.services.four_agents.base import AgentContext, AgentResult, BaseAgent @@ -30,6 +30,7 @@ async def run(self, context: AgentContext) -> AgentResult: 包含 ResearchPlan 的执行结果。 """ hypothesis_data = context.config.get("hypothesis") + ideas_data = context.config.get("ideas", []) if not hypothesis_data: return AgentResult( success=False, @@ -47,6 +48,8 @@ async def run(self, context: AgentContext) -> AgentResult: feasibility = self._assess_feasibility(hypothesis, context) self._set_progress(50, "生成研究方案") + selected_idea = self._select_idea(ideas_data) + # 生成方案 plan = await self._create_plan(hypothesis, context, feasibility) self._set_progress(90, "方案生成完成") @@ -55,7 +58,8 @@ async def run(self, context: AgentContext) -> AgentResult: success=True, output={ "plan": plan.model_dump(), - "feasibility": feasibility + "feasibility": feasibility, + "selectedIdea": selected_idea.model_dump(mode="json") if selected_idea else None, } ) @@ -124,4 +128,21 @@ async def _create_plan( hypothesisId=hypothesis.hypothesisId, steps=steps, createdAt=datetime.utcnow().isoformat() - ) \ No newline at end of file + ) + + @staticmethod + def _select_idea(ideas_data: list[dict]) -> ResearchIdea | None: + ideas: list[ResearchIdea] = [] + for item in ideas_data: + if not isinstance(item, dict): + continue + try: + ideas.append(ResearchIdea.model_validate(item)) + except Exception: + continue + if not ideas: + return None + selected = [idea for idea in ideas if idea.status.value == "SELECTED"] + if selected: + return selected[0] + return max(ideas, key=lambda idea: idea.scoreCard.overallScore) diff --git a/backend/app/services/idea_service.py b/backend/app/services/idea_service.py new file mode 100644 index 0000000..a018e05 --- /dev/null +++ b/backend/app/services/idea_service.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import httpx + +from app.core.config import settings +from app.core.utils import new_id +from app.models.schemas import ( + ExperimentProposal, + Evidence, + IdeaStatus, + LLMProvider, + RelatedWorkItem, + ResearchIdea, + ResearchMode, + ResearchScoreCard, + RiskAssessment, + TaskConfig, +) +from app.prompts.ideation import build_ideation_system_prompt, build_ideation_user_prompt +from app.services.retrieval import RetrievalService + + +class IdeaService: + def __init__(self, retrieval_service: RetrievalService | None = None) -> None: + self.retrieval_service = retrieval_service + + def generate_ideas(self, *, topic: str, config: TaskConfig) -> tuple[list[ResearchIdea], list[Evidence]]: + evidences = self._collect_seed_evidence(topic=topic, config=config) + llm_ideas: list[ResearchIdea] = [] + if config.researchMode in {ResearchMode.EXPERIMENTAL_RESEARCH, ResearchMode.PAPER_WRITEUP}: + llm_ideas = self._generate_with_llm(topic=topic, config=config, evidences=evidences) + ideas = llm_ideas or self._fallback_ideas(topic=topic, config=config, evidences=evidences) + return ideas[: config.numInitialIdeas], evidences + + def _collect_seed_evidence(self, *, topic: str, config: TaskConfig) -> list[Evidence]: + if self.retrieval_service is None: + return [] + try: + asyncio.get_running_loop() + return [] + except RuntimeError: + pass + try: + results = asyncio.run( + self.retrieval_service.retrieve( + task_id="conversation-idea", + node_id="conversation-idea", + query=topic, + sources=config.searchSources, + ) + ) + return results[:6] + except Exception: + return [] + + def _generate_with_llm( + self, + *, + topic: str, + config: TaskConfig, + evidences: list[Evidence], + ) -> list[ResearchIdea]: + if settings.use_mock_sources: + return [] + base_url, api_key, model = self._resolve_provider(config.llmProvider) + if not base_url or not api_key: + return [] + + evidence_snippets = self._evidence_snippets(evidences) + system_prompt = build_ideation_system_prompt( + num_ideas=config.numInitialIdeas, + num_reflections=config.numReflections, + ) + user_prompt = build_ideation_user_prompt( + topic=topic, + search_sources=config.searchSources, + evidence_snippets=evidence_snippets, + ) + try: + with httpx.Client(timeout=settings.llm_timeout_medium) as client: + response = client.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "temperature": 0.4, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + }, + ) + response.raise_for_status() + content = ( + response.json().get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + except Exception: + return [] + + payload = self._extract_json_payload(content) + ideas_raw = payload.get("ideas") if isinstance(payload, dict) else None + if not isinstance(ideas_raw, list): + return [] + normalized: list[ResearchIdea] = [] + for index, item in enumerate(ideas_raw[: config.numInitialIdeas]): + if not isinstance(item, dict): + continue + normalized.append( + self._normalize_idea_payload( + payload=item, + topic=topic, + fallback_index=index, + evidences=evidences, + ) + ) + return normalized + + def _fallback_ideas( + self, + *, + topic: str, + config: TaskConfig, + evidences: list[Evidence], + ) -> list[ResearchIdea]: + evidence_ids = [ev.id for ev in evidences[:4]] + evidence_titles = [ev.metadata.title for ev in evidences[:3] if ev.metadata.title] + directions = [ + "现状与瓶颈", + "方法机制与改进空间", + "评估体系与风险边界", + "落地条件与组织影响", + "与现有方案的差异化价值", + ] + ideas: list[ResearchIdea] = [] + for index in range(config.numInitialIdeas): + direction = directions[index % len(directions)] + title = f"{topic}:{direction}" + related = [ + RelatedWorkItem( + title=evidence_titles[i] if i < len(evidence_titles) else f"{topic} 相关工作线索 {i + 1}", + summary=f"与“{title}”相关的已有研究线索,用于后续 novelty 对照。", + relevanceScore=round(max(0.35, 0.72 - i * 0.1), 2), + ) + for i in range(min(2, max(1, len(evidence_titles) or 1))) + ] + ideas.append( + ResearchIdea( + ideaId=new_id(), + title=title, + problemStatement=f"围绕“{topic}”识别{direction},建立可验证的问题定义与分析边界。", + shortHypothesis=f"如果围绕“{direction}”组织研究链路,可以更系统地回答“{topic}”的核心问题。", + abstract=( + f"该 idea 聚焦“{topic}”的{direction},通过相关工作对照、证据归纳和实验/验证设想," + "形成可执行的研究入口。" + ), + relatedWork=related, + differentiators=[ + f"强调“{direction}”作为独立切入点,而不是泛泛罗列资料。", + "要求把差异点、风险和验证方法前置到研究入口。", + ], + experimentProposals=[ + ExperimentProposal( + title=f"{direction}验证方案", + objective=f"验证“{topic}”在“{direction}”上的关键判断是否成立。", + method="整理代表性案例、提炼对照维度,并用统一指标比较优劣。", + metrics=["结论一致性", "证据覆盖度", "实施复杂度"], + expectedOutcome="得到可写入研究方案的核心判断和待验证假设。", + ) + ], + riskFactors=[ + RiskAssessment( + risk="相关工作重叠度可能偏高", + severity="medium", + mitigation="在 novelty gate 中补充与已有工作的差异化对照。", + ) + ], + limitations=[ + "首轮 idea 仍基于有限证据线索,后续需补充更系统的相关工作检查。" + ], + scoreCard=ResearchScoreCard(), + sourceEvidenceIds=evidence_ids, + status=IdeaStatus.CANDIDATE, + ) + ) + return ideas + + def _normalize_idea_payload( + self, + *, + payload: dict[str, Any], + topic: str, + fallback_index: int, + evidences: list[Evidence], + ) -> ResearchIdea: + related_raw = payload.get("relatedWork") + experiments_raw = payload.get("experimentProposals") + risks_raw = payload.get("riskFactors") + related = [ + RelatedWorkItem.model_validate(item) + for item in related_raw + if isinstance(item, dict) + ] if isinstance(related_raw, list) else [] + experiments = [ + ExperimentProposal.model_validate(item) + for item in experiments_raw + if isinstance(item, dict) + ] if isinstance(experiments_raw, list) else [] + risks = [ + RiskAssessment.model_validate(item) + for item in risks_raw + if isinstance(item, dict) + ] if isinstance(risks_raw, list) else [] + + return ResearchIdea( + ideaId=str(payload.get("ideaId") or new_id()), + title=str(payload.get("title") or f"{topic} 候选方案 {fallback_index + 1}")[:200], + problemStatement=str(payload.get("problemStatement") or f"围绕“{topic}”构建问题定义。")[:2000], + shortHypothesis=str(payload.get("shortHypothesis") or f"该 idea 用于回答“{topic}”的关键研究问题。")[:1000], + abstract=str(payload.get("abstract") or f"围绕“{topic}”形成结构化研究构想。")[:3000], + relatedWork=related, + differentiators=[str(item)[:300] for item in payload.get("differentiators", []) if str(item).strip()], + experimentProposals=experiments, + riskFactors=risks, + limitations=[str(item)[:300] for item in payload.get("limitations", []) if str(item).strip()], + sourceEvidenceIds=[ev.id for ev in evidences[:4]], + status=IdeaStatus.CANDIDATE, + ) + + @staticmethod + def _extract_json_payload(content: str) -> dict[str, Any]: + text = content.strip() + if text.startswith("```"): + lines = text.splitlines() + if len(lines) >= 3: + text = "\n".join(lines[1:-1]).strip() + try: + payload = json.loads(text) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + @staticmethod + def _evidence_snippets(evidences: list[Evidence]) -> str: + if not evidences: + return "" + return "\n".join( + f"- [{ev.id}] {ev.metadata.title} | {ev.metadata.publishDate or '未知'} | {ev.metadata.abstract[:180]}" + for ev in evidences[:4] + ) + + @staticmethod + def _resolve_provider(provider: LLMProvider | str | None = None) -> tuple[str, str, str]: + selected = (provider.value if isinstance(provider, LLMProvider) else provider) or settings.default_llm_provider + provider_name = selected.lower().strip() + if provider_name == "openrouter": + return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model + if provider_name == "deepseek": + return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model + if provider_name == "openai": + return settings.openai_base_url, settings.openai_api_key, settings.openai_model + return "", "", "" diff --git a/backend/app/services/novelty_gate.py b/backend/app/services/novelty_gate.py new file mode 100644 index 0000000..c1a4cec --- /dev/null +++ b/backend/app/services/novelty_gate.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import json + +import httpx + +from app.core.config import settings +from app.models.schemas import ( + Evidence, + FeasibilityAssessment, + IdeaStatus, + LLMProvider, + NoveltyAssessment, + ResearchIdea, + ResearchScoreCard, +) +from app.prompts.novelty import build_novelty_system_prompt, build_novelty_user_prompt + + +class NoveltyGateService: + NOVELTY_THRESHOLD = 0.55 + FEASIBILITY_THRESHOLD = 0.5 + + def evaluate_ideas( + self, + *, + topic: str, + ideas: list[ResearchIdea], + evidences: list[Evidence], + llm_provider: LLMProvider | str | None = None, + enforce_thresholds: bool = True, + ) -> list[ResearchIdea]: + evaluated = [ + self._evaluate_single_idea( + topic=topic, + idea=idea, + evidences=evidences, + llm_provider=llm_provider, + enforce_thresholds=enforce_thresholds, + ) + for idea in ideas + ] + if not evaluated: + return [] + + best = max(evaluated, key=lambda idea: idea.scoreCard.overallScore) + selected_id = best.ideaId + normalized: list[ResearchIdea] = [] + for idea in evaluated: + if idea.ideaId == selected_id: + normalized.append(idea.model_copy(update={"status": IdeaStatus.SELECTED})) + continue + status = IdeaStatus.REJECTED if enforce_thresholds and not ( + idea.noveltyAssessment.noveltyScore >= self.NOVELTY_THRESHOLD + and idea.feasibilityAssessment.feasibilityScore >= self.FEASIBILITY_THRESHOLD + ) else IdeaStatus.CANDIDATE + normalized.append(idea.model_copy(update={"status": status})) + return normalized + + def _evaluate_single_idea( + self, + *, + topic: str, + idea: ResearchIdea, + evidences: list[Evidence], + llm_provider: LLMProvider | str | None, + enforce_thresholds: bool, + ) -> ResearchIdea: + llm_assessment = self._evaluate_with_llm( + topic=topic, + idea=idea, + evidences=evidences, + llm_provider=llm_provider, + ) + + evidence_strength = min(1.0, 0.3 + 0.12 * min(len(idea.sourceEvidenceIds or evidences), 4)) + writeup_readiness = min( + 1.0, + 0.35 + + (0.15 if idea.abstract.strip() else 0.0) + + (0.15 if idea.problemStatement.strip() else 0.0) + + (0.15 if idea.shortHypothesis.strip() else 0.0) + + 0.05 * min(len(idea.limitations), 2), + ) + + novelty = llm_assessment["novelty"].noveltyScore if llm_assessment else min( + 1.0, + 0.34 + + 0.08 * min(len(idea.differentiators), 3) + + 0.05 * min(len(idea.experimentProposals), 2) + + self._title_variation_boost(idea.title), + ) + feasibility = llm_assessment["feasibility"].feasibilityScore if llm_assessment else min( + 1.0, + 0.32 + + 0.1 * min(len(idea.experimentProposals), 3) + + 0.06 * min(len(idea.limitations), 2) + + 0.08 * min(len(idea.riskFactors), 2) + + 0.08 * evidence_strength, + ) + novelty_assessment = llm_assessment["novelty"] if llm_assessment else NoveltyAssessment( + summary="基于差异点、相关工作和验证方案做了启发式 novelty 评估。", + noveltyScore=round(novelty, 4), + isNovel=novelty >= self.NOVELTY_THRESHOLD if enforce_thresholds else True, + similarWork=[item.title for item in idea.relatedWork[:2]], + differentiationNotes=idea.differentiators[:3], + ) + feasibility_assessment = llm_assessment["feasibility"] if llm_assessment else FeasibilityAssessment( + summary="基于验证方案、限制与风险因素做了启发式 feasibility 评估。", + feasibilityScore=round(feasibility, 4), + isFeasible=feasibility >= self.FEASIBILITY_THRESHOLD if enforce_thresholds else True, + blockers=[], + assumptions=["后续仍需补充更多相关工作和证据。"], + ) + overall = round( + novelty_assessment.noveltyScore * 0.35 + + feasibility_assessment.feasibilityScore * 0.3 + + evidence_strength * 0.2 + + writeup_readiness * 0.15, + 4, + ) + status = IdeaStatus.CANDIDATE + if enforce_thresholds and ( + novelty_assessment.noveltyScore < self.NOVELTY_THRESHOLD + or feasibility_assessment.feasibilityScore < self.FEASIBILITY_THRESHOLD + ): + status = IdeaStatus.REJECTED + + return idea.model_copy( + update={ + "noveltyAssessment": novelty_assessment, + "feasibilityAssessment": feasibility_assessment, + "scoreCard": ResearchScoreCard( + noveltyScore=round(novelty_assessment.noveltyScore, 4), + feasibilityScore=round(feasibility_assessment.feasibilityScore, 4), + evidenceStrengthScore=round(evidence_strength, 4), + writeupReadinessScore=round(writeup_readiness, 4), + overallScore=overall, + ), + "status": status, + } + ) + + def _evaluate_with_llm( + self, + *, + topic: str, + idea: ResearchIdea, + evidences: list[Evidence], + llm_provider: LLMProvider | str | None, + ) -> dict[str, NoveltyAssessment | FeasibilityAssessment] | None: + if settings.use_mock_sources: + return None + base_url, api_key, model = self._resolve_provider(llm_provider) + if not base_url or not api_key: + return None + try: + with httpx.Client(timeout=settings.llm_timeout_short) as client: + response = client.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "temperature": 0.2, + "messages": [ + {"role": "system", "content": build_novelty_system_prompt()}, + { + "role": "user", + "content": build_novelty_user_prompt( + topic=topic, + idea_json=idea.model_dump_json(indent=2), + evidence_snippets=self._evidence_snippets(evidences), + ), + }, + ], + }, + ) + response.raise_for_status() + content = ( + response.json().get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + payload = self._extract_json(content) + novelty = NoveltyAssessment( + summary=str(payload.get("summary") or ""), + noveltyScore=float(payload.get("noveltyScore") or 0.0), + isNovel=bool(payload.get("isNovel")), + similarWork=[str(item) for item in payload.get("similarWork", []) if str(item).strip()], + differentiationNotes=[str(item) for item in payload.get("differentiationNotes", []) if str(item).strip()], + ) + feasibility = FeasibilityAssessment( + summary=str(payload.get("feasibilitySummary") or payload.get("summary") or ""), + feasibilityScore=float(payload.get("feasibilityScore") or 0.0), + isFeasible=bool(payload.get("isFeasible")), + blockers=[str(item) for item in payload.get("blockers", []) if str(item).strip()], + assumptions=[str(item) for item in payload.get("assumptions", []) if str(item).strip()], + ) + return {"novelty": novelty, "feasibility": feasibility} + except Exception: + return None + + @staticmethod + def _extract_json(content: str) -> dict: + text = content.strip() + if text.startswith("```"): + lines = text.splitlines() + if len(lines) >= 3: + text = "\n".join(lines[1:-1]).strip() + try: + payload = json.loads(text) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + @staticmethod + def _title_variation_boost(title: str) -> float: + return (sum(ord(ch) for ch in title[:24]) % 12) / 100 + + @staticmethod + def _evidence_snippets(evidences: list[Evidence]) -> str: + return "\n".join( + f"- {ev.metadata.title} | {ev.metadata.publishDate or '未知'}" + for ev in evidences[:4] + ) + + @staticmethod + def _resolve_provider(provider: LLMProvider | str | None = None) -> tuple[str, str, str]: + selected = (provider.value if isinstance(provider, LLMProvider) else provider) or settings.default_llm_provider + provider_name = selected.lower().strip() + if provider_name == "openrouter": + return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model + if provider_name == "deepseek": + return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model + if provider_name == "openai": + return settings.openai_base_url, settings.openai_api_key, settings.openai_model + return "", "", "" diff --git a/backend/app/services/planner.py b/backend/app/services/planner.py index 762f085..f8f04a6 100644 --- a/backend/app/services/planner.py +++ b/backend/app/services/planner.py @@ -5,13 +5,107 @@ from app.core.utils import new_id, now_iso from app.models.schemas import DAGGraph, DAGEdge, NodeStatus, TaskConfig, TaskMetadata, TaskNode, WritingSectionPlan +from app.services.research_plan_generator import research_plan_generator class MasterPlanner: - """Builds a bounded DAG with BFS + DFS expansion and simple pruning.""" + """Builds a bounded DAG with LLM-generated structured research questions.""" def build_dag(self, root_task_id: str, title: str, description: str, config: TaskConfig) -> DAGGraph: + """Build DAG using LLM-generated structured research plan.""" ts = now_iso() + + # Generate structured research plan using LLM + try: + plan = research_plan_generator.generate( + topic=title, + description=description, + config=config, + ) + return self._build_dag_from_plan(root_task_id, title, description, plan, config, ts) + except Exception: + # Fallback to template-based generation + return self._build_dag_fallback(root_task_id, title, description, config, ts) + + def _build_dag_from_plan( + self, + root_task_id: str, + title: str, + description: str, + plan, + config: TaskConfig, + ts: str, + ) -> DAGGraph: + """Convert structured research plan to DAG format.""" + nodes: list[TaskNode] = [] + edges: list[DAGEdge] = [] + question_to_task: dict[str, str] = {} + + # Map question IDs to task IDs + for question_id in plan.all_questions: + question_to_task[question_id] = new_id() + + # Root task ID mapping + root_question = plan.root_question + question_to_task[root_question.question_id] = root_task_id + + # Create TaskNode for each research question + for question_id, question in plan.all_questions.items(): + task_id = question_to_task[question_id] + + # Calculate priority based on level (deeper = lower priority) + priority = max(1, config.priority - question.level) + + # Determine status based on level + status = NodeStatus.PENDING + + node = TaskNode( + taskId=task_id, + parentTaskId=question_to_task.get( + question.parent_id) if question.parent_id else None, + title=question.title, + description=question.description, + status=status, + priority=priority, + dependencies=[question_to_task[question.parent_id] + ] if question.parent_id else [], + children=[question_to_task[cid] for cid in question.children], + metadata=TaskMetadata( + estimatedTokenCost=800 + question.level * 200, + searchDepth=question.level, + infoGainScore=1.0 - (question.level * 0.15), + branchId=question.parent_id or root_question.question_id, + branchScore=max(0.0, 1.0 - (question.level * 0.15)), + branchDepth=question.level, + createdAt=ts, + updatedAt=ts, + ), + output=[], + ) + nodes.append(node) + + # Create edges based on parent-child relationships + for question_id, question in plan.all_questions.items(): + source_id = question_to_task[question_id] + for child_id in question.children: + target_id = question_to_task.get(child_id) + if target_id: + edges.append(DAGEdge.model_validate({ + "from": source_id, + "to": target_id, + })) + + return DAGGraph(nodes=nodes, edges=edges) + + def _build_dag_fallback( + self, + root_task_id: str, + title: str, + description: str, + config: TaskConfig, + ts: str, + ) -> DAGGraph: + """Fallback DAG generation when LLM fails.""" root = TaskNode( taskId=root_task_id, parentTaskId=None, @@ -25,6 +119,9 @@ def build_dag(self, root_task_id: str, title: str, description: str, config: Tas estimatedTokenCost=0, searchDepth=0, infoGainScore=1.0, + branchId="root", + branchScore=1.0, + branchDepth=0, createdAt=ts, updatedAt=ts, ), @@ -35,7 +132,6 @@ def build_dag(self, root_task_id: str, title: str, description: str, config: Tas edges: list[DAGEdge] = [] q: deque[tuple[TaskNode, int]] = deque([(root, 0)]) total_nodes = 1 - low_gain_streak = 0 while q and total_nodes < config.maxNodes: parent, depth = q.popleft() @@ -47,25 +143,22 @@ def build_dag(self, root_task_id: str, title: str, description: str, config: Tas if total_nodes >= config.maxNodes: break node_id = new_id() - info_gain = self._estimate_info_gain(node_id, depth + 1) - status = NodeStatus.PRUNED if low_gain_streak >= 1 and info_gain < 0.2 else NodeStatus.PENDING - if info_gain < 0.2: - low_gain_streak += 1 - else: - low_gain_streak = 0 node = TaskNode( taskId=node_id, parentTaskId=parent.taskId, title=ctitle, description=f"{ctitle}: {description}", - status=status, + status=NodeStatus.PENDING, priority=max(1, config.priority - depth), dependencies=[parent.taskId], children=[], metadata=TaskMetadata( estimatedTokenCost=800 + depth * 200, searchDepth=depth + 1, - infoGainScore=info_gain, + infoGainScore=0.5, + branchId=parent.taskId, + branchScore=max(0.0, 0.75 - ((depth + 1) * 0.1)), + branchDepth=depth + 1, createdAt=ts, updatedAt=ts, ), @@ -305,8 +398,3 @@ def _expand_topic(parent_title: str, description: str, depth: int) -> list[str]: if item not in deduped: deduped.append(item) return deduped[:4] - - @staticmethod - def _estimate_info_gain(seed: str, depth: int) -> float: - value = (sum(ord(ch) for ch in seed) % 100) / 100.0 - return max(0.05, round(value * (1.0 / (depth + 0.5)), 2)) diff --git a/backend/app/services/research_plan_generator.py b/backend/app/services/research_plan_generator.py new file mode 100644 index 0000000..e02bbdf --- /dev/null +++ b/backend/app/services/research_plan_generator.py @@ -0,0 +1,340 @@ +"""Research Plan Generator - Generates structured research questions using LLM.""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +import httpx + +from app.core.config import settings +from app.core.utils import new_id +from app.models.schemas import LLMProvider, TaskConfig + + +@dataclass(frozen=True) +class ResearchQuestion: + """A structured research question node.""" + question_id: str + title: str + description: str + level: int + rank: int + parent_id: str | None + children: list[str] + + +@dataclass(frozen=True) +class StructuredResearchPlan: + """A complete structured research plan with hierarchical questions.""" + root_question: ResearchQuestion + all_questions: dict[str, ResearchQuestion] + total_nodes: int + max_depth: int + + +class ResearchPlanGenerator: + """Generates structured research questions using LLM.""" + + _SYSTEM_PROMPT = """你是一个研究规划专家。你的任务是将用户的研究主题分解为一个结构化的研究问题树。 + +输出要求: +1. 必须输出有效的 JSON 格式 +2. 每个问题节点包含:title(标题)、description(描述)、level(层级,从0开始)、rank(同级排序) +3. 问题树必须是一个合理的层级结构,根节点 level=0 +4. 每个层级最多 4 个子问题 +5. 每个问题应该独立、可研究 + +输出格式示例: +{ + "questions": [ + { + "title": "核心研究问题", + "description": "问题的详细描述", + "level": 0, + "rank": 0, + "children": [ + { + "title": "子问题1", + "description": "子问题描述", + "level": 1, + "rank": 0, + "children": [] + } + ] + } + ] +} + +注意: +- 不要输出任何额外的解释文本 +- 确保 JSON 格式正确 +- title 应简洁(不超过30字) +- description 应具体(50-150字)""" + + def generate( + self, + *, + topic: str, + description: str, + config: TaskConfig, + ) -> StructuredResearchPlan: + """Generate a structured research plan using LLM.""" + user_prompt = self._build_user_prompt(topic=topic, description=description, config=config) + + try: + response_text = self._call_llm( + system_prompt=self._SYSTEM_PROMPT, + user_prompt=user_prompt, + provider=config.llmProvider, + ) + return self._parse_response(response_text, topic=topic, description=description) + except Exception: + # Fallback to template-based generation if LLM fails + return self._fallback_plan(topic=topic, description=description, config=config) + + def _build_user_prompt( + self, + *, + topic: str, + description: str, + config: TaskConfig, + ) -> str: + max_nodes = min(config.maxNodes, 12) + max_depth = min(config.maxDepth, 3) + + return f"""研究主题:{topic} + +研究背景:{description[:500]} + +约束条件: +- 最大深度:{max_depth} 层 +- 最大节点数:{max_nodes} 个 +- 每个层级最多 4 个并列问题 + +请生成结构化的研究问题树。""" + + def _call_llm( + self, + *, + system_prompt: str, + user_prompt: str, + provider: LLMProvider | str | None = None, + ) -> str: + if settings.use_mock_sources: + raise ValueError("Mock sources enabled") + + base_url, api_key, model = self._resolve_provider(provider) + if not base_url or not api_key: + raise ValueError("LLM provider not configured") + + with httpx.Client(timeout=settings.llm_timeout_medium) as client: + response = client.post( + f"{base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "temperature": 0.3, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + }, + ) + response.raise_for_status() + payload = response.json() + + return ( + payload.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + + @staticmethod + def _resolve_provider(provider: LLMProvider | str | None = None) -> tuple[str, str, str]: + selected = (provider.value if isinstance(provider, LLMProvider) else provider) or settings.default_llm_provider + provider_name = selected.lower().strip() + if provider_name == "openrouter": + return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model + if provider_name == "deepseek": + return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model + if provider_name == "openai": + return settings.openai_base_url, settings.openai_api_key, settings.openai_model + return "", "", "" + + def _parse_response( + self, + response_text: str, + *, + topic: str, + description: str, + ) -> StructuredResearchPlan: + """Parse LLM response into a structured research plan.""" + # Extract JSON from response + json_text = self._extract_json(response_text) + + try: + data = json.loads(json_text) + except json.JSONDecodeError: + return self._fallback_plan(topic=topic, description=description, config=TaskConfig()) + + questions_data = data.get("questions", []) + if not questions_data: + return self._fallback_plan(topic=topic, description=description, config=TaskConfig()) + + all_questions: dict[str, ResearchQuestion] = {} + root_id = new_id() + + def process_node( + node_data: dict[str, Any], + level: int, + parent_id: str | None, + ) -> str: + question_id = new_id() + children_data = node_data.get("children", []) + children_ids: list[str] = [] + + # Process children first to get their IDs + for child_data in children_data: + child_id = process_node(child_data, level + 1, question_id) + children_ids.append(child_id) + + question = ResearchQuestion( + question_id=question_id, + title=node_data.get("title", "研究问题")[:60], + description=node_data.get("description", "")[:500], + level=level, + rank=node_data.get("rank", 0), + parent_id=parent_id, + children=children_ids, + ) + all_questions[question_id] = question + return question_id + + # Process root node(s) + root_questions = [] + for i, q_data in enumerate(questions_data): + if i == 0: + # First question becomes the root + root_id = process_node(q_data, 0, None) + root_questions.append(root_id) + else: + # Additional top-level questions become children of root + q_id = process_node(q_data, 1, root_id) + root_questions.append(q_id) + + if not all_questions: + return self._fallback_plan(topic=topic, description=description, config=TaskConfig()) + + root = all_questions.get(root_id) + if not root: + root = ResearchQuestion( + question_id=root_id, + title=topic[:60], + description=description[:500], + level=0, + rank=0, + parent_id=None, + children=[], + ) + all_questions[root_id] = root + + return StructuredResearchPlan( + root_question=root, + all_questions=all_questions, + total_nodes=len(all_questions), + max_depth=max(q.level for q in all_questions.values()) if all_questions else 0, + ) + + def _extract_json(self, text: str) -> str: + """Extract JSON from text that might contain markdown code blocks.""" + text = text.strip() + + # Try to find JSON in code blocks + if "```json" in text: + start = text.find("```json") + 7 + end = text.find("```", start) + if end > start: + return text[start:end].strip() + + if "```" in text: + start = text.find("```") + 3 + end = text.find("```", start) + if end > start: + return text[start:end].strip() + + # Try to find JSON object directly + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + return text[start:end + 1] + + return text + + def _fallback_plan( + self, + *, + topic: str, + description: str, + config: TaskConfig, + ) -> StructuredResearchPlan: + """Generate a fallback plan when LLM fails.""" + root_id = new_id() + + questions: dict[str, ResearchQuestion] = { + root_id: ResearchQuestion( + question_id=root_id, + title=topic[:60], + description=description[:500], + level=0, + rank=0, + parent_id=None, + children=[], + ), + } + + # Generate sub-questions based on topic + sub_topics = [ + f"{topic}的核心问题", + f"{topic}的关键证据", + f"{topic}的争议与边界", + f"{topic}的落地条件", + ] + + for i, sub_title in enumerate(sub_topics[:4]): + sub_id = new_id() + questions[sub_id] = ResearchQuestion( + question_id=sub_id, + title=sub_title[:60], + description=f'围绕"{sub_title}"展开深入研究', + level=1, + rank=i, + parent_id=root_id, + children=[], + ) + # Update root's children + root = questions[root_id] + questions[root_id] = ResearchQuestion( + question_id=root_id, + title=root.title, + description=root.description, + level=root.level, + rank=root.rank, + parent_id=root.parent_id, + children=list(root.children) + [sub_id], + ) + + return StructuredResearchPlan( + root_question=questions[root_id], + all_questions=questions, + total_nodes=len(questions), + max_depth=1, + ) + + +# Singleton instance +research_plan_generator = ResearchPlanGenerator() \ No newline at end of file diff --git a/backend/app/services/search_strategy.py b/backend/app/services/search_strategy.py new file mode 100644 index 0000000..5e3df76 --- /dev/null +++ b/backend/app/services/search_strategy.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from app.models.schemas import TaskNode + + +@dataclass +class BranchScorer: + """Compute a stable branch score so scheduler can rank candidates.""" + + info_gain_weight: float = 0.6 + evidence_weight: float = 0.4 + + def score(self, node: TaskNode, evidence_count: int = 0) -> float: + info_gain = max(0.0, min(1.0, float(node.metadata.infoGainScore))) + normalized_evidence = max(0.0, min(1.0, evidence_count / 5.0)) + total = (self.info_gain_weight * info_gain) + \ + (self.evidence_weight * normalized_evidence) + return max(0.0, min(1.0, total)) + + +@dataclass +class BestFirstSearchStrategy: + """Prioritize high-score and shallow-depth nodes first.""" + + scorer: BranchScorer = field(default_factory=BranchScorer) + + def order(self, nodes: list[TaskNode]) -> list[TaskNode]: + return sorted( + nodes, + key=lambda n: ( + -max(0.0, min(1.0, float(n.metadata.branchScore))), + -max(0.0, min(1.0, float(n.metadata.infoGainScore))), + n.metadata.searchDepth, + n.priority, + ), + ) diff --git a/backend/app/services/writer.py b/backend/app/services/writer.py index cee22b5..4644403 100644 --- a/backend/app/services/writer.py +++ b/backend/app/services/writer.py @@ -10,7 +10,7 @@ import httpx from app.core.config import settings -from app.models.schemas import Citation, Evidence, ReportDraft, SectionDraft, WritingSectionPlan +from app.models.schemas import Citation, Evidence, LLMProvider, ReportDraft, SectionDraft, WritingSectionPlan logger = logging.getLogger(__name__) @@ -95,6 +95,7 @@ def write_report( locked_sections: set[str] | None = None, blueprint: ReportBlueprint | None = None, report_body: str | None = None, + llm_provider: LLMProvider | str | None = None, ) -> tuple[str, str, dict[str, Citation]]: """生成研究文章和引用列表两个独立的文件。 @@ -116,6 +117,7 @@ def write_report( sections=sections, evidences=evidences, blueprint=blueprint, + llm_provider=llm_provider, ) else: generated_body = report_body @@ -181,6 +183,7 @@ def generate_body( evidences: list[Evidence], blueprint: ReportBlueprint | None = None, writing_plan: list[WritingSectionPlan] | None = None, + llm_provider: LLMProvider | str | None = None, ) -> str: selected_blueprint = blueprint or self._default_blueprint() draft = self.generate_draft( @@ -190,6 +193,7 @@ def generate_body( evidences=evidences, blueprint=selected_blueprint, writing_plan=writing_plan, + llm_provider=llm_provider, ) return draft.body @@ -202,6 +206,7 @@ def generate_draft( evidences: list[Evidence], blueprint: ReportBlueprint | None = None, writing_plan: list[WritingSectionPlan] | None = None, + llm_provider: LLMProvider | str | None = None, ) -> ReportDraft: selected_blueprint = blueprint or self._default_blueprint() return self._generate_body( @@ -211,6 +216,7 @@ def generate_draft( evidences=evidences, blueprint=selected_blueprint, writing_plan=writing_plan, + llm_provider=llm_provider, ) def rewrite_body( @@ -225,6 +231,7 @@ def rewrite_body( feedback_issues: list[str], targeted_sections: list[str] | None = None, writing_plan: list[WritingSectionPlan] | None = None, + llm_provider: LLMProvider | str | None = None, ) -> str: draft = self.rewrite_draft( task_title=task_title, @@ -236,6 +243,7 @@ def rewrite_body( feedback_issues=feedback_issues, targeted_sections=targeted_sections, writing_plan=writing_plan, + llm_provider=llm_provider, ) return draft.body @@ -251,6 +259,7 @@ def rewrite_draft( feedback_issues: list[str], targeted_sections: list[str] | None = None, writing_plan: list[WritingSectionPlan] | None = None, + llm_provider: LLMProvider | str | None = None, ) -> ReportDraft: outlines = self._build_section_outlines( task_title=task_title, @@ -304,6 +313,7 @@ def rewrite_draft( blueprint=blueprint, rewrite_context=draft_body, feedback_issues=feedback_issues, + llm_provider=llm_provider, ) if not section_body.strip(): section_body = existing_sections.get( @@ -344,12 +354,13 @@ def generate_title( task_description: str, body: str, evidences: list[Evidence], + llm_provider: LLMProvider | str | None = None, ) -> str: body = self._normalize_text(body) if settings.use_mock_sources: return self._derive_title_from_text(task_title=task_title, body=body) - base_url, api_key, model = self._resolve_provider() + base_url, api_key, model = self._resolve_provider(llm_provider) if not base_url or not api_key: return self._derive_title_from_text(task_title=task_title, body=body) @@ -407,6 +418,7 @@ def _generate_body( evidences: list[Evidence], blueprint: ReportBlueprint, writing_plan: list[WritingSectionPlan] | None = None, + llm_provider: LLMProvider | str | None = None, ) -> ReportDraft: """生成文章正文内容,不含内部标记。""" outlines = self._build_section_outlines( @@ -433,6 +445,7 @@ def _generate_body( evidences=evidences, blueprint=blueprint, outlines=outlines, + llm_provider=llm_provider, ) return self._finalize_report_draft( drafts=section_drafts, @@ -450,8 +463,9 @@ def _generate_with_llm( evidences: list[Evidence], blueprint: ReportBlueprint, outlines: list[SectionOutline], + llm_provider: LLMProvider | str | None = None, ) -> list[SectionDraft]: - base_url, api_key, model = self._resolve_provider() + base_url, api_key, model = self._resolve_provider(llm_provider) if not base_url or not api_key: return [] @@ -543,13 +557,14 @@ def _generate_with_llm( return article_sections - def _resolve_provider(self) -> tuple[str, str, str]: - provider = settings.default_llm_provider.lower().strip() - if provider == "openrouter": + def _resolve_provider(self, provider: LLMProvider | str | None = None) -> tuple[str, str, str]: + selected = (provider.value if isinstance(provider, LLMProvider) else provider) or settings.default_llm_provider + provider_name = selected.lower().strip() + if provider_name == "openrouter": return settings.openrouter_base_url, settings.openrouter_api_key, settings.openrouter_model - if provider == "deepseek": + if provider_name == "deepseek": return settings.deepseek_base_url, settings.deepseek_api_key, settings.deepseek_model - if provider == "openai": + if provider_name == "openai": return settings.openai_base_url, settings.openai_api_key, settings.openai_model return "", "", "" @@ -826,12 +841,13 @@ def _generate_single_section_with_llm( system_prompt: str | None = None, rewrite_context: str = "", feedback_issues: list[str] | None = None, + llm_provider: LLMProvider | str | None = None, ) -> str: resolved_base_url = base_url resolved_api_key = api_key resolved_model = model if not resolved_base_url or not resolved_api_key or not resolved_model: - resolved_base_url, resolved_api_key, resolved_model = self._resolve_provider() + resolved_base_url, resolved_api_key, resolved_model = self._resolve_provider(llm_provider) if not resolved_base_url or not resolved_api_key or not resolved_model: return "" diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..c110c5f --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,483 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, +] + +[[package]] +name = "research-flow-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "uvicorn" }, + { name = "websockets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "pydantic", specifier = ">=2.9.0" }, + { name = "pydantic-settings", specifier = ">=2.5.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.9" }, + { name = "uvicorn", specifier = ">=0.30.0" }, + { name = "websockets", specifier = ">=12.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "ruff" +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394 }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693 }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044 }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135 }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041 }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987 }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057 }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613 }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557 }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440 }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963 }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484 }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426 }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125 }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959 }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893 }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175 }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340 }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022 }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319 }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631 }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870 }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361 }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615 }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246 }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684 }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365 }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038 }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915 }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152 }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583 }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880 }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261 }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693 }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947 }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260 }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071 }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968 }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, +] diff --git a/docs/AI_SCIENTIST_V2_ADVANTAGES.md b/docs/AI_SCIENTIST_V2_ADVANTAGES.md new file mode 100644 index 0000000..0ac2a39 --- /dev/null +++ b/docs/AI_SCIENTIST_V2_ADVANTAGES.md @@ -0,0 +1,596 @@ +# AI-Scientist-v2 相对本项目的优势清单 + +本文目标不是做中性综述,而是尽量穷举当前公开可见实现下,`SakanaAI/AI-Scientist-v2` 相对本项目的优势点,尤其聚焦: + +- prompt 设计 +- 生成机制 +- 路线探索 + +对比对象: + +- 对方项目:[`SakanaAI/AI-Scientist-v2`](https://github.com/SakanaAI/AI-Scientist-v2) +- 本项目:当前仓库 `Deep-Research` + +说明: + +- “明确优势”表示可以从公开 README / 公开代码 / 论文摘要直接确认。 +- “推断性优势”表示结合公开配置和实现入口,可以较高置信度推断,但细节未在当前对比中完全展开。 +- 由于 GitHub 网页抓取对部分长文件有折叠,以下结论以公开 README、公开原始文件入口、论文摘要,以及本项目现有代码实现为依据。 + +## 1. 总体判断 + +如果把本项目定义为“对话驱动的深度研究与报告生成系统”,那么 `AI-Scientist-v2` 的核心优势不是 UI 或通用信息整理,而是它更像一个面向机器学习科研产出的“自主实验型研究系统”。 + +它的强项集中在三件事: + +1. 它把“研究”定义为可执行实验搜索,而不是主要定义为文献检索加报告写作。 +2. 它把“生成”做成了多阶段、可回退、可并行、可 debug 的搜索过程,而不是单次规划后顺序执行。 +3. 它把“路线探索”做成了显式的 tree search 和 manager-driven exploration,而不是当前本项目这种受 `maxDepth/maxNodes` 约束的启发式 DAG 展开。 + +因此,如果用户目标是“产出一篇有实验、有图表、有消融、有论文格式的科研稿件”,`AI-Scientist-v2` 的方法论明显更强;如果用户目标是“围绕任意主题做资料研究、证据整理和中文报告生成”,本项目的交互性和产品形态更友好,但研究内核的开放式探索能力不如对方。 + +## 2. Prompt 设计方面的优势 + +### 2.1 Prompt 目标定义更高阶,不只是格式约束 + +`AI-Scientist-v2` 的 ideation prompt 不是简单要求“输出一个方案”,而是把模型角色直接设定为: + +- 提出高影响力研究想法 +- 类 grant proposal +- 必须新颖 +- 要与现有文献清楚区分 +- 资源约束必须落在学术实验室可承受范围 +- 目标是顶会可发表 + +这比本项目当前的方案生成 prompt 更强。 + +本项目的初始规划 prompt 在 [`backend/app/services/conversation_agent.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/conversation_agent.py#L1022) 附近,本质上是: + +- 生成一个可执行研究方案 +- 必须是 Markdown +- 必须带 front matter +- 正文包含固定章节 + +这个 prompt 的优势是稳定、可控、易解析,但问题是它主要在约束“输出格式”,而不是约束“研究质量上限”。对比之下,`AI-Scientist-v2` 的 prompt 从一开始就在约束: + +- novelty +- feasibility +- publishability +- distinction from prior work + +这使得它更像“科研提案生成 prompt”,而本项目更像“研究计划文档生成 prompt”。 + +### 2.2 Prompt 内建工具使用要求,形成“先检索再定稿”的硬约束 + +`perform_ideation_temp_free.py` 中的系统 prompt 明确要求至少做一次文献搜索后才能 finalize idea,并且暴露了 `SemanticScholarSearchTool` 与 `FinalizeIdea` 两类动作。 + +这比本项目当前的规划阶段强在: + +- 对方不是“先写计划,再进检索” +- 而是“在想法形成阶段就把检索与 novelty check 绑定” + +本项目当前的初始计划生成并没有把“先做 novelty / related work 检查再确认计划”做成 prompt 级硬约束,而是先产出计划,再由执行阶段去检索。这会导致计划更容易出现: + +- 选题重复 +- 问题过宽 +- 方法设定先验不足 +- 与现有工作区分度不够 + +### 2.3 Prompt 采用动作协议,而不是纯自然语言大段输出 + +`AI-Scientist-v2` 的 ideation prompt 要求模型按 `ACTION:` / `ARGUMENTS:` 格式输出,并在 finalize 时输出结构化 IDEA JSON。 + +这个设计的优点: + +- 更接近 agent protocol +- 更利于中间轮调用工具 +- 便于做反思轮次中的状态延续 +- 便于失败时定位是“动作错”还是“内容错” + +本项目当前的计划生成和计划修订仍然是标准 chat completion: + +- system prompt +- user prompt +- 返回整段 Markdown + +这种方式更轻,但在复杂研究任务上更脆弱,因为: + +- 中间思考过程不可见 +- 工具调用不是 prompt 原生协议的一部分 +- 失败恢复只能靠 fallback 或重试整段文本 + +### 2.4 Prompt 自带 reflection loop,研究想法不是一次性吐出 + +`AI-Scientist-v2` 的 ideation 有 `num_reflections` 参数,且反思 prompt 明确要求模型评估: + +- quality +- novelty +- feasibility +- clarity +- concise +- JSON correctness + +这个机制比本项目当前的方案修订强很多。本项目虽支持用户继续“改方案”,但默认系统不会主动在内部进行多轮自我批判和 refinement。也就是说: + +- 本项目的修订是“用户驱动” +- 对方的修订是“系统内生” + +在开放式科研任务里,后者对质量上限更有帮助。 + +### 2.5 Prompt 对实验细节的要求更具体 + +`FinalizeIdea` 要求输出内容包含: + +- `Short Hypothesis` +- `Related Work` +- `Abstract` +- `Experiments` +- `Risk Factors and Limitations` + +且实验部分要求: + +- simple and feasible +- specific +- exactly how to test the hypothesis +- precise algorithmic changes +- evaluation metrics + +相比之下,本项目当前方案 prompt 更偏“研究任务拆解”,不是“科研实验设计”。因此在科研场景下,对方 prompt 在以下方面更强: + +- 假设表达更明确 +- 实验可证伪性更强 +- 评价指标前置 +- 风险和局限是原生字段而不是补充段落 + +### 2.6 写作 prompt 与研究 prompt 分工更清晰 + +`AI-Scientist-v2` 把 ideation、experimentation、writeup、review 分开配置,且 README 中明确不同阶段允许使用不同模型: + +- experiment/code +- writeup +- citation +- review +- plot aggregation + +本项目虽然也有 planning / retrieval / writing / checking 等模块,但 prompt 层面的职责隔离没有对方那么强。比如本项目 [`backend/app/services/writer.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/writer.py#L519) 的 system prompt 主要聚焦中文学术写作质量,而不是把“写作”“引文收集”“审稿”“图表审美检查”拆成多个独立 prompt 闭环。 + +对方的好处是: + +- 每个 prompt 只解决一种问题 +- 每种模型调用的目标函数更单一 +- 更容易做阶段性替换和针对性调参 + +## 3. 生成机制方面的优势 + +### 3.1 从“生成文档”升级为“生成研究过程” + +本项目的主链路更接近: + +1. 生成方案 +2. 构建 DAG +3. 检索证据 +4. 分析冲突 +5. 写报告 + +`AI-Scientist-v2` 更接近: + +1. 生成研究 idea +2. 将 idea 转成实验工作区 +3. 运行 agentic tree search 做实验 +4. 汇总图表和结果 +5. 自动写 paper +6. 自动 review + +它的核心优势是:生成对象不是“报告文本”,而是“从假设到实验再到论文的完整科研过程”。这让它天然更适合: + +- 实证型研究 +- 模型改进研究 +- 需要代码试验和结果反馈的任务 + +### 3.2 真正把代码执行纳入生成闭环 + +README 明确写了: + +- 会执行 LLM-written code +- 有 experiment manager agent +- 有 debug depth +- 有 tree visualization + +这比本项目强在:生成内容不是停留在语言层,而是进入“代码生成 -> 执行 -> 结果反馈 -> 再生成”的闭环。 + +本项目虽然有执行引擎 [`backend/app/services/execution_engine.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/execution_engine.py),但当前主要执行的是: + +- 检索 +- 证据分析 +- 报告生成 + +不是广义的实验代码探索。换句话说,本项目是“知识工作流执行器”,对方是“科研实验工作流执行器”。 + +### 3.3 多模型分工更成熟 + +`launch_scientist_bfts.py` 公开暴露了独立模型参数: + +- `model_writeup` +- `model_citation` +- `model_review` +- `model_agg_plots` +- 配置文件中的 `code` / `feedback` / `vlm_feedback` + +这说明对方把生成机制拆成了多目标优化: + +- 代码生成模型 +- 文本写作模型 +- 审稿模型 +- 图像/图表反馈模型 + +本项目虽支持不同 provider,但主链路上仍更接近“单一 LLM 路由 + 少量 specialized prompt”。对方的优点是: + +- 能针对阶段选最适合模型 +- 降低单模型包打天下的失配 +- 成本和质量更容易阶段化权衡 + +### 3.4 原生支持失败恢复,而不是主要依赖 fallback 文本 + +本项目在 plan 生成和 section 生成里都有 fallback 逻辑,这是实用的,但说明主机制在失败时常回退到“启发式保底文本”。 + +对方的生成机制更偏: + +- 多 draft +- 多 worker +- 多 stage +- debug 重试 +- writeup retries + +这类设计的优势是:失败恢复仍然尝试保持在“真实搜索空间”内部,而不是快速退化成模板化结果。 + +### 3.5 中间产物更科研化 + +对方的公开输出包括: + +- idea JSON +- timestamped experiment folder +- tree visualization HTML +- experiment_results +- plots aggregation +- PDF paper +- text review +- image/caption/reference review +- token tracking + +本项目输出更偏: + +- Markdown 报告 +- references / bib +- DAG +- evidence / conflicts + +对方优势在于,它的中间产物更适合科研过程审计与复现实验,而不仅是阅读最终结论。 + +### 3.6 写作不是直接拼章节,而是建立在实验结果之后 + +本项目写作阶段虽然有章节规划、章节证据选择、审校与重写,但整体仍然是“围绕检索证据写一篇文章”。 + +`AI-Scientist-v2` 的写作建立在以下更强的基础上: + +- 已执行实验 +- 已产出图表 +- 已进行 citation gathering +- 已有 review / VLM review + +所以它的 writeup 不是单纯叙述型生成,而是实验结果驱动的 manuscript generation。这一点在科研场景中是本质优势。 + +### 3.7 将图表质量纳入生成链路 + +从 README 和论文摘要可见,对方引入了 VLM feedback loop,用于改进 figures 的内容与美观性。 + +这比本项目强很多。本项目当前没有真正把: + +- 图表审美 +- 图表和正文一致性 +- caption-ref 对齐 + +作为原生生成闭环的一部分。对方在论文式交付上明显更完整。 + +## 4. 路线探索方面的优势 + +这是 `AI-Scientist-v2` 相对本项目最核心、最明显的优势。 + +### 4.1 显式 tree search,探索结构比本项目 DAG 扩展更强 + +本项目的 [`MasterPlanner.build_dag()`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/planner.py#L12) 是一个有界 DAG 生成器,本质特征是: + +- BFS + DFS 混合扩展 +- 基于 `_seed_topics` / `_expand_topic` +- 用启发式 `infoGainScore` 做简单剪枝 +- 受 `maxDepth` / `maxNodes` 强约束 + +这套机制足够做“研究任务拆解”,但还不是“探索算法”。 + +对方则明确采用: + +- progressive agentic tree search +- best-first tree search 配置 +- `num_workers` +- `num_drafts` +- `max_debug_depth` +- `debug_prob` + +它的优势在于: + +- 搜索空间是实验路线,而不仅是话题子节点 +- 节点扩展质量由执行反馈决定,而不只是启发式标题展开 +- 可以并行探索多个候选研究方向 +- 可以在失败节点上继续 debug,而不是单纯 prune + +### 4.2 路线探索依赖真实反馈,不只依赖先验启发式 + +本项目的路线扩展更多依赖: + +- 标题与描述分解 +- 关键词 seed +- 预设优先级 +- 启发式信息增益 + +对方的路线探索依赖: + +- 代码执行结果 +- 实验成败 +- debug 可修复性 +- 阶段性目标 + +这意味着它做的是“闭环搜索”,而本项目做的是“静态规划后执行”。 + +闭环搜索的优势非常明显: + +- 能及时放弃无效方向 +- 能把资源集中到有效分支 +- 能从失败里获得局部改进 +- 更适合 open-ended research + +### 4.3 原生并行探索多个分支 + +`bfts_config.yaml` 中有 `num_workers` 和 `num_drafts`。这意味着对方不是只生成一个方案再局部修补,而是天然支持: + +- 多个起始草稿 +- 多条并行探索路径 +- 多个 worker 同时推进 + +本项目当前虽然也能执行多节点 DAG,但其节点多来自单一计划展开,不是“多候选研究路线并行竞争”。对方的优势是: + +- 起始多样性更强 +- 更不容易被第一版计划锁死 +- 有更大机会发现意外但有效的方向 + +### 4.4 将 debug 视为搜索动作的一部分 + +对方配置里有 `max_debug_depth` 和 `debug_prob`。这背后的设计非常重要: + +- 失败节点不是立即丢弃 +- 系统允许为一个失败分支投入调试预算 +- debug 本身被纳入 tree expansion 策略 + +本项目没有把 debug 作为研究路线探索的一等公民。当前系统更像: + +- 规划 +- 检索 +- 生成 +- 失败则 fallback 或结束 + +而不是: + +- 失败 +- 诊断 +- 局部修改 +- 再试 +- 选择是否继续保留该分支 + +对于真正复杂的科研探索,这是一项关键能力差异。 + +### 4.5 阶段化探索比单层规划更成熟 + +公开资料显示对方 tree search 是 progressive / staged 的,并带有 experiment manager agent。 + +这意味着它不是一口气把所有自由度同时打开,而是分阶段推进,例如: + +- 初步调查 +- 调参 +- 研究议程推进 +- 消融 + +即使不展开每个内部实现,单从设计哲学上也明显优于本项目当前的统一式 DAG。 + +本项目 DAG 更像“一次性把任务拆好”;对方更像“研究管理器根据阶段切换搜索策略”。其优势是: + +- 早期广搜,后期精搜更自然 +- 不同阶段可使用不同节点类型和评估标准 +- 研究路线不容易在初期就过度承诺 + +### 4.6 可视化树结构提升可审计性 + +对方公开产物里有 `unified_tree_viz.html`。这说明其路线探索不是黑箱串行日志,而是可视化的搜索树。 + +这比本项目当前 DAG 可视化更有价值的地方在于: + +- 用户能看到被探索过哪些假设和实验分支 +- 能看到哪些路径被放弃、为何放弃 +- 能看到最终论文是从哪条搜索路径收敛出来的 + +本项目虽然有 DAG editor / plan editor,但更多是“计划视图”,不是“实验探索历史视图”。 + +## 5. 研究质量控制方面的优势 + +### 5.1 novelty checking 更前置 + +从 README 可见,Semantic Scholar 在 ideation 阶段就用于 novelty 相关检查。 + +本项目当前检索强在资料获取,但“与既有工作是否重复”没有前置成强机制。对方的优势是: + +- 更少走重复路线 +- 更少出现已有论文已经覆盖的问题 +- 能在想法形成前就做 literature-grounded filtering + +### 5.2 review 是原生阶段,不是附属检查 + +对方写完后还有: + +- text review +- image/caption/reference review + +本项目虽然也有 review / checking,但当前重心仍然是清洗输出、避免 prompt 泄漏、保证章节质量。对方的 review 更接近“模拟论文审稿场景”,并且交付目标是 workshop-level paper,因此标准更接近论文投稿而不是普通研究报告。 + +### 5.3 引文收集是单独阶段 + +`launch_scientist_bfts.py` 暴露了: + +- `model_citation` +- `num_cite_rounds` + +说明 citation gathering 是独立预算、独立模型、独立回合数的任务。 + +本项目当前引用生成主要来自证据集合和文章末尾参考文献组织,缺少独立的“引文检索迭代阶段”。对方优势是: + +- 引文链更可能完整 +- citation 质量不被正文写作阶段吞掉 +- 参考文献可以持续补全 + +### 5.4 结果优先于叙事 + +对方整个系统的收敛目标是“实验结果能否支撑论文”,而不是“能否生成一篇像样的报告”。 + +这会带来一个很重要的优势: + +- 写作受结果约束,而不是受文风约束 + +本项目当前在中文写作质量、结构完整性、去污染方面做得不错,但从研究质量控制的角度,对方更强调“结果是否成立”。 + +## 6. 工程与运行机制方面的优势 + +### 6.1 工作区隔离更适合高自治实验 + +对方明确: + +- 用独立 workspace +- 会复制数据到 workspace +- 会执行代码 +- 强烈建议在受控沙箱运行 + +这表明它在工程上默认面对的是高风险、高自治执行。相比之下,本项目执行引擎更偏应用内任务编排,风险面较窄。对方在自治实验系统工程上更成熟。 + +### 6.2 成本、阶段、重试参数暴露得更完整 + +公开 CLI 和配置可直接调: + +- generation 数 +- reflection 数 +- writeup retries +- cite rounds +- workers +- stage iters +- debug depth + +这说明系统的搜索预算、生成预算、审查预算都是显式参数,而不是隐藏在代码内部。其优点是: + +- 更易做 ablation +- 更易做成本控制 +- 更易做大规模批量实验 + +### 6.3 token tracking 更系统 + +`launch_scientist_bfts.py` 里会把 token tracker summary 和 interactions 存盘。 + +本项目当前没有形成同等粒度的跨阶段 token 审计。对方的优势是: + +- 成本归因更容易 +- 能分析哪一阶段最贵 +- 方便后续优化策略 + +### 6.4 交付物天然适合论文工作流 + +对方的最终交付直接面向: + +- PDF manuscript +- review outputs +- figures +- citations + +本项目当前更偏: + +- Markdown 报告 +- 对话与计划编辑 + +对于科研产线,对方交付形态更接近真实学术工作流。 + +## 7. 逐项列出对方相对本项目的优势 + +下面给出尽量穷举的扁平列表,便于后续转成 roadmap。 + +- 更强的目标函数:不是“写一个研究方案”,而是“生成可发表的研究产出”。 +- 更强的 novelty 导向:在 ideation 阶段就要求至少一次文献搜索。 +- 更强的相关工作约束:prompt 显式要求与现有文献区分。 +- 更强的实验可执行性约束:要求给出具体实验、具体算法变化、评估指标。 +- 更强的 reflection 机制:一个 idea 会经历多轮自评和 refinement。 +- 更强的动作协议:`ACTION/ARGUMENTS` 比单段 Markdown 输出更 agentic。 +- 更强的结构化中间产物:idea JSON 比自由文本方案更利于后续自动处理。 +- 更强的多模型分工:code / feedback / writeup / citation / review / VLM review 分离。 +- 更强的失败恢复:debug、retry、multi-draft,而不是主要依赖 fallback 文本。 +- 更强的路线探索:真正做 tree search,不是启发式 DAG 扩展。 +- 更强的并行性:多个 workers 和多个 drafts 并行探索。 +- 更强的分阶段研究管理:progressive stages 明显优于单层统一规划。 +- 更强的分支保留策略:失败分支可继续 debug,而不是直接终止。 +- 更强的反馈闭环:实验结果反过来影响后续路线。 +- 更强的科研真实性:代码执行、实验结果、图表和论文连成闭环。 +- 更强的图表治理:VLM 参与 figure quality 改进。 +- 更强的论文式 review:不仅评正文,还评图像、caption、reference。 +- 更强的 citation 流水线:引文收集是独立阶段并有独立轮数。 +- 更强的实验审计:tree visualization、experiment folder、token tracker 更完整。 +- 更强的 reproducibility 倾向:工作区、日志、结果目录分层更适合复现。 +- 更强的预算控制:很多关键探索超参数在配置中显式暴露。 +- 更强的开放式研究能力:更适合发现新方向,而不只是整理已有资料。 +- 更强的 domain generalization 目标:明确强调移除 human-authored templates。 +- 更强的结果导向:系统以“论文能否成立”为目标,而不是“文本是否顺畅”为目标。 + +## 8. 对本项目最值得优先借鉴的点 + +如果只挑最值得抄的 10 个点,优先级如下: + +1. 把“novelty / related work check”前移到计划生成前。 +2. 把当前 plan prompt 从“格式约束”升级为“研究质量约束 + 结构化输出”。 +3. 引入 `reflection rounds`,让方案在系统内部先做 2 到 5 轮自我修订。 +4. 把计划输出改成结构化 schema,而不是 Markdown 为唯一主载体。 +5. 把 DAG 扩展升级成“多 draft + 多 worker + 明确评分”的探索机制。 +6. 把失败处理从 fallback 文本改成“局部 debug / regenerate / branch repair”。 +7. 增加显式 novelty score / differentiation score / feasibility score。 +8. 将 citation gathering 独立成单独阶段。 +9. 将 figure / table / appendix 生成纳入原生交付链路。 +10. 为研究过程增加搜索树或分支历史可视化,而不是只展示静态计划。 + +## 9. 结论 + +一句话概括: + +`AI-Scientist-v2` 的优势不在于“文案更好”,而在于它把 prompt、生成机制和路线探索都建立在“科研搜索系统”而不是“报告生成系统”的范式上。 + +相对本项目,它最强的三个优势分别是: + +- prompt 更像科研提案与实验设计器,而不是格式化计划生成器; +- 生成机制更像多阶段实验闭环,而不是检索后写作; +- 路线探索更像真实 tree search + debug 搜索,而不是启发式 DAG 拆分。 + +如果本项目后续要向“更强研究 agent”演化,最应该补的不是 UI,而是: + +- 前置 novelty/related-work 检查 +- reflection + structured proposal generation +- branch-based exploration and repair + +## 10. 依据来源 + +- `AI-Scientist-v2` README: [GitHub README](https://github.com/SakanaAI/AI-Scientist-v2) +- `AI-Scientist-v2` ideation 入口: [perform_ideation_temp_free.py](https://raw.githubusercontent.com/SakanaAI/AI-Scientist-v2/main/ai_scientist/perform_ideation_temp_free.py) +- `AI-Scientist-v2` 启动入口: [launch_scientist_bfts.py](https://raw.githubusercontent.com/SakanaAI/AI-Scientist-v2/main/launch_scientist_bfts.py) +- `AI-Scientist-v2` 搜索配置: [bfts_config.yaml](https://raw.githubusercontent.com/SakanaAI/AI-Scientist-v2/main/bfts_config.yaml) +- `AI-Scientist-v2` 论文摘要页: [Hugging Face Papers](https://huggingface.co/papers/2504.08066) +- 本项目计划生成: [`backend/app/services/conversation_agent.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/conversation_agent.py) +- 本项目 DAG 规划: [`backend/app/services/planner.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/planner.py) +- 本项目执行引擎: [`backend/app/services/execution_engine.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/execution_engine.py) +- 本项目写作服务: [`backend/app/services/writer.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/writer.py) + diff --git a/docs/AI_SCIENTIST_V2_LEARNING_PLAN.md b/docs/AI_SCIENTIST_V2_LEARNING_PLAN.md new file mode 100644 index 0000000..c58faaa --- /dev/null +++ b/docs/AI_SCIENTIST_V2_LEARNING_PLAN.md @@ -0,0 +1,1042 @@ +# 全面向 AI-Scientist-v2 学习的改进方案列表 + +本文不是“可以考虑”的建议集合,而是一份按落地顺序组织的系统改造清单。目标只有一个: + +- 把本项目从“会话驱动的研究报告系统”,升级为“具备自主探索、路线竞争、实验反馈和论文级交付能力的研究 Agent 平台”。 + +配套背景文档: + +- [`docs/AI_SCIENTIST_V2_ADVANTAGES.md`](/Users/xcy/Program/SH-Program/Deep-Research/docs/AI_SCIENTIST_V2_ADVANTAGES.md) + +--- + +## 1. 北极星目标 + +我们要学的不是某几个 prompt,而是整套范式。 + +目标架构要从当前模式: + +- 用户给题目 +- 系统生成 Markdown 方案 +- DAG 检索资料 +- 写一篇中文报告 + +升级为目标模式: + +- 用户给研究方向 +- 系统先做 novelty / related-work / feasibility 检查 +- 系统生成多个候选研究路线 +- 系统在分支上并行探索、执行、debug、淘汰 +- 系统沉淀结构化研究资产 +- 系统产出报告 / 论文 / 图表 / 审稿意见 / 过程树 + +一句话: + +- 从“生成答案”升级为“搜索研究空间并收敛到答案”。 + +--- + +## 2. 先定三条总原则 + +### 2.1 先改数据结构,再改 prompt + +现在本项目很多能力做不出来,不是 prompt 不够长,而是中间对象太弱。只要核心对象还是: + +- plan markdown +- dag node +- evidence +- report + +那么再怎么调 prompt,也很难做出 `AI-Scientist-v2` 那种多轮探索和分支竞争。 + +所以第一优先级不是“换一个更猛的 prompt”,而是补齐: + +- idea schema +- branch schema +- experiment schema +- review schema +- search tree schema +- run artifact schema + +### 2.2 让系统围绕“分支”工作,而不是围绕“单方案”工作 + +当前系统有一个明显短板: + +- 起点通常只有一个方案 +- 后续多是围绕这一个方案修修补补 + +要全面向对方学习,就必须把“多个候选研究路线并行竞争”变成一等公民。 + +### 2.3 失败不能直接 fallback 成模板文本,必须先进入 repair loop + +现在本项目很多失败路径最终会退化成: + +- fallback plan +- fallback section +- fallback wording + +这个对 demo 友好,但对研究系统伤害很大。后续要改成: + +- failed -> diagnose -> repair -> retry -> score -> decide prune + +而不是: + +- failed -> 生成保底文本 -> 继续往下走 + +--- + +## 3. 总体改造路线图 + +建议拆成 4 个阶段。 + +### Phase 1: 把“研究计划系统”升级成“结构化研究提案系统” + +目标: + +- 不再以 Markdown 方案为唯一核心对象 +- 引入 idea / novelty / feasibility / experiment 等结构化 schema + +### Phase 2: 把“静态 DAG 执行”升级成“多分支探索引擎” + +目标: + +- 不只展开节点 +- 开始展开候选路线、候选实验和候选修复 + +### Phase 3: 把“文献报告生成”升级成“实验反馈驱动的研究生成” + +目标: + +- 支持 workspace、代码执行、实验结果回流、debug loop + +### Phase 4: 把“最终报告”升级成“论文级科研交付物” + +目标: + +- 引文、图表、审稿、过程树、token 统计、论文式导出全部补齐 + +--- + +## 4. 全面改进方案列表 + +下面按工作流拆解,每一项都给出: + +- 要学什么 +- 为什么必须做 +- 具体怎么改 +- 验收标准 + +--- + +## 5. 工作流一:目标函数重写 + +### 5.1 把系统目标从“写报告”改成“完成研究任务” + +要学什么: + +- `AI-Scientist-v2` 的目标不是生成顺滑文本,而是收敛到可成立的研究产出。 + +怎么改: + +- 在产品定义和后端状态机层面新增 `research objective` 概念。 +- `TaskConfig` 增加 `researchMode`,至少支持: + - `survey` + - `evidence_report` + - `experimental_research` + - `paper_writeup` +- 会话创建时先判断研究类型,再决定后续链路。 + +建议新增字段: + +- `researchMode` +- `deliverableTypes` +- `requiresNoveltyCheck` +- `requiresExperimentLoop` +- `requiresPeerReview` + +验收标准: + +- 新建任务时,系统能根据模式走不同执行链路。 +- `experimental_research` 不再直接复用“检索 -> 写报告”的默认流水线。 + +### 5.2 把“成功”定义成多维评分,而不是是否生成了文件 + +怎么改: + +- 增加统一的 `ResearchScoreCard`: + - novelty_score + - feasibility_score + - evidence_strength_score + - execution_success_score + - writeup_score + - review_score +- 每轮运行结束都生成 scorecard。 + +验收标准: + +- 系统不再仅凭 `reportPath` 存在就视为完成。 +- 任务详情页可看到多维得分。 + +--- + +## 6. 工作流二:核心数据结构升级 + +### 6.1 新增 `ResearchIdea` schema + +要学什么: + +- 对方把 ideation 输出成结构化 idea,而不是自由文本方案。 + +怎么改: + +- 在 [`backend/app/models/schemas.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/models/schemas.py) 新增: + - `ResearchIdea` + - `NoveltyAssessment` + - `FeasibilityAssessment` + - `RelatedWorkItem` + - `ExperimentProposal` + - `RiskAssessment` + +建议字段: + +- `ideaId` +- `title` +- `problemStatement` +- `shortHypothesis` +- `abstract` +- `relatedWork` +- `differentiators` +- `noveltyAssessment` +- `feasibilityAssessment` +- `experimentProposals` +- `riskFactors` +- `limitations` +- `score` +- `sourceEvidenceIds` + +验收标准: + +- 首轮 ideation 结果能以 JSON 落库存储。 +- UI 和 API 能读取结构化 idea,而不是只读 Markdown。 + +### 6.2 新增 `SearchBranch` / `SearchTree` schema + +怎么改: + +- 为路线探索引擎补结构: + - `SearchTree` + - `SearchBranch` + - `BranchAction` + - `BranchEvaluation` + - `BranchFailure` + - `BranchRepairAttempt` + +关键字段: + +- branch_id +- parent_branch_id +- branch_type +- branch_goal +- action_type +- action_input +- action_output +- score_before +- score_after +- status +- prune_reason +- debug_depth +- worker_id + +验收标准: + +- 系统能持久化“探索过哪些分支,为什么保留/剪掉”。 +- 后端 API 可以返回 branch tree,而不仅是 DAG。 + +### 6.3 新增 `ExperimentRun` 和 `Artifact` schema + +怎么改: + +- 增加实验型对象: + - `ExperimentWorkspace` + - `ExperimentRun` + - `ExperimentMetric` + - `ExperimentArtifact` + - `FigureArtifact` + - `ReviewArtifact` + +验收标准: + +- 一次实验运行可以挂多个 artifact,而不是只产出 evidence 和 report。 + +--- + +## 7. 工作流三:Prompt 体系重构 + +### 7.1 彻底拆分 prompt 职责 + +当前问题: + +- 计划 prompt、写作 prompt 比较强,但缺少真正的研究探索 prompt 体系。 + +改法: + +- 新建独立 prompt 模块目录,例如: + - `backend/app/prompts/ideation.py` + - `backend/app/prompts/novelty.py` + - `backend/app/prompts/branching.py` + - `backend/app/prompts/repair.py` + - `backend/app/prompts/experiment.py` + - `backend/app/prompts/writeup.py` + - `backend/app/prompts/review.py` + - `backend/app/prompts/figure_review.py` + +原则: + +- 一个 prompt 只负责一个动作。 +- 不再让一个 prompt 同时负责“想法、计划、写作、修订”。 + +验收标准: + +- prompt 文件按职责拆分完成。 +- 每个阶段可以独立替换模型和参数。 + +### 7.2 把计划生成从 Markdown 优先改成 JSON 优先 + +怎么改: + +- 方案生成时先输出结构化 JSON: + - idea + - evaluation + - branches + - experiments +- Markdown 计划变成衍生视图,而不是源数据。 + +验收标准: + +- `ConversationAgent._generate_initial_plan()` 不再直接依赖 Markdown 作为主事实来源。 +- front matter 只用于显示和兼容,不再承担系统事实存储职责。 + +### 7.3 引入动作协议 + +要学什么: + +- 对方 ideation 是 action-based,而不是一次吐全文。 + +怎么改: + +- 定义内部动作协议: + - `SEARCH_LITERATURE` + - `ASSESS_NOVELTY` + - `PROPOSE_IDEA` + - `REFINE_IDEA` + - `SPAWN_BRANCH` + - `RUN_EXPERIMENT` + - `REPAIR_BRANCH` + - `FINALIZE_WRITEUP` +- 让 agent 输出结构化 action,而不是直接写长文。 + +验收标准: + +- ideation / branching / repair 阶段都能以 action 协议驱动。 + +### 7.4 引入 reflection rounds + +怎么改: + +- 所有关键阶段支持 `num_reflections`。 +- 至少覆盖: + - idea 生成 + - 分支评分 + - 实验失败诊断 + - 写作审校 + +建议: + +- 默认 2 轮 +- 高质量模式 4 到 6 轮 + +验收标准: + +- 每个关键输出都能看到初稿、反思、修正版。 + +--- + +## 8. 工作流四:Novelty 与 Related Work 前置 + +### 8.1 在生成研究路线前增加 novelty gate + +怎么改: + +- 新增 `NoveltyGateService`。 +- 执行顺序改为: + - 检索相关工作 + - 归纳已有方法 + - 识别空白点 + - 生成候选 idea + - 给出区分点 + +验收标准: + +- 没过 novelty gate 的 idea 不进入主搜索树。 + +### 8.2 新增“相似工作对照表” + +怎么改: + +- 每个 idea 自动生成 related-work diff: + - prior_work + - overlap + - difference + - expected_gain + - uncertainty + +验收标准: + +- 每个候选 idea 都能说明“和已有工作相比到底新在哪”。 + +### 8.3 让 plan 里出现“反对理由” + +怎么改: + +- ideation 输出除了支持理由,还必须产出: + - why_this_may_fail + - why_this_may_not_be_novel + - missing_evidence + +验收标准: + +- 每个 idea 至少有 3 条自我反驳。 + +--- + +## 9. 工作流五:从 DAG 扩展器升级为 Tree Search Engine + +### 9.1 保留 DAG,但让 DAG 退居“执行图” + +当前问题: + +- [`MasterPlanner.build_dag()`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/planner.py) 现在既承担拆题,又承担路线探索,职责混在一起。 + +改法: + +- DAG 继续用于“执行依赖关系”。 +- 新增 Tree Search Engine 专门负责“候选路线探索”。 + +目标分工: + +- Search Tree:找哪条路线值得做 +- Execution DAG:把选中的路线执行出来 + +验收标准: + +- 路线选择和任务执行分离成两个子系统。 + +### 9.2 引入 best-first / beam search / progressive widening + +怎么改: + +- 新增 `SearchStrategy` 抽象: + - `best_first` + - `beam_search` + - `staged_search` +- 初期可以先做 best-first,后续再加 progressive widening。 + +新增配置: + +- `searchStrategy` +- `numDrafts` +- `numWorkers` +- `beamWidth` +- `branchBudget` +- `maxDebugDepth` +- `debugProbability` + +验收标准: + +- 系统不再只按 BFS/启发式展开 topic。 +- 可通过配置选择搜索策略。 + +### 9.3 明确 branch scoring 机制 + +怎么改: + +- 每个 branch 用统一评分函数: + - novelty + - feasibility + - expected_info_gain + - execution_cost + - evidence_availability + - risk + +示例公式: + +- `branch_score = novelty*0.25 + feasibility*0.2 + info_gain*0.2 + evidence*0.15 - cost*0.1 - risk*0.1` + +验收标准: + +- 保留/剪枝有可解释打分,而不是低信息增益 streak 这类简单启发式。 + +### 9.4 支持多 draft 起点 + +怎么改: + +- 初始 ideation 一次至少生成 3 到 5 个候选 idea。 +- 每个 idea 进入独立 branch。 + +验收标准: + +- 首轮运行默认不是一个方案,而是多个候选方案竞争。 + +### 9.5 支持 branch repair,而不是只 prune + +怎么改: + +- 当 branch 失败时先做: + - diagnose + - repair proposal + - retry + - rescore +- 失败若可修复则保留,不可修复再 prune。 + +验收标准: + +- 分支失败后至少支持一次 repair 回合。 + +--- + +## 10. 工作流六:引入 Experiment Manager + +### 10.1 增加实验管理层 + +要学什么: + +- 对方有 manager agent,不只是任务节点。 + +怎么改: + +- 新增 `ExperimentManagerService`,职责包括: + - 选择要跑哪些实验 + - 分配 worker + - 监控实验状态 + - 汇总失败原因 + - 决定是否继续 debug + +验收标准: + +- 实验执行有统一 manager,而不是直接从 planner 跳到 execution。 + +### 10.2 区分“研究分支”和“实验运行” + +怎么改: + +- 一个研究分支下可包含多个 experiment runs。 +- experiment run 结果回写 branch score。 + +验收标准: + +- 一个 branch 可以有 baseline、variant、ablation 多次运行记录。 + +### 10.3 增加实验预算控制 + +怎么改: + +- 配置项增加: + - `maxExperimentRuns` + - `maxTokensPerBranch` + - `maxRuntimePerBranch` + - `maxFailedRunsBeforePrune` + +验收标准: + +- 系统能按 branch 控制预算,而不是全局粗放执行。 + +--- + +## 11. 工作流七:Workspace 和代码执行闭环 + +### 11.1 为实验型任务引入隔离 workspace + +怎么改: + +- 每个 experimental task 建立独立目录: + - `runs///` +- 存放: + - generated code + - configs + - logs + - metrics + - plots + - review notes + +验收标准: + +- 同一次任务的不同 branch 有独立工作区,互不污染。 + +### 11.2 引入 `CodeExecutionService` + +怎么改: + +- 新增代码执行服务: + - 写入实验代码 + - 运行命令 + - 收集 stdout/stderr + - 采集指标 + - 解析失败信号 + +验收标准: + +- 实验型任务支持真正的运行反馈,不是只靠语言判断。 + +### 11.3 引入失败诊断器 + +怎么改: + +- 新增 `ExecutionFailureAnalyzer`,提取: + - syntax error + - dependency error + - runtime error + - timeout + - metric regression + +验收标准: + +- repair loop 能基于失败类型走不同策略。 + +--- + +## 12. 工作流八:多 Agent 体系重做 + +### 12.1 当前四 Agent 需要从“展示型”变成“实战型” + +当前问题: + +- `four_agents` 目录里已有 ideation / planning / writing / checking,但整体还偏简化实现,没真正成为主引擎。 + +改法: + +- 重新定义 agent 职责: + - `IdeationAgent`: 生成多候选 idea + novelty check + - `PlanningAgent`: 将 idea 转成 branch plan 和 experiment plan + - `ExecutionAgent`: 跑实验、收集反馈 + - `RepairAgent`: 对失败分支做诊断和修复 + - `WritingAgent`: 生成论文式写作草稿 + - `ReviewAgent`: 文本审稿 + - `FigureReviewAgent`: 图表审稿 + - `CitationAgent`: 引文补全 + +验收标准: + +- 现有四 Agent 体系升级为真实工作流,而不是演示式流水线。 + +### 12.2 增加 Agent Manager + +怎么改: + +- 新增 `AgentManager` 统一调度各 agent。 +- 支持: + - round-based orchestration + - branch assignment + - retry policy + - handoff payload + +验收标准: + +- agent 之间的协作由 manager 协调,不再靠执行引擎硬编码串接。 + +--- + +## 13. 工作流九:模型路由升级 + +### 13.1 不再默认一个模型包打天下 + +怎么改: + +- `TaskConfig` 增加独立模型路由: + - `modelIdeation` + - `modelNovelty` + - `modelPlanning` + - `modelExecution` + - `modelRepair` + - `modelWriteup` + - `modelCitation` + - `modelReview` + - `modelFigureReview` + +验收标准: + +- 每阶段模型可独立配置。 + +### 13.2 为不同阶段设不同 temperature / timeout / budget + +怎么改: + +- 配置项拆分: + - `temperatureIdeation` + - `temperatureRepair` + - `timeoutExecution` + - `timeoutReview` + +验收标准: + +- 阶段参数不再被一个通用 `_chat_complete()` 吃掉。 + +--- + +## 14. 工作流十:写作链路升级成论文式写作链路 + +### 14.1 从“章节生成”升级成“manuscript assembly” + +怎么改: + +- 写作阶段新增文稿对象: + - abstract + - introduction + - related work + - method + - experiments + - results + - limitations + - conclusion + +验收标准: + +- `paper_writeup` 模式下不再复用通用中文报告结构。 + +### 14.2 引文补全变成独立阶段 + +怎么改: + +- 新增 `CitationAgent`: + - 扫描正文 claim + - 找缺失引用 + - 补足 citation candidates + - 生成 citation confidence + +验收标准: + +- 写作完成后会单独跑 citation pass。 + +### 14.3 引入章节级 rewrite loop + +怎么改: + +- 每章都能经历: + - draft + - review + - rewrite + - accept + +验收标准: + +- 章节不是一次生成后直接拼装,而是通过小闭环迭代。 + +--- + +## 15. 工作流十一:图表与视觉资产纳入主链路 + +### 15.1 新增 Figure / Table Planner + +怎么改: + +- 系统根据实验结果自动规划: + - 哪些表格该出现 + - 哪些图该出现 + - 每张图的目的是什么 + +验收标准: + +- 报告或论文包含结构化 figure plan。 + +### 15.2 新增 Figure Review Agent + +怎么改: + +- 审核: + - 信息量是否足够 + - 配色和标注是否清晰 + - caption 是否和正文一致 + +验收标准: + +- 图表不是附件,而是有独立 review 分数。 + +--- + +## 16. 工作流十二:Review 体系升级 + +### 16.1 把 checking 从“污染检测”升级成“审稿系统” + +当前问题: + +- 现有 checking 更偏 prompt leakage、机械措辞、脏输出治理。 + +改法: + +- 扩展 review 维度: + - novelty + - clarity + - methodology soundness + - evidence sufficiency + - citation quality + - figure quality + - reproducibility + +验收标准: + +- review 输出是结构化审稿意见,不只是“需不需要重写”。 + +### 16.2 新增 reviewer personas + +怎么改: + +- 支持多个 reviewer 视角: + - harsh reviewer + - method reviewer + - writing reviewer + - reproducibility reviewer + +验收标准: + +- 一次 writeup 至少经过 2 个独立 reviewer 视角。 + +--- + +## 17. 工作流十三:可视化与产品层补强 + +### 17.1 除计划视图外新增搜索树视图 + +怎么改: + +- 前端新增: + - search tree explorer + - branch detail panel + - branch score diff + - prune reason badge + +验收标准: + +- 用户能看到系统探索过哪些候选路线,而不是只看到最终计划。 + +### 17.2 新增实验资产视图 + +怎么改: + +- 前端展示: + - branch runs + - logs + - metrics + - plots + - review notes + +验收标准: + +- 用户能审查中间实验产物。 + +### 17.3 新增研究轨迹时间线 + +怎么改: + +- 时间线不只显示 progress group,还显示: + - idea accepted/rejected + - branch spawned/pruned + - experiment failed/repaired + - review passed/failed + +验收标准: + +- 时间线真正体现研究收敛过程。 + +--- + +## 18. 工作流十四:观测、成本和评测体系 + +### 18.1 增加 token / latency / success-rate 追踪 + +怎么改: + +- 每个阶段记录: + - token_in + - token_out + - latency_ms + - retry_count + - success/failure + +验收标准: + +- 任务详情页可以看到成本和耗时分布。 + +### 18.2 建立研究任务评测集 + +怎么改: + +- 建一个 benchmark 目录,覆盖: + - 综述型任务 + - 证据冲突型任务 + - novelty-sensitive 任务 + - experimental_research 任务 + - paper_writeup 任务 + +验收标准: + +- 每次重大改造后可批量回归。 + +### 18.3 建立 A/B 验证机制 + +怎么改: + +- 对比: + - 单方案 vs 多分支 + - 无 novelty gate vs 有 novelty gate + - 无 reflection vs 3 轮 reflection + +验收标准: + +- 关键机制改造有量化证据支撑。 + +--- + +## 19. Phase-by-Phase 落地清单 + +下面是推荐实施顺序,不然会陷入到处改、处处半成品。 + +### P0:两周内必须做完的基础改造 + +- 新增 `ResearchIdea`、`NoveltyAssessment`、`ExperimentProposal` schema。 +- 将当前 plan 生成改为“结构化 JSON 为主,Markdown 为视图”。 +- 新增 `NoveltyGateService`。 +- 初始 ideation 改为一次生成 3 个候选 idea。 +- 增加 `num_reflections` 配置。 +- 为每个候选 idea 生成 scorecard。 + +交付结果: + +- 系统第一次具备“多候选研究路线”的能力。 + +### P1:一个月内完成的搜索引擎升级 + +- 新增 `SearchTree` / `SearchBranch` 模型。 +- 新增 `SearchStrategy` 和 `BranchScorer`。 +- 将 `MasterPlanner` 从路线探索中剥离,只负责执行 DAG。 +- 支持 best-first + branch prune + repair。 +- 前端新增 branch tree 基础视图。 + +交付结果: + +- 系统第一次具备显式路线探索能力。 + +### P2:两个月内完成的实验闭环升级 + +- 新增 `ExperimentManagerService`。 +- 引入 workspace 和 `CodeExecutionService`。 +- 引入 `ExecutionFailureAnalyzer`。 +- 支持 baseline / variant / ablation runs。 +- experiment run 结果回写 branch score。 + +交付结果: + +- 系统第一次具备“代码执行反馈驱动的研究收敛能力”。 + +### P3:论文级交付能力补齐 + +- 写作模式拆成 `report` 和 `paper_writeup`。 +- 新增 citation pass。 +- 新增 figure planner / figure review。 +- 新增 multi-reviewer pass。 +- 支持 PDF / appendix / review note 导出。 + +交付结果: + +- 系统第一次具备“科研稿件交付能力”。 + +--- + +## 20. 优先级排序:什么最该先抄 + +如果资源有限,不要平均用力。最值钱的是下面 12 项。 + +### Top 1-4:先补研究核心 + +- 前置 novelty gate +- 多候选 idea 生成 +- reflection loop +- SearchTree + branch scorer + +### Top 5-8:再补探索闭环 + +- branch repair loop +- ExperimentManager +- workspace + code execution +- failure analyzer + +### Top 9-12:最后补论文交付 + +- citation pass +- review personas +- figure review +- search tree visualization + +--- + +## 21. 明确哪些不要抄偏 + +全面向对方学习,不等于机械照搬。下面三类不要先做。 + +### 21.1 不要先卷 UI 皮肤 + +真正差距在研究内核,不在界面样式。 + +### 21.2 不要先把 prompt 越写越长 + +如果 schema、状态机、分支结构没变,prompt 再长也只是高成本模板生成。 + +### 21.3 不要先把所有模式都变成实验型任务 + +本项目有自己的优势: + +- 对话驱动 +- 通用主题研究 +- 中文报告体验 + +正确做法是双轨: + +- `survey/evidence_report` 继续保留当前强项 +- `experimental_research/paper_writeup` 走新链路 + +--- + +## 22. 建议直接开工的代码切入点 + +第一批建议改这些位置: + +- [`backend/app/models/schemas.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/models/schemas.py) +- [`backend/app/services/conversation_agent.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/conversation_agent.py) +- [`backend/app/services/planner.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/planner.py) +- [`backend/app/services/execution_engine.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/execution_engine.py) +- [`backend/app/services/four_agents/ideation_agent.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/four_agents/ideation_agent.py) +- [`backend/app/services/four_agents/planning_agent.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/four_agents/planning_agent.py) +- [`backend/app/services/writer.py`](/Users/xcy/Program/SH-Program/Deep-Research/backend/app/services/writer.py) + +建议新增目录: + +- `backend/app/prompts/` +- `backend/app/services/search_tree/` +- `backend/app/services/experiments/` +- `backend/app/services/review/` +- `backend/app/services/citation/` + +--- + +## 23. 最后的结论 + +真正全面向 `AI-Scientist-v2` 学习,不是“把 prompt 写得更像它”,而是做三次范式切换: + +1. 从 Markdown 计划范式,切到结构化研究对象范式。 +2. 从单方案执行范式,切到多分支搜索范式。 +3. 从文献报告范式,切到实验反馈驱动的科研产出范式。 + +如果只允许我给一句最核心的执行建议,那就是: + +- 先把 `novelty gate + multi-idea + search tree + repair loop` 做出来。 + +这是所有后续能力的底座。没有这四个,本项目很难真正追上 `AI-Scientist-v2` 的研究内核。 + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4277c93..7f113be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { deleteConversation, downloadConversationReport, getConversation, + getLLMSettings, listConversations, renameConversation, reviseConversationPlan, @@ -21,6 +22,7 @@ import { Dialog } from "./components/Dialog"; import { ExportModal } from "./components/ExportModal"; import { LibraryPage } from "./components/LibraryPage"; import { PlanEditorPane } from "./components/PlanEditorPane"; +import { SettingsModal } from "./components/SettingsModal"; import { APP_CONFIG, STATUS_LABEL } from "./constants"; import type { AgentState, @@ -29,6 +31,7 @@ import type { ConversationMessage, ConversationStatus, ConversationSummary, + LLMProvider, ProgressEvent, } from "./types"; @@ -229,6 +232,8 @@ export function App() { const [currentPhase, setCurrentPhase] = useState(null); const [streamClock, setStreamClock] = useState(() => Date.now()); const [showLibrary, setShowLibrary] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [selectedLLMProvider, setSelectedLLMProvider] = useState(null); const composerRef = useRef(null); const progressWsRef = useRef(null); @@ -1083,6 +1088,14 @@ export function App() { > 文献库 +

{activeSummary?.topic ?? (draftMode ? "新研究" : "Research Flow")}

@@ -1138,6 +1151,7 @@ export function App() { starting={starting} downloading={downloading} status={activeStatus} + currentIdeas={activeDetail?.currentIdeas ?? []} onRequestCloseMobile={() => setMobileEditorOpen(false)} onChange={(value) => { setPlanDraft(value); @@ -1250,6 +1264,13 @@ export function App() {
)} + setShowSettings(false)} + selectedProvider={selectedLLMProvider} + onSelectProvider={setSelectedLLMProvider} + /> + {error &&
{error}
} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9e40250..d7000ec 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,11 +1,20 @@ import type { + BranchAction, + BranchRepairAttempt, ConflictRecord, ConversationBulkDeleteResponse, ConversationDeleteResponse, ConversationDetail, ConversationSummary, Evidence, + ExperimentRun, + LLMSettingsResponse, + ProviderConfigResponse, + ProviderConfigUpdate, + TaskMappingResponse, + TaskMappingUpdate, RevisePlanResponse, + SearchBranch, RunConversationResponse, TaskResponse } from "./types"; @@ -153,6 +162,32 @@ export async function listConflicts(taskId: string, options?: RequestOptions): P return json(`${API_BASE}/api/v1/tasks/${taskId}/conflicts`, undefined, options); } +export async function listSearchBranches(taskId: string, options?: RequestOptions): Promise { + return json(`${API_BASE}/api/v1/tasks/${taskId}/search-branches`, undefined, options); +} + +export async function listBranchActions( + taskId: string, + branchId?: string, + options?: RequestOptions +): Promise { + const suffix = branchId ? `?branchId=${encodeURIComponent(branchId)}` : ""; + return json(`${API_BASE}/api/v1/tasks/${taskId}/branch-actions${suffix}`, undefined, options); +} + +export async function listBranchRepairs( + taskId: string, + branchId?: string, + options?: RequestOptions +): Promise { + const suffix = branchId ? `?branchId=${encodeURIComponent(branchId)}` : ""; + return json(`${API_BASE}/api/v1/tasks/${taskId}/branch-repairs${suffix}`, undefined, options); +} + +export async function listExperiments(taskId: string, options?: RequestOptions): Promise { + return json(`${API_BASE}/api/v1/tasks/${taskId}/experiments`, undefined, options); +} + export async function voteConflict(payload: { evidenceId: string; conflictId: string; @@ -387,3 +422,45 @@ export async function getLibraryKeywords(topN: number = 50): Promise { return json(`${API_BASE}/api/v1/library/summary`); } + +export async function getLLMSettings(): Promise { + return json(`${API_BASE}/api/v1/settings/llm`); +} + +// Extended LLM Settings APIs +export async function getProviderConfigs(): Promise { + return json(`${API_BASE}/api/v1/settings/llm/providers`); +} + +export async function getProviderConfig(provider: LLMProvider): Promise { + return json(`${API_BASE}/api/v1/settings/llm/providers/${provider}`); +} + +export async function updateProviderConfig( + provider: LLMProvider, + update: ProviderConfigUpdate +): Promise { + return json(`${API_BASE}/api/v1/settings/llm/providers/${provider}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(update), + }); +} + +export async function resetProviderConfig(provider: LLMProvider): Promise { + await json(`${API_BASE}/api/v1/settings/llm/providers/${provider}`, { + method: "DELETE", + }); +} + +export async function getTaskMapping(): Promise { + return json(`${API_BASE}/api/v1/settings/llm/task-mapping`); +} + +export async function updateTaskMapping(update: TaskMappingUpdate): Promise { + return json(`${API_BASE}/api/v1/settings/llm/task-mapping`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(update), + }); +} diff --git a/frontend/src/components/ChatTimeline.tsx b/frontend/src/components/ChatTimeline.tsx index 8a4325b..9edcd70 100644 --- a/frontend/src/components/ChatTimeline.tsx +++ b/frontend/src/components/ChatTimeline.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import type { ConversationMessage, ConversationStatus } from "../types"; import { @@ -12,7 +12,8 @@ import { formatLocalTime } from "../utils/formatTime"; import { ReportViewer } from "./ReportViewer"; import { ProgressBar } from "./ProgressIndicator"; import { DAGEditorModal } from "./DAGEditorModal"; -import { getDag } from "../api"; +import { getDag, listBranchActions, listBranchRepairs, listConflicts, listEvidence, listExperiments } from "../api"; +import type { BranchAction, BranchRepairAttempt, ConflictRecord, Evidence, ExperimentRun } from "../types"; import type { DAGGraph, TaskNode, DAGEdge, TaskNodeStatus } from "../hooks/useDAGEditor"; export interface ChatTimelineProps { @@ -63,6 +64,67 @@ export function ChatTimeline(props: ChatTimelineProps) { // DAG Editor modal state const [isDAGEditorOpen, setIsDAGEditorOpen] = useState(false); const [editingTaskId, setEditingTaskId] = useState(null); + const [selectedBranchId, setSelectedBranchId] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [branchInsight, setBranchInsight] = useState<{ + loading: boolean; + evidenceCount: number; + conflictCount: number; + actionCount: number; + repairCount: number; + experimentCount: number; + topSources: Array<{ source: string; count: number }>; + }>({ + loading: false, + evidenceCount: 0, + conflictCount: 0, + actionCount: 0, + repairCount: 0, + experimentCount: 0, + topSources: [], + }); + const [nodeEvidence, setNodeEvidence] = useState<{ + loading: boolean; + items: Evidence[]; + }>({ + loading: false, + items: [], + }); + const [nodeConflicts, setNodeConflicts] = useState<{ + loading: boolean; + items: Array<{ + conflictId: string; + parameter: string; + variance: number; + resolutionStatus: string; + context: string; + disputedValues: Array<{ evidenceId: string; value: number; unit: string; source: string }>; + }>; + }>({ + loading: false, + items: [], + }); + const [expandedConflictIds, setExpandedConflictIds] = useState>({}); + const [branchTrace, setBranchTrace] = useState<{ + loading: boolean; + actions: BranchAction[]; + repairs: BranchRepairAttempt[]; + experiments: ExperimentRun[]; + }>({ + loading: false, + actions: [], + repairs: [], + experiments: [], + }); + const [traceExpanded, setTraceExpanded] = useState<{ + actions: boolean; + repairs: boolean; + experiments: boolean; + }>({ + actions: false, + repairs: false, + experiments: false, + }); // DAG state for PLAN_READY phase (fetched from API) const [planReadyDag, setPlanReadyDag] = useState({ nodes: [], edges: [] }); @@ -95,7 +157,7 @@ export function ChatTimeline(props: ChatTimelineProps) { })); // Convert edges from backend format (from/to) to frontend format (source/target) - const dagEdges: DAGEdge[] = (dagData.edges || []).map((edge, index) => ({ + const dagEdges: DAGEdge[] = (dagData.edges || []).map((edge) => ({ id: `edge-${edge.from}-${edge.to}`, source: edge.from, // Backend uses 'from', frontend uses 'source' target: edge.to, // Backend uses 'to', frontend uses 'target' @@ -154,6 +216,7 @@ export function ChatTimeline(props: ChatTimelineProps) { activeStatus === "PLAN_READY" || activeStatus === "COMPLETED" || activeStatus === "FAILED"; const activeBundle = currentTaskId ? progressBundles.get(currentTaskId) ?? null : null; const activeEntries = activeBundle ? activeBundle.entries.slice(-6).reverse() : []; + const eventRefs = useRef>({}); // Get DAG nodes: prefer API-fetched DAG in PLAN_READY state, otherwise from progress bundle const progressDagNodes = activeBundle @@ -174,6 +237,9 @@ export function ChatTimeline(props: ChatTimelineProps) { title: n.title, status: n.status, searchDepth: n.searchDepth, + branchId: undefined, + branchScore: undefined, + branchDepth: n.searchDepth, dependencies: [], elapsedMs: n.elapsedMs, retryCount: n.retryCount, @@ -182,8 +248,230 @@ export function ChatTimeline(props: ChatTimelineProps) { const currentPhase = activeEntries[0]?.phase ?? ""; const idleWarning = idleSeconds >= 20; - const dagColumns = groupDagNodesByDepth(latestDagNodes); + const branchGroups = groupDagNodesByBranch(latestDagNodes); + const displayedDagNodes = selectedBranchId + ? latestDagNodes.filter((node) => (node.branchId || "unassigned") === selectedBranchId) + : latestDagNodes; + const dagColumns = groupDagNodesByDepth(displayedDagNodes); const dagSummary = summarizeDagNodes(latestDagNodes); + const nodeTitleById = useMemo(() => { + const map = new Map(); + for (const node of latestDagNodes) { + map.set(node.nodeId, node.title); + } + return map; + }, [latestDagNodes]); + + const relatedEventIndex = useMemo(() => { + if (!selectedNodeId) return -1; + return activeEntries.findIndex((entry) => isEntryRelatedToNode(entry, selectedNodeId, nodeTitleById)); + }, [activeEntries, selectedNodeId, nodeTitleById]); + + useEffect(() => { + if (branchGroups.length === 0) { + setSelectedBranchId(null); + return; + } + if (!selectedBranchId) { + setSelectedBranchId(branchGroups[0].branchId); + return; + } + const exists = branchGroups.some((group) => group.branchId === selectedBranchId); + if (!exists) { + setSelectedBranchId(branchGroups[0].branchId); + } + }, [branchGroups, selectedBranchId]); + + useEffect(() => { + if (displayedDagNodes.length === 0) { + setSelectedNodeId(null); + return; + } + if (!selectedNodeId || !displayedDagNodes.some((node) => node.nodeId === selectedNodeId)) { + setSelectedNodeId(displayedDagNodes[0].nodeId); + } + }, [displayedDagNodes, selectedNodeId]); + + useEffect(() => { + if (!currentTaskId || !selectedBranchId) { + setBranchInsight((prev) => ({ + ...prev, + loading: false, + evidenceCount: 0, + conflictCount: 0, + actionCount: 0, + repairCount: 0, + experimentCount: 0, + topSources: [], + })); + setBranchTrace({ loading: false, actions: [], repairs: [], experiments: [] }); + return; + } + + const selectedNodeIds = new Set( + latestDagNodes + .filter((node) => (node.branchId || "unassigned") === selectedBranchId) + .map((node) => node.nodeId), + ); + if (selectedNodeIds.size === 0) { + setBranchInsight((prev) => ({ + ...prev, + loading: false, + evidenceCount: 0, + conflictCount: 0, + actionCount: 0, + repairCount: 0, + experimentCount: 0, + topSources: [], + })); + setBranchTrace({ loading: false, actions: [], repairs: [], experiments: [] }); + return; + } + + let cancelled = false; + setBranchInsight((prev) => ({ ...prev, loading: true })); + setBranchTrace((prev) => ({ ...prev, loading: true })); + + const load = async () => { + try { + const branchFilter = selectedBranchId === "unassigned" ? null : selectedBranchId; + const [allEvidences, allConflicts, branchActions, branchRepairs, experimentRuns] = await Promise.all([ + listEvidence(currentTaskId), + listConflicts(currentTaskId), + branchFilter ? listBranchActions(currentTaskId, branchFilter) : Promise.resolve([]), + branchFilter ? listBranchRepairs(currentTaskId, branchFilter) : Promise.resolve([]), + listExperiments(currentTaskId), + ]); + if (cancelled) return; + + const evidences = allEvidences.filter((item: Evidence) => selectedNodeIds.has(item.nodeId)); + const evidenceIdToNodeId = new Map(evidences.map((item) => [item.id, item.nodeId])); + + const conflictCount = allConflicts.filter((conflict: ConflictRecord) => + conflict.disputedValues?.some((value) => { + const nodeId = evidenceIdToNodeId.get(value.evidenceId); + return typeof nodeId === "string" && selectedNodeIds.has(nodeId); + }), + ).length; + + const sourceCounter = new Map(); + for (const item of evidences) { + const source = item.sourceType || "unknown"; + sourceCounter.set(source, (sourceCounter.get(source) ?? 0) + 1); + } + const topSources = Array.from(sourceCounter.entries()) + .map(([source, count]) => ({ source, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 3); + + setBranchInsight({ + loading: false, + evidenceCount: evidences.length, + conflictCount, + actionCount: branchActions.length, + repairCount: branchRepairs.length, + experimentCount: + selectedBranchId === "unassigned" + ? 0 + : experimentRuns.filter((run) => run.branchId === selectedBranchId).length, + topSources, + }); + const recentActions = [...branchActions].slice(-5).reverse(); + const recentRepairs = [...branchRepairs].slice(-5).reverse(); + const recentExperiments = + selectedBranchId === "unassigned" + ? [] + : experimentRuns + .filter((run) => run.branchId === selectedBranchId) + .slice(-5) + .reverse(); + setBranchTrace({ + loading: false, + actions: recentActions, + repairs: recentRepairs, + experiments: recentExperiments, + }); + } catch { + if (cancelled) return; + setBranchInsight({ + loading: false, + evidenceCount: 0, + conflictCount: 0, + actionCount: 0, + repairCount: 0, + experimentCount: 0, + topSources: [], + }); + setBranchTrace({ loading: false, actions: [], repairs: [], experiments: [] }); + } + }; + + void load(); + return () => { + cancelled = true; + }; + }, [currentTaskId, selectedBranchId, latestDagNodes]); + + useEffect(() => { + if (!currentTaskId || !selectedNodeId) { + setNodeEvidence({ loading: false, items: [] }); + setNodeConflicts({ loading: false, items: [] }); + setExpandedConflictIds({}); + return; + } + let cancelled = false; + setNodeEvidence((prev) => ({ ...prev, loading: true })); + setNodeConflicts((prev) => ({ ...prev, loading: true })); + + const load = async () => { + try { + const [allEvidence, allConflicts] = await Promise.all([ + listEvidence(currentTaskId), + listConflicts(currentTaskId), + ]); + if (cancelled) return; + const nodeEvidenceAll = allEvidence.filter((item) => item.nodeId === selectedNodeId); + const items = nodeEvidenceAll + .sort((a, b) => b.score - a.score) + .slice(0, 6); + + const selectedEvidenceIds = new Set(nodeEvidenceAll.map((item) => item.id)); + const conflicts = allConflicts + .filter((conflict) => + conflict.disputedValues?.some((value) => selectedEvidenceIds.has(value.evidenceId)), + ) + .map((conflict) => ({ + conflictId: conflict.conflictId, + parameter: conflict.parameter, + variance: conflict.variance, + resolutionStatus: conflict.resolutionStatus, + context: conflict.context, + disputedValues: conflict.disputedValues || [], + })) + .slice(0, 4); + + setNodeEvidence({ loading: false, items }); + setNodeConflicts({ loading: false, items: conflicts }); + setExpandedConflictIds({}); + } catch { + if (cancelled) return; + setNodeEvidence({ loading: false, items: [] }); + setNodeConflicts({ loading: false, items: [] }); + } + }; + + void load(); + return () => { + cancelled = true; + }; + }, [currentTaskId, selectedNodeId]); + + const scrollToRelatedEvent = () => { + if (relatedEventIndex < 0) return; + const key = buildEventKey(activeEntries[relatedEventIndex], relatedEventIndex); + const target = eventRefs.current[key]; + target?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }; // Helper to render progress bundle const renderProgressBundle = (bundle: ProgressBundle) => { @@ -276,16 +564,25 @@ export function ChatTimeline(props: ChatTimelineProps) { {activeEntries.length > 0 && (
- {activeEntries.map((entry) => ( -
- {entry.phase} - {entry.detail || entry.summary} - {entry.progress !== null ? `${entry.progress}%` : "--"} -
- ))} + {activeEntries.map((entry, eventIndex) => { + const eventKey = buildEventKey(entry, eventIndex); + const related = Boolean( + selectedNodeId && isEntryRelatedToNode(entry, selectedNodeId, nodeTitleById), + ); + return ( +
{ + eventRefs.current[eventKey] = el; + }} + > + {entry.phase} + {entry.detail || entry.summary} + {entry.progress !== null ? `${entry.progress}%` : "--"} +
+ ); + })}
)} {latestDagNodes.length > 0 && ( @@ -293,10 +590,119 @@ export function ChatTimeline(props: ChatTimelineProps) {
任务 DAG 实时视图 - 总计 {dagSummary.total} | 运行中 {dagSummary.running} | 已完成 {dagSummary.completed} | 失败 {dagSummary.failed} + 总计 {dagSummary.total} | 运行中 {dagSummary.running} | 已完成 {dagSummary.completed} | 失败 {dagSummary.failed} | 剪枝 {dagSummary.pruned}
+ {branchGroups.length > 0 && ( +
+
+ 分支视图 + + 当前分支 {selectedBranchId || "-"} / 共 {branchGroups.length} + +
+
+ {branchGroups.map((group) => ( + + ))} +
+
+ 证据 {branchInsight.loading ? "加载中..." : branchInsight.evidenceCount} + 冲突 {branchInsight.loading ? "加载中..." : branchInsight.conflictCount} + 动作 {branchInsight.loading ? "加载中..." : branchInsight.actionCount} + 修复 {branchInsight.loading ? "加载中..." : branchInsight.repairCount} + 实验 {branchInsight.loading ? "加载中..." : branchInsight.experimentCount} + + 来源 + {branchInsight.topSources.length > 0 + ? ` ${branchInsight.topSources.map((item) => `${item.source}(${item.count})`).join(" / ")}` + : " -"} + +
+
+ + {traceExpanded.actions && ( +
+ {branchTrace.loading ? ( +
加载中...
+ ) : branchTrace.actions.length === 0 ? ( +
暂无分支动作记录。
+ ) : ( + branchTrace.actions.map((action) => ( +
+ {action.actionType} + {action.status} · {action.scoreBefore.toFixed(2)} {"->"} {action.scoreAfter.toFixed(2)} +
+ )) + )} +
+ )} + + + {traceExpanded.repairs && ( +
+ {branchTrace.loading ? ( +
加载中...
+ ) : branchTrace.repairs.length === 0 ? ( +
暂无修复尝试记录。
+ ) : ( + branchTrace.repairs.map((repair) => ( +
+ Attempt {repair.attempt}: {repair.diagnosis || "-"} + {repair.succeeded ? "SUCCESS" : "FAILED"} +
+ )) + )} +
+ )} + + + {traceExpanded.experiments && ( +
+ {branchTrace.loading ? ( +
加载中...
+ ) : branchTrace.experiments.length === 0 ? ( +
暂无实验运行记录。
+ ) : ( + branchTrace.experiments.map((run) => ( +
+ {run.objective || run.runId} + {run.status} · exit {run.exitCode ?? "-"} +
+ )) + )} +
+ )} +
+
+ )} {dagColumns.map((column) => (
@@ -304,21 +710,106 @@ export function ChatTimeline(props: ChatTimelineProps) {
{column.nodes.map((node) => ( -
setSelectedNodeId(node.nodeId)} >
{node.title}
{statusLabel(node.status)} + 分支 {node.branchId || "-"} + 分数 {typeof node.branchScore === "number" ? node.branchScore.toFixed(2) : "-"} + 分支深度 {typeof node.branchDepth === "number" ? node.branchDepth : "-"} 耗时 {formatElapsed(node.elapsedMs)} 重试 {node.retryCount}
-
+ ))}
))} +
+
+ 节点证据预览 + 节点 {selectedNodeId || "-"} +
+ + {nodeEvidence.loading ? ( +
正在加载证据...
+ ) : nodeEvidence.items.length === 0 ? ( +
该节点暂无证据。
+ ) : ( + + )} +
+ 节点冲突预览 + {nodeConflicts.loading ? ( +
正在加载冲突...
+ ) : nodeConflicts.items.length === 0 ? ( +
该节点暂无冲突记录。
+ ) : ( +
+ {nodeConflicts.items.map((conflict) => ( +
+
+ {conflict.parameter} + variance {conflict.variance.toFixed(3)} · {conflict.resolutionStatus} +
+ + {expandedConflictIds[conflict.conflictId] && ( +
+
{conflict.context || "无上下文描述"}
+ {conflict.disputedValues.length > 0 && ( +
+ {conflict.disputedValues + .slice(0, 5) + .map((value) => `${value.source}: ${value.value}${value.unit}`) + .join(" | ")} +
+ )} +
+ )} +
+ ))} +
+ )} +
+
)} @@ -492,6 +983,23 @@ export function ChatTimeline(props: ChatTimelineProps) { ); } +function buildEventKey(entry: { phase: string; state: string; summary: string; progress: number | null; detail?: string }, index: number): string { + return `${index}-${entry.phase}-${entry.state}-${entry.summary}-${entry.progress ?? "na"}-${entry.detail ?? ""}`; +} + +function isEntryRelatedToNode( + entry: { nodeId?: string; summary: string; detail?: string }, + nodeId: string, + nodeTitleById: Map, +): boolean { + if (entry.nodeId && entry.nodeId === nodeId) return true; + const text = `${entry.summary} ${entry.detail || ""}`.toLowerCase(); + if (text.includes(nodeId.toLowerCase())) return true; + const nodeTitle = nodeTitleById.get(nodeId); + if (nodeTitle && text.includes(nodeTitle.toLowerCase())) return true; + return false; +} + function formatElapsed(elapsedMs: number): string { const seconds = Math.max(0, Math.floor(elapsedMs / 1000)); const mins = Math.floor(seconds / 60); @@ -510,21 +1018,44 @@ function statusLabel(status: string): string { return "失败"; case "SUSPENDED": return "暂停"; + case "PRUNED": + return "已剪枝"; default: return "待处理"; } } function summarizeDagNodes(nodes: Array<{ status: string }>) { - const summary = { total: nodes.length, running: 0, completed: 0, failed: 0 }; + const summary = { total: nodes.length, running: 0, completed: 0, failed: 0, pruned: 0 }; for (const node of nodes) { if (node.status === "RUNNING") summary.running += 1; if (node.status === "COMPLETED") summary.completed += 1; if (node.status === "FAILED") summary.failed += 1; + if (node.status === "PRUNED") summary.pruned += 1; } return summary; } +function groupDagNodesByBranch(nodes: T[]) { + const grouped = new Map(); + for (const node of nodes) { + const branchId = node.branchId || "unassigned"; + const score = typeof node.branchScore === "number" ? node.branchScore : 0; + const current = grouped.get(branchId) ?? { branchId, count: 0, scoreTotal: 0 }; + current.count += 1; + current.scoreTotal += score; + grouped.set(branchId, current); + } + return Array.from(grouped.values()) + .map((item) => ({ + branchId: item.branchId, + branchLabel: item.branchId === "unassigned" ? "未分配分支" : item.branchId, + count: item.count, + averageScore: item.count > 0 ? item.scoreTotal / item.count : 0, + })) + .sort((a, b) => b.averageScore - a.averageScore); +} + function groupDagNodesByDepth(nodes: T[]): Array<{ depth: number; nodes: T[] }> { const grouped = new Map(); for (const node of nodes) { diff --git a/frontend/src/components/DAGEditor.tsx b/frontend/src/components/DAGEditor.tsx index bf3689c..5bdf049 100644 --- a/frontend/src/components/DAGEditor.tsx +++ b/frontend/src/components/DAGEditor.tsx @@ -18,13 +18,14 @@ type DagreLayoutOptions = LayoutOptions & { }; const NODE_MIN_WIDTH = 220; -const NODE_MAX_WIDTH = 300; +const NODE_MAX_WIDTH = 320; const NODE_MIN_HEIGHT = 72; -const NODE_HORIZONTAL_PADDING = 36; +const NODE_HORIZONTAL_PADDING = 40; const NODE_VERTICAL_PADDING = 28; const NODE_FONT_SIZE = 14; -const NODE_LINE_HEIGHT = 18; -const NODE_CHARACTERS_PER_LINE = 14; +const NODE_LINE_HEIGHT = 20; +const AVG_CHAR_WIDTH = 9; +const CHINESE_CHAR_WIDTH_MULTIPLIER = 1.85; const FIT_PADDING = 36; const MIN_FIT_ZOOM = 0.62; const MAX_FIT_ZOOM = 1.05; @@ -81,21 +82,79 @@ function hexToRgba(hexColor: string, alpha: number): string { return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } +/** + * Generate glassmorphism colors for a node based on status + */ +function getNodeGlassColors(status: TaskNodeStatus): { + backgroundColor: string; + borderColor: string; + shadowColor: string; + textColor: string; +} { + const baseColor = getNodeColor(status); + return { + backgroundColor: hexToRgba(baseColor, 0.18), + borderColor: hexToRgba(baseColor, 0.55), + shadowColor: hexToRgba(baseColor, 0.28), + textColor: "#1e293b", + }; +} + +const CJK_REGEX = /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/; + +/** + * Calculate visual width of a character + * CJK characters are approximately 1.85x wider than Latin characters + */ +function getCharWidth(char: string): number { + return CJK_REGEX.test(char) ? AVG_CHAR_WIDTH * CHINESE_CHAR_WIDTH_MULTIPLIER : AVG_CHAR_WIDTH; +} + +/** + * Calculate total visual width of a string + */ +function calculateTextWidth(text: string): number { + let width = 0; + for (const char of text) { + width += getCharWidth(char); + } + return width; +} + function measureNode(title: string) { const trimmedTitle = title.trim() || "Untitled"; - const textLength = Array.from(trimmedTitle).length; - const lineCount = Math.max(1, Math.ceil(textLength / NODE_CHARACTERS_PER_LINE)); - const widestLineChars = Math.min( - NODE_CHARACTERS_PER_LINE + 4, - Math.max(NODE_CHARACTERS_PER_LINE, textLength) - ); + const maxLineWidth = NODE_MAX_WIDTH - NODE_HORIZONTAL_PADDING; + + // Split text into lines based on visual width + const lines: string[] = []; + let currentLine = ""; + let currentLineWidth = 0; + + for (const char of trimmedTitle) { + const charWidth = getCharWidth(char); + if (currentLineWidth + charWidth > maxLineWidth && currentLine.length > 0) { + lines.push(currentLine); + currentLine = char; + currentLineWidth = charWidth; + } else { + currentLine += char; + currentLineWidth += charWidth; + } + } + if (currentLine) { + lines.push(currentLine); + } + + // Calculate the width of the widest line + const widestLineWidth = Math.max(...lines.map(line => calculateTextWidth(line))); + const width = Math.min( NODE_MAX_WIDTH, - Math.max(NODE_MIN_WIDTH, widestLineChars * NODE_FONT_SIZE + NODE_HORIZONTAL_PADDING) + Math.max(NODE_MIN_WIDTH, widestLineWidth + NODE_HORIZONTAL_PADDING) ); const height = Math.max( NODE_MIN_HEIGHT, - lineCount * NODE_LINE_HEIGHT + NODE_VERTICAL_PADDING + lines.length * NODE_LINE_HEIGHT + NODE_VERTICAL_PADDING ); return { @@ -214,29 +273,34 @@ export function DAGEditor({ width: "data(width)", height: "data(height)", shape: "roundrectangle", - "corner-radius": "24px", + "corner-radius": "16px", padding: "14px", "text-wrap": "wrap", "text-max-width": "data(labelMaxWidth)", "text-valign": "center", "text-halign": "center", "font-size": NODE_FONT_SIZE, - "line-height": 1.3, - color: "#0f172a", - "border-width": 1.75, + "line-height": 1.4, + color: "#1e293b", + "border-width": 1.5, "border-color": "data(borderColor)", + "border-opacity": 0.6, "text-outline-width": 0, "shadow-color": "data(shadowColor)", - "shadow-blur": 18, - "shadow-opacity": 0.4, - "shadow-offset-y": 6, + "shadow-blur": 24, + "shadow-opacity": 0.35, + "shadow-offset-x": 0, + "shadow-offset-y": 8, + "background-opacity": 0.85, }; const selectedNodeStyle: Record = { - "border-width": 3, - "border-color": "#111827", - "shadow-blur": 24, - "shadow-opacity": 0.58, + "border-width": 2.5, + "border-color": "#1e293b", + "border-opacity": 1, + "shadow-blur": 32, + "shadow-opacity": 0.5, + "shadow-offset-y": 12, }; /** @@ -448,7 +512,7 @@ export function DAGEditor({ // Map nodes to Cytoscape format const cyNodes = nodes.map((node) => { - const baseColor = getNodeColor(node.status); + const glassColors = getNodeGlassColors(node.status); const nodeSize = measureNode(node.title); const fallbackPosition = { x: averageX + ((nodes.findIndex((n) => n.nodeId === node.nodeId) % 4) - 1.5) * 92, @@ -459,10 +523,10 @@ export function DAGEditor({ data: { id: node.nodeId, label: node.title, - color: baseColor, - backgroundColor: hexToRgba(baseColor, 0.3), - borderColor: hexToRgba(baseColor, 0.85), - shadowColor: hexToRgba(baseColor, 0.42), + color: glassColors.textColor, + backgroundColor: glassColors.backgroundColor, + borderColor: glassColors.borderColor, + shadowColor: glassColors.shadowColor, width: nodeSize.width, height: nodeSize.height, labelMaxWidth: nodeSize.labelMaxWidth, diff --git a/frontend/src/components/PlanEditorPane.tsx b/frontend/src/components/PlanEditorPane.tsx index 4439bc3..bd76bd5 100644 --- a/frontend/src/components/PlanEditorPane.tsx +++ b/frontend/src/components/PlanEditorPane.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import type { ConversationStatus } from "../types"; +import type { ConversationStatus, ResearchIdea } from "../types"; import { PlanConfigForm } from "./PlanConfigForm"; import { parseYamlFrontmatter, serializeYamlFrontmatter } from "../utils/yamlFrontmatter"; @@ -11,6 +11,7 @@ export interface PlanEditorPaneProps { starting: boolean; downloading: boolean; status: ConversationStatus | null; + currentIdeas?: ResearchIdea[]; onRequestCloseMobile: () => void; onChange: (value: string) => void; onReset: () => void; @@ -57,6 +58,7 @@ function PlanEditorPaneBase(props: PlanEditorPaneProps) { starting, downloading, status, + currentIdeas, onRequestCloseMobile, onChange, onReset, @@ -158,6 +160,32 @@ function PlanEditorPaneBase(props: PlanEditorPaneProps) { showResetButton={false} /> + {Array.isArray(currentIdeas) && currentIdeas.length > 0 && ( +
+
+

候选 Ideas

+

当前展示首轮结构化研究想法,已按分数筛出主路线。

+
+
+ {currentIdeas.map((idea) => ( +
+
+ {idea.title} + {idea.status} +
+

{idea.shortHypothesis || idea.problemStatement}

+
+ overall {idea.scoreCard?.overallScore?.toFixed?.(2) ?? "0.00"} + novelty {idea.scoreCard?.noveltyScore?.toFixed?.(2) ?? "0.00"} +
+
+ ))} +
+
+ )}