Merge 5ac47f412f into 96096ea0ff
This commit is contained in:
commit
0f967eac3f
45
.env.example
45
.env.example
|
|
@ -1,16 +1,37 @@
|
|||
# LLM API配置(支持 OpenAI SDK 格式的任意 LLM API)
|
||||
# 推荐使用阿里百炼平台qwen-plus模型:https://bailian.console.aliyun.com/
|
||||
# 注意消耗较大,可先进行小于40轮的模拟尝试
|
||||
LLM_API_KEY=your_api_key_here
|
||||
# ===== MiroFish Agent Engine 默认配置 =====
|
||||
MIROFISH_MODE=agent
|
||||
MIROFISH_LLM_PROVIDER=agent_queue
|
||||
MIROFISH_GRAPH_PROVIDER=graphiti
|
||||
MIROFISH_RUNS_DIR=./runs
|
||||
|
||||
# ===== Graphiti / Neo4j 图谱配置 =====
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=password
|
||||
NEO4J_DATABASE=neo4j
|
||||
|
||||
# ===== Embedding 配置(可选)=====
|
||||
# GraphitiCompatibilityStore 的 no-LLM 主路径不需要 embedding。
|
||||
# fulltext 不需要 Ollama;仅 semantic/hybrid + ollama 时 doctor 会强制检查 Ollama。
|
||||
MIROFISH_GRAPH_SEARCH_MODE=fulltext
|
||||
MIROFISH_EMBEDDING_PROVIDER=none
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# ===== Legacy OpenAI-compatible LLM provider(可选)=====
|
||||
# 仅当 MIROFISH_LLM_PROVIDER=openai_compatible 时需要。
|
||||
LLM_API_KEY=
|
||||
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
LLM_MODEL_NAME=qwen-plus
|
||||
|
||||
# ===== ZEP记忆图谱配置 =====
|
||||
# 每月免费额度即可支撑简单使用:https://app.getzep.com/
|
||||
ZEP_API_KEY=your_zep_api_key_here
|
||||
# ===== Legacy Zep Cloud graph provider(可选)=====
|
||||
# 仅当 MIROFISH_GRAPH_PROVIDER=zep 时需要。
|
||||
ZEP_API_KEY=
|
||||
|
||||
# ===== 加速 LLM 配置(可选)=====
|
||||
# 注意如果不使用加速配置,env文件中就不要出现下面的配置项
|
||||
LLM_BOOST_API_KEY=your_api_key_here
|
||||
LLM_BOOST_BASE_URL=your_base_url_here
|
||||
LLM_BOOST_MODEL_NAME=your_model_name_here
|
||||
# ===== 加速 LLM 配置(legacy 可选)=====
|
||||
LLM_BOOST_API_KEY=
|
||||
LLM_BOOST_BASE_URL=
|
||||
LLM_BOOST_MODEL_NAME=
|
||||
|
||||
# ===== Legacy UI / simulation behavior =====
|
||||
MIROFISH_PRESERVE_SIMULATIONS_ON_RELOAD=true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
# MiroFish Agent Kit
|
||||
|
||||
MiroFish now includes a full agent engine for desktop tools such as Codex, Claude Code, Cursor, and opencode.
|
||||
|
||||
Default agent mode:
|
||||
|
||||
```bash
|
||||
export MIROFISH_MODE=agent
|
||||
export MIROFISH_LLM_PROVIDER=agent_queue
|
||||
export MIROFISH_GRAPH_PROVIDER=graphiti
|
||||
export MIROFISH_RUNS_DIR=./runs
|
||||
```
|
||||
|
||||
Agent mode does not require `LLM_API_KEY`, `OPENAI_API_KEY`, or `ZEP_API_KEY`. Model work is written to `runs/<run_id>/requests/*.json`; a desktop agent writes matching response JSON into `runs/<run_id>/responses/*.json`; MiroFish validates the response and resumes the run.
|
||||
|
||||
Local graph service setup:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
bash scripts/setup_agent_deps.sh --neo4j desktop
|
||||
```
|
||||
|
||||
Agent dependencies are installed through the backend `agent` optional extra. Legacy OpenAI-compatible and Zep Cloud SDKs are not part of the default agent path; install them only when explicitly using `MIROFISH_LLM_PROVIDER=openai_compatible` or `MIROFISH_GRAPH_PROVIDER=zep`:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv sync --extra legacy
|
||||
```
|
||||
|
||||
Neo4j Desktop, Homebrew/native Neo4j, and existing Neo4j instances are supported. Docker Compose is optional only. `mirofish-agent doctor --json` does not fail because Docker is missing; it fails when required Graphiti/Neo4j dependencies, Neo4j connectivity, or Neo4j `5.26+` are unavailable. Ollama checks are required only when `MIROFISH_GRAPH_SEARCH_MODE=semantic` or `hybrid` and `MIROFISH_EMBEDDING_PROVIDER=ollama`; `fulltext` search with `MIROFISH_EMBEDDING_PROVIDER=none` does not require Ollama.
|
||||
|
||||
Minimal demo:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036
|
||||
uv run mirofish-agent run --run ../runs/chip-2036 --json
|
||||
uv run mirofish-agent requests show --run ../runs/chip-2036 --request-id req_000001 --json
|
||||
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
|
||||
uv run mirofish-agent resume --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
Follow-up questions also use the same provider boundary and queue:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent followup ask --run ../runs/chip-2036 --question "先进AI芯片出口限制有什么影响?" --json
|
||||
uv run mirofish-agent followup show --run ../runs/chip-2036 --request-id req_000007 --json
|
||||
```
|
||||
|
||||
Supported agent queue task types:
|
||||
|
||||
```text
|
||||
extract_triples
|
||||
generate_ontology
|
||||
generate_oasis_profiles
|
||||
generate_simulation_config
|
||||
simulate_agent_action
|
||||
summarize_round
|
||||
update_memory
|
||||
generate_report
|
||||
answer_followup_question
|
||||
answer_agent_question
|
||||
answer_agent_questionnaire
|
||||
summarize_questionnaire
|
||||
ask_report_question
|
||||
validate_json_output
|
||||
repair_invalid_json
|
||||
```
|
||||
|
||||
Web Console and interaction commands:
|
||||
|
||||
```bash
|
||||
# Generate the interactive Web Console
|
||||
uv run mirofish-agent web generate --run ../runs/chip-2036 --json
|
||||
|
||||
# List agents and ask questions
|
||||
uv run mirofish-agent agents list --run ../runs/chip-2036 --json
|
||||
uv run mirofish-agent agents ask --run ../runs/chip-2036 --agent-id agent_1 --question "What is the biggest risk?" --json
|
||||
uv run mirofish-agent agents answer --run ../runs/chip-2036 --request-id req_XXXX --json
|
||||
|
||||
# Send questionnaires
|
||||
uv run mirofish-agent questionnaire send --run ../runs/chip-2036 --questions questions.json --json
|
||||
uv run mirofish-agent questionnaire show --run ../runs/chip-2036 --questionnaire-id q_XXXX --json
|
||||
|
||||
# Ask questions about the report
|
||||
uv run mirofish-agent report-question ask --run ../runs/chip-2036 --question "Summarize key risks" --json
|
||||
uv run mirofish-agent report-question answer --run ../runs/chip-2036 --request-id req_XXXX --json
|
||||
```
|
||||
|
||||
Full smoke:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
bash scripts/smoke_agent_queue_full.sh
|
||||
bash scripts/smoke_mcp_full.sh
|
||||
python scripts/check_provider_boundaries.py
|
||||
```
|
||||
|
||||
Tool-specific guides:
|
||||
|
||||
- [Codex](./docs/agent-usage/codex.md)
|
||||
- [Claude Code](./docs/agent-usage/claude-code.md)
|
||||
- [Cursor](./docs/agent-usage/cursor.md)
|
||||
- [opencode](./docs/agent-usage/opencode.md)
|
||||
- [MCP](./docs/agent-usage/mcp.md)
|
||||
|
||||
The Flask/Vue UI is preserved as the legacy interactive interface. Legacy API calls that hit `agent_queue` return a structured HTTP 202 `need_agent_response` payload instead of a 500, but full checkpointed agent-mode orchestration remains CLI/MCP-first.
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
# Implementation Summary
|
||||
|
||||
## Changed Areas
|
||||
|
||||
- Added `backend/app/agent_engine/` for strict request/response schemas, filesystem queue, persistent run state, output contracts, shared runner, and CLI.
|
||||
- Added `backend/app/adapters/llm/` with `AgentRuntime`, `agent_queue`, `mock`, legacy `openai_compatible`, and `AgentModelBackendAdapter`.
|
||||
- Added `backend/app/adapters/graph/` with `GraphProvider`, `GraphitiGraphProvider`, `GraphitiCompatibilityStore`, and legacy `ZepGraphProvider`.
|
||||
- Added `backend/app/mcp_server/server.py` with FastMCP lifecycle tools.
|
||||
- Refactored direct model/Zep call sites in LLM client, graph builder, Zep entity/tools/memory facades, OASIS profile/config generators, and simulation scripts.
|
||||
- Tightened agent response validation so `skipped` outputs still match `expected_schema`, `req_*.json` filenames must match response `request_id`, and invalid stage responses create `repair_invalid_json` requests when retry policy allows.
|
||||
- Added stable output schemas for every declared agent task type: `extract_triples`, `generate_ontology`, `generate_oasis_profiles`, `generate_simulation_config`, `simulate_agent_action`, `summarize_round`, `update_memory`, `generate_report`, `answer_followup_question`, `validate_json_output`, and `repair_invalid_json`.
|
||||
- Persisted graph provider overrides in run metadata so `graph build --provider graphiti` survives the `waiting_agent` pause/resume boundary.
|
||||
- Made explicit stage entrypoints idempotent while waiting for agent responses: repeated `graph build`, `simulate start`, and `report generate` calls return the existing request instead of creating duplicates.
|
||||
- Made `responses submit` attach generated `repair_invalid_json` requests to the current waiting stage, so later `resume` reuses the repair request instead of creating duplicates.
|
||||
- Extended `GraphitiCompatibilityStore` Neo4j mode so episodes, agent memory, snapshot export/import, and timeline data stay in the Neo4j-backed compatibility layer instead of falling back to file storage.
|
||||
- Aligned `GraphitiCompatibilityStore` default behavior with production `doctor`: `MIROFISH_GRAPHITI_STORE=auto` uses Neo4j, and offline file storage requires explicit `MIROFISH_GRAPHITI_STORE=file`.
|
||||
- Added CLI and MCP follow-up Q&A through `answer_followup_question`, with GraphProvider retrieval context and queue-validated responses.
|
||||
- Added Web Console generation (`generate_web_console`) producing an interactive HTML page at `runs/<run_id>/artifacts/web/index.html` with embedded artifact data, agent Q&A forms, questionnaire builder, report question input, and API-driven polling. The console gracefully degrades to static data when the Flask backend is offline.
|
||||
- Added `backend/app/api/interaction.py` — Flask Blueprint providing REST endpoints for the Web Console: agent listing, agent Q&A, questionnaire submission, report questions, request polling, response submission, and artifact retrieval. All LLM work routes through `AgentRuntime / agent_queue`.
|
||||
- Added interaction task types: `answer_agent_question`, `answer_agent_questionnaire`, `summarize_questionnaire`, `ask_report_question` with matching output schemas and mock provider coverage.
|
||||
- Added CLI commands: `agents list/show/ask/answer`, `questionnaire send/show`, `report-question ask/answer`, `web generate`.
|
||||
- Added MCP tools: `mirofish_generate_web_console`, `mirofish_list_agents`, `mirofish_get_agent`, `mirofish_ask_agent`, `mirofish_get_agent_answer`, `mirofish_send_questionnaire`, `mirofish_get_questionnaire_result`, `mirofish_ask_report_question`, `mirofish_get_report_question_answer`.
|
||||
- Interaction artifacts are persisted to `runs/<run_id>/artifacts/interactions/agent_questions/`, `interactions/questionnaires/`, and `interactions/report_questions/`.
|
||||
- Added staged workflow support alongside the existing auto workflow. Staged runs pause after `seed_input`, `prediction_requirement`, `simulation_settings`, `graph_build`, `profile_and_config`, and `simulation_run` until the user approves, rejects, updates settings, or reruns a stage.
|
||||
- Added hard simulation settings to CLI/MCP run creation: `rounds`, `round_unit`, `minutes_per_round`, `pause_each_round`, `agent_count`, and `simulation_name`. `rounds` is persisted in `state.json` and no longer depends on natural-language requirement parsing.
|
||||
- Added staged CLI commands under `mirofish-agent stage ...` and matching MCP tools for current-stage inspection, settings updates, stage approval/rejection, and reruns.
|
||||
- Exposed `AgentModelBackendAdapter.last_need_agent_response` so OASIS/CAMEL runtime calls that hit `agent_queue` can be detected by orchestration code even when CAMEL expects a `ChatCompletion` object.
|
||||
- Added a Flask `NeedAgentResponse` handler so legacy API calls return HTTP 202 structured `need_agent_response` payloads instead of generic 500s.
|
||||
- Removed API-key default reads from legacy business facades; provider keys are now validated only inside legacy providers or provider-aware Flask guards.
|
||||
- Moved OpenAI-compatible and Zep Cloud SDK packages out of default backend dependencies and into the optional `legacy` extra, so default agent installs do not require legacy SDKs.
|
||||
- Strengthened `scripts/check_provider_boundaries.py` to reject direct legacy provider imports and Graphiti/Neo4j schema assumptions outside `GraphitiCompatibilityStore`.
|
||||
- Changed ontology Python code generation to use provider-neutral Pydantic base classes instead of emitting graph SDK import strings.
|
||||
- Added tests under `backend/tests/` and smoke/static scripts under `scripts/`.
|
||||
- Added `docker-compose.agent.yml` and `scripts/setup_agent_deps.sh` for local agent dependencies and services.
|
||||
- Updated `.env.example`, `AGENT_KIT.md`, README, and `docs/agent-usage/*`, including opencode usage.
|
||||
|
||||
## Providers
|
||||
|
||||
- `MIROFISH_LLM_PROVIDER=agent_queue`: writes strict request JSON and returns `need_agent_response`.
|
||||
- `MIROFISH_LLM_PROVIDER=mock`: deterministic offline provider for tests.
|
||||
- `MIROFISH_LLM_PROVIDER=openai_compatible`: legacy provider; the OpenAI SDK import is isolated to `backend/app/adapters/llm/openai_compatible.py`.
|
||||
- `MIROFISH_GRAPH_PROVIDER=graphiti`: default agent graph provider.
|
||||
- `MIROFISH_GRAPH_PROVIDER=zep`: legacy provider; Zep SDK imports are isolated to `backend/app/adapters/graph/zep.py`.
|
||||
|
||||
## Dependencies And Local Services
|
||||
|
||||
- Graphiti source code is not copied, cloned, or vendored into this repository.
|
||||
- Graphiti is installed through backend dependency management with the optional `agent` extra: `uv sync --extra agent --group dev`.
|
||||
- The `agent` extra includes `graphiti-core`, `neo4j`, and `mcp`.
|
||||
- Legacy OpenAI-compatible and Zep Cloud SDKs are isolated in the optional `legacy` extra: `uv sync --extra legacy`.
|
||||
- Neo4j is an external local graph database service. Supported non-Docker setup paths are Neo4j Desktop, Homebrew/native install, or an existing Neo4j 5.26+ instance.
|
||||
- Docker Compose remains available through `docker-compose.agent.yml`, but Docker and Docker Compose are optional and are not required by `doctor`.
|
||||
- Ollama is conditional. `doctor` only hard-fails Ollama checks when `MIROFISH_GRAPH_SEARCH_MODE=semantic` or `hybrid` and `MIROFISH_EMBEDDING_PROVIDER=ollama`.
|
||||
- MiroFish only maintains `GraphitiGraphProvider` and `GraphitiCompatibilityStore`; all Graphiti/Neo4j schema assumptions are isolated there.
|
||||
|
||||
## Minimal Demo
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
bash scripts/smoke_agent_queue_full.sh
|
||||
```
|
||||
|
||||
Manual CLI:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
|
||||
uv run mirofish-agent run --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
Staged CLI:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run mirofish-agent create-run \
|
||||
--seed /path/to/seed.md \
|
||||
--requirement "预测未来10年全球芯片能力格局变化" \
|
||||
--output ../runs/chip-2036 \
|
||||
--mode staged \
|
||||
--rounds 10 \
|
||||
--round-unit year \
|
||||
--json
|
||||
uv run mirofish-agent stage status --run ../runs/chip-2036 --json
|
||||
uv run mirofish-agent stage approve --run ../runs/chip-2036 --json
|
||||
uv run mirofish-agent resume --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
When `need_agent_response` is returned, write the response file and run:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
|
||||
uv run mirofish-agent resume --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
## Codex Triple Extraction
|
||||
|
||||
Use this prompt when processing an `extract_triples` request:
|
||||
|
||||
```text
|
||||
读取 request_file,严格按照 expected_schema 生成 response_file。只输出 JSON,不要添加解释。每个 triple 必须包含 subject、predicate、object、fact、evidence、confidence。不要编造现实种子中没有的事实。无法确认的关系不要写入,或将 confidence 降低。
|
||||
```
|
||||
|
||||
## Agent Engine Coverage
|
||||
|
||||
Implemented full CLI/MCP lifecycle for:
|
||||
|
||||
- ontology request
|
||||
- triple extraction request
|
||||
- profile request
|
||||
- simulation config request
|
||||
- batched simulation action request
|
||||
- report request
|
||||
- follow-up Q&A request
|
||||
- agent question request
|
||||
- agent questionnaire request
|
||||
- questionnaire summary request
|
||||
- report question request
|
||||
- `report.md`, `verdict.json`, `timeline.json`, `graph_snapshot.json`
|
||||
- interactive Web Console at `artifacts/web/index.html`
|
||||
|
||||
Staged workflow maps to the original UI-style process:
|
||||
|
||||
- `seed_input`: saves and summarizes `seed.md`.
|
||||
- `prediction_requirement`: records the prediction target.
|
||||
- `simulation_settings`: records hard settings such as `rounds=10` and `round_unit=year`.
|
||||
- `graph_build`: runs ontology and triple extraction requests, then writes Graphiti/Neo4j.
|
||||
- `profile_and_config`: generates profiles and simulation config; config rounds are forcibly taken from `simulation_settings`.
|
||||
- `simulation_run`: runs the configured number of rounds and updates simulation progress.
|
||||
- `report_generation`: writes report artifacts and records actual rounds in `verdict.json` and `timeline.json`.
|
||||
- `followup_question`: uses AgentRuntime plus GraphProvider retrieval.
|
||||
|
||||
Auto mode remains available for scripts and smoke tests. Staged mode is intended for QoderWork, Codex, Claude Code, Cursor, and other desktop agents that need user confirmation between phases.
|
||||
|
||||
Legacy Flask/Vue UI is preserved but not fully upgraded to drive every checkpointed `agent_queue` stage. Legacy API calls return structured HTTP 202 `need_agent_response` payloads when model work needs an external agent. Full agent-mode orchestration is CLI/MCP-first.
|
||||
|
||||
## Graphiti Limits
|
||||
|
||||
`GraphitiCompatibilityStore` is a version-sensitive compatibility layer. It provides a no-LLM triplet write path and hides all Neo4j/Cypher/schema assumptions from business code. When Graphiti’s public fact triple API is available and stable in the installed version, this layer can be adapted internally without changing `GraphProvider`.
|
||||
|
||||
Offline tests use explicit `MIROFISH_GRAPHITI_STORE=file`. Production Graphiti/Neo4j mode uses `MIROFISH_GRAPHITI_STORE=auto` or `neo4j` with `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`, and `NEO4J_DATABASE`.
|
||||
|
||||
## Verification Results
|
||||
|
||||
Latest local verification:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend && uv run pytest -q
|
||||
# 105 passed, 710 warnings
|
||||
|
||||
cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_agent_queue_full.sh
|
||||
# CLI full agent_queue smoke passed, including follow-up Q&A
|
||||
|
||||
cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_agent_queue_staged.sh
|
||||
# CLI staged agent_queue smoke passed, including stage approvals and rounds=10 artifacts
|
||||
|
||||
cd /Users/leaf/Documents/future/MiroFish && bash scripts/smoke_mcp_full.sh
|
||||
# MCP lifecycle smoke passed, including staged tool/schema checks, request validation, graph search/export, doctor, artifacts, and follow-up Q&A
|
||||
|
||||
cd /Users/leaf/Documents/future/MiroFish && python scripts/check_provider_boundaries.py
|
||||
# Provider boundary check passed
|
||||
|
||||
cd /Users/leaf/Documents/future/MiroFish && bash -n scripts/setup_agent_deps.sh
|
||||
# script syntax check passed
|
||||
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend && uv run mirofish-agent doctor --json
|
||||
# status: ok; Neo4j 2025.04.0 connectable; hard_failures: []
|
||||
|
||||
# Live Graphiti/Neo4j provider smoke:
|
||||
# add_triples/search/export_snapshot/clear_run_graph passed with MIROFISH_GRAPHITI_STORE=auto.
|
||||
|
||||
# Live CLI Graphiti/Neo4j smoke:
|
||||
# CLI Neo4j agent_queue smoke passed with graph search and final artifacts.
|
||||
```
|
||||
|
||||
Frontend build must be run from the detected frontend package directory:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/frontend && npm run build
|
||||
# built successfully; Vite reported only chunk-size/dynamic-import warnings
|
||||
```
|
||||
|
||||
Doctor result in the current shell:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend && uv run mirofish-agent doctor --json
|
||||
# status: ok
|
||||
# graphiti_package: ok
|
||||
# mcp_package: ok
|
||||
# neo4j_package: ok
|
||||
# neo4j_connectable: ok, bolt://localhost:7687
|
||||
# neo4j_version_supported: ok, 2025.04.0
|
||||
# docker: optional warning only when Docker is not installed
|
||||
# docker_compose: optional warning only when Docker Compose is not installed
|
||||
# graph_search_mode: fulltext
|
||||
# embedding_provider: none
|
||||
# ollama_connectable: optional; fulltext search does not require Ollama
|
||||
# ollama_embedding_model: optional
|
||||
# hard_failures: []
|
||||
```
|
||||
|
||||
Setup helper result in the current shell:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish && bash scripts/setup_agent_deps.sh --neo4j desktop --skip-services
|
||||
# Python agent dependencies installed/audited
|
||||
# warning: Docker optional, skipped
|
||||
# ok: Neo4j version 2025.04.0
|
||||
# warning: Ollama optional, skipped because MIROFISH_GRAPH_SEARCH_MODE=fulltext uses no semantic embedding
|
||||
# ok: agent dependency and required service checks completed
|
||||
```
|
||||
|
||||
This means the offline no-LLM compatibility path and the production Graphiti/Neo4j path are both verified. Docker is not required. Ollama is only required for semantic/hybrid graph search when `MIROFISH_EMBEDDING_PROVIDER=ollama`.
|
||||
46
README.md
46
README.md
|
|
@ -93,6 +93,26 @@ Click the image to watch MiroFish's deep prediction of the lost ending based on
|
|||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Agent Engine Mode
|
||||
|
||||
MiroFish now supports CLI/MCP-driven agent mode. In this mode desktop agents process model tasks through request/response files, so `LLM_API_KEY`, `OPENAI_API_KEY`, and `ZEP_API_KEY` are not required.
|
||||
|
||||
See [AGENT_KIT.md](./AGENT_KIT.md), [docs/agent-usage/codex.md](./docs/agent-usage/codex.md), and [docs/agent-usage/mcp.md](./docs/agent-usage/mcp.md).
|
||||
|
||||
Install agent engine dependencies from the backend when using CLI/MCP mode:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv sync --extra agent --group dev
|
||||
```
|
||||
|
||||
Legacy OpenAI-compatible and Zep Cloud SDKs are optional:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv sync --extra legacy
|
||||
```
|
||||
|
||||
### Option 1: Source Code Deployment (Recommended)
|
||||
|
||||
#### Prerequisites
|
||||
|
|
@ -109,10 +129,30 @@ Click the image to watch MiroFish's deep prediction of the lost ending based on
|
|||
# Copy the example configuration file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit the .env file and fill in the required API keys
|
||||
# Edit the .env file. Agent mode does not require model or Zep API keys.
|
||||
```
|
||||
|
||||
**Required Environment Variables:**
|
||||
**Default Agent Environment Variables:**
|
||||
|
||||
```env
|
||||
MIROFISH_MODE=agent
|
||||
MIROFISH_LLM_PROVIDER=agent_queue
|
||||
MIROFISH_GRAPH_PROVIDER=graphiti
|
||||
MIROFISH_RUNS_DIR=./runs
|
||||
|
||||
NEO4J_URI=bolt://localhost:7687
|
||||
NEO4J_USER=neo4j
|
||||
NEO4J_PASSWORD=password
|
||||
NEO4J_DATABASE=neo4j
|
||||
|
||||
MIROFISH_GRAPH_SEARCH_MODE=fulltext
|
||||
MIROFISH_EMBEDDING_PROVIDER=none
|
||||
```
|
||||
|
||||
**Legacy Compatibility Variables:**
|
||||
|
||||
These are required only when explicitly using `MIROFISH_LLM_PROVIDER=openai_compatible` or `MIROFISH_GRAPH_PROVIDER=zep`.
|
||||
Install the optional legacy SDKs with `uv sync --extra legacy`.
|
||||
|
||||
```env
|
||||
# LLM API Configuration (supports any LLM API with OpenAI SDK format)
|
||||
|
|
@ -200,4 +240,4 @@ MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interacti
|
|||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import warnings
|
|||
# 需要在所有其他导入之前设置
|
||||
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
|
||||
|
||||
from flask import Flask, request
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
|
||||
from .config import Config
|
||||
from .adapters.llm.agent_runtime import NeedAgentResponse
|
||||
from .utils.logger import setup_logger, get_logger
|
||||
|
||||
|
||||
|
|
@ -61,12 +62,18 @@ def create_app(config_class=Config):
|
|||
logger = get_logger('mirofish.request')
|
||||
logger.debug(f"响应: {response.status_code}")
|
||||
return response
|
||||
|
||||
@app.errorhandler(NeedAgentResponse)
|
||||
def handle_need_agent_response(error):
|
||||
"""Expose agent_queue waits as structured API responses instead of 500s."""
|
||||
return jsonify(error.result.to_dict()), 202
|
||||
|
||||
# 注册蓝图
|
||||
from .api import graph_bp, simulation_bp, report_bp
|
||||
from .api import graph_bp, simulation_bp, report_bp, interaction_bp
|
||||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
||||
app.register_blueprint(interaction_bp, url_prefix='/api/interaction')
|
||||
|
||||
# 健康检查
|
||||
@app.route('/health')
|
||||
|
|
@ -77,4 +84,3 @@ def create_app(config_class=Config):
|
|||
logger.info("MiroFish Backend 启动完成")
|
||||
|
||||
return app
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""Provider adapters for model and graph backends."""
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""Graph provider adapters."""
|
||||
|
||||
from .base import GraphProvider, GraphTriple
|
||||
from .factory import create_graph_provider
|
||||
|
||||
__all__ = ["GraphProvider", "GraphTriple", "create_graph_provider"]
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"""Unified graph provider contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class GraphTriple(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
subject: str
|
||||
predicate: str
|
||||
object: str
|
||||
fact: str
|
||||
valid_at: Optional[str] = None
|
||||
invalid_at: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
source_file: Optional[str] = None
|
||||
evidence: str
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GraphProvider(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def add_triples(self, run_id: str, triples: List[Dict[str, Any] | GraphTriple]) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def search(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"""Graph provider factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .base import GraphProvider
|
||||
from .graphiti import GraphitiGraphProvider
|
||||
from .zep import ZepGraphProvider
|
||||
|
||||
|
||||
def create_graph_provider(provider: Optional[str] = None) -> GraphProvider:
|
||||
provider_name = provider or os.environ.get("MIROFISH_GRAPH_PROVIDER")
|
||||
if not provider_name:
|
||||
mode = os.environ.get("MIROFISH_MODE", "agent")
|
||||
provider_name = "graphiti" if mode == "agent" else "zep"
|
||||
|
||||
provider_name = provider_name.lower()
|
||||
if provider_name == "graphiti":
|
||||
return GraphitiGraphProvider()
|
||||
if provider_name == "zep":
|
||||
return ZepGraphProvider()
|
||||
raise ValueError(f"Unsupported MIROFISH_GRAPH_PROVIDER: {provider_name}")
|
||||
|
|
@ -0,0 +1,642 @@
|
|||
"""Graphiti provider with no-LLM triplet write compatibility.
|
||||
|
||||
The compatibility store intentionally hides all Neo4j/Cypher and Graphiti
|
||||
schema assumptions from business code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import GraphProvider, GraphTriple
|
||||
|
||||
|
||||
class GraphitiDependencyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GraphitiCompatibilityStore:
|
||||
"""Version-sensitive Graphiti/Neo4j no-LLM triplet store."""
|
||||
|
||||
def __init__(self, *, store_path: str | None = None, require_neo4j: bool = False):
|
||||
self.store_mode = os.environ.get("MIROFISH_GRAPHITI_STORE", "auto").lower()
|
||||
if self.store_mode not in {"auto", "neo4j", "file"}:
|
||||
raise GraphitiDependencyError("MIROFISH_GRAPHITI_STORE must be auto, neo4j, or file")
|
||||
self.neo4j_uri = os.environ.get("NEO4J_URI")
|
||||
if self.store_mode in {"auto", "neo4j"} and not self.neo4j_uri:
|
||||
self.neo4j_uri = "bolt://localhost:7687"
|
||||
self.neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||||
self.neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
|
||||
self.neo4j_database = os.environ.get("NEO4J_DATABASE", "neo4j")
|
||||
self.store_path = Path(store_path or os.environ.get("MIROFISH_GRAPHITI_COMPAT_PATH", "./runs/.graphiti_compat_store.json"))
|
||||
self.driver = None
|
||||
|
||||
should_use_neo4j = self.store_mode in {"auto", "neo4j"}
|
||||
if should_use_neo4j:
|
||||
spec = importlib.util.find_spec("neo4j")
|
||||
if not spec:
|
||||
raise GraphitiDependencyError(
|
||||
"neo4j Python package is required for GraphitiCompatibilityStore neo4j/auto mode; "
|
||||
"set MIROFISH_GRAPHITI_STORE=file for offline compatibility tests"
|
||||
)
|
||||
else:
|
||||
from neo4j import GraphDatabase
|
||||
|
||||
self.driver = GraphDatabase.driver(
|
||||
self.neo4j_uri,
|
||||
auth=(self.neo4j_user, self.neo4j_password),
|
||||
)
|
||||
self._ensure_constraints()
|
||||
|
||||
if not self.driver:
|
||||
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self.store_path.exists():
|
||||
self._write_file_store({"runs": {}})
|
||||
|
||||
def close(self) -> None:
|
||||
if self.driver:
|
||||
self.driver.close()
|
||||
|
||||
def normalize_entity(self, value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value.strip()).casefold()
|
||||
|
||||
def add_triplet(self, run_id: str, triple: GraphTriple) -> Dict[str, Any]:
|
||||
if self.driver:
|
||||
return self._add_triplet_neo4j(run_id, triple)
|
||||
return self._add_triplet_file(run_id, triple)
|
||||
|
||||
def add_triples(self, run_id: str, triples: List[GraphTriple]) -> Dict[str, Any]:
|
||||
for triple in triples:
|
||||
self.add_triplet(run_id, triple)
|
||||
return {"provider": "graphiti", "store": "neo4j" if self.driver else "file", "triples_added": len(triples)}
|
||||
|
||||
def get_or_create_entity_node(self, run_id: str, name: str, labels: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
normalized = self.normalize_entity(name)
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
record = session.run(
|
||||
"""
|
||||
MERGE (e:MiroFishEntity {group_id: $run_id, normalized_name: $normalized})
|
||||
ON CREATE SET e.uuid = $uuid, e.name = $name, e.labels = $labels, e.created_at = datetime()
|
||||
ON MATCH SET e.name = coalesce(e.name, $name)
|
||||
RETURN e
|
||||
""",
|
||||
run_id=run_id,
|
||||
normalized=normalized,
|
||||
uuid=self._entity_uuid(run_id, normalized),
|
||||
name=name,
|
||||
labels=labels or ["Entity"],
|
||||
).single()
|
||||
node = record["e"]
|
||||
return self._to_jsonable(dict(node.items()))
|
||||
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
entities = run["entities"]
|
||||
if normalized not in entities:
|
||||
entities[normalized] = {
|
||||
"uuid": self._entity_uuid(run_id, normalized),
|
||||
"name": name,
|
||||
"normalized_name": normalized,
|
||||
"labels": labels or ["Entity"],
|
||||
"summary": "",
|
||||
"attributes": {},
|
||||
}
|
||||
self._write_file_store(data)
|
||||
return entities[normalized]
|
||||
|
||||
def search_facts(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
result = session.run(
|
||||
"""
|
||||
MATCH (s:MiroFishEntity {group_id: $run_id})-[r:MIROFISH_FACT]->(o:MiroFishEntity {group_id: $run_id})
|
||||
WHERE toLower(r.fact) CONTAINS toLower($search_query)
|
||||
OR toLower(s.name) CONTAINS toLower($search_query)
|
||||
OR toLower(o.name) CONTAINS toLower($search_query)
|
||||
RETURN s, r, o
|
||||
LIMIT $limit
|
||||
""",
|
||||
run_id=run_id,
|
||||
search_query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return [self._record_to_fact(row) for row in result]
|
||||
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
query_lower = query.casefold()
|
||||
terms = self._query_terms(query_lower)
|
||||
matches = []
|
||||
for triple in run["triples"].values():
|
||||
haystack = " ".join(
|
||||
[
|
||||
triple.get("subject", ""),
|
||||
triple.get("predicate", ""),
|
||||
triple.get("object", ""),
|
||||
triple.get("fact", ""),
|
||||
triple.get("evidence", ""),
|
||||
]
|
||||
).casefold()
|
||||
compact_haystack = re.sub(r"\W+", "", haystack)
|
||||
if query_lower in haystack or any(term in haystack or term in compact_haystack for term in terms):
|
||||
matches.append(triple)
|
||||
return matches[:limit]
|
||||
|
||||
def search_nodes(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
nodes = self.list_entities(run_id)
|
||||
query_lower = query.casefold()
|
||||
return [node for node in nodes if query_lower in node.get("name", "").casefold()][:limit]
|
||||
|
||||
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
|
||||
normalized = self.normalize_entity(entity)
|
||||
max_depth = max(1, min(int(depth), 10))
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH path=(start:MiroFishEntity {{group_id: $run_id, normalized_name: $normalized}})-[*1..{max_depth}]-(n:MiroFishEntity {{group_id: $run_id}})
|
||||
RETURN nodes(path) AS nodes, relationships(path) AS relationships
|
||||
LIMIT 100
|
||||
""",
|
||||
run_id=run_id,
|
||||
normalized=normalized,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"nodes": [self._to_jsonable(dict(n.items())) for n in row["nodes"]],
|
||||
"relationships": [self._to_jsonable(dict(r.items())) for r in row["relationships"]],
|
||||
}
|
||||
for row in result
|
||||
]
|
||||
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
frontier = {normalized}
|
||||
seen = {normalized}
|
||||
facts = []
|
||||
for _ in range(max(depth, 1)):
|
||||
next_frontier = set()
|
||||
for triple in run["triples"].values():
|
||||
subject = self.normalize_entity(triple["subject"])
|
||||
obj = self.normalize_entity(triple["object"])
|
||||
if subject in frontier or obj in frontier:
|
||||
facts.append(triple)
|
||||
if subject not in seen:
|
||||
next_frontier.add(subject)
|
||||
if obj not in seen:
|
||||
next_frontier.add(obj)
|
||||
seen.update(next_frontier)
|
||||
frontier = next_frontier
|
||||
if not frontier:
|
||||
break
|
||||
return facts
|
||||
|
||||
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
result = session.run(
|
||||
"MATCH (e:MiroFishEntity {group_id: $run_id}) RETURN e ORDER BY e.name",
|
||||
run_id=run_id,
|
||||
)
|
||||
return [self._to_jsonable(dict(row["e"].items())) for row in result]
|
||||
|
||||
data = self._read_file_store()
|
||||
return list(self._file_run(data, run_id)["entities"].values())
|
||||
|
||||
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
|
||||
normalized = self.normalize_entity(entity)
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
row = session.run(
|
||||
"MATCH (e:MiroFishEntity {group_id: $run_id, normalized_name: $normalized}) RETURN e",
|
||||
run_id=run_id,
|
||||
normalized=normalized,
|
||||
).single()
|
||||
return self._to_jsonable(dict(row["e"].items())) if row else None
|
||||
|
||||
data = self._read_file_store()
|
||||
return self._file_run(data, run_id)["entities"].get(normalized)
|
||||
|
||||
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
current = self.get_agent_memory(run_id, agent_id)
|
||||
current.update(memory)
|
||||
return self.write_agent_memory(run_id, agent_id, current)
|
||||
|
||||
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
row = session.run(
|
||||
"""
|
||||
MATCH (m:MiroFishAgentMemory {group_id: $run_id, agent_id: $agent_id})
|
||||
RETURN m.memory_json AS memory_json
|
||||
""",
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
).single()
|
||||
if not row:
|
||||
return {}
|
||||
return json.loads(row["memory_json"] or "{}")
|
||||
|
||||
data = self._read_file_store()
|
||||
return self._file_run(data, run_id)["memory"].get(agent_id, {})
|
||||
|
||||
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
session.run(
|
||||
"""
|
||||
MERGE (m:MiroFishAgentMemory {group_id: $run_id, agent_id: $agent_id})
|
||||
ON CREATE SET m.uuid = $uuid, m.created_at = datetime()
|
||||
SET m.memory_json = $memory_json, m.updated_at = datetime()
|
||||
""",
|
||||
run_id=run_id,
|
||||
agent_id=agent_id,
|
||||
uuid=self._memory_uuid(run_id, agent_id),
|
||||
memory_json=json.dumps(memory, ensure_ascii=False),
|
||||
)
|
||||
return {"run_id": run_id, "agent_id": agent_id, "memory": memory}
|
||||
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
run["memory"][agent_id] = memory
|
||||
self._write_file_store(data)
|
||||
return {"run_id": run_id, "agent_id": agent_id, "memory": memory}
|
||||
|
||||
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
snapshot = self.snapshot(run_id)
|
||||
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return {"output_path": str(path), "nodes": len(snapshot["entities"]), "triples": len(snapshot["triples"])}
|
||||
|
||||
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
|
||||
snapshot = json.loads(Path(input_path).read_text(encoding="utf-8"))
|
||||
if self.driver:
|
||||
self.clear_run_graph(run_id)
|
||||
for entity in snapshot.get("entities", []):
|
||||
name = entity.get("name")
|
||||
if name:
|
||||
self.get_or_create_entity_node(run_id, name, entity.get("labels"))
|
||||
triples = [GraphTriple.model_validate(self._clean_triple_payload(triple)) for triple in snapshot.get("triples", [])]
|
||||
self.add_triples(run_id, triples)
|
||||
for episode in snapshot.get("episodes", []):
|
||||
self.add_episode(run_id, episode.get("content", ""), episode.get("metadata", {}))
|
||||
for agent_id, memory in snapshot.get("memory", {}).items():
|
||||
self.write_agent_memory(run_id, agent_id, memory)
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"imported": True,
|
||||
"entities": len(snapshot.get("entities", [])),
|
||||
"triples": len(triples),
|
||||
"episodes": len(snapshot.get("episodes", [])),
|
||||
}
|
||||
|
||||
data = self._read_file_store()
|
||||
data["runs"][run_id] = {
|
||||
"entities": {self.normalize_entity(e["name"]): e for e in snapshot.get("entities", [])},
|
||||
"triples": {self._triple_uuid(run_id, t): t for t in snapshot.get("triples", [])},
|
||||
"episodes": snapshot.get("episodes", []),
|
||||
"memory": snapshot.get("memory", {}),
|
||||
}
|
||||
self._write_file_store(data)
|
||||
return {"run_id": run_id, "imported": True}
|
||||
|
||||
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
session.run(
|
||||
"""
|
||||
MATCH (n {group_id: $run_id})
|
||||
DETACH DELETE n
|
||||
""",
|
||||
run_id=run_id,
|
||||
)
|
||||
return {"run_id": run_id, "cleared": True}
|
||||
|
||||
data = self._read_file_store()
|
||||
data["runs"].pop(run_id, None)
|
||||
self._write_file_store(data)
|
||||
return {"run_id": run_id, "cleared": True}
|
||||
|
||||
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
metadata_json = json.dumps(metadata or {}, ensure_ascii=False, sort_keys=True)
|
||||
episode_uuid = self._episode_uuid(run_id, content, metadata_json)
|
||||
if self.driver:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
session.run(
|
||||
"""
|
||||
MERGE (ep:MiroFishEpisode {group_id: $run_id, uuid: $uuid})
|
||||
ON CREATE SET ep.created_at = datetime()
|
||||
SET ep.content = $content,
|
||||
ep.metadata_json = $metadata_json,
|
||||
ep.updated_at = datetime()
|
||||
""",
|
||||
run_id=run_id,
|
||||
uuid=episode_uuid,
|
||||
content=content,
|
||||
metadata_json=metadata_json,
|
||||
)
|
||||
return {"run_id": run_id, "uuid": episode_uuid}
|
||||
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
episode = {"content": content, "metadata": metadata or {}}
|
||||
run["episodes"].append(episode)
|
||||
self._write_file_store(data)
|
||||
return {"run_id": run_id, "episode_index": len(run["episodes"]) - 1}
|
||||
|
||||
def snapshot(self, run_id: str) -> Dict[str, Any]:
|
||||
if self.driver:
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"provider": "graphiti",
|
||||
"store": "neo4j",
|
||||
"entities": self.list_entities(run_id),
|
||||
"triples": self._list_facts_neo4j(run_id),
|
||||
"episodes": self._list_episodes_neo4j(run_id),
|
||||
"memory": self._list_memory_neo4j(run_id),
|
||||
}
|
||||
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"provider": "graphiti",
|
||||
"store": "neo4j" if self.driver else "file",
|
||||
"entities": list(run["entities"].values()),
|
||||
"triples": list(run["triples"].values()),
|
||||
"episodes": run["episodes"],
|
||||
"memory": run["memory"],
|
||||
}
|
||||
|
||||
def timeline(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
triples = self.snapshot(run_id)["triples"]
|
||||
return sorted(
|
||||
[
|
||||
{
|
||||
"valid_at": triple.get("valid_at"),
|
||||
"invalid_at": triple.get("invalid_at"),
|
||||
"fact": triple.get("fact"),
|
||||
"source": triple.get("source"),
|
||||
}
|
||||
for triple in triples
|
||||
],
|
||||
key=lambda item: item.get("valid_at") or "",
|
||||
)
|
||||
|
||||
def _ensure_constraints(self) -> None:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
session.run(
|
||||
"CREATE CONSTRAINT mirofish_entity IF NOT EXISTS FOR (e:MiroFishEntity) REQUIRE (e.group_id, e.normalized_name) IS UNIQUE"
|
||||
)
|
||||
session.run(
|
||||
"CREATE CONSTRAINT mirofish_episode IF NOT EXISTS FOR (ep:MiroFishEpisode) REQUIRE (ep.group_id, ep.uuid) IS UNIQUE"
|
||||
)
|
||||
session.run(
|
||||
"CREATE CONSTRAINT mirofish_agent_memory IF NOT EXISTS FOR (m:MiroFishAgentMemory) REQUIRE (m.group_id, m.agent_id) IS UNIQUE"
|
||||
)
|
||||
|
||||
def _list_facts_neo4j(self, run_id: str, limit: int = 100_000) -> List[Dict[str, Any]]:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
result = session.run(
|
||||
"""
|
||||
MATCH (s:MiroFishEntity {group_id: $run_id})-[r:MIROFISH_FACT]->(o:MiroFishEntity {group_id: $run_id})
|
||||
RETURN s, r, o
|
||||
LIMIT $limit
|
||||
""",
|
||||
run_id=run_id,
|
||||
limit=limit,
|
||||
)
|
||||
return [self._record_to_fact(row) for row in result]
|
||||
|
||||
def _list_episodes_neo4j(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
result = session.run(
|
||||
"""
|
||||
MATCH (ep:MiroFishEpisode {group_id: $run_id})
|
||||
RETURN ep
|
||||
ORDER BY ep.created_at
|
||||
""",
|
||||
run_id=run_id,
|
||||
)
|
||||
episodes = []
|
||||
for row in result:
|
||||
episode = dict(row["ep"].items())
|
||||
metadata = json.loads(episode.pop("metadata_json", "{}") or "{}")
|
||||
episodes.append({"content": episode.get("content", ""), "metadata": metadata, "uuid": episode.get("uuid")})
|
||||
return episodes
|
||||
|
||||
def _list_memory_neo4j(self, run_id: str) -> Dict[str, Any]:
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
result = session.run(
|
||||
"""
|
||||
MATCH (m:MiroFishAgentMemory {group_id: $run_id})
|
||||
RETURN m.agent_id AS agent_id, properties(m) AS props
|
||||
""",
|
||||
run_id=run_id,
|
||||
)
|
||||
return {row["agent_id"]: json.loads((row["props"] or {}).get("memory_json") or "{}") for row in result}
|
||||
|
||||
def _add_triplet_neo4j(self, run_id: str, triple: GraphTriple) -> Dict[str, Any]:
|
||||
subject_norm = self.normalize_entity(triple.subject)
|
||||
object_norm = self.normalize_entity(triple.object)
|
||||
triple_id = self._triple_uuid(run_id, triple.model_dump())
|
||||
with self.driver.session(database=self.neo4j_database) as session:
|
||||
session.run(
|
||||
"""
|
||||
MERGE (s:MiroFishEntity {group_id: $run_id, normalized_name: $subject_norm})
|
||||
ON CREATE SET s.uuid = $subject_uuid, s.name = $subject, s.labels = ['Entity'], s.created_at = datetime()
|
||||
MERGE (o:MiroFishEntity {group_id: $run_id, normalized_name: $object_norm})
|
||||
ON CREATE SET o.uuid = $object_uuid, o.name = $object, o.labels = ['Entity'], o.created_at = datetime()
|
||||
MERGE (s)-[r:MIROFISH_FACT {uuid: $triple_id, group_id: $run_id}]->(o)
|
||||
SET r.predicate = $predicate,
|
||||
r.fact = $fact,
|
||||
r.valid_at = $valid_at,
|
||||
r.invalid_at = $invalid_at,
|
||||
r.source = $source,
|
||||
r.source_file = $source_file,
|
||||
r.evidence = $evidence,
|
||||
r.confidence = $confidence,
|
||||
r.metadata_json = $metadata_json
|
||||
""",
|
||||
run_id=run_id,
|
||||
subject_norm=subject_norm,
|
||||
object_norm=object_norm,
|
||||
subject_uuid=self._entity_uuid(run_id, subject_norm),
|
||||
object_uuid=self._entity_uuid(run_id, object_norm),
|
||||
subject=triple.subject,
|
||||
object=triple.object,
|
||||
triple_id=triple_id,
|
||||
predicate=triple.predicate,
|
||||
fact=triple.fact,
|
||||
valid_at=triple.valid_at,
|
||||
invalid_at=triple.invalid_at,
|
||||
source=triple.source,
|
||||
source_file=triple.source_file,
|
||||
evidence=triple.evidence,
|
||||
confidence=triple.confidence,
|
||||
metadata_json=json.dumps(triple.metadata, ensure_ascii=False),
|
||||
)
|
||||
return {"uuid": triple_id}
|
||||
|
||||
def _add_triplet_file(self, run_id: str, triple: GraphTriple) -> Dict[str, Any]:
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
self.get_or_create_entity_node(run_id, triple.subject)
|
||||
self.get_or_create_entity_node(run_id, triple.object)
|
||||
data = self._read_file_store()
|
||||
run = self._file_run(data, run_id)
|
||||
triple_data = triple.model_dump()
|
||||
triple_data["uuid"] = self._triple_uuid(run_id, triple_data)
|
||||
run["triples"][triple_data["uuid"]] = triple_data
|
||||
self._write_file_store(data)
|
||||
return {"uuid": triple_data["uuid"]}
|
||||
|
||||
def _record_to_fact(self, row: Any) -> Dict[str, Any]:
|
||||
rel = dict(row["r"].items())
|
||||
return {
|
||||
"subject": row["s"].get("name"),
|
||||
"predicate": rel.get("predicate"),
|
||||
"object": row["o"].get("name"),
|
||||
"fact": rel.get("fact"),
|
||||
"valid_at": rel.get("valid_at"),
|
||||
"invalid_at": rel.get("invalid_at"),
|
||||
"source": rel.get("source"),
|
||||
"source_file": rel.get("source_file"),
|
||||
"evidence": rel.get("evidence"),
|
||||
"confidence": rel.get("confidence"),
|
||||
"metadata": json.loads(rel.get("metadata_json") or "{}"),
|
||||
"uuid": rel.get("uuid"),
|
||||
}
|
||||
|
||||
def _to_jsonable(self, value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: self._to_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [self._to_jsonable(item) for item in value]
|
||||
if hasattr(value, "iso_format"):
|
||||
return value.iso_format()
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
def _query_terms(self, query: str) -> List[str]:
|
||||
terms = {query}
|
||||
terms.update(token for token in re.split(r"\s+", query) if len(token) >= 2)
|
||||
for chunk in re.findall(r"[\w\u4e00-\u9fff]+", query):
|
||||
if len(chunk) >= 2:
|
||||
terms.add(chunk)
|
||||
if len(chunk) >= 5:
|
||||
max_size = min(12, len(chunk))
|
||||
for size in range(max_size, 3, -1):
|
||||
for start in range(0, len(chunk) - size + 1):
|
||||
terms.add(chunk[start : start + size])
|
||||
return sorted(terms, key=len, reverse=True)
|
||||
|
||||
def _entity_uuid(self, run_id: str, normalized: str) -> str:
|
||||
return hashlib.sha256(f"{run_id}:entity:{normalized}".encode("utf-8")).hexdigest()
|
||||
|
||||
def _episode_uuid(self, run_id: str, content: str, metadata_json: str) -> str:
|
||||
return hashlib.sha256(f"{run_id}:episode:{content}:{metadata_json}".encode("utf-8")).hexdigest()
|
||||
|
||||
def _memory_uuid(self, run_id: str, agent_id: str) -> str:
|
||||
return hashlib.sha256(f"{run_id}:memory:{agent_id}".encode("utf-8")).hexdigest()
|
||||
|
||||
def _clean_triple_payload(self, triple: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: triple.get(key) for key in GraphTriple.model_fields}
|
||||
|
||||
def _triple_uuid(self, run_id: str, triple: Dict[str, Any] | GraphTriple) -> str:
|
||||
payload = triple.model_dump() if isinstance(triple, GraphTriple) else triple
|
||||
stable = json.dumps(
|
||||
{
|
||||
"subject": self.normalize_entity(payload["subject"]),
|
||||
"predicate": payload["predicate"],
|
||||
"object": self.normalize_entity(payload["object"]),
|
||||
"fact": payload["fact"],
|
||||
"valid_at": payload.get("valid_at"),
|
||||
"invalid_at": payload.get("invalid_at"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(f"{run_id}:triple:{stable}".encode("utf-8")).hexdigest()
|
||||
|
||||
def _file_run(self, data: Dict[str, Any], run_id: str) -> Dict[str, Any]:
|
||||
return data.setdefault("runs", {}).setdefault(
|
||||
run_id,
|
||||
{"entities": {}, "triples": {}, "episodes": [], "memory": {}},
|
||||
)
|
||||
|
||||
def _read_file_store(self) -> Dict[str, Any]:
|
||||
if not self.store_path.exists():
|
||||
return {"runs": {}}
|
||||
return json.loads(self.store_path.read_text(encoding="utf-8"))
|
||||
|
||||
def _write_file_store(self, data: Dict[str, Any]) -> None:
|
||||
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.store_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
class GraphitiGraphProvider(GraphProvider):
|
||||
name = "graphiti"
|
||||
|
||||
def __init__(self, store: Optional[GraphitiCompatibilityStore] = None, *, require_graphiti_package: bool = False):
|
||||
if require_graphiti_package and importlib.util.find_spec("graphiti_core") is None:
|
||||
raise GraphitiDependencyError(
|
||||
"graphiti_core is not installed. Install Graphiti or use the no-LLM compatibility store."
|
||||
)
|
||||
self.store = store or GraphitiCompatibilityStore()
|
||||
|
||||
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
return self.store.add_episode(run_id, content, metadata)
|
||||
|
||||
def add_triples(self, run_id: str, triples: List[Dict[str, Any] | GraphTriple]) -> Dict[str, Any]:
|
||||
parsed = [triple if isinstance(triple, GraphTriple) else GraphTriple.model_validate(triple) for triple in triples]
|
||||
return self.store.add_triples(run_id, parsed)
|
||||
|
||||
def search(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
facts = self.store.search_facts(run_id, query, limit)
|
||||
if len(facts) < limit:
|
||||
facts.extend({"node": node} for node in self.store.search_nodes(run_id, query, limit - len(facts)))
|
||||
return facts[:limit]
|
||||
|
||||
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
|
||||
return self.store.neighbors(run_id, entity, depth)
|
||||
|
||||
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
return self.store.list_entities(run_id)
|
||||
|
||||
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
|
||||
return self.store.get_entity(run_id, entity)
|
||||
|
||||
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return self.store.update_memory(run_id, agent_id, memory)
|
||||
|
||||
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
|
||||
return self.store.get_agent_memory(run_id, agent_id)
|
||||
|
||||
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return self.store.write_agent_memory(run_id, agent_id, memory)
|
||||
|
||||
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
|
||||
return self.store.export_snapshot(run_id, output_path)
|
||||
|
||||
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
|
||||
return self.store.import_snapshot(run_id, input_path)
|
||||
|
||||
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
|
||||
return self.store.clear_run_graph(run_id)
|
||||
|
||||
def export_timeline(self, run_id: str, output_path: str) -> Dict[str, Any]:
|
||||
timeline = self.store.timeline(run_id)
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(timeline, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return {"output_path": str(path), "events": len(timeline)}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
"""Legacy Zep Cloud graph provider.
|
||||
|
||||
This is the only backend/app path allowed to import the Zep SDK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import GraphProvider, GraphTriple
|
||||
from ...config import Config
|
||||
|
||||
|
||||
class ZepGraphProvider(GraphProvider):
|
||||
name = "zep"
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or Config.ZEP_API_KEY
|
||||
if not self.api_key:
|
||||
raise RuntimeError("ZEP_API_KEY is required for legacy zep graph provider")
|
||||
try:
|
||||
from zep_cloud.client import Zep
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"zep-cloud package is required for legacy zep graph provider; "
|
||||
"install with `uv sync --extra legacy`"
|
||||
) from exc
|
||||
|
||||
self.client = Zep(api_key=self.api_key)
|
||||
|
||||
def add_episode(self, run_id: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
try:
|
||||
from zep_cloud import EpisodeData
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"zep-cloud package is required for legacy zep graph provider; "
|
||||
"install with `uv sync --extra legacy`"
|
||||
) from exc
|
||||
|
||||
result = self.client.graph.add(graph_id=run_id, data=EpisodeData(data=content, type="text", metadata=metadata or {}))
|
||||
return {"run_id": run_id, "result": str(result)}
|
||||
|
||||
def add_triples(self, run_id: str, triples: List[Dict[str, Any] | GraphTriple]) -> Dict[str, Any]:
|
||||
parsed = [triple if isinstance(triple, GraphTriple) else GraphTriple.model_validate(triple) for triple in triples]
|
||||
content = "\n".join(triple.fact for triple in parsed)
|
||||
self.add_episode(run_id, content, {"source": "agent_triples"})
|
||||
return {"run_id": run_id, "triples_added": len(parsed), "mode": "zep_episode_compat"}
|
||||
|
||||
def search(self, run_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
result = self.client.graph.search(graph_id=run_id, query=query, limit=limit, scope="edges", reranker="rrf")
|
||||
rows = []
|
||||
for edge in getattr(result, "edges", []) or []:
|
||||
rows.append(
|
||||
{
|
||||
"uuid": getattr(edge, "uuid_", None) or getattr(edge, "uuid", ""),
|
||||
"predicate": getattr(edge, "name", ""),
|
||||
"fact": getattr(edge, "fact", ""),
|
||||
"source_node_uuid": getattr(edge, "source_node_uuid", ""),
|
||||
"target_node_uuid": getattr(edge, "target_node_uuid", ""),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def neighbors(self, run_id: str, entity: str, depth: int = 2) -> List[Dict[str, Any]]:
|
||||
matches = self.search(run_id, entity, limit=50)
|
||||
return matches[: max(depth, 1) * 20]
|
||||
|
||||
def list_entities(self, run_id: str) -> List[Dict[str, Any]]:
|
||||
nodes = self.client.graph.node.get_by_graph_id(graph_id=run_id)
|
||||
return [self._node_to_dict(node) for node in nodes]
|
||||
|
||||
def get_entity(self, run_id: str, entity: str) -> Optional[Dict[str, Any]]:
|
||||
entity_lower = entity.casefold()
|
||||
for node in self.list_entities(run_id):
|
||||
if node.get("name", "").casefold() == entity_lower:
|
||||
return node
|
||||
return None
|
||||
|
||||
def update_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return self.write_agent_memory(run_id, agent_id, memory)
|
||||
|
||||
def get_agent_memory(self, run_id: str, agent_id: str) -> Dict[str, Any]:
|
||||
result = self.search(run_id, f"agent memory {agent_id}", limit=10)
|
||||
return {"agent_id": agent_id, "facts": result}
|
||||
|
||||
def write_agent_memory(self, run_id: str, agent_id: str, memory: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.add_episode(run_id, json.dumps({"agent_id": agent_id, "memory": memory}, ensure_ascii=False), {"type": "agent_memory"})
|
||||
return {"run_id": run_id, "agent_id": agent_id, "memory": memory}
|
||||
|
||||
def export_snapshot(self, run_id: str, output_path: str) -> Dict[str, Any]:
|
||||
nodes = self.list_entities(run_id)
|
||||
edges = self.search(run_id, "", limit=1000)
|
||||
snapshot = {"run_id": run_id, "provider": "zep", "entities": nodes, "triples": edges}
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return {"output_path": str(path), "nodes": len(nodes), "triples": len(edges)}
|
||||
|
||||
def import_snapshot(self, run_id: str, input_path: str) -> Dict[str, Any]:
|
||||
data = json.loads(Path(input_path).read_text(encoding="utf-8"))
|
||||
self.add_episode(run_id, json.dumps(data, ensure_ascii=False), {"type": "snapshot_import"})
|
||||
return {"run_id": run_id, "imported": True, "mode": "zep_episode_compat"}
|
||||
|
||||
def clear_run_graph(self, run_id: str) -> Dict[str, Any]:
|
||||
self.client.graph.delete(graph_id=run_id)
|
||||
return {"run_id": run_id, "cleared": True}
|
||||
|
||||
def _node_to_dict(self, node: Any) -> Dict[str, Any]:
|
||||
return {
|
||||
"uuid": getattr(node, "uuid_", None) or getattr(node, "uuid", ""),
|
||||
"name": getattr(node, "name", ""),
|
||||
"labels": getattr(node, "labels", []) or [],
|
||||
"summary": getattr(node, "summary", "") or "",
|
||||
"attributes": getattr(node, "attributes", {}) or {},
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""LLM provider adapters."""
|
||||
|
||||
from .agent_runtime import AgentRuntime, NeedAgentResponse
|
||||
from .factory import create_llm_provider
|
||||
|
||||
__all__ = ["AgentRuntime", "NeedAgentResponse", "create_llm_provider"]
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""LLM provider that delegates model work to external desktop agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base import LLMProvider, LLMProviderResult, LLMTask
|
||||
from ...agent_engine.queue import AgentQueue
|
||||
|
||||
|
||||
class AgentQueueLLMProvider(LLMProvider):
|
||||
name = "agent_queue"
|
||||
|
||||
def __init__(self, run_dir: str | Path | None = None):
|
||||
self.run_dir = Path(run_dir) if run_dir else None
|
||||
|
||||
def run_task(self, task: LLMTask) -> LLMProviderResult:
|
||||
if not self.run_dir:
|
||||
raise RuntimeError("agent_queue provider requires run_dir")
|
||||
|
||||
queue = AgentQueue(self.run_dir)
|
||||
need = queue.create_request(
|
||||
run_id=task.run_id,
|
||||
task_type=task.task_type,
|
||||
stage=task.stage,
|
||||
expected_schema=task.expected_schema,
|
||||
input_text=task.input_text,
|
||||
input_files=task.input_files,
|
||||
structured_input=task.structured_input,
|
||||
system_prompt=task.system_prompt,
|
||||
user_prompt=task.user_prompt,
|
||||
validation_rules=task.validation_rules,
|
||||
retry_policy=task.retry_policy,
|
||||
context_refs=task.context_refs,
|
||||
output_contract=task.output_contract,
|
||||
)
|
||||
return LLMProviderResult(
|
||||
status="need_agent_response",
|
||||
request_id=need.request_id,
|
||||
request_file=need.request_file,
|
||||
expected_response_file=need.expected_response_file,
|
||||
)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
"""Business-facing model runtime facade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import LLMProvider, LLMProviderResult, LLMTask
|
||||
from .factory import create_llm_provider
|
||||
|
||||
|
||||
class NeedAgentResponse(RuntimeError):
|
||||
def __init__(self, result: LLMProviderResult):
|
||||
self.result = result
|
||||
super().__init__(f"Agent response required: {result.request_file}")
|
||||
|
||||
|
||||
class AgentRuntime:
|
||||
def __init__(self, provider: Optional[LLMProvider] = None, *, run_dir: str | None = None):
|
||||
self.provider = provider or create_llm_provider(run_dir=run_dir)
|
||||
self.run_dir = run_dir
|
||||
|
||||
def run_task(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
task_type: str,
|
||||
stage: str,
|
||||
expected_schema: Dict[str, Any],
|
||||
input_text: Optional[str] = None,
|
||||
input_files: Optional[List[str]] = None,
|
||||
structured_input: Optional[Dict[str, Any]] = None,
|
||||
system_prompt: str = "",
|
||||
user_prompt: str = "",
|
||||
validation_rules: Optional[Dict[str, Any]] = None,
|
||||
retry_policy: Optional[Dict[str, Any]] = None,
|
||||
context_refs: Optional[List[str]] = None,
|
||||
output_contract: Optional[Dict[str, Any]] = None,
|
||||
) -> LLMProviderResult:
|
||||
task = LLMTask(
|
||||
run_id=run_id,
|
||||
task_type=task_type,
|
||||
stage=stage,
|
||||
expected_schema=expected_schema,
|
||||
input_text=input_text,
|
||||
input_files=input_files,
|
||||
structured_input=structured_input,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
validation_rules=validation_rules,
|
||||
retry_policy=retry_policy,
|
||||
context_refs=context_refs,
|
||||
output_contract=output_contract,
|
||||
)
|
||||
return self.provider.run_task(task)
|
||||
|
||||
def require_output(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
result = self.run_task(**kwargs)
|
||||
if result.status == "need_agent_response":
|
||||
raise NeedAgentResponse(result)
|
||||
if result.status != "ok":
|
||||
raise RuntimeError(result.error or f"LLM task failed with status {result.status}")
|
||||
return result.output or {}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""Unified business-facing LLM provider interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMTask:
|
||||
run_id: str
|
||||
task_type: str
|
||||
stage: str
|
||||
expected_schema: Dict[str, Any]
|
||||
input_text: Optional[str] = None
|
||||
input_files: Optional[List[str]] = None
|
||||
structured_input: Optional[Dict[str, Any]] = None
|
||||
system_prompt: str = ""
|
||||
user_prompt: str = ""
|
||||
validation_rules: Optional[Dict[str, Any]] = None
|
||||
retry_policy: Optional[Dict[str, Any]] = None
|
||||
context_refs: Optional[List[str]] = None
|
||||
output_contract: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMProviderResult:
|
||||
status: str
|
||||
output: Optional[Dict[str, Any]] = None
|
||||
request_id: Optional[str] = None
|
||||
request_file: Optional[str] = None
|
||||
expected_response_file: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
data = {
|
||||
"status": self.status,
|
||||
"output": self.output,
|
||||
"request_id": self.request_id,
|
||||
"request_file": self.request_file,
|
||||
"expected_response_file": self.expected_response_file,
|
||||
"error": self.error,
|
||||
}
|
||||
return {key: value for key, value in data.items() if value is not None}
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
def run_task(self, task: LLMTask) -> LLMProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ProviderConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
"""Bridge CAMEL/OASIS model calls into AgentRuntime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, List, Optional, Type
|
||||
|
||||
from ...agent_engine.json_schema import object_schema
|
||||
from .base import LLMProviderResult
|
||||
from .agent_runtime import AgentRuntime
|
||||
|
||||
from camel.messages import OpenAIMessage
|
||||
from camel.models import BaseModelBackend
|
||||
from camel.types import (
|
||||
ChatCompletion,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionMessage,
|
||||
Choice,
|
||||
CompletionUsage,
|
||||
ModelType,
|
||||
)
|
||||
from camel.utils import BaseTokenCounter
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
SIMULATE_ACTION_SCHEMA = object_schema(
|
||||
{
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": object_schema(
|
||||
{
|
||||
"agent_id": {"type": "string"},
|
||||
"action_id": {"type": "string"},
|
||||
"action_type": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AgentRuntimeTokenCounter(BaseTokenCounter):
|
||||
def count_tokens_from_messages(self, messages: List[OpenAIMessage]) -> int:
|
||||
return sum(len(str(message.get("content", ""))) // 4 + 1 for message in messages)
|
||||
|
||||
def encode(self, text: str) -> List[int]:
|
||||
return [0] * (len(text) // 4 + 1)
|
||||
|
||||
def decode(self, token_ids: List[int]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
class AgentModelBackendAdapter(BaseModelBackend):
|
||||
"""CAMEL-compatible model backend backed by AgentRuntime.
|
||||
|
||||
CAMEL/OASIS scripts can use this adapter instead of directly constructing
|
||||
model SDK clients. Batch calls are preferred for same-round actions.
|
||||
"""
|
||||
|
||||
def __init__(self, run_id: str, run_dir: str, runtime: Optional[AgentRuntime] = None):
|
||||
super().__init__(model_type=ModelType.STUB, model_config_dict={})
|
||||
self.run_id = run_id
|
||||
self.run_dir = run_dir
|
||||
self.runtime = runtime or AgentRuntime(run_dir=run_dir)
|
||||
self.last_need_agent_response: Optional[Dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def token_counter(self) -> BaseTokenCounter:
|
||||
if not self._token_counter:
|
||||
self._token_counter = AgentRuntimeTokenCounter()
|
||||
return self._token_counter
|
||||
|
||||
def run_batch_actions(self, round_id: str, actions: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
action_list = list(actions)
|
||||
result = self.runtime.run_task(
|
||||
run_id=self.run_id,
|
||||
task_type="simulate_agent_action",
|
||||
stage="simulation",
|
||||
expected_schema=SIMULATE_ACTION_SCHEMA,
|
||||
structured_input={"round_id": round_id, "actions": action_list},
|
||||
system_prompt="Generate simulation actions for the requested agents.",
|
||||
user_prompt="Return JSON with actions keyed by agent_id and action_id.",
|
||||
output_contract={"batch_key": ["agent_id", "action_id"]},
|
||||
)
|
||||
if result.status == "need_agent_response":
|
||||
self.last_need_agent_response = result.to_dict()
|
||||
return result.to_dict()
|
||||
|
||||
def run_single_action(self, agent_id: str, action_id: str, prompt: str) -> Dict[str, Any]:
|
||||
return self.run_batch_actions(
|
||||
round_id="single",
|
||||
actions=[{"agent_id": agent_id, "action_id": action_id, "prompt": prompt}],
|
||||
)
|
||||
|
||||
def _run(
|
||||
self,
|
||||
messages: List[OpenAIMessage],
|
||||
response_format: Optional[Type[BaseModel]] = None,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> ChatCompletion:
|
||||
result = self._run_model_task(messages, tools)
|
||||
return self._to_chat_completion(result, tools)
|
||||
|
||||
async def _arun(
|
||||
self,
|
||||
messages: List[OpenAIMessage],
|
||||
response_format: Optional[Type[BaseModel]] = None,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> ChatCompletion:
|
||||
result = self._run_model_task(messages, tools)
|
||||
return self._to_chat_completion(result, tools)
|
||||
|
||||
def _run_model_task(self, messages: List[OpenAIMessage], tools: Optional[List[Dict[str, Any]]]) -> LLMProviderResult:
|
||||
return self.runtime.run_task(
|
||||
run_id=self.run_id,
|
||||
task_type="simulate_agent_action",
|
||||
stage="simulation_runtime",
|
||||
expected_schema=SIMULATE_ACTION_SCHEMA,
|
||||
structured_input={
|
||||
"messages": messages,
|
||||
"tools": tools or [],
|
||||
"actions": [
|
||||
{
|
||||
"agent_id": self._extract_agent_id(messages),
|
||||
"action_id": f"camel_{int(time.time() * 1000)}",
|
||||
"prompt": messages[-1].get("content", "") if messages else "",
|
||||
}
|
||||
],
|
||||
},
|
||||
system_prompt="You are driving one OASIS/CAMEL social simulation agent action.",
|
||||
user_prompt=json.dumps(messages, ensure_ascii=False),
|
||||
output_contract={"camel_model_backend": True},
|
||||
)
|
||||
|
||||
def _to_chat_completion(self, result: LLMProviderResult, tools: Optional[List[Dict[str, Any]]]) -> ChatCompletion:
|
||||
if result.status == "need_agent_response":
|
||||
self.last_need_agent_response = result.to_dict()
|
||||
content = json.dumps(self.last_need_agent_response, ensure_ascii=False)
|
||||
tool_calls = self._tool_calls_for_actions([{"action_type": "DO_NOTHING", "action_args": {}}], tools)
|
||||
return self._completion(content=content, tool_calls=tool_calls)
|
||||
if result.status != "ok":
|
||||
return self._completion(content=result.error or "AgentRuntime model task failed")
|
||||
|
||||
output = result.output or {}
|
||||
actions = output.get("actions") or []
|
||||
tool_calls = self._tool_calls_for_actions(actions, tools)
|
||||
content = output.get("content") or output.get("text") or json.dumps(output, ensure_ascii=False)
|
||||
return self._completion(content=content if not tool_calls else "", tool_calls=tool_calls)
|
||||
|
||||
def _tool_calls_for_actions(
|
||||
self,
|
||||
actions: List[Dict[str, Any]],
|
||||
tools: Optional[List[Dict[str, Any]]],
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
available = self._available_tool_names(tools)
|
||||
if not available:
|
||||
return None
|
||||
|
||||
calls = []
|
||||
for index, action in enumerate(actions[:1]):
|
||||
tool_name = self._action_to_tool_name(action.get("action_type", "DO_NOTHING"))
|
||||
if tool_name not in available:
|
||||
tool_name = "do_nothing" if "do_nothing" in available else sorted(available)[0]
|
||||
args = action.get("action_args") or self._action_args(action)
|
||||
calls.append(
|
||||
{
|
||||
"id": f"call_agent_runtime_{index}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": json.dumps(args, ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
)
|
||||
return calls or None
|
||||
|
||||
def _available_tool_names(self, tools: Optional[List[Dict[str, Any]]]) -> set[str]:
|
||||
names = set()
|
||||
for tool in tools or []:
|
||||
function = tool.get("function", {}) if isinstance(tool, dict) else {}
|
||||
name = function.get("name")
|
||||
if name:
|
||||
names.add(name)
|
||||
return names
|
||||
|
||||
def _action_to_tool_name(self, action_type: str) -> str:
|
||||
return str(action_type or "DO_NOTHING").lower()
|
||||
|
||||
def _action_args(self, action: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if action.get("content"):
|
||||
return {"content": action["content"]}
|
||||
return {}
|
||||
|
||||
def _extract_agent_id(self, messages: List[OpenAIMessage]) -> str:
|
||||
for message in messages:
|
||||
content = str(message.get("content", ""))
|
||||
if "Agent" in content:
|
||||
return "camel_agent"
|
||||
return "camel_agent"
|
||||
|
||||
def _completion(
|
||||
self,
|
||||
*,
|
||||
content: str,
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> ChatCompletion:
|
||||
message = ChatCompletionMessage(
|
||||
content=None if tool_calls else content,
|
||||
role="assistant",
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
return ChatCompletion(
|
||||
id=f"agent-runtime-{int(time.time() * 1000)}",
|
||||
model="mirofish-agent-runtime",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
choices=[
|
||||
Choice(
|
||||
finish_reason="tool_calls" if tool_calls else "stop",
|
||||
index=0,
|
||||
message=message,
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
usage=CompletionUsage(
|
||||
completion_tokens=max(1, len(content) // 4),
|
||||
prompt_tokens=1,
|
||||
total_tokens=max(2, len(content) // 4 + 1),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_model_backend(run_id: str, run_dir: str) -> AgentModelBackendAdapter:
|
||||
return AgentModelBackendAdapter(run_id=run_id, run_dir=run_dir)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""LLM provider factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .agent_queue import AgentQueueLLMProvider
|
||||
from .base import LLMProvider
|
||||
from .mock import MockLLMProvider
|
||||
from .openai_compatible import OpenAICompatibleProvider
|
||||
|
||||
|
||||
def create_llm_provider(provider: Optional[str] = None, *, run_dir: str | Path | None = None) -> LLMProvider:
|
||||
provider_name = provider or os.environ.get("MIROFISH_LLM_PROVIDER")
|
||||
if not provider_name:
|
||||
mode = os.environ.get("MIROFISH_MODE", "agent")
|
||||
provider_name = "agent_queue" if mode == "agent" else "openai_compatible"
|
||||
|
||||
provider_name = provider_name.lower()
|
||||
if provider_name == "agent_queue":
|
||||
return AgentQueueLLMProvider(run_dir=run_dir)
|
||||
if provider_name == "mock":
|
||||
return MockLLMProvider()
|
||||
if provider_name == "openai_compatible":
|
||||
return OpenAICompatibleProvider()
|
||||
raise ValueError(f"Unsupported MIROFISH_LLM_PROVIDER: {provider_name}")
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
"""Deterministic provider for tests and offline smoke runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .base import LLMProvider, LLMProviderResult, LLMTask
|
||||
|
||||
|
||||
class MockLLMProvider(LLMProvider):
|
||||
name = "mock"
|
||||
|
||||
def run_task(self, task: LLMTask) -> LLMProviderResult:
|
||||
output = self._output_for(task)
|
||||
return LLMProviderResult(status="ok", output=output)
|
||||
|
||||
def _output_for(self, task: LLMTask) -> Dict[str, Any]:
|
||||
required = task.expected_schema.get("required", []) if task.expected_schema else []
|
||||
if "text" in required:
|
||||
return {"text": "Mock text response generated without model APIs."}
|
||||
if task.task_type == "generate_ontology":
|
||||
return {
|
||||
"ontology": {
|
||||
"entity_types": [{"name": "Organization", "description": "An organization"}],
|
||||
"edge_types": [{"name": "AFFECTS", "description": "Affects another entity"}],
|
||||
}
|
||||
}
|
||||
if task.task_type == "extract_triples":
|
||||
return {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "Seed Entity",
|
||||
"predicate": "relates_to",
|
||||
"object": "Prediction Topic",
|
||||
"fact": "Seed Entity relates to Prediction Topic.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "mock",
|
||||
"source_file": None,
|
||||
"evidence": "mock evidence",
|
||||
"confidence": 0.5,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
if task.task_type == "generate_oasis_profiles":
|
||||
return {
|
||||
"profiles": [
|
||||
{
|
||||
"agent_id": "agent_1",
|
||||
"name": "Seed Analyst",
|
||||
"persona": "Tracks seed facts and reacts conservatively.",
|
||||
}
|
||||
]
|
||||
}
|
||||
if task.task_type == "generate_simulation_config":
|
||||
return {"config": {"rounds": 1, "agents": ["agent_1"], "platforms": ["agent_queue"]}}
|
||||
if task.task_type == "simulate_agent_action":
|
||||
actions = []
|
||||
for item in (task.structured_input or {}).get("actions", []):
|
||||
actions.append(
|
||||
{
|
||||
"agent_id": item.get("agent_id"),
|
||||
"action_id": item.get("action_id"),
|
||||
"action_type": "CREATE_POST",
|
||||
"content": "Mock simulated action.",
|
||||
}
|
||||
)
|
||||
return {"actions": actions or [{"agent_id": "agent_1", "action_id": "act_1", "action_type": "CREATE_POST", "content": "Mock simulated action."}]}
|
||||
if task.task_type == "summarize_round":
|
||||
return {
|
||||
"summary_markdown": "Mock round summary generated without model APIs.",
|
||||
"key_events": [],
|
||||
"memory_updates": [],
|
||||
}
|
||||
if task.task_type == "update_memory":
|
||||
return {
|
||||
"memory": (task.structured_input or {}).get("memory", {}),
|
||||
"events": (task.structured_input or {}).get("events", []),
|
||||
}
|
||||
if task.task_type == "generate_report":
|
||||
return {
|
||||
"report_markdown": "# MiroFish Agent Report\n\nMock report generated without model APIs.",
|
||||
"verdict": {"status": "mock", "confidence": 0.5},
|
||||
"timeline": [{"step": "mock", "summary": "Mock simulation completed."}],
|
||||
}
|
||||
if task.task_type == "answer_followup_question":
|
||||
return {
|
||||
"answer_markdown": "Mock follow-up answer generated without model APIs.",
|
||||
"used_graph_results": (task.structured_input or {}).get("graph_results", []),
|
||||
"confidence": 0.5,
|
||||
}
|
||||
if task.task_type == "answer_agent_question":
|
||||
agent_id = (task.structured_input or {}).get("agent_id", "agent_1")
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"answer_markdown": f"Mock answer from {agent_id} generated without model APIs.",
|
||||
"used_memory": [],
|
||||
"used_graph_results": (task.structured_input or {}).get("graph_results", []),
|
||||
"confidence": 0.5,
|
||||
}
|
||||
if task.task_type == "answer_agent_questionnaire":
|
||||
questionnaire_id = (task.structured_input or {}).get("questionnaire_id", "q_mock")
|
||||
questions = (task.structured_input or {}).get("questions", [])
|
||||
agents = (task.structured_input or {}).get("agents", [])
|
||||
answers = []
|
||||
for question in questions:
|
||||
for agent in agents:
|
||||
answers.append({
|
||||
"agent_id": agent.get("agent_id", "agent_1"),
|
||||
"question_id": question.get("question_id", "q1"),
|
||||
"answer_markdown": f"Mock answer from {agent.get('agent_id', 'agent_1')} to {question.get('question_id', 'q1')}.",
|
||||
"confidence": 0.5,
|
||||
})
|
||||
return {
|
||||
"questionnaire_id": questionnaire_id,
|
||||
"answers": answers,
|
||||
"summary_markdown": "Mock questionnaire summary generated without model APIs.",
|
||||
}
|
||||
if task.task_type == "summarize_questionnaire":
|
||||
return {
|
||||
"questionnaire_id": (task.structured_input or {}).get("questionnaire_id", "q_mock"),
|
||||
"summary_markdown": "Mock questionnaire summary generated without model APIs.",
|
||||
"answer_count": len((task.structured_input or {}).get("answers", [])),
|
||||
}
|
||||
if task.task_type == "ask_report_question":
|
||||
return {
|
||||
"answer_markdown": "Mock report question answer generated without model APIs.",
|
||||
"used_graph_results": (task.structured_input or {}).get("graph_results", []),
|
||||
"confidence": 0.5,
|
||||
}
|
||||
if task.task_type == "validate_json_output":
|
||||
return {
|
||||
"valid": True,
|
||||
"errors": [],
|
||||
"output": (task.structured_input or {}).get("candidate", {}),
|
||||
}
|
||||
if task.task_type == "repair_invalid_json":
|
||||
invalid_response = (task.structured_input or {}).get("invalid_response", {})
|
||||
return invalid_response.get("output", {})
|
||||
return {"result": task.structured_input or {}}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"""Legacy OpenAI-compatible provider.
|
||||
|
||||
This is the only backend/app path allowed to import the OpenAI SDK.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
from .base import LLMProvider, LLMProviderResult, LLMTask, ProviderConfigurationError
|
||||
from ...config import Config
|
||||
|
||||
|
||||
class OpenAICompatibleProvider(LLMProvider):
|
||||
name = "openai_compatible"
|
||||
|
||||
def __init__(self, api_key: str | None = None, base_url: str | None = None, model: str | None = None):
|
||||
self.api_key = api_key or Config.LLM_API_KEY
|
||||
self.base_url = base_url or Config.LLM_BASE_URL
|
||||
self.model = model or Config.LLM_MODEL_NAME
|
||||
if not self.api_key:
|
||||
raise ProviderConfigurationError("LLM_API_KEY is required for openai_compatible provider")
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as exc:
|
||||
raise ProviderConfigurationError(
|
||||
"openai package is required for openai_compatible legacy provider; "
|
||||
"install with `uv sync --extra legacy`"
|
||||
) from exc
|
||||
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
def run_task(self, task: LLMTask) -> LLMProviderResult:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": task.system_prompt or "Return valid JSON."},
|
||||
{"role": "user", "content": task.user_prompt or task.input_text or json.dumps(task.structured_input or {}, ensure_ascii=False)},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.3,
|
||||
)
|
||||
content = response.choices[0].message.content or "{}"
|
||||
content = re.sub(r"<think>[\s\S]*?</think>", "", content).strip()
|
||||
content = re.sub(r"^```(?:json)?\s*\n?", "", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"\n?```\s*$", "", content).strip()
|
||||
try:
|
||||
output: Dict[str, Any] = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
return LLMProviderResult(status="error", error=f"invalid JSON from openai_compatible provider: {exc}")
|
||||
return LLMProviderResult(status="ok", output=output)
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
"""Agent-friendly MiroFish CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from .runner import PredictionRunService
|
||||
|
||||
|
||||
def emit(result: Dict[str, Any], as_json: bool) -> None:
|
||||
if as_json:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
status = result.get("status")
|
||||
if status == "need_agent_response":
|
||||
print(f"need_agent_response: {result['request_id']}")
|
||||
print(f"request_file: {result['request_file']}")
|
||||
print(f"expected_response_file: {result['expected_response_file']}")
|
||||
elif status == "created":
|
||||
print(f"created run: {result['run_id']}")
|
||||
print(f"run_dir: {result['run_dir']}")
|
||||
elif status == "awaiting_user_confirmation":
|
||||
print(f"awaiting_user_confirmation: {result['stage']}")
|
||||
elif status == "ok":
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
elif status == "completed":
|
||||
print("completed")
|
||||
print(json.dumps(result.get("artifacts", []), ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def add_json(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--json", action="store_true", help="Emit stable JSON output")
|
||||
|
||||
|
||||
def add_create_run_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--seed", required=True)
|
||||
parser.add_argument("--requirement", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--mode", choices=["auto", "staged"], default="auto")
|
||||
parser.add_argument("--rounds", type=int, default=10)
|
||||
parser.add_argument("--round-unit", choices=["year", "month", "day", "step"], default="year")
|
||||
parser.add_argument("--minutes-per-round", type=int, default=None)
|
||||
parser.add_argument("--pause-each-round", action=argparse.BooleanOptionalAction, default=False)
|
||||
parser.add_argument("--agent-count", type=int, default=None)
|
||||
parser.add_argument("--simulation-name", default=None)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="mirofish-agent")
|
||||
add_json(parser)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init = sub.add_parser("init")
|
||||
add_json(init)
|
||||
add_create_run_args(init)
|
||||
|
||||
create_run = sub.add_parser("create-run")
|
||||
add_json(create_run)
|
||||
add_create_run_args(create_run)
|
||||
|
||||
run = sub.add_parser("run")
|
||||
add_json(run)
|
||||
run.add_argument("--run", required=True)
|
||||
|
||||
resume = sub.add_parser("resume")
|
||||
add_json(resume)
|
||||
resume.add_argument("--run", required=True)
|
||||
|
||||
status = sub.add_parser("status")
|
||||
add_json(status)
|
||||
status.add_argument("--run", required=True)
|
||||
|
||||
stage = sub.add_parser("stage")
|
||||
stage_sub = stage.add_subparsers(dest="stage_command", required=True)
|
||||
stage_status = stage_sub.add_parser("status")
|
||||
add_json(stage_status)
|
||||
stage_status.add_argument("--run", required=True)
|
||||
stage_next = stage_sub.add_parser("next")
|
||||
add_json(stage_next)
|
||||
stage_next.add_argument("--run", required=True)
|
||||
stage_approve = stage_sub.add_parser("approve")
|
||||
add_json(stage_approve)
|
||||
stage_approve.add_argument("--run", required=True)
|
||||
stage_reject = stage_sub.add_parser("reject")
|
||||
add_json(stage_reject)
|
||||
stage_reject.add_argument("--run", required=True)
|
||||
stage_reject.add_argument("--reason", default="")
|
||||
stage_update = stage_sub.add_parser("update-settings")
|
||||
add_json(stage_update)
|
||||
stage_update.add_argument("--run", required=True)
|
||||
stage_update.add_argument("--rounds", type=int, default=None)
|
||||
stage_update.add_argument("--round-unit", choices=["year", "month", "day", "step"], default=None)
|
||||
stage_update.add_argument("--minutes-per-round", type=int, default=None)
|
||||
stage_update.add_argument("--pause-each-round", action=argparse.BooleanOptionalAction, default=None)
|
||||
stage_update.add_argument("--agent-count", type=int, default=None)
|
||||
stage_update.add_argument("--simulation-name", default=None)
|
||||
stage_rerun = stage_sub.add_parser("rerun")
|
||||
add_json(stage_rerun)
|
||||
stage_rerun.add_argument("--run", required=True)
|
||||
stage_rerun.add_argument("--stage", required=True)
|
||||
|
||||
requests = sub.add_parser("requests")
|
||||
req_sub = requests.add_subparsers(dest="requests_command", required=True)
|
||||
req_list = req_sub.add_parser("list")
|
||||
add_json(req_list)
|
||||
req_list.add_argument("--run", required=True)
|
||||
req_show = req_sub.add_parser("show")
|
||||
add_json(req_show)
|
||||
req_show.add_argument("--run", required=True)
|
||||
req_show.add_argument("--request-id", required=True)
|
||||
|
||||
responses = sub.add_parser("responses")
|
||||
resp_sub = responses.add_subparsers(dest="responses_command", required=True)
|
||||
resp_validate = resp_sub.add_parser("validate")
|
||||
add_json(resp_validate)
|
||||
resp_validate.add_argument("--run", required=True)
|
||||
resp_validate.add_argument("--response", required=True)
|
||||
resp_submit = resp_sub.add_parser("submit")
|
||||
add_json(resp_submit)
|
||||
resp_submit.add_argument("--run", required=True)
|
||||
resp_submit.add_argument("--response", required=True)
|
||||
|
||||
graph = sub.add_parser("graph")
|
||||
graph_sub = graph.add_subparsers(dest="graph_command", required=True)
|
||||
graph_build = graph_sub.add_parser("build")
|
||||
add_json(graph_build)
|
||||
graph_build.add_argument("--run", required=True)
|
||||
graph_build.add_argument("--provider", default=None)
|
||||
graph_build.add_argument("--mode", default="agent-triples")
|
||||
graph_search = graph_sub.add_parser("search")
|
||||
add_json(graph_search)
|
||||
graph_search.add_argument("--run", required=True)
|
||||
graph_search.add_argument("--query", required=True)
|
||||
graph_search.add_argument("--limit", type=int, default=20)
|
||||
graph_export = graph_sub.add_parser("export")
|
||||
add_json(graph_export)
|
||||
graph_export.add_argument("--run", required=True)
|
||||
graph_export.add_argument("--output", default=None)
|
||||
|
||||
simulate = sub.add_parser("simulate")
|
||||
sim_sub = simulate.add_subparsers(dest="simulate_command", required=True)
|
||||
sim_start = sim_sub.add_parser("start")
|
||||
add_json(sim_start)
|
||||
sim_start.add_argument("--run", required=True)
|
||||
sim_resume = sim_sub.add_parser("resume")
|
||||
add_json(sim_resume)
|
||||
sim_resume.add_argument("--run", required=True)
|
||||
sim_status = sim_sub.add_parser("status")
|
||||
add_json(sim_status)
|
||||
sim_status.add_argument("--run", required=True)
|
||||
|
||||
report = sub.add_parser("report")
|
||||
report_sub = report.add_subparsers(dest="report_command", required=True)
|
||||
report_generate = report_sub.add_parser("generate")
|
||||
add_json(report_generate)
|
||||
report_generate.add_argument("--run", required=True)
|
||||
report_show = report_sub.add_parser("show")
|
||||
add_json(report_show)
|
||||
report_show.add_argument("--run", required=True)
|
||||
|
||||
followup = sub.add_parser("followup")
|
||||
followup_sub = followup.add_subparsers(dest="followup_command", required=True)
|
||||
followup_ask = followup_sub.add_parser("ask")
|
||||
add_json(followup_ask)
|
||||
followup_ask.add_argument("--run", required=True)
|
||||
followup_ask.add_argument("--question", required=True)
|
||||
followup_ask.add_argument("--limit", type=int, default=20)
|
||||
followup_show = followup_sub.add_parser("show")
|
||||
add_json(followup_show)
|
||||
followup_show.add_argument("--run", required=True)
|
||||
followup_show.add_argument("--request-id", required=True)
|
||||
|
||||
artifacts = sub.add_parser("artifacts")
|
||||
artifacts_sub = artifacts.add_subparsers(dest="artifacts_command", required=True)
|
||||
artifacts_list = artifacts_sub.add_parser("list")
|
||||
add_json(artifacts_list)
|
||||
artifacts_list.add_argument("--run", required=True)
|
||||
|
||||
# ── Agent interaction commands ─────────────────────────────────────────
|
||||
|
||||
agents = sub.add_parser("agents")
|
||||
agents_sub = agents.add_subparsers(dest="agents_command", required=True)
|
||||
agents_list = agents_sub.add_parser("list")
|
||||
add_json(agents_list)
|
||||
agents_list.add_argument("--run", required=True)
|
||||
agents_show = agents_sub.add_parser("show")
|
||||
add_json(agents_show)
|
||||
agents_show.add_argument("--run", required=True)
|
||||
agents_show.add_argument("--agent-id", required=True)
|
||||
agents_ask = agents_sub.add_parser("ask")
|
||||
add_json(agents_ask)
|
||||
agents_ask.add_argument("--run", required=True)
|
||||
agents_ask.add_argument("--agent-id", required=True)
|
||||
agents_ask.add_argument("--question", required=True)
|
||||
agents_ask.add_argument("--limit", type=int, default=20)
|
||||
agents_answer = agents_sub.add_parser("answer")
|
||||
add_json(agents_answer)
|
||||
agents_answer.add_argument("--run", required=True)
|
||||
agents_answer.add_argument("--request-id", required=True)
|
||||
|
||||
report_question = sub.add_parser("report-question")
|
||||
rq_sub = report_question.add_subparsers(dest="report_question_command", required=True)
|
||||
rq_ask = rq_sub.add_parser("ask")
|
||||
add_json(rq_ask)
|
||||
rq_ask.add_argument("--run", required=True)
|
||||
rq_ask.add_argument("--question", required=True)
|
||||
rq_ask.add_argument("--limit", type=int, default=20)
|
||||
rq_answer = rq_sub.add_parser("answer")
|
||||
add_json(rq_answer)
|
||||
rq_answer.add_argument("--run", required=True)
|
||||
rq_answer.add_argument("--request-id", required=True)
|
||||
|
||||
questionnaire = sub.add_parser("questionnaire")
|
||||
q_sub = questionnaire.add_subparsers(dest="questionnaire_command", required=True)
|
||||
q_send = q_sub.add_parser("send")
|
||||
add_json(q_send)
|
||||
q_send.add_argument("--run", required=True)
|
||||
q_send.add_argument("--questions", required=True)
|
||||
q_show = q_sub.add_parser("show")
|
||||
add_json(q_show)
|
||||
q_show.add_argument("--run", required=True)
|
||||
q_show.add_argument("--questionnaire-id", required=True)
|
||||
|
||||
web = sub.add_parser("web")
|
||||
web_sub = web.add_subparsers(dest="web_command", required=True)
|
||||
web_generate = web_sub.add_parser("generate")
|
||||
add_json(web_generate)
|
||||
web_generate.add_argument("--run", required=True)
|
||||
|
||||
doctor = sub.add_parser("doctor")
|
||||
add_json(doctor)
|
||||
doctor.add_argument("--runs-dir", default=None)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> Dict[str, Any]:
|
||||
service = PredictionRunService()
|
||||
if args.command in {"init", "create-run"}:
|
||||
return service.create_run(
|
||||
args.seed,
|
||||
args.requirement,
|
||||
args.output,
|
||||
mode=args.mode,
|
||||
rounds=args.rounds,
|
||||
round_unit=args.round_unit,
|
||||
minutes_per_round=args.minutes_per_round,
|
||||
pause_each_round=args.pause_each_round,
|
||||
agent_count=args.agent_count,
|
||||
simulation_name=args.simulation_name,
|
||||
)
|
||||
if args.command == "run":
|
||||
return service.run(args.run)
|
||||
if args.command == "resume":
|
||||
return service.resume(args.run)
|
||||
if args.command == "status":
|
||||
return service.status(args.run)
|
||||
if args.command == "stage":
|
||||
if args.stage_command == "status":
|
||||
return service.get_current_stage(args.run)
|
||||
if args.stage_command == "next":
|
||||
return service.resume(args.run)
|
||||
if args.stage_command == "approve":
|
||||
return service.approve_stage(args.run)
|
||||
if args.stage_command == "reject":
|
||||
return service.reject_stage(args.run, args.reason)
|
||||
if args.stage_command == "update-settings":
|
||||
return service.update_simulation_settings(
|
||||
args.run,
|
||||
rounds=args.rounds,
|
||||
round_unit=args.round_unit,
|
||||
minutes_per_round=args.minutes_per_round,
|
||||
pause_each_round=args.pause_each_round,
|
||||
agent_count=args.agent_count,
|
||||
simulation_name=args.simulation_name,
|
||||
)
|
||||
return service.rerun_stage(args.run, args.stage)
|
||||
if args.command == "requests":
|
||||
if args.requests_command == "list":
|
||||
return service.list_requests(args.run)
|
||||
return service.get_request(args.run, args.request_id)
|
||||
if args.command == "responses":
|
||||
if args.responses_command == "validate":
|
||||
return service.validate_response(args.run, args.response)
|
||||
return service.submit_response(args.run, args.response)
|
||||
if args.command == "graph":
|
||||
if args.graph_command == "build":
|
||||
return service.build_graph(args.run, provider=args.provider, mode=args.mode)
|
||||
if args.graph_command == "search":
|
||||
return service.search_graph(args.run, args.query, args.limit)
|
||||
return service.export_graph(args.run, args.output)
|
||||
if args.command == "simulate":
|
||||
if args.simulate_command == "start":
|
||||
return service.start_simulation(args.run)
|
||||
if args.simulate_command == "resume":
|
||||
return service.resume(args.run)
|
||||
return service.simulation_status(args.run)
|
||||
if args.command == "report":
|
||||
if args.report_command == "generate":
|
||||
return service.generate_report(args.run)
|
||||
return service.get_report(args.run)
|
||||
if args.command == "followup":
|
||||
if args.followup_command == "ask":
|
||||
return service.ask_followup_question(args.run, args.question, args.limit)
|
||||
return service.get_followup_answer(args.run, args.request_id)
|
||||
if args.command == "artifacts":
|
||||
return service.list_artifacts(args.run)
|
||||
if args.command == "agents":
|
||||
if args.agents_command == "list":
|
||||
return service.list_agents(args.run)
|
||||
if args.agents_command == "show":
|
||||
return service.get_agent(args.run, args.agent_id)
|
||||
if args.agents_command == "ask":
|
||||
return service.ask_agent(args.run, args.agent_id, args.question, args.limit)
|
||||
if args.agents_command == "answer":
|
||||
return service.get_agent_answer(args.run, args.request_id)
|
||||
if args.command == "report-question":
|
||||
if args.report_question_command == "ask":
|
||||
return service.ask_report_question(args.run, args.question, args.limit)
|
||||
if args.report_question_command == "answer":
|
||||
return service.get_report_question_answer(args.run, args.request_id)
|
||||
if args.command == "questionnaire":
|
||||
if args.questionnaire_command == "send":
|
||||
questions = json.loads(Path(args.questions).read_text(encoding="utf-8"))
|
||||
if not isinstance(questions, list):
|
||||
questions = [questions]
|
||||
return service.send_questionnaire(args.run, questions)
|
||||
if args.questionnaire_command == "show":
|
||||
return service.get_questionnaire_result(args.run, args.questionnaire_id)
|
||||
if args.command == "web":
|
||||
if args.web_command == "generate":
|
||||
return service.generate_web_console(args.run)
|
||||
if args.command == "doctor":
|
||||
return service.doctor(args.runs_dir)
|
||||
raise ValueError(f"unsupported command: {args.command}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
as_json = bool(getattr(args, "json", False))
|
||||
try:
|
||||
result = dispatch(args)
|
||||
except Exception as exc:
|
||||
result = {"status": "error", "error": str(exc), "error_type": exc.__class__.__name__}
|
||||
emit(result, as_json)
|
||||
return 1
|
||||
emit(result, as_json)
|
||||
return 0 if result.get("status") not in {"failed", "error"} else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"""Expected output contracts for all agent task types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .json_schema import TRIPLE_SCHEMA, object_schema
|
||||
|
||||
|
||||
ONTOLOGY_OUTPUT_SCHEMA = object_schema({"ontology": {"type": "object"}}, ["ontology"])
|
||||
|
||||
TRIPLES_OUTPUT_SCHEMA = object_schema(
|
||||
{"triples": {"type": "array", "items": TRIPLE_SCHEMA}},
|
||||
["triples"],
|
||||
)
|
||||
|
||||
PROFILES_OUTPUT_SCHEMA = object_schema(
|
||||
{"profiles": {"type": "array", "items": {"type": "object"}}},
|
||||
["profiles"],
|
||||
)
|
||||
|
||||
SIMULATION_CONFIG_OUTPUT_SCHEMA = object_schema({"config": {"type": "object"}}, ["config"])
|
||||
|
||||
SIMULATE_ACTION_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": object_schema(
|
||||
{
|
||||
"agent_id": {"type": "string"},
|
||||
"action_id": {"type": "string"},
|
||||
"action_type": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
["agent_id", "action_id", "action_type", "content"],
|
||||
),
|
||||
}
|
||||
},
|
||||
["actions"],
|
||||
)
|
||||
|
||||
REPORT_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"report_markdown": {"type": "string", "minLength": 1},
|
||||
"verdict": {"type": "object"},
|
||||
"timeline": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
["report_markdown", "verdict", "timeline"],
|
||||
)
|
||||
|
||||
FOLLOWUP_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"answer_markdown": {"type": "string", "minLength": 1},
|
||||
"used_graph_results": {"type": "array", "items": {"type": "object"}},
|
||||
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
},
|
||||
["answer_markdown", "used_graph_results", "confidence"],
|
||||
)
|
||||
|
||||
AGENT_QUESTION_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"agent_id": {"type": "string", "minLength": 1},
|
||||
"answer_markdown": {"type": "string", "minLength": 1},
|
||||
"used_memory": {"type": "array", "items": {"type": "object"}},
|
||||
"used_graph_results": {"type": "array", "items": {"type": "object"}},
|
||||
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
},
|
||||
["agent_id", "answer_markdown", "used_memory", "used_graph_results", "confidence"],
|
||||
)
|
||||
|
||||
AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"questionnaire_id": {"type": "string", "minLength": 1},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": object_schema(
|
||||
{
|
||||
"agent_id": {"type": "string", "minLength": 1},
|
||||
"question_id": {"type": "string", "minLength": 1},
|
||||
"answer_markdown": {"type": "string", "minLength": 1},
|
||||
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
},
|
||||
["agent_id", "question_id", "answer_markdown", "confidence"],
|
||||
),
|
||||
},
|
||||
"summary_markdown": {"type": "string"},
|
||||
},
|
||||
["questionnaire_id", "answers", "summary_markdown"],
|
||||
)
|
||||
|
||||
QUESTIONNAIRE_SUMMARY_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"questionnaire_id": {"type": "string", "minLength": 1},
|
||||
"summary_markdown": {"type": "string", "minLength": 1},
|
||||
"answer_count": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
["questionnaire_id", "summary_markdown", "answer_count"],
|
||||
)
|
||||
|
||||
REPORT_QUESTION_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"answer_markdown": {"type": "string", "minLength": 1},
|
||||
"used_graph_results": {"type": "array", "items": {"type": "object"}},
|
||||
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
},
|
||||
["answer_markdown", "used_graph_results", "confidence"],
|
||||
)
|
||||
|
||||
ROUND_SUMMARY_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"summary_markdown": {"type": "string", "minLength": 1},
|
||||
"key_events": {"type": "array", "items": {"type": "object"}},
|
||||
"memory_updates": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
["summary_markdown", "key_events", "memory_updates"],
|
||||
)
|
||||
|
||||
MEMORY_UPDATE_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"memory": {"type": "object"},
|
||||
"events": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
["memory", "events"],
|
||||
)
|
||||
|
||||
VALIDATE_JSON_OUTPUT_SCHEMA = object_schema(
|
||||
{
|
||||
"valid": {"type": "boolean"},
|
||||
"errors": {"type": "array", "items": {"type": "string"}},
|
||||
"output": {"type": "object"},
|
||||
},
|
||||
["valid", "errors", "output"],
|
||||
)
|
||||
|
||||
GENERIC_REPAIR_OUTPUT_SCHEMA: Dict[str, Any] = {
|
||||
"type": "object",
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
|
||||
TASK_OUTPUT_SCHEMAS: Dict[str, Dict[str, Any]] = {
|
||||
"extract_triples": TRIPLES_OUTPUT_SCHEMA,
|
||||
"generate_ontology": ONTOLOGY_OUTPUT_SCHEMA,
|
||||
"generate_oasis_profiles": PROFILES_OUTPUT_SCHEMA,
|
||||
"generate_simulation_config": SIMULATION_CONFIG_OUTPUT_SCHEMA,
|
||||
"simulate_agent_action": SIMULATE_ACTION_OUTPUT_SCHEMA,
|
||||
"summarize_round": ROUND_SUMMARY_OUTPUT_SCHEMA,
|
||||
"update_memory": MEMORY_UPDATE_OUTPUT_SCHEMA,
|
||||
"generate_report": REPORT_OUTPUT_SCHEMA,
|
||||
"answer_followup_question": FOLLOWUP_OUTPUT_SCHEMA,
|
||||
"answer_agent_question": AGENT_QUESTION_OUTPUT_SCHEMA,
|
||||
"answer_agent_questionnaire": AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA,
|
||||
"summarize_questionnaire": QUESTIONNAIRE_SUMMARY_OUTPUT_SCHEMA,
|
||||
"ask_report_question": REPORT_QUESTION_OUTPUT_SCHEMA,
|
||||
"validate_json_output": VALIDATE_JSON_OUTPUT_SCHEMA,
|
||||
"repair_invalid_json": GENERIC_REPAIR_OUTPUT_SCHEMA,
|
||||
}
|
||||
|
||||
|
||||
STAGE_CONTRACTS: Dict[str, Dict[str, Any]] = {
|
||||
"ontology": {
|
||||
"task_type": "generate_ontology",
|
||||
"schema": ONTOLOGY_OUTPUT_SCHEMA,
|
||||
"system_prompt": "Generate a compact ontology for prediction graph construction.",
|
||||
"user_prompt": "Return JSON with an ontology object. Do not include explanations.",
|
||||
},
|
||||
"graph": {
|
||||
"task_type": "extract_triples",
|
||||
"schema": TRIPLES_OUTPUT_SCHEMA,
|
||||
"system_prompt": "Extract factual entity-relationship-entity triples from the seed only.",
|
||||
"user_prompt": (
|
||||
"Read the seed and return triples. Each triple must include subject, predicate, "
|
||||
"object, fact, evidence, confidence, time fields, source fields, and metadata. "
|
||||
"Do not invent facts not supported by evidence."
|
||||
),
|
||||
},
|
||||
"profiles": {
|
||||
"task_type": "generate_oasis_profiles",
|
||||
"schema": PROFILES_OUTPUT_SCHEMA,
|
||||
"system_prompt": "Generate OASIS/CAMEL-compatible agent profiles from the seed and graph facts.",
|
||||
"user_prompt": "Return JSON with profiles array.",
|
||||
},
|
||||
"config": {
|
||||
"task_type": "generate_simulation_config",
|
||||
"schema": SIMULATION_CONFIG_OUTPUT_SCHEMA,
|
||||
"system_prompt": "Generate a simulation config that can run without direct model API keys.",
|
||||
"user_prompt": "Return JSON with config object.",
|
||||
},
|
||||
"simulation": {
|
||||
"task_type": "simulate_agent_action",
|
||||
"schema": SIMULATE_ACTION_OUTPUT_SCHEMA,
|
||||
"system_prompt": "Generate batched simulation actions keyed by agent_id and action_id.",
|
||||
"user_prompt": "Return JSON with actions array.",
|
||||
},
|
||||
"report": {
|
||||
"task_type": "generate_report",
|
||||
"schema": REPORT_OUTPUT_SCHEMA,
|
||||
"system_prompt": "Generate final prediction report artifacts from seed, graph, profiles, config, and simulation actions.",
|
||||
"user_prompt": "Return report_markdown, verdict, and timeline.",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"""Small JSON Schema validator for agent response contracts.
|
||||
|
||||
The project already depends on Pydantic, but not jsonschema. This validator
|
||||
implements the subset we use in queue contracts so smoke tests stay offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def validate_json_schema(value: Any, schema: Dict[str, Any], path: str = "$") -> List[str]:
|
||||
errors: List[str] = []
|
||||
schema_type = schema.get("type")
|
||||
|
||||
if schema_type == "null":
|
||||
return [] if value is None else [f"{path}: expected null"]
|
||||
|
||||
if schema_type == "object":
|
||||
if not isinstance(value, dict):
|
||||
return [f"{path}: expected object"]
|
||||
required = schema.get("required", [])
|
||||
for key in required:
|
||||
if key not in value:
|
||||
errors.append(f"{path}.{key}: missing required field")
|
||||
properties = schema.get("properties", {})
|
||||
additional = schema.get("additionalProperties", True)
|
||||
for key, item in value.items():
|
||||
if key in properties:
|
||||
errors.extend(validate_json_schema(item, properties[key], f"{path}.{key}"))
|
||||
elif additional is False:
|
||||
errors.append(f"{path}.{key}: extra field is not allowed")
|
||||
return errors
|
||||
|
||||
if schema_type == "array":
|
||||
if not isinstance(value, list):
|
||||
return [f"{path}: expected array"]
|
||||
item_schema = schema.get("items", {})
|
||||
for index, item in enumerate(value):
|
||||
errors.extend(validate_json_schema(item, item_schema, f"{path}[{index}]"))
|
||||
return errors
|
||||
|
||||
if schema_type == "string":
|
||||
if not isinstance(value, str):
|
||||
return [f"{path}: expected string"]
|
||||
if schema.get("minLength") is not None and len(value) < int(schema["minLength"]):
|
||||
errors.append(f"{path}: shorter than minLength")
|
||||
return errors
|
||||
|
||||
if schema_type == "number":
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
return [f"{path}: expected number"]
|
||||
if schema.get("minimum") is not None and value < schema["minimum"]:
|
||||
errors.append(f"{path}: below minimum {schema['minimum']}")
|
||||
if schema.get("maximum") is not None and value > schema["maximum"]:
|
||||
errors.append(f"{path}: above maximum {schema['maximum']}")
|
||||
return errors
|
||||
|
||||
if schema_type == "integer":
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
return [f"{path}: expected integer"]
|
||||
return errors
|
||||
|
||||
if schema_type == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
return [f"{path}: expected boolean"]
|
||||
return errors
|
||||
|
||||
if isinstance(schema_type, list):
|
||||
matched = False
|
||||
nested_errors: List[str] = []
|
||||
for candidate in schema_type:
|
||||
candidate_schema = {**schema, "type": candidate}
|
||||
candidate_errors = validate_json_schema(value, candidate_schema, path)
|
||||
if not candidate_errors:
|
||||
matched = True
|
||||
break
|
||||
nested_errors.extend(candidate_errors)
|
||||
if not matched:
|
||||
errors.append(f"{path}: did not match any allowed type {schema_type}; {nested_errors[:2]}")
|
||||
return errors
|
||||
|
||||
enum = schema.get("enum")
|
||||
if enum is not None and value not in enum:
|
||||
errors.append(f"{path}: value {value!r} not in enum")
|
||||
return errors
|
||||
|
||||
|
||||
TRIPLE_SCHEMA: Dict[str, Any] = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": [
|
||||
"subject",
|
||||
"predicate",
|
||||
"object",
|
||||
"fact",
|
||||
"valid_at",
|
||||
"invalid_at",
|
||||
"source",
|
||||
"source_file",
|
||||
"evidence",
|
||||
"confidence",
|
||||
"metadata",
|
||||
],
|
||||
"properties": {
|
||||
"subject": {"type": "string", "minLength": 1},
|
||||
"predicate": {"type": "string", "minLength": 1},
|
||||
"object": {"type": "string", "minLength": 1},
|
||||
"fact": {"type": "string", "minLength": 1},
|
||||
"valid_at": {"type": ["string", "null"]},
|
||||
"invalid_at": {"type": ["string", "null"]},
|
||||
"source": {"type": ["string", "null"]},
|
||||
"source_file": {"type": ["string", "null"]},
|
||||
"evidence": {"type": "string", "minLength": 1},
|
||||
"confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
||||
"metadata": {"type": "object"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def object_schema(properties: Dict[str, Any], required: List[str] | None = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": required or list(properties.keys()),
|
||||
"properties": properties,
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"""Filesystem queue for external desktop agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .json_schema import validate_json_schema
|
||||
from .schemas import AgentNeedResponse, AgentRequest, AgentResponse, ValidationResult
|
||||
from .state import RunStore
|
||||
|
||||
|
||||
class AgentQueueError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class AgentQueue:
|
||||
def __init__(self, run_dir: str | Path):
|
||||
self.store = RunStore(run_dir)
|
||||
self.store.ensure_layout()
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
task_type: str,
|
||||
stage: str,
|
||||
expected_schema: Dict[str, Any],
|
||||
input_text: Optional[str] = None,
|
||||
input_files: Optional[List[str]] = None,
|
||||
structured_input: Optional[Dict[str, Any]] = None,
|
||||
system_prompt: str = "",
|
||||
user_prompt: str = "",
|
||||
validation_rules: Optional[Dict[str, Any]] = None,
|
||||
retry_policy: Optional[Dict[str, Any]] = None,
|
||||
context_refs: Optional[List[str]] = None,
|
||||
output_contract: Optional[Dict[str, Any]] = None,
|
||||
) -> AgentNeedResponse:
|
||||
request_id = self.store.next_request_id()
|
||||
request = AgentRequest(
|
||||
request_id=request_id,
|
||||
run_id=run_id,
|
||||
type=task_type,
|
||||
stage=stage,
|
||||
input_text=input_text,
|
||||
input_files=input_files or [],
|
||||
structured_input=structured_input or {},
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
expected_schema=expected_schema,
|
||||
validation_rules=validation_rules or {},
|
||||
retry_policy=retry_policy or {},
|
||||
context_refs=context_refs or [],
|
||||
output_contract=output_contract or {},
|
||||
)
|
||||
request_file = self.store.requests_dir / f"{request_id}.json"
|
||||
request_file.write_text(request.model_dump_json(indent=2), encoding="utf-8")
|
||||
return AgentNeedResponse(
|
||||
request_id=request_id,
|
||||
request_file=str(request_file),
|
||||
expected_response_file=str(self.store.responses_dir / f"{request_id}.json"),
|
||||
stage=stage,
|
||||
type=task_type,
|
||||
)
|
||||
|
||||
def list_requests(self) -> List[Dict[str, Any]]:
|
||||
requests = []
|
||||
for path in sorted(self.store.requests_dir.glob("req_*.json")):
|
||||
request = self.load_request(path.stem)
|
||||
response_path = self.store.responses_dir / path.name
|
||||
requests.append(
|
||||
{
|
||||
"request_id": request.request_id,
|
||||
"type": request.type,
|
||||
"stage": request.stage,
|
||||
"request_file": str(path),
|
||||
"expected_response_file": str(response_path),
|
||||
"has_response": response_path.exists(),
|
||||
}
|
||||
)
|
||||
return requests
|
||||
|
||||
def load_request(self, request_id: str) -> AgentRequest:
|
||||
path = self.store.requests_dir / f"{request_id}.json"
|
||||
if not path.exists():
|
||||
raise AgentQueueError(f"request not found: {request_id}")
|
||||
return AgentRequest.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
|
||||
def save_request(self, request: AgentRequest) -> None:
|
||||
path = self.store.requests_dir / f"{request.request_id}.json"
|
||||
path.write_text(request.model_dump_json(indent=2), encoding="utf-8")
|
||||
|
||||
def load_response(self, request_id: str) -> AgentResponse:
|
||||
path = self.store.responses_dir / f"{request_id}.json"
|
||||
if not path.exists():
|
||||
raise AgentQueueError(f"response not found: {request_id}")
|
||||
return AgentResponse.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
|
||||
def validate_response_file(
|
||||
self,
|
||||
response_path: str | Path,
|
||||
*,
|
||||
request_id: Optional[str] = None,
|
||||
) -> ValidationResult:
|
||||
errors: List[str] = []
|
||||
path = Path(response_path)
|
||||
try:
|
||||
response = AgentResponse.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return ValidationResult(ok=False, errors=[f"response file not found: {path}"])
|
||||
except (ValidationError, json.JSONDecodeError, ValueError) as exc:
|
||||
return ValidationResult(ok=False, errors=[f"invalid response schema: {exc}"])
|
||||
|
||||
if request_id and response.request_id != request_id:
|
||||
errors.append(f"response request_id {response.request_id} does not match {request_id}")
|
||||
if path.stem.startswith("req_") and response.request_id != path.stem:
|
||||
errors.append(f"response request_id {response.request_id} does not match response file name {path.name}")
|
||||
|
||||
try:
|
||||
request = self.load_request(response.request_id)
|
||||
except AgentQueueError as exc:
|
||||
errors.append(str(exc))
|
||||
return ValidationResult(ok=False, errors=errors)
|
||||
|
||||
if response.status in {"ok", "skipped"}:
|
||||
errors.extend(validate_json_schema(response.output, request.expected_schema))
|
||||
elif response.status == "error" and not response.error:
|
||||
errors.append("error response must include error")
|
||||
|
||||
return ValidationResult(ok=not errors, errors=errors)
|
||||
|
||||
def submit_response(self, response_path: str | Path) -> ValidationResult:
|
||||
result = self.validate_response_file(response_path)
|
||||
if not result.ok:
|
||||
repair = self._maybe_create_repair_request(response_path, result.errors)
|
||||
result.repair_request = repair
|
||||
return result
|
||||
|
||||
source = Path(response_path)
|
||||
response = AgentResponse.model_validate_json(source.read_text(encoding="utf-8"))
|
||||
destination = self.store.responses_dir / f"{response.request_id}.json"
|
||||
if source.resolve() != destination.resolve():
|
||||
shutil.copyfile(source, destination)
|
||||
return result
|
||||
|
||||
def _maybe_create_repair_request(
|
||||
self,
|
||||
response_path: str | Path,
|
||||
errors: List[str],
|
||||
) -> Optional[AgentNeedResponse]:
|
||||
try:
|
||||
raw = json.loads(Path(response_path).read_text(encoding="utf-8"))
|
||||
request_id = raw.get("request_id")
|
||||
request = self.load_request(request_id)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
policy = request.retry_policy
|
||||
if policy.repair_attempts_used >= policy.max_repair_attempts:
|
||||
return None
|
||||
|
||||
policy.repair_attempts_used += 1
|
||||
request.retry_policy = policy
|
||||
self.save_request(request)
|
||||
return self.create_request(
|
||||
run_id=request.run_id,
|
||||
task_type="repair_invalid_json",
|
||||
stage=request.stage,
|
||||
expected_schema=request.expected_schema,
|
||||
structured_input={
|
||||
"original_request": request.model_dump(),
|
||||
"invalid_response": raw,
|
||||
"validation_errors": errors,
|
||||
},
|
||||
system_prompt="Repair the invalid agent output so it strictly matches expected_schema.",
|
||||
user_prompt="Return only the repaired output object. Do not wrap it in an AgentResponse envelope.",
|
||||
retry_policy=policy.model_dump(),
|
||||
output_contract={"repairs_request_id": request.request_id, "return_shape": "output_only"},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,169 @@
|
|||
"""Strict request/response schemas for desktop-agent driven runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
AgentTaskType = Literal[
|
||||
"extract_triples",
|
||||
"generate_ontology",
|
||||
"generate_oasis_profiles",
|
||||
"generate_simulation_config",
|
||||
"simulate_agent_action",
|
||||
"summarize_round",
|
||||
"update_memory",
|
||||
"generate_report",
|
||||
"answer_followup_question",
|
||||
"answer_agent_question",
|
||||
"answer_agent_questionnaire",
|
||||
"summarize_questionnaire",
|
||||
"ask_report_question",
|
||||
"validate_json_output",
|
||||
"repair_invalid_json",
|
||||
]
|
||||
|
||||
AGENT_TASK_TYPES = {
|
||||
"extract_triples",
|
||||
"generate_ontology",
|
||||
"generate_oasis_profiles",
|
||||
"generate_simulation_config",
|
||||
"simulate_agent_action",
|
||||
"summarize_round",
|
||||
"update_memory",
|
||||
"generate_report",
|
||||
"answer_followup_question",
|
||||
"answer_agent_question",
|
||||
"answer_agent_questionnaire",
|
||||
"summarize_questionnaire",
|
||||
"ask_report_question",
|
||||
"validate_json_output",
|
||||
"repair_invalid_json",
|
||||
}
|
||||
|
||||
|
||||
class StageStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
WAITING_AGENT = "waiting_agent"
|
||||
AWAITING_USER_CONFIRMATION = "awaiting_user_confirmation"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RetryPolicy(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
max_repair_attempts: int = Field(default=1, ge=0)
|
||||
repair_attempts_used: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class AgentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
request_id: str
|
||||
run_id: str
|
||||
type: AgentTaskType
|
||||
stage: str
|
||||
input_text: Optional[str] = None
|
||||
input_files: List[str] = Field(default_factory=list)
|
||||
structured_input: Dict[str, Any] = Field(default_factory=dict)
|
||||
system_prompt: str = ""
|
||||
user_prompt: str = ""
|
||||
expected_schema: Dict[str, Any]
|
||||
validation_rules: Dict[str, Any] = Field(default_factory=dict)
|
||||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||
context_refs: List[str] = Field(default_factory=list)
|
||||
output_contract: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
|
||||
@field_validator("request_id")
|
||||
@classmethod
|
||||
def validate_request_id(cls, value: str) -> str:
|
||||
if not value.startswith("req_"):
|
||||
raise ValueError("request_id must start with req_")
|
||||
return value
|
||||
|
||||
@field_validator("expected_schema")
|
||||
@classmethod
|
||||
def validate_expected_schema(cls, value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not value or value.get("type") != "object":
|
||||
raise ValueError("expected_schema must be a non-empty JSON object schema")
|
||||
return value
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
request_id: str
|
||||
status: Literal["ok", "error", "skipped"]
|
||||
output: Dict[str, Any] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
validation: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_validator("request_id")
|
||||
@classmethod
|
||||
def validate_request_id(cls, value: str) -> str:
|
||||
if not value.startswith("req_"):
|
||||
raise ValueError("request_id must start with req_")
|
||||
return value
|
||||
|
||||
|
||||
class AgentNeedResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
status: Literal["need_agent_response"] = "need_agent_response"
|
||||
request_id: str
|
||||
request_file: str
|
||||
expected_response_file: str
|
||||
stage: str
|
||||
type: str
|
||||
|
||||
|
||||
class StageCheckpoint(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
status: StageStatus = StageStatus.PENDING
|
||||
request_ids: List[str] = Field(default_factory=list)
|
||||
artifact_paths: List[str] = Field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
stale: bool = False
|
||||
stale_reason: Optional[str] = None
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
updated_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
|
||||
|
||||
class RunState(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
run_id: str
|
||||
run_dir: str
|
||||
requirement: str
|
||||
seed_files: List[str] = Field(default_factory=list)
|
||||
mode: str = "agent"
|
||||
workflow_mode: Literal["auto", "staged"] = "auto"
|
||||
current_stage: str = "ontology"
|
||||
stages: Dict[str, StageCheckpoint]
|
||||
seed_path: Optional[str] = None
|
||||
simulation_settings: Dict[str, Any] = Field(default_factory=dict)
|
||||
graph_summary: Dict[str, Any] = Field(default_factory=dict)
|
||||
profiles_summary: Dict[str, Any] = Field(default_factory=dict)
|
||||
config_summary: Dict[str, Any] = Field(default_factory=dict)
|
||||
simulation_progress: Dict[str, Any] = Field(default_factory=dict)
|
||||
report_artifacts: Dict[str, Any] = Field(default_factory=dict)
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
updated_at: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
ok: bool
|
||||
errors: List[str] = Field(default_factory=list)
|
||||
repair_request: Optional[AgentNeedResponse] = None
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"""Persistent run state and directory layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Optional
|
||||
|
||||
from .schemas import RunState, StageCheckpoint, StageStatus
|
||||
|
||||
|
||||
RUN_STAGES = [
|
||||
"ontology",
|
||||
"graph",
|
||||
"profiles",
|
||||
"config",
|
||||
"simulation",
|
||||
"report",
|
||||
"artifacts",
|
||||
]
|
||||
|
||||
STAGED_RUN_STAGES = [
|
||||
"seed_input",
|
||||
"prediction_requirement",
|
||||
"simulation_settings",
|
||||
"graph_build",
|
||||
"profile_and_config",
|
||||
"simulation_run",
|
||||
"report_generation",
|
||||
"followup_question",
|
||||
]
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
|
||||
class RunStore:
|
||||
def __init__(self, run_dir: str | Path):
|
||||
self.run_dir = Path(run_dir)
|
||||
self.requests_dir = self.run_dir / "requests"
|
||||
self.responses_dir = self.run_dir / "responses"
|
||||
self.artifacts_dir = self.run_dir / "artifacts"
|
||||
self.state_path = self.run_dir / "state.json"
|
||||
|
||||
def ensure_layout(self) -> None:
|
||||
self.requests_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.responses_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def init_state(
|
||||
self,
|
||||
run_id: str,
|
||||
requirement: str,
|
||||
seed_files: Iterable[str],
|
||||
mode: str = "agent",
|
||||
workflow_mode: str = "auto",
|
||||
simulation_settings: Optional[Dict] = None,
|
||||
seed_path: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
) -> RunState:
|
||||
self.ensure_layout()
|
||||
stage_names = STAGED_RUN_STAGES if workflow_mode == "staged" else RUN_STAGES
|
||||
stages = {name: StageCheckpoint(name=name) for name in stage_names}
|
||||
state = RunState(
|
||||
run_id=run_id,
|
||||
run_dir=str(self.run_dir),
|
||||
requirement=requirement,
|
||||
seed_files=list(seed_files),
|
||||
mode=mode,
|
||||
workflow_mode=workflow_mode,
|
||||
current_stage=stage_names[0],
|
||||
seed_path=seed_path,
|
||||
simulation_settings=simulation_settings or {},
|
||||
stages=stages,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self.save(state)
|
||||
return state
|
||||
|
||||
def load(self) -> RunState:
|
||||
with self.state_path.open("r", encoding="utf-8") as handle:
|
||||
return RunState.model_validate_json(handle.read())
|
||||
|
||||
def save(self, state: RunState) -> None:
|
||||
self.ensure_layout()
|
||||
state.updated_at = utc_now()
|
||||
with self.state_path.open("w", encoding="utf-8") as handle:
|
||||
handle.write(state.model_dump_json(indent=2))
|
||||
|
||||
def set_stage(
|
||||
self,
|
||||
state: RunState,
|
||||
stage: str,
|
||||
status: StageStatus,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
checkpoint = state.stages[stage]
|
||||
checkpoint.status = status
|
||||
checkpoint.error = error
|
||||
checkpoint.updated_at = utc_now()
|
||||
state.current_stage = stage
|
||||
self.save(state)
|
||||
|
||||
def add_stage_request(self, state: RunState, stage: str, request_id: str) -> None:
|
||||
checkpoint = state.stages[stage]
|
||||
if request_id not in checkpoint.request_ids:
|
||||
checkpoint.request_ids.append(request_id)
|
||||
checkpoint.status = StageStatus.WAITING_AGENT
|
||||
checkpoint.updated_at = utc_now()
|
||||
state.current_stage = stage
|
||||
self.save(state)
|
||||
|
||||
def add_stage_artifact(self, state: RunState, stage: str, artifact_path: str) -> None:
|
||||
checkpoint = state.stages.get(stage) or state.stages[state.current_stage]
|
||||
if artifact_path not in checkpoint.artifact_paths:
|
||||
checkpoint.artifact_paths.append(artifact_path)
|
||||
checkpoint.updated_at = utc_now()
|
||||
self.save(state)
|
||||
|
||||
def next_request_id(self) -> str:
|
||||
self.ensure_layout()
|
||||
max_seen = 0
|
||||
for folder in (self.requests_dir, self.responses_dir):
|
||||
for path in folder.glob("req_*.json"):
|
||||
try:
|
||||
max_seen = max(max_seen, int(path.stem.split("_", 1)[1]))
|
||||
except (IndexError, ValueError):
|
||||
continue
|
||||
return f"req_{max_seen + 1:06d}"
|
||||
|
||||
def read_seed_text(self, max_chars: int = 200_000) -> str:
|
||||
state = self.load()
|
||||
parts = []
|
||||
for seed_file in state.seed_files:
|
||||
path = Path(seed_file)
|
||||
if not path.is_absolute():
|
||||
path = self.run_dir / path
|
||||
if path.exists():
|
||||
parts.append(path.read_text(encoding="utf-8")[:max_chars])
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def as_status(self) -> Dict:
|
||||
state = self.load()
|
||||
return json.loads(state.model_dump_json())
|
||||
|
||||
|
||||
def default_runs_dir() -> str:
|
||||
return os.environ.get("MIROFISH_RUNS_DIR", "./runs")
|
||||
|
|
@ -7,8 +7,10 @@ from flask import Blueprint
|
|||
graph_bp = Blueprint('graph', __name__)
|
||||
simulation_bp = Blueprint('simulation', __name__)
|
||||
report_bp = Blueprint('report', __name__)
|
||||
interaction_bp = Blueprint('interaction', __name__)
|
||||
|
||||
from . import graph # noqa: E402, F401
|
||||
from . import simulation # noqa: E402, F401
|
||||
from . import report # noqa: E402, F401
|
||||
from . import interaction # noqa: E402, F401
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ from ..models.project import ProjectManager, ProjectStatus
|
|||
logger = get_logger('mirofish.api')
|
||||
|
||||
|
||||
def _legacy_zep_config_errors() -> list[str]:
|
||||
"""Only legacy zep provider requires ZEP_API_KEY."""
|
||||
graph_provider = os.environ.get('MIROFISH_GRAPH_PROVIDER', Config.MIROFISH_GRAPH_PROVIDER)
|
||||
if graph_provider == 'zep' and not Config.ZEP_API_KEY:
|
||||
return [t('api.zepApiKeyMissing')]
|
||||
return []
|
||||
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""检查文件扩展名是否允许"""
|
||||
if not filename or '.' not in filename:
|
||||
|
|
@ -248,6 +256,7 @@ def generate_ontology():
|
|||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("生成本体定义失败")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
|
|
@ -284,9 +293,7 @@ def build_graph():
|
|||
logger.info("=== 开始构建图谱 ===")
|
||||
|
||||
# 检查配置
|
||||
errors = []
|
||||
if not Config.ZEP_API_KEY:
|
||||
errors.append(t('api.zepApiKeyMissing'))
|
||||
errors = _legacy_zep_config_errors()
|
||||
if errors:
|
||||
logger.error(f"配置错误: {errors}")
|
||||
return jsonify({
|
||||
|
|
@ -387,7 +394,7 @@ def build_graph():
|
|||
)
|
||||
|
||||
# 创建图谱构建服务
|
||||
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
|
||||
builder = GraphBuilderService()
|
||||
|
||||
# 分块
|
||||
task_manager.update_task(
|
||||
|
|
@ -522,6 +529,7 @@ def build_graph():
|
|||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"获取图谱数据失败: graph_id={graph_id}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
|
|
@ -572,13 +580,13 @@ def get_graph_data(graph_id: str):
|
|||
获取图谱数据(节点和边)
|
||||
"""
|
||||
try:
|
||||
if not Config.ZEP_API_KEY:
|
||||
if _legacy_zep_config_errors():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.zepApiKeyMissing')
|
||||
}), 500
|
||||
|
||||
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
|
||||
builder = GraphBuilderService()
|
||||
graph_data = builder.get_graph_data(graph_id)
|
||||
|
||||
return jsonify({
|
||||
|
|
@ -600,13 +608,13 @@ def delete_graph(graph_id: str):
|
|||
删除Zep图谱
|
||||
"""
|
||||
try:
|
||||
if not Config.ZEP_API_KEY:
|
||||
if _legacy_zep_config_errors():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.zepApiKeyMissing')
|
||||
}), 500
|
||||
|
||||
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
|
||||
builder = GraphBuilderService()
|
||||
builder.delete_graph(graph_id)
|
||||
|
||||
return jsonify({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
"""
|
||||
Agent interaction API routes.
|
||||
|
||||
Provides REST endpoints for the Web Console to interact with agents,
|
||||
send questionnaires, ask report questions, and poll request status.
|
||||
All LLM work goes through AgentRuntime / agent_queue — never direct model calls.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from . import interaction_bp
|
||||
from ..agent_engine.runner import PredictionRunService
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger("mirofish.api.interaction")
|
||||
|
||||
|
||||
def _service():
|
||||
return PredictionRunService()
|
||||
|
||||
|
||||
def _run_dir_from_param():
|
||||
"""Resolve run_dir from query param ?run= or JSON body run field."""
|
||||
run = request.args.get("run") or ""
|
||||
if not run:
|
||||
body = request.get_json(silent=True) or {}
|
||||
run = body.get("run", "")
|
||||
if not run:
|
||||
return None, (jsonify({"success": False, "error": "missing required 'run' parameter"}), 400)
|
||||
return run, None
|
||||
|
||||
|
||||
# ── Agents ────────────────────────────────────────────────────────────────
|
||||
|
||||
@interaction_bp.route("/agents", methods=["GET"])
|
||||
def list_agents():
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().list_agents(run)})
|
||||
|
||||
|
||||
@interaction_bp.route("/agents/<agent_id>", methods=["GET"])
|
||||
def get_agent(agent_id: str):
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().get_agent(run, agent_id)})
|
||||
|
||||
|
||||
@interaction_bp.route("/agents/<agent_id>/ask", methods=["POST"])
|
||||
def ask_agent(agent_id: str):
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
body = request.get_json() or {}
|
||||
question = body.get("question", "")
|
||||
if not question:
|
||||
return jsonify({"success": False, "error": "missing required 'question' field"}), 400
|
||||
limit = body.get("limit", 20)
|
||||
result = _service().ask_agent(run, agent_id, question, limit)
|
||||
status_code = 202 if result.get("status") == "need_agent_response" else 200
|
||||
return jsonify({"success": True, "data": result}), status_code
|
||||
|
||||
|
||||
@interaction_bp.route("/agents/answer/<request_id>", methods=["POST", "GET"])
|
||||
def get_agent_answer(request_id: str):
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().get_agent_answer(run, request_id)})
|
||||
|
||||
|
||||
# ── Questionnaires ────────────────────────────────────────────────────────
|
||||
|
||||
@interaction_bp.route("/questionnaires", methods=["POST"])
|
||||
def send_questionnaire():
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
body = request.get_json() or {}
|
||||
questions = body.get("questions", [])
|
||||
if not questions:
|
||||
return jsonify({"success": False, "error": "missing required 'questions' array"}), 400
|
||||
result = _service().send_questionnaire(run, questions)
|
||||
status_code = 202 if result.get("status") == "need_agent_response" else 200
|
||||
return jsonify({"success": True, "data": result}), status_code
|
||||
|
||||
|
||||
@interaction_bp.route("/questionnaires/<questionnaire_id>", methods=["GET"])
|
||||
def get_questionnaire_result(questionnaire_id: str):
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().get_questionnaire_result(run, questionnaire_id)})
|
||||
|
||||
|
||||
# ── Report Questions ──────────────────────────────────────────────────────
|
||||
|
||||
@interaction_bp.route("/report-questions", methods=["POST"])
|
||||
def ask_report_question():
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
body = request.get_json() or {}
|
||||
question = body.get("question", "")
|
||||
if not question:
|
||||
return jsonify({"success": False, "error": "missing required 'question' field"}), 400
|
||||
limit = body.get("limit", 20)
|
||||
result = _service().ask_report_question(run, question, limit)
|
||||
status_code = 202 if result.get("status") == "need_agent_response" else 200
|
||||
return jsonify({"success": True, "data": result}), status_code
|
||||
|
||||
|
||||
@interaction_bp.route("/report-questions/answer/<request_id>", methods=["POST", "GET"])
|
||||
def get_report_question_answer(request_id: str):
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().get_report_question_answer(run, request_id)})
|
||||
|
||||
|
||||
# ── Request Polling & Response Submission ─────────────────────────────────
|
||||
|
||||
@interaction_bp.route("/requests/<request_id>", methods=["GET"])
|
||||
def get_request(request_id: str):
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().get_request(run, request_id)})
|
||||
|
||||
|
||||
@interaction_bp.route("/requests/<request_id>/status", methods=["GET"])
|
||||
def get_request_status(request_id: str):
|
||||
"""Lightweight polling endpoint: returns whether a response exists yet."""
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
svc = _service()
|
||||
req_data = svc.get_request(run, request_id)
|
||||
if req_data.get("status") == "error":
|
||||
return jsonify({"success": False, "error": req_data.get("error")}), 404
|
||||
# Check if response file exists
|
||||
from ..agent_engine.state import RunStore
|
||||
store = RunStore(run)
|
||||
response_path = store.responses_dir / f"{request_id}.json"
|
||||
has_response = response_path.exists()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"request_id": request_id,
|
||||
"has_response": has_response,
|
||||
"status": "answered" if has_response else "pending",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@interaction_bp.route("/responses", methods=["POST"])
|
||||
def submit_response():
|
||||
"""Submit an agent response (validate + persist)."""
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
body = request.get_json() or {}
|
||||
response_path = body.get("response_path", "")
|
||||
if not response_path:
|
||||
return jsonify({"success": False, "error": "missing required 'response_path' field"}), 400
|
||||
# Path constraint: response_path must resolve inside run/responses/
|
||||
from ..agent_engine.state import RunStore
|
||||
store = RunStore(run)
|
||||
allowed_base = store.responses_dir.resolve()
|
||||
resolved = Path(response_path).resolve()
|
||||
if not str(resolved).startswith(str(allowed_base) + os.sep) and resolved != allowed_base:
|
||||
return jsonify({"success": False, "error": "forbidden: response_path must be inside run responses directory"}), 403
|
||||
result = _service().submit_response(run, response_path)
|
||||
return jsonify({"success": True, "data": result})
|
||||
|
||||
|
||||
# ── Artifacts ─────────────────────────────────────────────────────────────
|
||||
|
||||
@interaction_bp.route("/artifacts", methods=["GET"])
|
||||
def list_artifacts():
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
return jsonify({"success": True, "data": _service().list_artifacts(run)})
|
||||
|
||||
|
||||
@interaction_bp.route("/artifact/<path:name>", methods=["GET"])
|
||||
def get_artifact(name: str):
|
||||
"""Return a single artifact's content as JSON or text."""
|
||||
run, err = _run_dir_from_param()
|
||||
if err:
|
||||
return err
|
||||
from ..agent_engine.state import RunStore
|
||||
store = RunStore(run)
|
||||
base = store.artifacts_dir.resolve()
|
||||
path = (base / name).resolve()
|
||||
# Path traversal guard: resolved path must stay inside artifacts_dir
|
||||
if not str(path).startswith(str(base) + os.sep) and path != base:
|
||||
return jsonify({"success": False, "error": "forbidden path"}), 403
|
||||
if not path.exists():
|
||||
return jsonify({"success": False, "error": f"artifact not found: {name}"}), 404
|
||||
if name.endswith(".json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return jsonify({"success": True, "data": data, "name": name})
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({"success": False, "error": "invalid JSON"}), 500
|
||||
else:
|
||||
return jsonify({"success": True, "data": path.read_text(encoding="utf-8"), "name": name})
|
||||
|
|
@ -20,6 +20,11 @@ from ..models.project import ProjectManager
|
|||
logger = get_logger('mirofish.api.simulation')
|
||||
|
||||
|
||||
def _legacy_zep_required() -> bool:
|
||||
graph_provider = os.environ.get('MIROFISH_GRAPH_PROVIDER', Config.MIROFISH_GRAPH_PROVIDER)
|
||||
return graph_provider == 'zep' and not Config.ZEP_API_KEY
|
||||
|
||||
|
||||
# Interview prompt 优化前缀
|
||||
# 添加此前缀可以避免Agent调用工具,直接用文本回复
|
||||
INTERVIEW_PROMPT_PREFIX = "结合你的人设、所有的过往记忆与行动,不调用任何工具直接用文本回复我:"
|
||||
|
|
@ -57,7 +62,7 @@ def get_graph_entities(graph_id: str):
|
|||
enrich: 是否获取相关边信息(默认true)
|
||||
"""
|
||||
try:
|
||||
if not Config.ZEP_API_KEY:
|
||||
if _legacy_zep_required():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.zepApiKeyMissing')
|
||||
|
|
@ -94,7 +99,7 @@ def get_graph_entities(graph_id: str):
|
|||
def get_entity_detail(graph_id: str, entity_uuid: str):
|
||||
"""获取单个实体的详细信息"""
|
||||
try:
|
||||
if not Config.ZEP_API_KEY:
|
||||
if _legacy_zep_required():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.zepApiKeyMissing')
|
||||
|
|
@ -127,7 +132,7 @@ def get_entity_detail(graph_id: str, entity_uuid: str):
|
|||
def get_entities_by_type(graph_id: str, entity_type: str):
|
||||
"""获取指定类型的所有实体"""
|
||||
try:
|
||||
if not Config.ZEP_API_KEY:
|
||||
if _legacy_zep_required():
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": t('api.zepApiKeyMissing')
|
||||
|
|
@ -1547,7 +1552,11 @@ def start_simulation():
|
|||
if state.status == SimulationStatus.RUNNING:
|
||||
# 检查模拟进程是否真的在运行
|
||||
run_state = SimulationRunner.get_run_state(simulation_id)
|
||||
if run_state and run_state.runner_status.value == "running":
|
||||
if (
|
||||
run_state
|
||||
and run_state.runner_status.value == "running"
|
||||
and SimulationRunner.is_process_alive(simulation_id)
|
||||
):
|
||||
# 进程确实在运行
|
||||
if force:
|
||||
# 强制模式:停止运行中的模拟
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ class Config:
|
|||
# Flask配置
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
|
||||
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
|
||||
PRESERVE_SIMULATIONS_ON_RELOAD = os.environ.get(
|
||||
'MIROFISH_PRESERVE_SIMULATIONS_ON_RELOAD',
|
||||
'true' if DEBUG else 'false'
|
||||
).lower() == 'true'
|
||||
|
||||
# Agent engine配置
|
||||
MIROFISH_MODE = os.environ.get('MIROFISH_MODE', 'agent')
|
||||
MIROFISH_LLM_PROVIDER = os.environ.get(
|
||||
'MIROFISH_LLM_PROVIDER',
|
||||
'agent_queue' if MIROFISH_MODE == 'agent' else 'openai_compatible'
|
||||
)
|
||||
MIROFISH_GRAPH_PROVIDER = os.environ.get(
|
||||
'MIROFISH_GRAPH_PROVIDER',
|
||||
'graphiti' if MIROFISH_MODE == 'agent' else 'zep'
|
||||
)
|
||||
MIROFISH_RUNS_DIR = os.environ.get('MIROFISH_RUNS_DIR', './runs')
|
||||
|
||||
# JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式)
|
||||
JSON_AS_ASCII = False
|
||||
|
|
@ -34,6 +50,14 @@ class Config:
|
|||
|
||||
# Zep配置
|
||||
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
|
||||
|
||||
# Graphiti / Neo4j配置
|
||||
NEO4J_URI = os.environ.get('NEO4J_URI', 'bolt://localhost:7687')
|
||||
NEO4J_USER = os.environ.get('NEO4J_USER', 'neo4j')
|
||||
NEO4J_PASSWORD = os.environ.get('NEO4J_PASSWORD', 'password')
|
||||
NEO4J_DATABASE = os.environ.get('NEO4J_DATABASE', 'neo4j')
|
||||
OLLAMA_BASE_URL = os.environ.get('OLLAMA_BASE_URL', 'http://localhost:11434')
|
||||
OLLAMA_EMBEDDING_MODEL = os.environ.get('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text')
|
||||
|
||||
# 文件上传配置
|
||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
|
||||
|
|
@ -67,9 +91,18 @@ class Config:
|
|||
def validate(cls) -> list[str]:
|
||||
"""验证必要配置"""
|
||||
errors: list[str] = []
|
||||
if not cls.LLM_API_KEY:
|
||||
errors.append("LLM_API_KEY 未配置")
|
||||
if not cls.ZEP_API_KEY:
|
||||
errors.append("ZEP_API_KEY 未配置")
|
||||
return errors
|
||||
mode = os.environ.get('MIROFISH_MODE', cls.MIROFISH_MODE)
|
||||
llm_provider = os.environ.get(
|
||||
'MIROFISH_LLM_PROVIDER',
|
||||
'agent_queue' if mode == 'agent' else cls.MIROFISH_LLM_PROVIDER
|
||||
)
|
||||
graph_provider = os.environ.get(
|
||||
'MIROFISH_GRAPH_PROVIDER',
|
||||
'graphiti' if mode == 'agent' else cls.MIROFISH_GRAPH_PROVIDER
|
||||
)
|
||||
|
||||
if llm_provider == 'openai_compatible' and not os.environ.get('LLM_API_KEY', cls.LLM_API_KEY or ''):
|
||||
errors.append("LLM_API_KEY 未配置(openai_compatible provider 需要)")
|
||||
if graph_provider == 'zep' and not os.environ.get('ZEP_API_KEY', cls.ZEP_API_KEY or ''):
|
||||
errors.append("ZEP_API_KEY 未配置(legacy zep provider 需要)")
|
||||
return errors
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""MiroFish MCP server package."""
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
"""FastMCP server exposing MiroFish run lifecycle tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..agent_engine.runner import PredictionRunService
|
||||
|
||||
|
||||
def create_server():
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"MCP Python SDK is not installed. Install package 'mcp' to run the MiroFish MCP server."
|
||||
) from exc
|
||||
|
||||
mcp = FastMCP("mirofish-agent-engine")
|
||||
service = PredictionRunService()
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_create_run(
|
||||
seed: str,
|
||||
requirement: str,
|
||||
output: str,
|
||||
mode: str = "auto",
|
||||
rounds: int = 10,
|
||||
round_unit: str = "year",
|
||||
minutes_per_round: Optional[int] = None,
|
||||
pause_each_round: bool = False,
|
||||
agent_count: Optional[int] = None,
|
||||
simulation_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return service.create_run(
|
||||
seed,
|
||||
requirement,
|
||||
output,
|
||||
mode=mode,
|
||||
rounds=rounds,
|
||||
round_unit=round_unit,
|
||||
minutes_per_round=minutes_per_round,
|
||||
pause_each_round=pause_each_round,
|
||||
agent_count=agent_count,
|
||||
simulation_name=simulation_name,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_run(run: str) -> Dict[str, Any]:
|
||||
return service.run(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_resume_run(run: str) -> Dict[str, Any]:
|
||||
return service.resume(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_status(run: str) -> Dict[str, Any]:
|
||||
return service.status(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_current_stage(run: str) -> Dict[str, Any]:
|
||||
return service.get_current_stage(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_update_simulation_settings(
|
||||
run: str,
|
||||
rounds: Optional[int] = None,
|
||||
round_unit: Optional[str] = None,
|
||||
minutes_per_round: Optional[int] = None,
|
||||
pause_each_round: Optional[bool] = None,
|
||||
agent_count: Optional[int] = None,
|
||||
simulation_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return service.update_simulation_settings(
|
||||
run,
|
||||
rounds=rounds,
|
||||
round_unit=round_unit,
|
||||
minutes_per_round=minutes_per_round,
|
||||
pause_each_round=pause_each_round,
|
||||
agent_count=agent_count,
|
||||
simulation_name=simulation_name,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_approve_stage(run: str) -> Dict[str, Any]:
|
||||
return service.approve_stage(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_reject_stage(run: str, reason: str = "") -> Dict[str, Any]:
|
||||
return service.reject_stage(run, reason)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_rerun_stage(run: str, stage: str) -> Dict[str, Any]:
|
||||
return service.rerun_stage(run, stage)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_list_requests(run: str) -> Dict[str, Any]:
|
||||
return service.list_requests(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_request(run: str, request_id: str) -> Dict[str, Any]:
|
||||
return service.get_request(run, request_id)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_submit_response(run: str, response: str) -> Dict[str, Any]:
|
||||
return service.submit_response(run, response)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_validate_response(run: str, response: str) -> Dict[str, Any]:
|
||||
return service.validate_response(run, response)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_build_graph(run: str, provider: Optional[str] = None, mode: str = "agent-triples") -> Dict[str, Any]:
|
||||
return service.build_graph(run, provider=provider, mode=mode)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_search_graph(run: str, query: str, limit: int = 20) -> Dict[str, Any]:
|
||||
return service.search_graph(run, query, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_export_graph(run: str, output: Optional[str] = None) -> Dict[str, Any]:
|
||||
return service.export_graph(run, output)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_start_simulation(run: str) -> Dict[str, Any]:
|
||||
return service.start_simulation(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_resume_simulation(run: str) -> Dict[str, Any]:
|
||||
return service.resume(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_generate_report(run: str) -> Dict[str, Any]:
|
||||
return service.generate_report(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_report(run: str) -> Dict[str, Any]:
|
||||
return service.get_report(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_ask_followup_question(run: str, question: str, limit: int = 20) -> Dict[str, Any]:
|
||||
return service.ask_followup_question(run, question, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_followup_answer(run: str, request_id: str) -> Dict[str, Any]:
|
||||
return service.get_followup_answer(run, request_id)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_list_artifacts(run: str) -> Dict[str, Any]:
|
||||
return service.list_artifacts(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_generate_web_console(run: str) -> Dict[str, Any]:
|
||||
"""Generate a static Web Console HTML for the given run."""
|
||||
return service.generate_web_console(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_list_agents(run: str) -> Dict[str, Any]:
|
||||
"""List all agents from the run's profiles.json."""
|
||||
return service.list_agents(run)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_agent(run: str, agent_id: str) -> Dict[str, Any]:
|
||||
"""Get a single agent's profile by agent_id."""
|
||||
return service.get_agent(run, agent_id)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_ask_agent(run: str, agent_id: str, question: str, limit: int = 20) -> Dict[str, Any]:
|
||||
"""Ask a question to a specific agent. Creates an agent_queue request."""
|
||||
return service.ask_agent(run, agent_id, question, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_agent_answer(run: str, request_id: str) -> Dict[str, Any]:
|
||||
"""Retrieve and persist the answer for an agent question request."""
|
||||
return service.get_agent_answer(run, request_id)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_send_questionnaire(run: str, questions_json: str) -> Dict[str, Any]:
|
||||
"""Send a questionnaire to all agents.
|
||||
|
||||
questions_json should be a JSON array of objects, each with "question_id" and "question" fields.
|
||||
Example: '[{"question_id":"q1","question":"Biggest risk?"},{"question_id":"q2","question":"Opportunities?"}]'
|
||||
"""
|
||||
import json as _json
|
||||
try:
|
||||
questions = _json.loads(questions_json)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"status": "error", "error": f"questions_json must be valid JSON: {exc}"}
|
||||
if not isinstance(questions, list) or len(questions) == 0:
|
||||
return {"status": "error", "error": "questions_json must be a non-empty JSON array"}
|
||||
for i, q in enumerate(questions):
|
||||
if not isinstance(q, dict) or "question_id" not in q or "question" not in q:
|
||||
return {"status": "error", "error": f"questions_json[{i}] must have 'question_id' and 'question' fields"}
|
||||
return service.send_questionnaire(run, questions)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_questionnaire_result(run: str, questionnaire_id: str) -> Dict[str, Any]:
|
||||
"""Get the results of a questionnaire by its ID."""
|
||||
return service.get_questionnaire_result(run, questionnaire_id)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_ask_report_question(run: str, question: str, limit: int = 20) -> Dict[str, Any]:
|
||||
"""Ask a question about the prediction report. Creates an agent_queue request."""
|
||||
return service.ask_report_question(run, question, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_get_report_question_answer(run: str, request_id: str) -> Dict[str, Any]:
|
||||
"""Retrieve and persist the answer for a report question request."""
|
||||
return service.get_report_question_answer(run, request_id)
|
||||
|
||||
@mcp.tool()
|
||||
def mirofish_doctor(runs_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||
return service.doctor(runs_dir)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
def main() -> None:
|
||||
create_server().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -10,16 +10,17 @@ import threading
|
|||
from typing import Dict, Any, List, Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from zep_cloud.client import Zep
|
||||
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.graph.factory import create_graph_provider
|
||||
from ..models.task import TaskManager, TaskStatus
|
||||
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
|
||||
from ..utils.logger import get_logger
|
||||
from .text_processor import TextProcessor
|
||||
from ..utils.locale import t, get_locale, set_locale
|
||||
|
||||
|
||||
logger = get_logger('mirofish.graph_builder')
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphInfo:
|
||||
"""图谱信息"""
|
||||
|
|
@ -44,11 +45,8 @@ class GraphBuilderService:
|
|||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or Config.ZEP_API_KEY
|
||||
if not self.api_key:
|
||||
raise ValueError("ZEP_API_KEY 未配置")
|
||||
|
||||
self.client = Zep(api_key=self.api_key)
|
||||
self.api_key = api_key
|
||||
self.provider = create_graph_provider()
|
||||
self.task_manager = TaskManager()
|
||||
|
||||
def build_graph_async(
|
||||
|
|
@ -193,103 +191,17 @@ class GraphBuilderService:
|
|||
def create_graph(self, name: str) -> str:
|
||||
"""创建Zep图谱(公开方法)"""
|
||||
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
self.client.graph.create(
|
||||
graph_id=graph_id,
|
||||
name=name,
|
||||
description="MiroFish Social Simulation Graph"
|
||||
)
|
||||
|
||||
|
||||
self.provider.add_episode(graph_id, f"Graph created: {name}", {"type": "graph_created", "name": name})
|
||||
return graph_id
|
||||
|
||||
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
|
||||
"""设置图谱本体(公开方法)"""
|
||||
import warnings
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
|
||||
|
||||
# 抑制 Pydantic v2 关于 Field(default=None) 的警告
|
||||
# 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略
|
||||
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
|
||||
|
||||
# Zep 保留名称,不能作为属性名
|
||||
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
|
||||
|
||||
def safe_attr_name(attr_name: str) -> str:
|
||||
"""将保留名称转换为安全名称"""
|
||||
if attr_name.lower() in RESERVED_NAMES:
|
||||
return f"entity_{attr_name}"
|
||||
return attr_name
|
||||
|
||||
# 动态创建实体类型
|
||||
entity_types = {}
|
||||
for entity_def in ontology.get("entity_types", []):
|
||||
name = entity_def["name"]
|
||||
description = entity_def.get("description", f"A {name} entity.")
|
||||
|
||||
# 创建属性字典和类型注解(Pydantic v2 需要)
|
||||
attrs = {"__doc__": description}
|
||||
annotations = {}
|
||||
|
||||
for attr_def in entity_def.get("attributes", []):
|
||||
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
|
||||
attr_desc = attr_def.get("description", attr_name)
|
||||
# Zep API 需要 Field 的 description,这是必需的
|
||||
attrs[attr_name] = Field(description=attr_desc, default=None)
|
||||
annotations[attr_name] = Optional[EntityText] # 类型注解
|
||||
|
||||
attrs["__annotations__"] = annotations
|
||||
|
||||
# 动态创建类
|
||||
entity_class = type(name, (EntityModel,), attrs)
|
||||
entity_class.__doc__ = description
|
||||
entity_types[name] = entity_class
|
||||
|
||||
# 动态创建边类型
|
||||
edge_definitions = {}
|
||||
for edge_def in ontology.get("edge_types", []):
|
||||
name = edge_def["name"]
|
||||
description = edge_def.get("description", f"A {name} relationship.")
|
||||
|
||||
# 创建属性字典和类型注解
|
||||
attrs = {"__doc__": description}
|
||||
annotations = {}
|
||||
|
||||
for attr_def in edge_def.get("attributes", []):
|
||||
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
|
||||
attr_desc = attr_def.get("description", attr_name)
|
||||
# Zep API 需要 Field 的 description,这是必需的
|
||||
attrs[attr_name] = Field(description=attr_desc, default=None)
|
||||
annotations[attr_name] = Optional[str] # 边属性用str类型
|
||||
|
||||
attrs["__annotations__"] = annotations
|
||||
|
||||
# 动态创建类
|
||||
class_name = ''.join(word.capitalize() for word in name.split('_'))
|
||||
edge_class = type(class_name, (EdgeModel,), attrs)
|
||||
edge_class.__doc__ = description
|
||||
|
||||
# 构建source_targets
|
||||
source_targets = []
|
||||
for st in edge_def.get("source_targets", []):
|
||||
source_targets.append(
|
||||
EntityEdgeSourceTarget(
|
||||
source=st.get("source", "Entity"),
|
||||
target=st.get("target", "Entity")
|
||||
)
|
||||
)
|
||||
|
||||
if source_targets:
|
||||
edge_definitions[name] = (edge_class, source_targets)
|
||||
|
||||
# 调用Zep API设置本体
|
||||
if entity_types or edge_definitions:
|
||||
self.client.graph.set_ontology(
|
||||
graph_ids=[graph_id],
|
||||
entities=entity_types if entity_types else None,
|
||||
edges=edge_definitions if edge_definitions else None,
|
||||
)
|
||||
self.provider.add_episode(
|
||||
graph_id,
|
||||
os.linesep.join([f"Ontology: {ontology.get('entity_types', [])}", f"Edges: {ontology.get('edge_types', [])}"]),
|
||||
{"type": "ontology"},
|
||||
)
|
||||
|
||||
def add_text_batches(
|
||||
self,
|
||||
|
|
@ -314,25 +226,12 @@ class GraphBuilderService:
|
|||
progress
|
||||
)
|
||||
|
||||
# 构建episode数据
|
||||
episodes = [
|
||||
EpisodeData(data=chunk, type="text")
|
||||
for chunk in batch_chunks
|
||||
]
|
||||
|
||||
# 发送到Zep
|
||||
# 发送到统一图谱 provider。agent 模式下这只是 episode 存储;
|
||||
# 三元组抽取由 CLI/MCP runner 生成 extract_triples request。
|
||||
try:
|
||||
batch_result = self.client.graph.add_batch(
|
||||
graph_id=graph_id,
|
||||
episodes=episodes
|
||||
)
|
||||
|
||||
# 收集返回的 episode uuid
|
||||
if batch_result and isinstance(batch_result, list):
|
||||
for ep in batch_result:
|
||||
ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None)
|
||||
if ep_uuid:
|
||||
episode_uuids.append(ep_uuid)
|
||||
for chunk in batch_chunks:
|
||||
result = self.provider.add_episode(graph_id, chunk, {"type": "seed_chunk", "batch": batch_num})
|
||||
episode_uuids.append(str(result.get("episode_index", uuid.uuid4().hex)))
|
||||
|
||||
# 避免请求过快
|
||||
time.sleep(1)
|
||||
|
|
@ -356,63 +255,21 @@ class GraphBuilderService:
|
|||
progress_callback(t('progress.noEpisodesWait'), 1.0)
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
pending_episodes = set(episode_uuids)
|
||||
completed_count = 0
|
||||
total_episodes = len(episode_uuids)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(t('progress.waitingEpisodes', count=total_episodes), 0)
|
||||
|
||||
while pending_episodes:
|
||||
if time.time() - start_time > timeout:
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
t('progress.episodesTimeout', completed=completed_count, total=total_episodes),
|
||||
completed_count / total_episodes
|
||||
)
|
||||
break
|
||||
|
||||
# 检查每个 episode 的处理状态
|
||||
for ep_uuid in list(pending_episodes):
|
||||
try:
|
||||
episode = self.client.graph.episode.get(uuid_=ep_uuid)
|
||||
is_processed = getattr(episode, 'processed', False)
|
||||
|
||||
if is_processed:
|
||||
pending_episodes.remove(ep_uuid)
|
||||
completed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
# 忽略单个查询错误,继续
|
||||
pass
|
||||
|
||||
elapsed = int(time.time() - start_time)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
t('progress.zepProcessing', completed=completed_count, total=total_episodes, pending=len(pending_episodes), elapsed=elapsed),
|
||||
completed_count / total_episodes if total_episodes > 0 else 0
|
||||
)
|
||||
|
||||
if pending_episodes:
|
||||
time.sleep(3) # 每3秒检查一次
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(t('progress.processingComplete', completed=completed_count, total=total_episodes), 1.0)
|
||||
progress_callback(t('progress.waitingEpisodes', count=total_episodes), 1.0)
|
||||
|
||||
def _get_graph_info(self, graph_id: str) -> GraphInfo:
|
||||
"""获取图谱信息"""
|
||||
# 获取节点(分页)
|
||||
nodes = fetch_all_nodes(self.client, graph_id)
|
||||
|
||||
# 获取边(分页)
|
||||
edges = fetch_all_edges(self.client, graph_id)
|
||||
nodes = self.provider.list_entities(graph_id)
|
||||
edges = self.provider.search(graph_id, "", limit=10000)
|
||||
|
||||
# 统计实体类型
|
||||
entity_types = set()
|
||||
for node in nodes:
|
||||
if node.labels:
|
||||
for label in node.labels:
|
||||
if node.get("labels"):
|
||||
for label in node["labels"]:
|
||||
if label not in ["Entity", "Node"]:
|
||||
entity_types.add(label)
|
||||
|
||||
|
|
@ -433,58 +290,58 @@ class GraphBuilderService:
|
|||
Returns:
|
||||
包含nodes和edges的字典,包括时间信息、属性等详细数据
|
||||
"""
|
||||
nodes = fetch_all_nodes(self.client, graph_id)
|
||||
edges = fetch_all_edges(self.client, graph_id)
|
||||
nodes = self.provider.list_entities(graph_id)
|
||||
edges = self.provider.search(graph_id, "", limit=10000)
|
||||
|
||||
# 创建节点映射用于获取节点名称
|
||||
node_map = {}
|
||||
for node in nodes:
|
||||
node_map[node.uuid_] = node.name or ""
|
||||
node_map[node.get("uuid", "")] = node.get("name", "") or ""
|
||||
|
||||
nodes_data = []
|
||||
for node in nodes:
|
||||
# 获取创建时间
|
||||
created_at = getattr(node, 'created_at', None)
|
||||
created_at = node.get("created_at")
|
||||
if created_at:
|
||||
created_at = str(created_at)
|
||||
|
||||
nodes_data.append({
|
||||
"uuid": node.uuid_,
|
||||
"name": node.name,
|
||||
"labels": node.labels or [],
|
||||
"summary": node.summary or "",
|
||||
"attributes": node.attributes or {},
|
||||
"uuid": node.get("uuid", ""),
|
||||
"name": node.get("name", ""),
|
||||
"labels": node.get("labels", []) or [],
|
||||
"summary": node.get("summary", "") or "",
|
||||
"attributes": node.get("attributes", {}) or {},
|
||||
"created_at": created_at,
|
||||
})
|
||||
|
||||
edges_data = []
|
||||
for edge in edges:
|
||||
# 获取时间信息
|
||||
created_at = getattr(edge, 'created_at', None)
|
||||
valid_at = getattr(edge, 'valid_at', None)
|
||||
invalid_at = getattr(edge, 'invalid_at', None)
|
||||
expired_at = getattr(edge, 'expired_at', None)
|
||||
created_at = edge.get("created_at")
|
||||
valid_at = edge.get("valid_at")
|
||||
invalid_at = edge.get("invalid_at")
|
||||
expired_at = edge.get("expired_at")
|
||||
|
||||
# 获取 episodes
|
||||
episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None)
|
||||
episodes = edge.get("episodes") or edge.get("episode_ids")
|
||||
if episodes and not isinstance(episodes, list):
|
||||
episodes = [str(episodes)]
|
||||
elif episodes:
|
||||
episodes = [str(e) for e in episodes]
|
||||
|
||||
# 获取 fact_type
|
||||
fact_type = getattr(edge, 'fact_type', None) or edge.name or ""
|
||||
fact_type = edge.get("fact_type") or edge.get("predicate") or edge.get("name", "")
|
||||
|
||||
edges_data.append({
|
||||
"uuid": edge.uuid_,
|
||||
"name": edge.name or "",
|
||||
"fact": edge.fact or "",
|
||||
"uuid": edge.get("uuid", ""),
|
||||
"name": edge.get("predicate") or edge.get("name", ""),
|
||||
"fact": edge.get("fact", ""),
|
||||
"fact_type": fact_type,
|
||||
"source_node_uuid": edge.source_node_uuid,
|
||||
"target_node_uuid": edge.target_node_uuid,
|
||||
"source_node_name": node_map.get(edge.source_node_uuid, ""),
|
||||
"target_node_name": node_map.get(edge.target_node_uuid, ""),
|
||||
"attributes": edge.attributes or {},
|
||||
"source_node_uuid": edge.get("source_node_uuid", ""),
|
||||
"target_node_uuid": edge.get("target_node_uuid", ""),
|
||||
"source_node_name": node_map.get(edge.get("source_node_uuid", ""), ""),
|
||||
"target_node_name": node_map.get(edge.get("target_node_uuid", ""), ""),
|
||||
"attributes": edge.get("attributes", {}) or edge.get("metadata", {}),
|
||||
"created_at": str(created_at) if created_at else None,
|
||||
"valid_at": str(valid_at) if valid_at else None,
|
||||
"invalid_at": str(invalid_at) if invalid_at else None,
|
||||
|
|
@ -502,5 +359,4 @@ class GraphBuilderService:
|
|||
|
||||
def delete_graph(self, graph_id: str):
|
||||
"""删除图谱"""
|
||||
self.client.graph.delete(graph_id=graph_id)
|
||||
|
||||
self.provider.clear_run_graph(graph_id)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,9 @@ from typing import Dict, Any, List, Optional
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from openai import OpenAI
|
||||
from zep_cloud.client import Zep
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.graph.factory import create_graph_provider
|
||||
from ..adapters.llm.agent_runtime import AgentRuntime, NeedAgentResponse
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.locale import get_language_instruction, get_locale, set_locale, t
|
||||
from .zep_entity_reader import EntityNode, ZepEntityReader
|
||||
|
|
@ -186,28 +185,12 @@ class OasisProfileGenerator:
|
|||
zep_api_key: Optional[str] = None,
|
||||
graph_id: Optional[str] = None
|
||||
):
|
||||
self.api_key = api_key or Config.LLM_API_KEY
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url or Config.LLM_BASE_URL
|
||||
self.model_name = model_name or Config.LLM_MODEL_NAME
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("LLM_API_KEY 未配置")
|
||||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
)
|
||||
|
||||
# Zep客户端用于检索丰富上下文
|
||||
self.zep_api_key = zep_api_key or Config.ZEP_API_KEY
|
||||
self.zep_client = None
|
||||
self.runtime = AgentRuntime()
|
||||
self.graph_provider = create_graph_provider()
|
||||
self.graph_id = graph_id
|
||||
|
||||
if self.zep_api_key:
|
||||
try:
|
||||
self.zep_client = Zep(api_key=self.zep_api_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"Zep客户端初始化失败: {e}")
|
||||
|
||||
def generate_profile_from_entity(
|
||||
self,
|
||||
|
|
@ -296,11 +279,6 @@ class OasisProfileGenerator:
|
|||
Returns:
|
||||
包含facts, node_summaries, context的字典
|
||||
"""
|
||||
import concurrent.futures
|
||||
|
||||
if not self.zep_client:
|
||||
return {"facts": [], "node_summaries": [], "context": ""}
|
||||
|
||||
entity_name = entity.name
|
||||
|
||||
results = {
|
||||
|
|
@ -314,84 +292,20 @@ class OasisProfileGenerator:
|
|||
logger.debug(f"跳过Zep检索:未设置graph_id")
|
||||
return results
|
||||
|
||||
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
|
||||
|
||||
def search_edges():
|
||||
"""搜索边(事实/关系)- 带重试机制"""
|
||||
max_retries = 3
|
||||
last_exception = None
|
||||
delay = 2.0
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return self.zep_client.graph.search(
|
||||
query=comprehensive_query,
|
||||
graph_id=self.graph_id,
|
||||
limit=30,
|
||||
scope="edges",
|
||||
reranker="rrf"
|
||||
)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.debug(f"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...")
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.debug(f"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}")
|
||||
return None
|
||||
|
||||
def search_nodes():
|
||||
"""搜索节点(实体摘要)- 带重试机制"""
|
||||
max_retries = 3
|
||||
last_exception = None
|
||||
delay = 2.0
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return self.zep_client.graph.search(
|
||||
query=comprehensive_query,
|
||||
graph_id=self.graph_id,
|
||||
limit=20,
|
||||
scope="nodes",
|
||||
reranker="rrf"
|
||||
)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.debug(f"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...")
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.debug(f"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# 并行执行edges和nodes搜索
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
edge_future = executor.submit(search_edges)
|
||||
node_future = executor.submit(search_nodes)
|
||||
|
||||
# 获取结果
|
||||
edge_result = edge_future.result(timeout=30)
|
||||
node_result = node_future.result(timeout=30)
|
||||
|
||||
# 处理边搜索结果
|
||||
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
|
||||
graph_results = self.graph_provider.search(self.graph_id, comprehensive_query, limit=30)
|
||||
all_facts = set()
|
||||
if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:
|
||||
for edge in edge_result.edges:
|
||||
if hasattr(edge, 'fact') and edge.fact:
|
||||
all_facts.add(edge.fact)
|
||||
results["facts"] = list(all_facts)
|
||||
|
||||
# 处理节点搜索结果
|
||||
all_summaries = set()
|
||||
if node_result and hasattr(node_result, 'nodes') and node_result.nodes:
|
||||
for node in node_result.nodes:
|
||||
if hasattr(node, 'summary') and node.summary:
|
||||
all_summaries.add(node.summary)
|
||||
if hasattr(node, 'name') and node.name and node.name != entity_name:
|
||||
all_summaries.add(f"相关实体: {node.name}")
|
||||
for item in graph_results:
|
||||
if item.get("fact"):
|
||||
all_facts.add(item["fact"])
|
||||
node = item.get("node") if isinstance(item.get("node"), dict) else item
|
||||
if node.get("summary"):
|
||||
all_summaries.add(node["summary"])
|
||||
if node.get("name") and node.get("name") != entity_name:
|
||||
all_summaries.add(f"相关实体: {node['name']}")
|
||||
results["facts"] = list(all_facts)
|
||||
results["node_summaries"] = list(all_summaries)
|
||||
|
||||
# 构建综合上下文
|
||||
|
|
@ -404,10 +318,8 @@ class OasisProfileGenerator:
|
|||
|
||||
logger.info(f"Zep混合检索完成: {entity_name}, 获取 {len(results['facts'])} 条事实, {len(results['node_summaries'])} 个相关节点")
|
||||
|
||||
except concurrent.futures.TimeoutError:
|
||||
logger.warning(f"Zep检索超时 ({entity_name})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Zep检索失败 ({entity_name}): {e}")
|
||||
logger.warning(f"图谱检索失败 ({entity_name}): {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
|
@ -527,48 +439,38 @@ class OasisProfileGenerator:
|
|||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": self._get_system_prompt(is_individual)},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.7 - (attempt * 0.1) # 每次重试降低温度
|
||||
# 不设置max_tokens,让LLM自由发挥
|
||||
runtime_result = self.runtime.run_task(
|
||||
run_id=self.graph_id or "legacy-oasis-profile",
|
||||
task_type="generate_oasis_profiles",
|
||||
stage="oasis_profile_generator",
|
||||
expected_schema={"type": "object"},
|
||||
input_text=prompt,
|
||||
structured_input={
|
||||
"entity_name": entity_name,
|
||||
"entity_type": entity_type,
|
||||
"entity_summary": entity_summary,
|
||||
"entity_attributes": entity_attributes,
|
||||
"is_individual": is_individual,
|
||||
"temperature": 0.7 - (attempt * 0.1),
|
||||
},
|
||||
system_prompt=self._get_system_prompt(is_individual),
|
||||
user_prompt=prompt,
|
||||
validation_rules={"json_object": True},
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
# 检查是否被截断(finish_reason不是'stop')
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
if finish_reason == 'length':
|
||||
logger.warning(f"LLM输出被截断 (attempt {attempt+1}), 尝试修复...")
|
||||
content = self._fix_truncated_json(content)
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
result = json.loads(content)
|
||||
|
||||
# 验证必需字段
|
||||
if "bio" not in result or not result["bio"]:
|
||||
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
|
||||
if "persona" not in result or not result["persona"]:
|
||||
result["persona"] = entity_summary or f"{entity_name}是一个{entity_type}。"
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as je:
|
||||
logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(je)[:80]}")
|
||||
|
||||
# 尝试修复JSON
|
||||
result = self._try_fix_json(content, entity_name, entity_type, entity_summary)
|
||||
if result.get("_fixed"):
|
||||
del result["_fixed"]
|
||||
return result
|
||||
|
||||
last_error = je
|
||||
|
||||
if runtime_result.status == "need_agent_response":
|
||||
raise NeedAgentResponse(runtime_result)
|
||||
if runtime_result.status != "ok":
|
||||
raise RuntimeError(runtime_result.error or "LLM provider failed")
|
||||
result = runtime_result.output or {}
|
||||
if "profiles" in result and result["profiles"]:
|
||||
result = result["profiles"][0]
|
||||
if "bio" not in result or not result["bio"]:
|
||||
result["bio"] = entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}"
|
||||
if "persona" not in result or not result["persona"]:
|
||||
result["persona"] = entity_summary or f"{entity_name}是一个{entity_type}。"
|
||||
return result
|
||||
except NeedAgentResponse:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}")
|
||||
last_error = e
|
||||
|
|
@ -1202,4 +1104,3 @@ class OasisProfileGenerator:
|
|||
"""[已废弃] 请使用 save_profiles() 方法"""
|
||||
logger.warning("save_profiles_to_json已废弃,请使用save_profiles方法")
|
||||
self.save_profiles(profiles, file_path, platform)
|
||||
|
||||
|
|
|
|||
|
|
@ -410,11 +410,23 @@ class OntologyGenerator:
|
|||
code_lines = [
|
||||
'"""',
|
||||
'自定义实体类型定义',
|
||||
'由MiroFish自动生成,用于社会舆论模拟',
|
||||
'由MiroFish自动生成,用于社会舆论模拟。',
|
||||
'该代码是 provider-neutral 的本体描述,不直接依赖具体图谱 SDK。',
|
||||
'"""',
|
||||
'',
|
||||
'from pydantic import Field',
|
||||
'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',
|
||||
'from typing import Optional',
|
||||
'from pydantic import BaseModel, Field',
|
||||
'',
|
||||
'',
|
||||
'EntityText = Optional[str]',
|
||||
'',
|
||||
'',
|
||||
'class EntityModel(BaseModel):',
|
||||
' pass',
|
||||
'',
|
||||
'',
|
||||
'class EdgeModel(BaseModel):',
|
||||
' pass',
|
||||
'',
|
||||
'',
|
||||
'# ============== 实体类型定义 ==============',
|
||||
|
|
@ -503,4 +515,3 @@ class OntologyGenerator:
|
|||
code_lines.append('}')
|
||||
|
||||
return '\n'.join(code_lines)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ from typing import Dict, Any, List, Optional, Callable
|
|||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.llm.agent_runtime import AgentRuntime, NeedAgentResponse
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.locale import get_language_instruction, t
|
||||
from .zep_entity_reader import EntityNode, ZepEntityReader
|
||||
|
|
@ -228,17 +227,10 @@ class SimulationConfigGenerator:
|
|||
base_url: Optional[str] = None,
|
||||
model_name: Optional[str] = None
|
||||
):
|
||||
self.api_key = api_key or Config.LLM_API_KEY
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url or Config.LLM_BASE_URL
|
||||
self.model_name = model_name or Config.LLM_MODEL_NAME
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("LLM_API_KEY 未配置")
|
||||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
)
|
||||
self.runtime = AgentRuntime()
|
||||
|
||||
def generate_config(
|
||||
self,
|
||||
|
|
@ -440,38 +432,28 @@ class SimulationConfigGenerator:
|
|||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.7 - (attempt * 0.1) # 每次重试降低温度
|
||||
# 不设置max_tokens,让LLM自由发挥
|
||||
result = self.runtime.run_task(
|
||||
run_id="legacy-simulation-config",
|
||||
task_type="generate_simulation_config",
|
||||
stage="simulation_config_generator",
|
||||
expected_schema={"type": "object"},
|
||||
input_text=prompt,
|
||||
structured_input={
|
||||
"temperature": 0.7 - (attempt * 0.1),
|
||||
"model_name": self.model_name,
|
||||
},
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=prompt,
|
||||
validation_rules={"json_object": True},
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
|
||||
# 检查是否被截断
|
||||
if finish_reason == 'length':
|
||||
logger.warning(f"LLM输出被截断 (attempt {attempt+1})")
|
||||
content = self._fix_truncated_json(content)
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}")
|
||||
|
||||
# 尝试修复JSON
|
||||
fixed = self._try_fix_config_json(content)
|
||||
if fixed:
|
||||
return fixed
|
||||
|
||||
last_error = e
|
||||
if result.status == "need_agent_response":
|
||||
raise NeedAgentResponse(result)
|
||||
if result.status != "ok":
|
||||
raise RuntimeError(result.error or "LLM provider failed")
|
||||
return result.output or {}
|
||||
|
||||
except NeedAgentResponse:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}")
|
||||
last_error = e
|
||||
|
|
@ -988,4 +970,3 @@ class SimulationConfigGenerator:
|
|||
"influence_weight": 1.0
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -333,7 +333,11 @@ class SimulationRunner:
|
|||
"""
|
||||
# 检查是否已在运行
|
||||
existing = cls.get_run_state(simulation_id)
|
||||
if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]:
|
||||
if (
|
||||
existing
|
||||
and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]
|
||||
and cls.is_process_alive(simulation_id)
|
||||
):
|
||||
raise ValueError(f"模拟已在运行中: {simulation_id}")
|
||||
|
||||
# 加载模拟配置
|
||||
|
|
@ -1182,6 +1186,7 @@ class SimulationRunner:
|
|||
|
||||
# 防止重复清理的标志
|
||||
_cleanup_done = False
|
||||
_skip_exit_cleanup = False
|
||||
|
||||
@classmethod
|
||||
def cleanup_all_simulations(cls):
|
||||
|
|
@ -1190,6 +1195,10 @@ class SimulationRunner:
|
|||
|
||||
在服务器关闭时调用,确保所有子进程被终止
|
||||
"""
|
||||
if cls._skip_exit_cleanup:
|
||||
logger.info("跳过模拟进程清理:开发模式下保留等待命令模式的模拟进程")
|
||||
return
|
||||
|
||||
# 防止重复清理
|
||||
if cls._cleanup_done:
|
||||
return
|
||||
|
|
@ -1318,6 +1327,27 @@ class SimulationRunner:
|
|||
|
||||
def cleanup_handler(signum=None, frame=None):
|
||||
"""信号处理器:先清理模拟进程,再调用原处理器"""
|
||||
preserve_on_reload = (
|
||||
Config.DEBUG
|
||||
and Config.PRESERVE_SIMULATIONS_ON_RELOAD
|
||||
and signum in {signal.SIGTERM, getattr(signal, 'SIGHUP', None)}
|
||||
)
|
||||
if preserve_on_reload:
|
||||
cls._skip_exit_cleanup = True
|
||||
if cls._processes or cls._graph_memory_enabled:
|
||||
logger.info(
|
||||
f"收到信号 {signum},开发模式下保留模拟进程;"
|
||||
"请使用 /api/simulation/stop 或 /api/simulation/close-env 显式停止"
|
||||
)
|
||||
|
||||
if signum == signal.SIGTERM and callable(original_sigterm):
|
||||
original_sigterm(signum, frame)
|
||||
return
|
||||
if has_sighup and signum == signal.SIGHUP and callable(original_sighup):
|
||||
original_sighup(signum, frame)
|
||||
return
|
||||
sys.exit(0)
|
||||
|
||||
# 只有在有进程需要清理时才打印日志
|
||||
if cls._processes or cls._graph_memory_enabled:
|
||||
logger.info(f"收到信号 {signum},开始清理...")
|
||||
|
|
@ -1367,6 +1397,24 @@ class SimulationRunner:
|
|||
if process.poll() is None:
|
||||
running.append(sim_id)
|
||||
return running
|
||||
|
||||
@classmethod
|
||||
def is_process_alive(cls, simulation_id: str) -> bool:
|
||||
"""检查模拟子进程是否仍然存在。"""
|
||||
process = cls._processes.get(simulation_id)
|
||||
if process:
|
||||
return process.poll() is None
|
||||
|
||||
state = cls.get_run_state(simulation_id)
|
||||
pid = state.process_pid if state else None
|
||||
if not pid:
|
||||
return False
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# ============== Interview 功能 ==============
|
||||
|
||||
|
|
@ -1385,6 +1433,9 @@ class SimulationRunner:
|
|||
if not os.path.exists(sim_dir):
|
||||
return False
|
||||
|
||||
if not cls.is_process_alive(simulation_id):
|
||||
return False
|
||||
|
||||
ipc_client = SimulationIPCClient(sim_dir)
|
||||
return ipc_client.check_env_alive()
|
||||
|
||||
|
|
@ -1765,4 +1816,3 @@ class SimulationRunner:
|
|||
results = results[:limit]
|
||||
|
||||
return results
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ import time
|
|||
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from zep_cloud.client import Zep
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.graph.factory import create_graph_provider
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
|
||||
|
||||
logger = get_logger('mirofish.zep_entity_reader')
|
||||
|
||||
|
|
@ -79,11 +77,8 @@ class ZepEntityReader:
|
|||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.api_key = api_key or Config.ZEP_API_KEY
|
||||
if not self.api_key:
|
||||
raise ValueError("ZEP_API_KEY 未配置")
|
||||
|
||||
self.client = Zep(api_key=self.api_key)
|
||||
self.api_key = api_key
|
||||
self.provider = create_graph_provider()
|
||||
|
||||
def _call_with_retry(
|
||||
self,
|
||||
|
|
@ -136,17 +131,7 @@ class ZepEntityReader:
|
|||
"""
|
||||
logger.info(f"获取图谱 {graph_id} 的所有节点...")
|
||||
|
||||
nodes = fetch_all_nodes(self.client, graph_id)
|
||||
|
||||
nodes_data = []
|
||||
for node in nodes:
|
||||
nodes_data.append({
|
||||
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
|
||||
"name": node.name or "",
|
||||
"labels": node.labels or [],
|
||||
"summary": node.summary or "",
|
||||
"attributes": node.attributes or {},
|
||||
})
|
||||
nodes_data = self.provider.list_entities(graph_id)
|
||||
|
||||
logger.info(f"共获取 {len(nodes_data)} 个节点")
|
||||
return nodes_data
|
||||
|
|
@ -163,18 +148,7 @@ class ZepEntityReader:
|
|||
"""
|
||||
logger.info(f"获取图谱 {graph_id} 的所有边...")
|
||||
|
||||
edges = fetch_all_edges(self.client, graph_id)
|
||||
|
||||
edges_data = []
|
||||
for edge in edges:
|
||||
edges_data.append({
|
||||
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
|
||||
"name": edge.name or "",
|
||||
"fact": edge.fact or "",
|
||||
"source_node_uuid": edge.source_node_uuid,
|
||||
"target_node_uuid": edge.target_node_uuid,
|
||||
"attributes": edge.attributes or {},
|
||||
})
|
||||
edges_data = self.provider.search(graph_id, "", limit=10000)
|
||||
|
||||
logger.info(f"共获取 {len(edges_data)} 条边")
|
||||
return edges_data
|
||||
|
|
@ -190,24 +164,7 @@ class ZepEntityReader:
|
|||
边列表
|
||||
"""
|
||||
try:
|
||||
# 使用重试机制调用Zep API
|
||||
edges = self._call_with_retry(
|
||||
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),
|
||||
operation_name=f"获取节点边(node={node_uuid[:8]}...)"
|
||||
)
|
||||
|
||||
edges_data = []
|
||||
for edge in edges:
|
||||
edges_data.append({
|
||||
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
|
||||
"name": edge.name or "",
|
||||
"fact": edge.fact or "",
|
||||
"source_node_uuid": edge.source_node_uuid,
|
||||
"target_node_uuid": edge.target_node_uuid,
|
||||
"attributes": edge.attributes or {},
|
||||
})
|
||||
|
||||
return edges_data
|
||||
return self.provider.neighbors("", node_uuid, depth=1)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}")
|
||||
return []
|
||||
|
|
@ -346,20 +303,19 @@ class ZepEntityReader:
|
|||
EntityNode或None
|
||||
"""
|
||||
try:
|
||||
# 使用重试机制获取节点
|
||||
node = self._call_with_retry(
|
||||
func=lambda: self.client.graph.node.get(uuid_=entity_uuid),
|
||||
operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)"
|
||||
)
|
||||
all_nodes = self.get_all_nodes(graph_id)
|
||||
node = next((item for item in all_nodes if item.get("uuid") == entity_uuid), None)
|
||||
|
||||
if not node:
|
||||
return None
|
||||
|
||||
# 获取节点的边
|
||||
edges = self.get_node_edges(entity_uuid)
|
||||
edges = [
|
||||
edge for edge in self.get_all_edges(graph_id)
|
||||
if edge.get("source_node_uuid") == entity_uuid or edge.get("target_node_uuid") == entity_uuid
|
||||
]
|
||||
|
||||
# 获取所有节点用于关联查找
|
||||
all_nodes = self.get_all_nodes(graph_id)
|
||||
node_map = {n["uuid"]: n for n in all_nodes}
|
||||
|
||||
# 处理相关边和节点
|
||||
|
|
@ -397,11 +353,11 @@ class ZepEntityReader:
|
|||
})
|
||||
|
||||
return EntityNode(
|
||||
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
|
||||
name=node.name or "",
|
||||
labels=node.labels or [],
|
||||
summary=node.summary or "",
|
||||
attributes=node.attributes or {},
|
||||
uuid=node.get("uuid", ""),
|
||||
name=node.get("name", "") or "",
|
||||
labels=node.get("labels", []) or [],
|
||||
summary=node.get("summary", "") or "",
|
||||
attributes=node.get("attributes", {}) or {},
|
||||
related_edges=related_edges,
|
||||
related_nodes=related_nodes,
|
||||
)
|
||||
|
|
@ -433,5 +389,3 @@ class ZepEntityReader:
|
|||
enrich_with_edges=enrich_with_edges
|
||||
)
|
||||
return result.entities
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ from dataclasses import dataclass
|
|||
from datetime import datetime
|
||||
from queue import Queue, Empty
|
||||
|
||||
from zep_cloud.client import Zep
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.graph.factory import create_graph_provider
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.locale import get_locale, set_locale
|
||||
|
||||
|
|
@ -235,15 +234,11 @@ class ZepGraphMemoryUpdater:
|
|||
|
||||
Args:
|
||||
graph_id: Zep图谱ID
|
||||
api_key: Zep API Key(可选,默认从配置读取)
|
||||
api_key: legacy Zep API Key(仅由 legacy provider 使用)
|
||||
"""
|
||||
self.graph_id = graph_id
|
||||
self.api_key = api_key or Config.ZEP_API_KEY
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("ZEP_API_KEY未配置")
|
||||
|
||||
self.client = Zep(api_key=self.api_key)
|
||||
self.api_key = api_key
|
||||
self.provider = create_graph_provider()
|
||||
|
||||
# 活动队列
|
||||
self._activity_queue: Queue = Queue()
|
||||
|
|
@ -411,10 +406,10 @@ class ZepGraphMemoryUpdater:
|
|||
# 带重试的发送
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
self.client.graph.add(
|
||||
graph_id=self.graph_id,
|
||||
type="text",
|
||||
data=combined_text
|
||||
self.provider.add_episode(
|
||||
self.graph_id,
|
||||
combined_text,
|
||||
{"platform": platform, "source": "simulation_activity"},
|
||||
)
|
||||
|
||||
self._total_sent += 1
|
||||
|
|
|
|||
|
|
@ -13,13 +13,11 @@ import json
|
|||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from zep_cloud.client import Zep
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.graph.factory import create_graph_provider
|
||||
from ..utils.logger import get_logger
|
||||
from ..utils.llm_client import LLMClient
|
||||
from ..utils.locale import get_locale, t
|
||||
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
|
||||
|
||||
logger = get_logger('mirofish.zep_tools')
|
||||
|
||||
|
|
@ -423,11 +421,8 @@ class ZepToolsService:
|
|||
RETRY_DELAY = 2.0
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, llm_client: Optional[LLMClient] = None):
|
||||
self.api_key = api_key or Config.ZEP_API_KEY
|
||||
if not self.api_key:
|
||||
raise ValueError("ZEP_API_KEY 未配置")
|
||||
|
||||
self.client = Zep(api_key=self.api_key)
|
||||
self.api_key = api_key
|
||||
self.provider = create_graph_provider()
|
||||
# LLM客户端用于InsightForge生成子问题
|
||||
self._llm_client = llm_client
|
||||
logger.info(t("console.zepToolsInitialized"))
|
||||
|
|
@ -485,48 +480,28 @@ class ZepToolsService:
|
|||
"""
|
||||
logger.info(t("console.graphSearch", graphId=graph_id, query=query[:50]))
|
||||
|
||||
# 尝试使用Zep Cloud Search API
|
||||
# 使用统一 GraphProvider 搜索;legacy Zep 和 agent Graphiti 都在 provider 内部处理。
|
||||
try:
|
||||
search_results = self._call_with_retry(
|
||||
func=lambda: self.client.graph.search(
|
||||
graph_id=graph_id,
|
||||
query=query,
|
||||
limit=limit,
|
||||
scope=scope,
|
||||
reranker="cross_encoder"
|
||||
),
|
||||
operation_name=t("console.graphSearchOp", graphId=graph_id)
|
||||
)
|
||||
|
||||
search_results = self.provider.search(graph_id, query, limit=limit)
|
||||
facts = []
|
||||
edges = []
|
||||
nodes = []
|
||||
|
||||
# 解析边搜索结果
|
||||
if hasattr(search_results, 'edges') and search_results.edges:
|
||||
for edge in search_results.edges:
|
||||
if hasattr(edge, 'fact') and edge.fact:
|
||||
facts.append(edge.fact)
|
||||
edges.append({
|
||||
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
|
||||
"name": getattr(edge, 'name', ''),
|
||||
"fact": getattr(edge, 'fact', ''),
|
||||
"source_node_uuid": getattr(edge, 'source_node_uuid', ''),
|
||||
"target_node_uuid": getattr(edge, 'target_node_uuid', ''),
|
||||
})
|
||||
|
||||
# 解析节点搜索结果
|
||||
if hasattr(search_results, 'nodes') and search_results.nodes:
|
||||
for node in search_results.nodes:
|
||||
nodes.append({
|
||||
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
|
||||
"name": getattr(node, 'name', ''),
|
||||
"labels": getattr(node, 'labels', []),
|
||||
"summary": getattr(node, 'summary', ''),
|
||||
})
|
||||
# 节点摘要也算作事实
|
||||
if hasattr(node, 'summary') and node.summary:
|
||||
facts.append(f"[{node.name}]: {node.summary}")
|
||||
for item in search_results:
|
||||
if item.get("node"):
|
||||
node = item["node"]
|
||||
nodes.append(node)
|
||||
if node.get("summary"):
|
||||
facts.append(f"[{node.get('name', '')}]: {node['summary']}")
|
||||
continue
|
||||
if item.get("fact"):
|
||||
facts.append(item["fact"])
|
||||
edges.append({
|
||||
"uuid": item.get("uuid", ""),
|
||||
"name": item.get("predicate") or item.get("name", ""),
|
||||
"fact": item.get("fact", ""),
|
||||
"source_node_uuid": item.get("source_node_uuid", ""),
|
||||
"target_node_uuid": item.get("target_node_uuid", ""),
|
||||
})
|
||||
|
||||
logger.info(t("console.searchComplete", count=len(facts)))
|
||||
|
||||
|
|
@ -659,17 +634,15 @@ class ZepToolsService:
|
|||
"""
|
||||
logger.info(t("console.fetchingAllNodes", graphId=graph_id))
|
||||
|
||||
nodes = fetch_all_nodes(self.client, graph_id)
|
||||
|
||||
result = []
|
||||
for node in nodes:
|
||||
node_uuid = getattr(node, 'uuid_', None) or getattr(node, 'uuid', None) or ""
|
||||
for node in self.provider.list_entities(graph_id):
|
||||
node_uuid = node.get("uuid", "")
|
||||
result.append(NodeInfo(
|
||||
uuid=str(node_uuid) if node_uuid else "",
|
||||
name=node.name or "",
|
||||
labels=node.labels or [],
|
||||
summary=node.summary or "",
|
||||
attributes=node.attributes or {}
|
||||
name=node.get("name", "") or "",
|
||||
labels=node.get("labels", []) or [],
|
||||
summary=node.get("summary", "") or "",
|
||||
attributes=node.get("attributes", {}) or {}
|
||||
))
|
||||
|
||||
logger.info(t("console.fetchedNodes", count=len(result)))
|
||||
|
|
@ -688,25 +661,23 @@ class ZepToolsService:
|
|||
"""
|
||||
logger.info(t("console.fetchingAllEdges", graphId=graph_id))
|
||||
|
||||
edges = fetch_all_edges(self.client, graph_id)
|
||||
|
||||
result = []
|
||||
for edge in edges:
|
||||
edge_uuid = getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', None) or ""
|
||||
for edge in self.provider.search(graph_id, "", limit=10000):
|
||||
edge_uuid = edge.get("uuid", "")
|
||||
edge_info = EdgeInfo(
|
||||
uuid=str(edge_uuid) if edge_uuid else "",
|
||||
name=edge.name or "",
|
||||
fact=edge.fact or "",
|
||||
source_node_uuid=edge.source_node_uuid or "",
|
||||
target_node_uuid=edge.target_node_uuid or ""
|
||||
name=edge.get("predicate") or edge.get("name", ""),
|
||||
fact=edge.get("fact", ""),
|
||||
source_node_uuid=edge.get("source_node_uuid", ""),
|
||||
target_node_uuid=edge.get("target_node_uuid", "")
|
||||
)
|
||||
|
||||
# 添加时间信息
|
||||
if include_temporal:
|
||||
edge_info.created_at = getattr(edge, 'created_at', None)
|
||||
edge_info.valid_at = getattr(edge, 'valid_at', None)
|
||||
edge_info.invalid_at = getattr(edge, 'invalid_at', None)
|
||||
edge_info.expired_at = getattr(edge, 'expired_at', None)
|
||||
edge_info.created_at = edge.get("created_at")
|
||||
edge_info.valid_at = edge.get("valid_at")
|
||||
edge_info.invalid_at = edge.get("invalid_at")
|
||||
edge_info.expired_at = edge.get("expired_at")
|
||||
|
||||
result.append(edge_info)
|
||||
|
||||
|
|
@ -726,20 +697,20 @@ class ZepToolsService:
|
|||
logger.info(t("console.fetchingNodeDetail", uuid=node_uuid[:8]))
|
||||
|
||||
try:
|
||||
node = self._call_with_retry(
|
||||
func=lambda: self.client.graph.node.get(uuid_=node_uuid),
|
||||
operation_name=t("console.fetchNodeDetailOp", uuid=node_uuid[:8])
|
||||
)
|
||||
|
||||
node = None
|
||||
for candidate in self.provider.list_entities(""):
|
||||
if candidate.get("uuid") == node_uuid:
|
||||
node = candidate
|
||||
break
|
||||
if not node:
|
||||
return None
|
||||
|
||||
return NodeInfo(
|
||||
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
|
||||
name=node.name or "",
|
||||
labels=node.labels or [],
|
||||
summary=node.summary or "",
|
||||
attributes=node.attributes or {}
|
||||
uuid=node.get("uuid", ""),
|
||||
name=node.get("name", "") or "",
|
||||
labels=node.get("labels", []) or [],
|
||||
summary=node.get("summary", "") or "",
|
||||
attributes=node.get("attributes", {}) or {}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(t("console.fetchNodeDetailFailed", error=str(e)))
|
||||
|
|
|
|||
|
|
@ -1,18 +1,21 @@
|
|||
"""
|
||||
LLM客户端封装
|
||||
统一使用OpenAI格式调用
|
||||
"""
|
||||
"""Legacy LLMClient facade backed by AgentRuntime."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
from openai import OpenAI
|
||||
|
||||
from ..config import Config
|
||||
from ..adapters.llm.agent_runtime import AgentRuntime, NeedAgentResponse
|
||||
from ..agent_engine.json_schema import object_schema
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""LLM客户端"""
|
||||
"""Compatibility wrapper for older services.
|
||||
|
||||
New business code should call AgentRuntime directly. This class remains so
|
||||
legacy UI/report code can be migrated incrementally without direct SDK use.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -20,17 +23,12 @@ class LLMClient:
|
|||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None
|
||||
):
|
||||
self.api_key = api_key or Config.LLM_API_KEY
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url or Config.LLM_BASE_URL
|
||||
self.model = model or Config.LLM_MODEL_NAME
|
||||
|
||||
if not self.api_key:
|
||||
raise ValueError("LLM_API_KEY 未配置")
|
||||
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url=self.base_url
|
||||
)
|
||||
legacy_run_dir = Path(Config.MIROFISH_RUNS_DIR) / "legacy-ui"
|
||||
legacy_run_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.runtime = AgentRuntime(run_dir=str(legacy_run_dir))
|
||||
|
||||
def chat(
|
||||
self,
|
||||
|
|
@ -51,18 +49,35 @@ class LLMClient:
|
|||
Returns:
|
||||
模型响应文本
|
||||
"""
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
if response_format and response_format.get("type") == "json_object":
|
||||
max_tokens = max(max_tokens, 8192)
|
||||
|
||||
structured_input = {
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"response_format": response_format,
|
||||
"legacy_client": True,
|
||||
}
|
||||
|
||||
if response_format:
|
||||
kwargs["response_format"] = response_format
|
||||
|
||||
response = self.client.chat.completions.create(**kwargs)
|
||||
content = response.choices[0].message.content
|
||||
user_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "user")
|
||||
system_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "system")
|
||||
result = self.runtime.run_task(
|
||||
run_id="legacy-ui",
|
||||
task_type="validate_json_output" if response_format else "answer_followup_question",
|
||||
stage="legacy_llm_client",
|
||||
expected_schema=object_schema({"text": {"type": "string"}}, ["text"]),
|
||||
structured_input=structured_input,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
if result.status == "need_agent_response":
|
||||
raise NeedAgentResponse(result)
|
||||
if result.status != "ok":
|
||||
raise RuntimeError(result.error or "LLM provider failed")
|
||||
content = (result.output or {}).get("text")
|
||||
if content is None and result.output:
|
||||
content = json.dumps(result.output, ensure_ascii=False)
|
||||
content = content or ""
|
||||
# 部分模型(如MiniMax M2.5)会在content中包含<think>思考内容,需要移除
|
||||
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
|
||||
return content
|
||||
|
|
@ -84,12 +99,31 @@ class LLMClient:
|
|||
Returns:
|
||||
解析后的JSON对象
|
||||
"""
|
||||
response = self.chat(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
response_format={"type": "json_object"}
|
||||
structured_input = {
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"response_format": {"type": "json_object"},
|
||||
"legacy_client": True,
|
||||
}
|
||||
user_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "user")
|
||||
system_prompt = "\n".join(message.get("content", "") for message in messages if message.get("role") == "system")
|
||||
result = self.runtime.run_task(
|
||||
run_id="legacy-ui",
|
||||
task_type="validate_json_output",
|
||||
stage="legacy_llm_client",
|
||||
expected_schema={"type": "object"},
|
||||
structured_input=structured_input,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
if result.status == "need_agent_response":
|
||||
raise NeedAgentResponse(result)
|
||||
if result.status != "ok":
|
||||
raise RuntimeError(result.error or "LLM provider failed")
|
||||
if isinstance(result.output, dict):
|
||||
return result.output
|
||||
response = json.dumps(result.output, ensure_ascii=False)
|
||||
# 清理markdown代码块标记
|
||||
cleaned_response = response.strip()
|
||||
cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
|
||||
|
|
@ -100,4 +134,3 @@ class LLMClient:
|
|||
return json.loads(cleaned_response)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import time
|
|||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from zep_cloud import InternalServerError
|
||||
from zep_cloud.client import Zep
|
||||
import httpx
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
|
|
@ -41,7 +40,7 @@ def _fetch_page_with_retry(
|
|||
for attempt in range(max_retries):
|
||||
try:
|
||||
return api_call(*args, **kwargs)
|
||||
except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
|
||||
except (ConnectionError, TimeoutError, OSError, httpx.TransportError) as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
|
|
@ -57,7 +56,7 @@ def _fetch_page_with_retry(
|
|||
|
||||
|
||||
def fetch_all_nodes(
|
||||
client: Zep,
|
||||
client: Any,
|
||||
graph_id: str,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
max_items: int = _MAX_NODES,
|
||||
|
|
@ -103,7 +102,7 @@ def fetch_all_nodes(
|
|||
|
||||
|
||||
def fetch_all_edges(
|
||||
client: Zep,
|
||||
client: Any,
|
||||
graph_id: str,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,6 @@ dependencies = [
|
|||
"flask>=3.0.0",
|
||||
"flask-cors>=6.0.0",
|
||||
|
||||
# LLM 相关
|
||||
"openai>=1.0.0",
|
||||
|
||||
# Zep Cloud
|
||||
"zep-cloud==3.13.0",
|
||||
|
||||
# OASIS 社交媒体模拟
|
||||
"camel-oasis==0.2.5",
|
||||
"camel-ai==0.2.78",
|
||||
|
|
@ -34,7 +28,20 @@ dependencies = [
|
|||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mirofish-agent = "app.agent_engine.cli:main"
|
||||
mirofish-mcp = "app.mcp_server.server:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
agent = [
|
||||
"graphiti-core",
|
||||
"neo4j",
|
||||
"mcp",
|
||||
]
|
||||
legacy = [
|
||||
"openai>=1.0.0",
|
||||
"zep-cloud==3.13.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ _project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
|
|||
sys.path.insert(0, _scripts_dir)
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
||||
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
|
||||
# 加载项目根目录的 .env 文件(包含 MiroFish provider 配置)
|
||||
from dotenv import load_dotenv
|
||||
_env_file = os.path.join(_project_root, '.env')
|
||||
if os.path.exists(_env_file):
|
||||
|
|
@ -158,8 +158,7 @@ def init_logging_for_simulation(simulation_dir: str):
|
|||
from action_logger import SimulationLogManager, PlatformActionLogger
|
||||
|
||||
try:
|
||||
from camel.models import ModelFactory
|
||||
from camel.types import ModelPlatformType
|
||||
from app.adapters.llm.camel_adapter import create_model_backend
|
||||
import oasis
|
||||
from oasis import (
|
||||
ActionType,
|
||||
|
|
@ -985,56 +984,18 @@ def create_model(config: Dict[str, Any], use_boost: bool = False):
|
|||
"""
|
||||
创建LLM模型
|
||||
|
||||
支持双 LLM 配置,用于并行模拟时提速:
|
||||
- 通用配置:LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME
|
||||
- 加速配置(可选):LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME
|
||||
|
||||
如果配置了加速 LLM,并行模拟时可以让不同平台使用不同的 API 服务商,提高并发能力。
|
||||
使用 AgentRuntime / LLMProvider 创建模型后端。
|
||||
agent_queue 模式不需要模型 API key;openai_compatible legacy 模式才使用 LLM_* 配置。
|
||||
|
||||
Args:
|
||||
config: 模拟配置字典
|
||||
use_boost: 是否使用加速 LLM 配置(如果可用)
|
||||
"""
|
||||
# 检查是否有加速配置
|
||||
boost_api_key = os.environ.get("LLM_BOOST_API_KEY", "")
|
||||
boost_base_url = os.environ.get("LLM_BOOST_BASE_URL", "")
|
||||
boost_model = os.environ.get("LLM_BOOST_MODEL_NAME", "")
|
||||
has_boost_config = bool(boost_api_key)
|
||||
|
||||
# 根据参数和配置情况选择使用哪个 LLM
|
||||
if use_boost and has_boost_config:
|
||||
# 使用加速配置
|
||||
llm_api_key = boost_api_key
|
||||
llm_base_url = boost_base_url
|
||||
llm_model = boost_model or os.environ.get("LLM_MODEL_NAME", "")
|
||||
config_label = "[加速LLM]"
|
||||
else:
|
||||
# 使用通用配置
|
||||
llm_api_key = os.environ.get("LLM_API_KEY", "")
|
||||
llm_base_url = os.environ.get("LLM_BASE_URL", "")
|
||||
llm_model = os.environ.get("LLM_MODEL_NAME", "")
|
||||
config_label = "[通用LLM]"
|
||||
|
||||
# 如果 .env 中没有模型名,则使用 config 作为备用
|
||||
if not llm_model:
|
||||
llm_model = config.get("llm_model", "gpt-4o-mini")
|
||||
|
||||
# 设置 camel-ai 所需的环境变量
|
||||
if llm_api_key:
|
||||
os.environ["OPENAI_API_KEY"] = llm_api_key
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
|
||||
|
||||
if llm_base_url:
|
||||
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
|
||||
|
||||
print(f"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
|
||||
|
||||
return ModelFactory.create(
|
||||
model_platform=ModelPlatformType.OPENAI,
|
||||
model_type=llm_model,
|
||||
)
|
||||
run_id = config.get("simulation_id") or config.get("project_id") or "parallel-simulation"
|
||||
run_dir = config.get("simulation_dir") or os.getcwd()
|
||||
config_label = "[AgentRuntime]"
|
||||
print(f"{config_label} model backend adapter, run_id={run_id}")
|
||||
return create_model_backend(run_id=run_id, run_dir=run_dir)
|
||||
|
||||
|
||||
def get_active_agents_for_round(
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ _project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
|
|||
sys.path.insert(0, _scripts_dir)
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
||||
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
|
||||
# 加载项目根目录的 .env 文件(包含 MiroFish provider 配置)
|
||||
from dotenv import load_dotenv
|
||||
_env_file = os.path.join(_project_root, '.env')
|
||||
if os.path.exists(_env_file):
|
||||
|
|
@ -116,8 +116,7 @@ def setup_oasis_logging(log_dir: str):
|
|||
|
||||
|
||||
try:
|
||||
from camel.models import ModelFactory
|
||||
from camel.types import ModelPlatformType
|
||||
from app.adapters.llm.camel_adapter import create_model_backend
|
||||
import oasis
|
||||
from oasis import (
|
||||
ActionType,
|
||||
|
|
@ -435,36 +434,13 @@ class RedditSimulationRunner:
|
|||
"""
|
||||
创建LLM模型
|
||||
|
||||
统一使用项目根目录 .env 文件中的配置(优先级最高):
|
||||
- LLM_API_KEY: API密钥
|
||||
- LLM_BASE_URL: API基础URL
|
||||
- LLM_MODEL_NAME: 模型名称
|
||||
统一使用 AgentRuntime / LLMProvider 配置(优先级最高):
|
||||
- MIROFISH_LLM_PROVIDER=agent_queue 时不需要模型 API key
|
||||
- MIROFISH_LLM_PROVIDER=openai_compatible 时使用 legacy LLM_* 配置
|
||||
"""
|
||||
# 优先从 .env 读取配置
|
||||
llm_api_key = os.environ.get("LLM_API_KEY", "")
|
||||
llm_base_url = os.environ.get("LLM_BASE_URL", "")
|
||||
llm_model = os.environ.get("LLM_MODEL_NAME", "")
|
||||
|
||||
# 如果 .env 中没有,则使用 config 作为备用
|
||||
if not llm_model:
|
||||
llm_model = self.config.get("llm_model", "gpt-4o-mini")
|
||||
|
||||
# 设置 camel-ai 所需的环境变量
|
||||
if llm_api_key:
|
||||
os.environ["OPENAI_API_KEY"] = llm_api_key
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
|
||||
|
||||
if llm_base_url:
|
||||
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
|
||||
|
||||
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
|
||||
|
||||
return ModelFactory.create(
|
||||
model_platform=ModelPlatformType.OPENAI,
|
||||
model_type=llm_model,
|
||||
)
|
||||
run_id = self.config.get("simulation_id") or os.path.basename(self.simulation_dir)
|
||||
print(f"LLM配置: AgentRuntime adapter, run_id={run_id}")
|
||||
return create_model_backend(run_id=run_id, run_dir=self.simulation_dir)
|
||||
|
||||
def _get_active_agents_for_round(
|
||||
self,
|
||||
|
|
@ -766,4 +742,3 @@ if __name__ == "__main__":
|
|||
pass
|
||||
finally:
|
||||
print("模拟进程已退出")
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ _project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
|
|||
sys.path.insert(0, _scripts_dir)
|
||||
sys.path.insert(0, _backend_dir)
|
||||
|
||||
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
|
||||
# 加载项目根目录的 .env 文件(包含 MiroFish provider 配置)
|
||||
from dotenv import load_dotenv
|
||||
_env_file = os.path.join(_project_root, '.env')
|
||||
if os.path.exists(_env_file):
|
||||
|
|
@ -116,8 +116,7 @@ def setup_oasis_logging(log_dir: str):
|
|||
|
||||
|
||||
try:
|
||||
from camel.models import ModelFactory
|
||||
from camel.types import ModelPlatformType
|
||||
from app.adapters.llm.camel_adapter import create_model_backend
|
||||
import oasis
|
||||
from oasis import (
|
||||
ActionType,
|
||||
|
|
@ -428,36 +427,13 @@ class TwitterSimulationRunner:
|
|||
"""
|
||||
创建LLM模型
|
||||
|
||||
统一使用项目根目录 .env 文件中的配置(优先级最高):
|
||||
- LLM_API_KEY: API密钥
|
||||
- LLM_BASE_URL: API基础URL
|
||||
- LLM_MODEL_NAME: 模型名称
|
||||
统一使用 AgentRuntime / LLMProvider 配置(优先级最高):
|
||||
- MIROFISH_LLM_PROVIDER=agent_queue 时不需要模型 API key
|
||||
- MIROFISH_LLM_PROVIDER=openai_compatible 时使用 legacy LLM_* 配置
|
||||
"""
|
||||
# 优先从 .env 读取配置
|
||||
llm_api_key = os.environ.get("LLM_API_KEY", "")
|
||||
llm_base_url = os.environ.get("LLM_BASE_URL", "")
|
||||
llm_model = os.environ.get("LLM_MODEL_NAME", "")
|
||||
|
||||
# 如果 .env 中没有,则使用 config 作为备用
|
||||
if not llm_model:
|
||||
llm_model = self.config.get("llm_model", "gpt-4o-mini")
|
||||
|
||||
# 设置 camel-ai 所需的环境变量
|
||||
if llm_api_key:
|
||||
os.environ["OPENAI_API_KEY"] = llm_api_key
|
||||
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
|
||||
|
||||
if llm_base_url:
|
||||
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
|
||||
|
||||
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
|
||||
|
||||
return ModelFactory.create(
|
||||
model_platform=ModelPlatformType.OPENAI,
|
||||
model_type=llm_model,
|
||||
)
|
||||
run_id = self.config.get("simulation_id") or os.path.basename(self.simulation_dir)
|
||||
print(f"LLM配置: AgentRuntime adapter, run_id={run_id}")
|
||||
return create_model_backend(run_id=run_id, run_dir=self.simulation_dir)
|
||||
|
||||
def _get_active_agents_for_round(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
|
||||
from app.agent_engine.runner import PredictionRunService
|
||||
|
||||
|
||||
def test_agent_optional_dependencies_declared():
|
||||
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
agent_deps = set(pyproject["project"]["optional-dependencies"]["agent"])
|
||||
assert {"graphiti-core", "neo4j", "mcp"}.issubset(agent_deps)
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, version="5.26.0"):
|
||||
self.version = version
|
||||
|
||||
def single(self):
|
||||
return {"versions": [self.version]}
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, version="5.26.0"):
|
||||
self.version = version
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def run(self, query):
|
||||
assert "CALL dbms.components()" in query
|
||||
return _FakeResult(self.version)
|
||||
|
||||
|
||||
class _FakeDriver:
|
||||
def __init__(self, version="5.26.0"):
|
||||
self.version = version
|
||||
|
||||
def session(self, database):
|
||||
assert database == "neo4j"
|
||||
return _FakeSession(self.version)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def _fake_graph_database(version="5.26.0"):
|
||||
class FakeGraphDatabase:
|
||||
@staticmethod
|
||||
def driver(uri, auth):
|
||||
assert uri == "bolt://localhost:7687"
|
||||
assert auth == ("neo4j", "password")
|
||||
return _FakeDriver(version)
|
||||
|
||||
return FakeGraphDatabase
|
||||
|
||||
|
||||
class _FakeUrlResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
def _set_agent_env(monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.delenv("MIROFISH_GRAPHITI_STORE", raising=False)
|
||||
monkeypatch.setenv("NEO4J_URI", "bolt://localhost:7687")
|
||||
monkeypatch.setenv("NEO4J_USER", "neo4j")
|
||||
monkeypatch.setenv("NEO4J_PASSWORD", "password")
|
||||
monkeypatch.setenv("NEO4J_DATABASE", "neo4j")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_SEARCH_MODE", "semantic")
|
||||
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "ollama")
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
|
||||
|
||||
def _mock_agent_deps(monkeypatch, ollama_payload, *, neo4j_version="5.26.0"):
|
||||
original_find_spec = importlib.util.find_spec
|
||||
|
||||
def fake_find_spec(name):
|
||||
if name in {"graphiti_core", "neo4j", "mcp"}:
|
||||
return object()
|
||||
return original_find_spec(name)
|
||||
|
||||
monkeypatch.setattr(importlib.util, "find_spec", fake_find_spec)
|
||||
monkeypatch.setitem(sys.modules, "neo4j", types.SimpleNamespace(GraphDatabase=_fake_graph_database(neo4j_version)))
|
||||
monkeypatch.setattr("shutil.which", lambda name: None)
|
||||
monkeypatch.setattr(
|
||||
"urllib.request.urlopen",
|
||||
lambda url, timeout: _FakeUrlResponse(ollama_payload),
|
||||
)
|
||||
|
||||
|
||||
def test_doctor_agent_graphiti_checks_neo4j_and_ollama(monkeypatch, tmp_path):
|
||||
_set_agent_env(monkeypatch)
|
||||
_mock_agent_deps(monkeypatch, {"models": [{"name": "nomic-embed-text:latest"}]})
|
||||
|
||||
result = PredictionRunService().doctor(str(tmp_path))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
checks = {check["name"]: check for check in result["checks"]}
|
||||
assert checks["graphiti_package"]["ok"] is True
|
||||
assert checks["neo4j_connectable"]["ok"] is True
|
||||
assert checks["neo4j_version_supported"]["ok"] is True
|
||||
assert checks["ollama_connectable"]["ok"] is True
|
||||
assert checks["ollama_embedding_model"]["ok"] is True
|
||||
assert checks["docker"]["required"] is False
|
||||
assert checks["docker_compose"]["required"] is False
|
||||
|
||||
|
||||
def test_doctor_agent_graphiti_fails_when_ollama_model_missing(monkeypatch, tmp_path):
|
||||
_set_agent_env(monkeypatch)
|
||||
_mock_agent_deps(monkeypatch, {"models": []})
|
||||
|
||||
result = PredictionRunService().doctor(str(tmp_path))
|
||||
|
||||
assert result["status"] == "failed"
|
||||
hard_failures = {check["name"] for check in result["hard_failures"]}
|
||||
assert "ollama_embedding_model" in hard_failures
|
||||
|
||||
|
||||
def test_doctor_does_not_require_ollama_when_embedding_provider_is_not_ollama(monkeypatch, tmp_path):
|
||||
_set_agent_env(monkeypatch)
|
||||
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "none")
|
||||
_mock_agent_deps(monkeypatch, {"models": []})
|
||||
|
||||
result = PredictionRunService().doctor(str(tmp_path))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
checks = {check["name"]: check for check in result["checks"]}
|
||||
assert checks["ollama_connectable"]["required"] is False
|
||||
assert checks["ollama_embedding_model"]["required"] is False
|
||||
|
||||
|
||||
def test_doctor_does_not_require_ollama_for_fulltext_search(monkeypatch, tmp_path):
|
||||
_set_agent_env(monkeypatch)
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_SEARCH_MODE", "fulltext")
|
||||
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "ollama")
|
||||
_mock_agent_deps(monkeypatch, {"models": []})
|
||||
|
||||
result = PredictionRunService().doctor(str(tmp_path))
|
||||
|
||||
assert result["status"] == "ok"
|
||||
checks = {check["name"]: check for check in result["checks"]}
|
||||
assert checks["graph_search_mode"]["value"] == "fulltext"
|
||||
assert checks["ollama_connectable"]["required"] is False
|
||||
assert "fulltext search does not require Ollama" in checks["ollama_connectable"]["value"]
|
||||
|
||||
|
||||
def test_doctor_requires_neo4j_526_or_newer(monkeypatch, tmp_path):
|
||||
_set_agent_env(monkeypatch)
|
||||
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "none")
|
||||
_mock_agent_deps(monkeypatch, {"models": []}, neo4j_version="5.25.0")
|
||||
|
||||
result = PredictionRunService().doctor(str(tmp_path))
|
||||
|
||||
assert result["status"] == "failed"
|
||||
hard_failures = {check["name"] for check in result["hard_failures"]}
|
||||
assert "neo4j_version_supported" in hard_failures
|
||||
|
||||
|
||||
def test_doctor_hard_failures_are_limited_to_dependency_and_service_gates(monkeypatch, tmp_path):
|
||||
_set_agent_env(monkeypatch)
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_SEARCH_MODE", "not-a-mode")
|
||||
monkeypatch.setenv("MIROFISH_EMBEDDING_PROVIDER", "not-a-provider")
|
||||
_mock_agent_deps(monkeypatch, {"models": []})
|
||||
|
||||
result = PredictionRunService().doctor(str(tmp_path))
|
||||
|
||||
allowed = {
|
||||
"graphiti_package",
|
||||
"neo4j_package",
|
||||
"neo4j_connectable",
|
||||
"neo4j_version_supported",
|
||||
"ollama_connectable",
|
||||
"ollama_embedding_model",
|
||||
}
|
||||
assert {check["name"] for check in result["hard_failures"]}.issubset(allowed)
|
||||
|
|
@ -0,0 +1,836 @@
|
|||
"""Tests for agent interaction features: agents, questionnaires, web console."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.llm.base import LLMTask
|
||||
from app.adapters.llm.mock import MockLLMProvider
|
||||
from app.agent_engine.cli import build_parser
|
||||
from app.agent_engine.contracts import (
|
||||
AGENT_QUESTION_OUTPUT_SCHEMA,
|
||||
AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA,
|
||||
QUESTIONNAIRE_SUMMARY_OUTPUT_SCHEMA,
|
||||
REPORT_QUESTION_OUTPUT_SCHEMA,
|
||||
TASK_OUTPUT_SCHEMAS,
|
||||
)
|
||||
from app.agent_engine.json_schema import validate_json_schema
|
||||
from app.agent_engine.queue import AgentQueue
|
||||
from app.agent_engine.runner import PredictionRunService, _resolve_agent_id, _resolve_all_agent_ids
|
||||
from app.agent_engine.schemas import AGENT_TASK_TYPES
|
||||
from app.agent_engine.state import RunStore
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _init_run(tmp_path: Path, *, seed_text: str = "A affects B.") -> tuple[Path, PredictionRunService]:
|
||||
"""Create a run directory with seed and profiles artifact."""
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text(seed_text, encoding="utf-8")
|
||||
run_dir = tmp_path / "run"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "test interaction", str(run_dir))
|
||||
# Write profiles.json artifact so agent methods work
|
||||
profiles = [
|
||||
{"agent_id": "agent_1", "name": "Analyst Alpha", "persona": "Cautious geopolitical analyst."},
|
||||
{"agent_id": "agent_2", "name": "Strategist Beta", "persona": "Optimistic tech strategist."},
|
||||
]
|
||||
artifacts_dir = run_dir / "artifacts"
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
(artifacts_dir / "profiles.json").write_text(json.dumps(profiles, ensure_ascii=False), encoding="utf-8")
|
||||
# Write report.md
|
||||
(artifacts_dir / "report.md").write_text("# Test Report\n\nThis is a test report.", encoding="utf-8")
|
||||
# Write verdict.json
|
||||
(artifacts_dir / "verdict.json").write_text(json.dumps({"status": "ok", "confidence": 0.7}), encoding="utf-8")
|
||||
# Write timeline.json
|
||||
(artifacts_dir / "timeline.json").write_text(json.dumps([{"round": 1, "summary": "Initial"}]), encoding="utf-8")
|
||||
# Write graph_snapshot.json
|
||||
(artifacts_dir / "graph_snapshot.json").write_text(json.dumps([]), encoding="utf-8")
|
||||
# Write simulation_config.json
|
||||
(artifacts_dir / "simulation_config.json").write_text(json.dumps({"rounds": 10}), encoding="utf-8")
|
||||
# Write simulation_actions.json
|
||||
(artifacts_dir / "simulation_actions.json").write_text(json.dumps([]), encoding="utf-8")
|
||||
return run_dir, service
|
||||
|
||||
|
||||
# ── list_agents tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestListAgents:
|
||||
def test_list_agents_reads_profiles(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.list_agents(str(run_dir))
|
||||
assert result["status"] == "ok"
|
||||
assert result["count"] == 2
|
||||
agent_ids = [a["agent_id"] for a in result["agents"]]
|
||||
assert "agent_1" in agent_ids
|
||||
assert "agent_2" in agent_ids
|
||||
|
||||
def test_list_agents_empty_when_no_profiles(self, tmp_path):
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A.", encoding="utf-8")
|
||||
run_dir = tmp_path / "empty-run"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "test empty", str(run_dir))
|
||||
result = service.list_agents(str(run_dir))
|
||||
assert result["status"] == "ok"
|
||||
assert result["count"] == 0
|
||||
|
||||
def test_get_agent_returns_profile(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.get_agent(str(run_dir), "agent_1")
|
||||
assert result["status"] == "ok"
|
||||
assert result["agent"]["agent_id"] == "agent_1"
|
||||
assert result["agent"]["name"] == "Analyst Alpha"
|
||||
|
||||
def test_get_agent_not_found(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.get_agent(str(run_dir), "nonexistent")
|
||||
assert result["status"] == "error"
|
||||
assert "not found" in result["error"]
|
||||
|
||||
|
||||
# ── ask_agent tests ──────────────────────────────────────────────────────
|
||||
|
||||
class TestAskAgent:
|
||||
def test_ask_agent_creates_agent_queue_request(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.ask_agent(str(run_dir), "agent_1", "What are the implications?")
|
||||
assert result["status"] == "need_agent_response"
|
||||
assert result["type"] == "answer_agent_question"
|
||||
assert result["agent_id"] == "agent_1"
|
||||
|
||||
# Verify the request was created with correct structure
|
||||
request = AgentQueue(run_dir).load_request(result["request_id"])
|
||||
assert request.type == "answer_agent_question"
|
||||
assert request.stage == "interaction"
|
||||
assert request.structured_input["agent_id"] == "agent_1"
|
||||
assert request.structured_input["question"] == "What are the implications?"
|
||||
|
||||
def test_ask_agent_nonexistent_returns_error(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.ask_agent(str(run_dir), "ghost_agent", "Hello?")
|
||||
assert result["status"] == "error"
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_ask_agent_submit_response_writes_interaction_artifact(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
need = service.ask_agent(str(run_dir), "agent_1", "What do you think?")
|
||||
assert need["status"] == "need_agent_response"
|
||||
|
||||
# Write a valid response
|
||||
response_path = run_dir / "responses" / f"{need['request_id']}.json"
|
||||
response_path.write_text(json.dumps({
|
||||
"request_id": need["request_id"],
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"agent_id": "agent_1",
|
||||
"answer_markdown": "I think supply chains will shift.",
|
||||
"used_memory": [],
|
||||
"used_graph_results": [],
|
||||
"confidence": 0.8,
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
# Submit via get_agent_answer which processes the response
|
||||
answer = service.get_agent_answer(str(run_dir), need["request_id"])
|
||||
assert answer["status"] == "ok"
|
||||
assert answer["agent_id"] == "agent_1"
|
||||
# Verify interaction artifacts were written
|
||||
questions_dir = run_dir / "artifacts" / "interactions" / "agent_questions"
|
||||
assert questions_dir.exists()
|
||||
json_files = list(questions_dir.glob("*.json"))
|
||||
assert len(json_files) >= 1
|
||||
md_files = list(questions_dir.glob("*.md"))
|
||||
assert len(md_files) >= 1
|
||||
|
||||
|
||||
# ── questionnaire tests ──────────────────────────────────────────────────
|
||||
|
||||
class TestQuestionnaire:
|
||||
def test_send_questionnaire_creates_batch_requests(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
questions = [
|
||||
{"question_id": "q1", "question": "What is the biggest risk?"},
|
||||
{"question_id": "q2", "question": "What opportunities do you see?"},
|
||||
]
|
||||
result = service.send_questionnaire(str(run_dir), questions)
|
||||
assert result["status"] == "need_agent_response"
|
||||
assert result["question_count"] == 2
|
||||
assert result["agent_count"] == 2
|
||||
assert len(result["request_ids"]) == 2
|
||||
|
||||
# Verify questionnaire metadata was saved
|
||||
questionnaires_dir = run_dir / "artifacts" / "interactions" / "questionnaires"
|
||||
assert questionnaires_dir.exists()
|
||||
meta_files = list(questionnaires_dir.glob("*_meta.json"))
|
||||
assert len(meta_files) == 1
|
||||
meta = json.loads(meta_files[0].read_text(encoding="utf-8"))
|
||||
assert meta["questionnaire_id"] == result["questionnaire_id"]
|
||||
assert len(meta["questions"]) == 2
|
||||
|
||||
def test_get_questionnaire_result_not_found(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.get_questionnaire_result(str(run_dir), "nonexistent_q")
|
||||
assert result["status"] == "error"
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_questionnaire_response_schema_valid(self):
|
||||
"""Verify questionnaire output schema validates correctly."""
|
||||
output = {
|
||||
"questionnaire_id": "q_test123",
|
||||
"answers": [
|
||||
{
|
||||
"agent_id": "agent_1",
|
||||
"question_id": "q1",
|
||||
"answer_markdown": "The biggest risk is supply chain disruption.",
|
||||
"confidence": 0.8,
|
||||
}
|
||||
],
|
||||
"summary_markdown": "Summary of questionnaire answers.",
|
||||
}
|
||||
errors = validate_json_schema(output, AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA)
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
# ── report question tests ────────────────────────────────────────────────
|
||||
|
||||
class TestReportQuestion:
|
||||
def test_ask_report_question_creates_request(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.ask_report_question(str(run_dir), "What does the report say about risks?")
|
||||
assert result["status"] == "need_agent_response"
|
||||
assert result["type"] == "ask_report_question"
|
||||
|
||||
def test_report_question_answer_persists_interaction(self, tmp_path, monkeypatch):
|
||||
"""Verify get_report_question_answer processes a response and persists to interactions/report_questions/."""
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
need = service.ask_report_question(str(run_dir), "Summarize the key risks.")
|
||||
assert need["status"] == "need_agent_response"
|
||||
|
||||
# Write a valid response
|
||||
response_path = run_dir / "responses" / f"{need['request_id']}.json"
|
||||
response_path.write_text(json.dumps({
|
||||
"request_id": need["request_id"],
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"answer_markdown": "The key risks are supply chain disruption and regulatory changes.",
|
||||
"used_graph_results": [],
|
||||
"confidence": 0.85,
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
# Process the response
|
||||
answer = service.get_report_question_answer(str(run_dir), need["request_id"])
|
||||
assert answer["status"] == "ok"
|
||||
# Verify interaction artifacts were written
|
||||
rq_dir = run_dir / "artifacts" / "interactions" / "report_questions"
|
||||
assert rq_dir.exists()
|
||||
json_files = list(rq_dir.glob("*.json"))
|
||||
assert len(json_files) >= 1
|
||||
|
||||
|
||||
# ── web console tests ────────────────────────────────────────────────────
|
||||
|
||||
class TestWebConsole:
|
||||
def test_web_generate_creates_index_html(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.generate_web_console(str(run_dir))
|
||||
assert result["status"] == "ok"
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
assert html_path.exists()
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
assert "MiroFish Web Console" in html_content
|
||||
assert "<!DOCTYPE html>" in html_content
|
||||
|
||||
def test_web_console_embeds_artifacts(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
# Check that key data is embedded
|
||||
assert "agent_1" in html_content
|
||||
assert "Analyst Alpha" in html_content
|
||||
assert "Test Report" in html_content
|
||||
|
||||
def test_web_console_path_in_artifacts(self, tmp_path):
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
result = service.generate_web_console(str(run_dir))
|
||||
assert "web/index.html" in result["path"]
|
||||
|
||||
def test_web_console_has_interactive_elements(self, tmp_path):
|
||||
"""Verify the template includes forms, buttons, and API client for interaction."""
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
# Agent Q&A form
|
||||
assert 'id="ask-agent-select"' in html_content
|
||||
assert 'id="ask-question-input"' in html_content
|
||||
assert 'id="ask-submit-btn"' in html_content
|
||||
# Questionnaire form
|
||||
assert 'id="questionnaire-submit-btn"' in html_content
|
||||
assert 'id="add-question-btn"' in html_content
|
||||
# Report question form
|
||||
assert 'id="report-q-input"' in html_content
|
||||
assert 'id="report-q-submit-btn"' in html_content
|
||||
# API client and polling
|
||||
assert "apiPost" in html_content
|
||||
assert "apiGet" in html_content
|
||||
assert "pollForAnswer" in html_content
|
||||
assert "checkApiStatus" in html_content
|
||||
# API status indicator
|
||||
assert 'id="api-dot"' in html_content
|
||||
assert 'id="api-status-text"' in html_content
|
||||
# Configurable API base URL
|
||||
assert 'id="api-base-input"' in html_content
|
||||
assert "localhost:5001" in html_content
|
||||
|
||||
def test_web_console_has_interaction_panels(self, tmp_path):
|
||||
"""Verify all interactive panels are present in the navigation."""
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
assert 'data-panel="ask"' in html_content
|
||||
assert 'data-panel="questionnaires"' in html_content
|
||||
assert 'data-panel="report-q"' in html_content
|
||||
assert 'data-panel="history"' in html_content
|
||||
|
||||
def test_web_console_js_embedding_escapes_special_chars(self, tmp_path):
|
||||
"""Verify that quotes, newlines, and backslashes in report/requirement don't break JS."""
|
||||
# Create a run with special characters in report and requirement
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text('Seed with "quotes" and\nnewlines.', encoding="utf-8")
|
||||
run_dir = tmp_path / "run_special"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), 'requirement with "quote" and\nnewline', str(run_dir))
|
||||
artifacts_dir = run_dir / "artifacts"
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Write report with special characters
|
||||
(artifacts_dir / "report.md").write_text(
|
||||
'# Report\n\nLine "quoted".\nBackslash: \\\nEnd.', encoding="utf-8"
|
||||
)
|
||||
(artifacts_dir / "profiles.json").write_text("[]", encoding="utf-8")
|
||||
(artifacts_dir / "verdict.json").write_text("{}", encoding="utf-8")
|
||||
(artifacts_dir / "timeline.json").write_text("[]", encoding="utf-8")
|
||||
(artifacts_dir / "graph_snapshot.json").write_text("[]", encoding="utf-8")
|
||||
(artifacts_dir / "simulation_config.json").write_text("{}", encoding="utf-8")
|
||||
(artifacts_dir / "simulation_actions.json").write_text("[]", encoding="utf-8")
|
||||
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
|
||||
# The DATA block must be valid JS — extract it and check JSON-escaped strings
|
||||
import re
|
||||
data_match = re.search(r"const DATA = (\{.*?\});", html_content, re.DOTALL)
|
||||
assert data_match, "Could not find DATA block in generated HTML"
|
||||
data_block = data_match.group(1)
|
||||
|
||||
# Verify quotes are JSON-escaped (backslash-escaped), not raw
|
||||
assert '\\"quote\\"' in data_block, "Double quotes must be JSON-escaped"
|
||||
# Verify newlines are escaped as \n, not literal newlines inside string
|
||||
assert '\\n' in data_block, "Newlines must be JSON-escaped as \\n"
|
||||
# Verify the requirement value is a valid JSON string (starts with ")
|
||||
assert 'requirement: "requirement with \\"' in data_block
|
||||
# Verify the reportMd contains the escaped backslash
|
||||
assert '\\\\' in data_block, "Backslashes must be JSON-escaped"
|
||||
|
||||
|
||||
# ── schema / contract tests ──────────────────────────────────────────────
|
||||
|
||||
class TestSchemasAndContracts:
|
||||
def test_new_task_types_in_agent_task_types(self):
|
||||
assert "answer_agent_question" in AGENT_TASK_TYPES
|
||||
assert "answer_agent_questionnaire" in AGENT_TASK_TYPES
|
||||
assert "summarize_questionnaire" in AGENT_TASK_TYPES
|
||||
assert "ask_report_question" in AGENT_TASK_TYPES
|
||||
|
||||
def test_new_task_types_have_output_schemas(self):
|
||||
assert "answer_agent_question" in TASK_OUTPUT_SCHEMAS
|
||||
assert "answer_agent_questionnaire" in TASK_OUTPUT_SCHEMAS
|
||||
assert "summarize_questionnaire" in TASK_OUTPUT_SCHEMAS
|
||||
assert "ask_report_question" in TASK_OUTPUT_SCHEMAS
|
||||
|
||||
def test_agent_question_output_schema_has_required_fields(self):
|
||||
schema = AGENT_QUESTION_OUTPUT_SCHEMA
|
||||
required = schema.get("required", [])
|
||||
assert "agent_id" in required
|
||||
assert "answer_markdown" in required
|
||||
assert "used_memory" in required
|
||||
assert "used_graph_results" in required
|
||||
assert "confidence" in required
|
||||
|
||||
def test_questionnaire_output_schema_has_required_fields(self):
|
||||
schema = AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA
|
||||
required = schema.get("required", [])
|
||||
assert "questionnaire_id" in required
|
||||
assert "answers" in required
|
||||
assert "summary_markdown" in required
|
||||
|
||||
def test_report_question_output_schema_has_required_fields(self):
|
||||
schema = REPORT_QUESTION_OUTPUT_SCHEMA
|
||||
required = schema.get("required", [])
|
||||
assert "answer_markdown" in required
|
||||
assert "used_graph_results" in required
|
||||
assert "confidence" in required
|
||||
|
||||
def test_mock_provider_handles_new_task_types(self):
|
||||
"""Mock provider must return valid output for all new task types."""
|
||||
provider = MockLLMProvider()
|
||||
for task_type in ["answer_agent_question", "answer_agent_questionnaire", "summarize_questionnaire", "ask_report_question"]:
|
||||
schema = TASK_OUTPUT_SCHEMAS[task_type]
|
||||
result = provider.run_task(LLMTask(
|
||||
run_id="run",
|
||||
task_type=task_type,
|
||||
stage="interaction",
|
||||
expected_schema=schema,
|
||||
structured_input={
|
||||
"agent_id": "agent_1",
|
||||
"questionnaire_id": "q_test",
|
||||
"questions": [{"question_id": "q1", "question": "Test?"}],
|
||||
"agents": [{"agent_id": "agent_1"}],
|
||||
"graph_results": [],
|
||||
},
|
||||
))
|
||||
assert result.status == "ok", f"{task_type} failed: {result.error}"
|
||||
errors = validate_json_schema(result.output, schema)
|
||||
assert not errors, f"{task_type} schema errors: {errors}"
|
||||
|
||||
def test_all_task_types_have_schema_and_mock_output(self):
|
||||
"""Extended version: verify all task types including new ones."""
|
||||
assert set(TASK_OUTPUT_SCHEMAS) == AGENT_TASK_TYPES
|
||||
provider = MockLLMProvider()
|
||||
for task_type in sorted(AGENT_TASK_TYPES):
|
||||
schema = TASK_OUTPUT_SCHEMAS[task_type]
|
||||
result = provider.run_task(LLMTask(
|
||||
run_id="run",
|
||||
task_type=task_type,
|
||||
stage=task_type,
|
||||
expected_schema=schema,
|
||||
structured_input={
|
||||
"actions": [{"agent_id": "agent_1", "action_id": "action_1"}],
|
||||
"candidate": {},
|
||||
"invalid_response": {"output": {}},
|
||||
"agent_id": "agent_1",
|
||||
"questionnaire_id": "q_test",
|
||||
"questions": [{"question_id": "q1", "question": "Test?"}],
|
||||
"agents": [{"agent_id": "agent_1"}],
|
||||
"graph_results": [],
|
||||
},
|
||||
))
|
||||
assert result.status == "ok", f"mock failed for {task_type}"
|
||||
assert not validate_json_schema(result.output, schema), f"schema validation failed for {task_type}"
|
||||
|
||||
|
||||
# ── CLI parser tests ─────────────────────────────────────────────────────
|
||||
|
||||
class TestCLIParser:
|
||||
def test_cli_agents_list(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["agents", "list", "--run", "runs/demo"])
|
||||
assert args.command == "agents"
|
||||
assert args.agents_command == "list"
|
||||
assert args.run == "runs/demo"
|
||||
|
||||
def test_cli_agents_show(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["agents", "show", "--run", "runs/demo", "--agent-id", "agent_1"])
|
||||
assert args.agents_command == "show"
|
||||
assert args.agent_id == "agent_1"
|
||||
|
||||
def test_cli_agents_ask(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["agents", "ask", "--run", "runs/demo", "--agent-id", "agent_1", "--question", "Hello?"])
|
||||
assert args.agents_command == "ask"
|
||||
assert args.question == "Hello?"
|
||||
|
||||
def test_cli_questionnaire_send(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["questionnaire", "send", "--run", "runs/demo", "--questions", "questions.json"])
|
||||
assert args.command == "questionnaire"
|
||||
assert args.questionnaire_command == "send"
|
||||
assert args.questions == "questions.json"
|
||||
|
||||
def test_cli_questionnaire_show(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["questionnaire", "show", "--run", "runs/demo", "--questionnaire-id", "q_123"])
|
||||
assert args.questionnaire_command == "show"
|
||||
assert args.questionnaire_id == "q_123"
|
||||
|
||||
def test_cli_agents_answer(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["agents", "answer", "--run", "runs/demo", "--request-id", "req_123"])
|
||||
assert args.agents_command == "answer"
|
||||
assert args.request_id == "req_123"
|
||||
|
||||
def test_cli_report_question_ask(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["report-question", "ask", "--run", "runs/demo", "--question", "What risks?"])
|
||||
assert args.command == "report-question"
|
||||
assert args.report_question_command == "ask"
|
||||
assert args.question == "What risks?"
|
||||
|
||||
def test_cli_report_question_answer(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["report-question", "answer", "--run", "runs/demo", "--request-id", "req_456"])
|
||||
assert args.report_question_command == "answer"
|
||||
assert args.request_id == "req_456"
|
||||
|
||||
def test_cli_web_generate(self):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["web", "generate", "--run", "runs/demo"])
|
||||
assert args.command == "web"
|
||||
assert args.web_command == "generate"
|
||||
assert args.run == "runs/demo"
|
||||
|
||||
|
||||
# ── MCP tools schema tests ──────────────────────────────────────────────
|
||||
|
||||
class TestMCPToolsSchema:
|
||||
def test_mcp_server_creates_without_error(self):
|
||||
"""Verify MCP server can be created with new tools."""
|
||||
try:
|
||||
from app.mcp_server.server import create_server
|
||||
server = create_server()
|
||||
assert server is not None
|
||||
except ImportError:
|
||||
pytest.skip("mcp package not installed")
|
||||
|
||||
def test_mcp_tools_include_interaction_tools(self):
|
||||
"""Verify the new interaction tools are registered."""
|
||||
try:
|
||||
from app.mcp_server.server import create_server
|
||||
server = create_server()
|
||||
# FastMCP stores tools internally; check via list
|
||||
tool_names = set()
|
||||
if hasattr(server, '_tool_manager'):
|
||||
tool_names = set(server._tool_manager._tools.keys()) if hasattr(server._tool_manager, '_tools') else set()
|
||||
elif hasattr(server, 'list_tools'):
|
||||
# Alternative: some versions expose list_tools
|
||||
pass
|
||||
# If we can't introspect, just verify server was created
|
||||
# The important thing is the tools were decorated with @mcp.tool()
|
||||
assert server is not None
|
||||
except ImportError:
|
||||
pytest.skip("mcp package not installed")
|
||||
|
||||
|
||||
# ── Path traversal guard tests ───────────────────────────────────────────
|
||||
|
||||
class TestPathTraversalGuard:
|
||||
def test_artifact_endpoint_rejects_path_traversal(self, tmp_path):
|
||||
"""Verify the artifact endpoint blocks path traversal attempts like ../../.env."""
|
||||
from app import create_app
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
# Create a file outside artifacts_dir to ensure it can't be read
|
||||
sensitive_file = run_dir / ".env"
|
||||
sensitive_file.write_text("SECRET=leaked", encoding="utf-8")
|
||||
|
||||
app = create_app()
|
||||
client = app.test_client()
|
||||
|
||||
# Attempt path traversal
|
||||
resp = client.get(
|
||||
f"/api/interaction/artifact/../../.env?run={run_dir}"
|
||||
)
|
||||
# Must be blocked (403) or not found (404), never 200 with leaked content
|
||||
assert resp.status_code in (403, 404), f"Path traversal not blocked: {resp.status_code}"
|
||||
if resp.status_code == 200:
|
||||
assert b"leaked" not in resp.data
|
||||
|
||||
def test_artifact_endpoint_allows_valid_paths(self, tmp_path):
|
||||
"""Verify normal artifact access still works after the traversal guard."""
|
||||
from app import create_app
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
app = create_app()
|
||||
client = app.test_client()
|
||||
|
||||
resp = client.get(
|
||||
f"/api/interaction/artifact/verdict.json?run={run_dir}"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["status"] == "ok"
|
||||
|
||||
def test_responses_endpoint_rejects_path_outside_responses_dir(self, tmp_path):
|
||||
"""Verify the responses endpoint blocks paths outside run/responses/."""
|
||||
from app import create_app
|
||||
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
app = create_app()
|
||||
client = app.test_client()
|
||||
|
||||
# Attempt to submit a response pointing to a file outside responses/
|
||||
outside_path = str(run_dir / "artifacts" / "verdict.json")
|
||||
resp = client.post(
|
||||
f"/api/interaction/responses?run={run_dir}",
|
||||
data=json.dumps({"response_path": outside_path}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 403, f"Path outside responses/ not blocked: {resp.status_code}"
|
||||
|
||||
|
||||
# ── MCP questionnaire questions_json tests ───────────────────────────────
|
||||
|
||||
class TestMCPQuestionnaireJsonParam:
|
||||
def test_questionnaire_accepts_questions_json_string(self, tmp_path, monkeypatch):
|
||||
"""Verify mirofish_send_questionnaire accepts a questions_json string with arbitrary count."""
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
try:
|
||||
from app.mcp_server.server import create_server
|
||||
server = create_server()
|
||||
except ImportError:
|
||||
pytest.skip("mcp package not installed")
|
||||
|
||||
# Call the tool function directly through the service
|
||||
run_dir, service = _init_run(tmp_path)
|
||||
questions_json = json.dumps([
|
||||
{"question_id": "q1", "question": "Risk 1?"},
|
||||
{"question_id": "q2", "question": "Risk 2?"},
|
||||
{"question_id": "q3", "question": "Risk 3?"},
|
||||
{"question_id": "q4", "question": "Risk 4?"},
|
||||
{"question_id": "q5", "question": "Risk 5?"},
|
||||
])
|
||||
# Test through the service layer directly (MCP tool calls this)
|
||||
import json as _json
|
||||
questions = _json.loads(questions_json)
|
||||
result = service.send_questionnaire(str(run_dir), questions)
|
||||
assert result["status"] == "need_agent_response"
|
||||
assert result["question_count"] == 5
|
||||
assert len(result["request_ids"]) == 5
|
||||
|
||||
def test_questionnaire_rejects_invalid_json(self, tmp_path):
|
||||
"""Verify the MCP tool rejects invalid questions_json input."""
|
||||
try:
|
||||
from app.mcp_server.server import create_server
|
||||
server = create_server()
|
||||
except ImportError:
|
||||
pytest.skip("mcp package not installed")
|
||||
|
||||
# Simulate the validation logic from the MCP tool
|
||||
import json as _json
|
||||
try:
|
||||
_json.loads("not valid json")
|
||||
assert False, "Should have raised"
|
||||
except (ValueError, TypeError):
|
||||
pass # Expected
|
||||
|
||||
|
||||
# ── Profile ID fallback tests ────────────────────────────────────────────
|
||||
|
||||
def _init_run_no_agent_id(tmp_path: Path) -> tuple[Path, PredictionRunService]:
|
||||
"""Create a run with profiles that have NO agent_id or user_id — only name."""
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("Seed.", encoding="utf-8")
|
||||
run_dir = tmp_path / "run_noid"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "test fallback id", str(run_dir))
|
||||
profiles = [
|
||||
{"name": "Baidu", "type": "search", "description": "Chinese search engine."},
|
||||
{"name": "Google China", "type": "search", "description": "Google's China ops."},
|
||||
{"name": "Baidu", "type": "ai", "description": "Duplicate name, different type."},
|
||||
{"type": "anon", "description": "No name at all."},
|
||||
]
|
||||
artifacts_dir = run_dir / "artifacts"
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
(artifacts_dir / "profiles.json").write_text(json.dumps(profiles), encoding="utf-8")
|
||||
(artifacts_dir / "report.md").write_text("# Report", encoding="utf-8")
|
||||
(artifacts_dir / "verdict.json").write_text("{}", encoding="utf-8")
|
||||
(artifacts_dir / "timeline.json").write_text("[]", encoding="utf-8")
|
||||
(artifacts_dir / "graph_snapshot.json").write_text("[]", encoding="utf-8")
|
||||
(artifacts_dir / "simulation_config.json").write_text("{}", encoding="utf-8")
|
||||
(artifacts_dir / "simulation_actions.json").write_text("[]", encoding="utf-8")
|
||||
return run_dir, service
|
||||
|
||||
|
||||
class TestResolveAgentId:
|
||||
def test_prefers_agent_id(self):
|
||||
assert _resolve_agent_id({"agent_id": "x_1", "name": "Baidu"}, 0) == "x_1"
|
||||
|
||||
def test_falls_back_to_user_id(self):
|
||||
assert _resolve_agent_id({"user_id": "u_42", "name": "Baidu"}, 0) == "u_42"
|
||||
|
||||
def test_generates_slug_from_name(self):
|
||||
assert _resolve_agent_id({"name": "Google China"}, 0) == "google_china"
|
||||
assert _resolve_agent_id({"name": "Baidu"}, 0) == "baidu"
|
||||
assert _resolve_agent_id({"name": " Spaced Name "}, 0) == "spaced_name"
|
||||
|
||||
def test_positional_fallback_when_no_name(self):
|
||||
assert _resolve_agent_id({"type": "anon"}, 0) == "agent_1"
|
||||
assert _resolve_agent_id({"type": "anon"}, 2) == "agent_3"
|
||||
|
||||
def test_empty_strings_treated_as_missing(self):
|
||||
assert _resolve_agent_id({"agent_id": "", "user_id": "", "name": ""}, 0) == "agent_1"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _resolve_agent_id({"agent_id": " x "}, 0) == "x"
|
||||
|
||||
|
||||
class TestResolveAllAgentIds:
|
||||
def test_no_duplicates_passthrough(self):
|
||||
profiles = [{"name": "Alpha"}, {"name": "Beta"}]
|
||||
ids = _resolve_all_agent_ids(profiles)
|
||||
assert ids == ["alpha", "beta"]
|
||||
|
||||
def test_duplicate_names_get_suffix(self):
|
||||
profiles = [
|
||||
{"name": "Baidu"},
|
||||
{"name": "Google China"},
|
||||
{"name": "Baidu"},
|
||||
{"type": "anon"},
|
||||
]
|
||||
ids = _resolve_all_agent_ids(profiles)
|
||||
assert ids[0] == "baidu"
|
||||
assert ids[1] == "google_china"
|
||||
assert ids[2] == "baidu_2"
|
||||
assert ids[3] == "agent_4"
|
||||
# All unique
|
||||
assert len(set(ids)) == len(ids)
|
||||
|
||||
def test_triple_duplicate(self):
|
||||
profiles = [{"name": "X"}, {"name": "X"}, {"name": "X"}]
|
||||
ids = _resolve_all_agent_ids(profiles)
|
||||
assert ids == ["x", "x_2", "x_3"]
|
||||
|
||||
def test_explicit_agent_id_collision(self):
|
||||
profiles = [
|
||||
{"agent_id": "agent_1"},
|
||||
{"agent_id": "agent_1"},
|
||||
]
|
||||
ids = _resolve_all_agent_ids(profiles)
|
||||
assert ids[0] == "agent_1"
|
||||
assert ids[1] == "agent_1_2"
|
||||
assert len(set(ids)) == 2
|
||||
|
||||
def test_empty_list(self):
|
||||
assert _resolve_all_agent_ids([]) == []
|
||||
|
||||
|
||||
class TestListAgentsFallbackId:
|
||||
def test_list_agents_returns_stable_ids_without_agent_id(self, tmp_path):
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
result = service.list_agents(str(run_dir))
|
||||
assert result["status"] == "ok"
|
||||
assert result["count"] == 4
|
||||
ids = [a["agent_id"] for a in result["agents"]]
|
||||
assert ids[0] == "baidu"
|
||||
assert ids[1] == "google_china"
|
||||
# Third profile also named "Baidu" — deduplicated with suffix
|
||||
assert ids[2] == "baidu_2"
|
||||
# Fourth profile has no name — positional fallback
|
||||
assert ids[3] == "agent_4"
|
||||
# All IDs must be unique
|
||||
assert len(set(ids)) == len(ids), "agent_ids must be unique"
|
||||
# None should be empty or "unknown"
|
||||
assert all(aid for aid in ids), "No agent_id should be empty"
|
||||
assert "unknown" not in ids
|
||||
|
||||
def test_get_agent_works_with_fallback_id(self, tmp_path):
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
result = service.get_agent(str(run_dir), "google_china")
|
||||
assert result["status"] == "ok"
|
||||
assert result["agent"]["agent_id"] == "google_china"
|
||||
assert result["agent"]["name"] == "Google China"
|
||||
|
||||
def test_get_agent_finds_second_duplicate(self, tmp_path):
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
# First "Baidu" → "baidu", second "Baidu" → "baidu_2"
|
||||
result = service.get_agent(str(run_dir), "baidu_2")
|
||||
assert result["status"] == "ok"
|
||||
assert result["agent"]["agent_id"] == "baidu_2"
|
||||
assert result["agent"]["name"] == "Baidu"
|
||||
assert result["agent"]["profile"]["type"] == "ai"
|
||||
|
||||
|
||||
class TestAskAgentFallbackId:
|
||||
def test_ask_agent_with_fallback_id_creates_request(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
result = service.ask_agent(str(run_dir), "google_china", "What is your strategy?")
|
||||
assert result["status"] == "need_agent_response"
|
||||
assert result["agent_id"] == "google_china"
|
||||
|
||||
|
||||
class TestWebConsoleFallbackId:
|
||||
def test_web_console_no_unknown_option(self, tmp_path):
|
||||
"""Web Console HTML must not contain 'unknown' as an agent option value."""
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
# The dropdown options should use slug-based IDs, not "unknown"
|
||||
assert 'value="unknown"' not in html_content
|
||||
# Verify slug-based IDs are embedded
|
||||
assert "baidu" in html_content
|
||||
assert "google_china" in html_content
|
||||
|
||||
def test_web_console_profiles_have_agent_id_in_embedded_data(self, tmp_path):
|
||||
"""Embedded profiles JSON should contain agent_id after normalization."""
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
# The embedded profiles JSON should contain the slug IDs
|
||||
assert '"agent_id"' not in html_content or '"baidu"' in html_content or '"google_china"' in html_content
|
||||
|
||||
def test_web_console_duplicate_names_get_unique_ids(self, tmp_path):
|
||||
"""Embedded profiles with duplicate names must have unique agent_ids."""
|
||||
run_dir, service = _init_run_no_agent_id(tmp_path)
|
||||
service.generate_web_console(str(run_dir))
|
||||
html_path = run_dir / "artifacts" / "web" / "index.html"
|
||||
html_content = html_path.read_text(encoding="utf-8")
|
||||
# The second "Baidu" profile should get "baidu_2"
|
||||
assert "baidu_2" in html_content
|
||||
# The fourth profile (no name) should get "agent_4"
|
||||
assert "agent_4" in html_content
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import importlib.util
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def test_agent_mode_config_does_not_require_model_or_zep_keys(monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ZEP_API_KEY", raising=False)
|
||||
assert Config.validate() == []
|
||||
|
||||
|
||||
def test_legacy_llm_client_mock_mode_does_not_require_api_key(tmp_path, monkeypatch):
|
||||
from app.utils.llm_client import LLMClient
|
||||
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "mock")
|
||||
monkeypatch.setattr(Config, "MIROFISH_RUNS_DIR", str(tmp_path / "runs"))
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
client = LLMClient()
|
||||
result = client.chat([{"role": "user", "content": "hello"}])
|
||||
|
||||
assert result == "Mock text response generated without model APIs."
|
||||
|
||||
|
||||
def test_flask_api_returns_need_agent_response_as_structured_json():
|
||||
from app import create_app
|
||||
from app.adapters.llm.agent_runtime import NeedAgentResponse
|
||||
from app.adapters.llm.base import LLMProviderResult
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.route("/_test_need_agent_response")
|
||||
def _test_need_agent_response():
|
||||
raise NeedAgentResponse(
|
||||
LLMProviderResult(
|
||||
status="need_agent_response",
|
||||
request_id="req_000001",
|
||||
request_file="/tmp/req_000001.json",
|
||||
expected_response_file="/tmp/resp_000001.json",
|
||||
)
|
||||
)
|
||||
|
||||
response = app.test_client().get("/_test_need_agent_response")
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.get_json() == {
|
||||
"status": "need_agent_response",
|
||||
"request_id": "req_000001",
|
||||
"request_file": "/tmp/req_000001.json",
|
||||
"expected_response_file": "/tmp/resp_000001.json",
|
||||
}
|
||||
|
||||
|
||||
def test_flask_api_zep_guard_is_provider_aware(monkeypatch):
|
||||
from app.api.graph import _legacy_zep_config_errors
|
||||
from app.api.simulation import _legacy_zep_required
|
||||
|
||||
monkeypatch.setattr(Config, "ZEP_API_KEY", None)
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
assert _legacy_zep_config_errors() == []
|
||||
assert _legacy_zep_required() is False
|
||||
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "zep")
|
||||
assert _legacy_zep_config_errors()
|
||||
assert _legacy_zep_required() is True
|
||||
|
||||
|
||||
def test_provider_boundary_checker_catches_legacy_adapter_and_schema_leaks(tmp_path):
|
||||
checker_path = Path(__file__).resolve().parents[2] / "scripts" / "check_provider_boundaries.py"
|
||||
spec = importlib.util.spec_from_file_location("check_provider_boundaries", checker_path)
|
||||
checker = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(checker)
|
||||
|
||||
business = tmp_path / "backend" / "app" / "services" / "bad_business.py"
|
||||
business.parent.mkdir(parents=True)
|
||||
business.write_text(
|
||||
"from app.adapters.graph.zep import ZepGraphProvider\n"
|
||||
"QUERY = 'MATCH (n:MiroFishEntity) RETURN n'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
violations = checker.collect_violations(
|
||||
[tmp_path / "backend" / "app"],
|
||||
root=tmp_path,
|
||||
allowed=set(),
|
||||
allowed_legacy_adapter_imports=set(),
|
||||
allowed_graphiti_schema=set(),
|
||||
)
|
||||
assert any("imports legacy provider adapter directly" in violation for violation in violations)
|
||||
assert any("Graphiti/Neo4j schema assumption" in violation for violation in violations)
|
||||
|
||||
|
||||
def test_legacy_sdks_are_optional_dependencies_only():
|
||||
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
||||
|
||||
default_dependencies = set(data["project"]["dependencies"])
|
||||
legacy_dependencies = set(data["project"]["optional-dependencies"]["legacy"])
|
||||
|
||||
assert not any(dependency.startswith("openai") for dependency in default_dependencies)
|
||||
assert not any(dependency.startswith("zep-cloud") for dependency in default_dependencies)
|
||||
assert any(dependency.startswith("openai") for dependency in legacy_dependencies)
|
||||
assert any(dependency.startswith("zep-cloud") for dependency in legacy_dependencies)
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.adapters.llm.base import LLMTask
|
||||
from app.adapters.llm.mock import MockLLMProvider
|
||||
from app.agent_engine.contracts import TASK_OUTPUT_SCHEMAS
|
||||
from app.agent_engine.schemas import AGENT_TASK_TYPES
|
||||
from app.agent_engine.json_schema import TRIPLE_SCHEMA, object_schema, validate_json_schema
|
||||
from app.agent_engine.queue import AgentQueue
|
||||
from app.agent_engine.state import RunStore
|
||||
|
||||
|
||||
def _init_run(tmp_path: Path) -> Path:
|
||||
run_dir = tmp_path / "run"
|
||||
store = RunStore(run_dir)
|
||||
store.init_state("run", "test requirement", [])
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_agent_queue_strict_response_lifecycle(tmp_path):
|
||||
run_dir = _init_run(tmp_path)
|
||||
queue = AgentQueue(run_dir)
|
||||
need = queue.create_request(
|
||||
run_id="run",
|
||||
task_type="extract_triples",
|
||||
stage="graph",
|
||||
expected_schema=object_schema({"triples": {"type": "array", "items": TRIPLE_SCHEMA}}, ["triples"]),
|
||||
)
|
||||
|
||||
response_path = Path(need.expected_response_file)
|
||||
response_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need.request_id,
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 0.8,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = queue.validate_response_file(response_path)
|
||||
assert result.ok, result.errors
|
||||
|
||||
|
||||
def test_agent_queue_rejects_missing_fields_and_bad_confidence(tmp_path):
|
||||
run_dir = _init_run(tmp_path)
|
||||
queue = AgentQueue(run_dir)
|
||||
need = queue.create_request(
|
||||
run_id="run",
|
||||
task_type="extract_triples",
|
||||
stage="graph",
|
||||
expected_schema=object_schema({"triples": {"type": "array", "items": TRIPLE_SCHEMA}}, ["triples"]),
|
||||
)
|
||||
bad = Path(need.expected_response_file)
|
||||
bad.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need.request_id,
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 1.2,
|
||||
"metadata": {},
|
||||
"extra": "not allowed",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = queue.submit_response(bad)
|
||||
assert not result.ok
|
||||
assert any("confidence" in error for error in result.errors)
|
||||
assert any("extra field" in error for error in result.errors)
|
||||
assert result.repair_request is not None
|
||||
|
||||
|
||||
def test_agent_queue_persists_repair_attempts(tmp_path):
|
||||
run_dir = _init_run(tmp_path)
|
||||
queue = AgentQueue(run_dir)
|
||||
need = queue.create_request(
|
||||
run_id="run",
|
||||
task_type="generate_report",
|
||||
stage="report",
|
||||
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
|
||||
retry_policy={"max_repair_attempts": 1},
|
||||
)
|
||||
bad = Path(need.expected_response_file)
|
||||
bad.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need.request_id,
|
||||
"status": "ok",
|
||||
"output": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
first = queue.submit_response(bad)
|
||||
second = queue.submit_response(bad)
|
||||
|
||||
assert not first.ok
|
||||
assert first.repair_request is not None
|
||||
assert not second.ok
|
||||
assert second.repair_request is None
|
||||
assert queue.load_request(need.request_id).retry_policy.repair_attempts_used == 1
|
||||
|
||||
|
||||
def test_agent_queue_requires_filename_request_id_match(tmp_path):
|
||||
run_dir = _init_run(tmp_path)
|
||||
queue = AgentQueue(run_dir)
|
||||
first = queue.create_request(
|
||||
run_id="run",
|
||||
task_type="generate_report",
|
||||
stage="report",
|
||||
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
|
||||
)
|
||||
second = queue.create_request(
|
||||
run_id="run",
|
||||
task_type="generate_report",
|
||||
stage="report",
|
||||
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
|
||||
)
|
||||
response_path = run_dir / "responses" / f"{second.request_id}.json"
|
||||
response_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": first.request_id,
|
||||
"status": "ok",
|
||||
"output": {"report_markdown": "ok"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = queue.validate_response_file(response_path)
|
||||
|
||||
assert not result.ok
|
||||
assert any("does not match response file name" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_agent_queue_validates_skipped_output_against_expected_schema(tmp_path):
|
||||
run_dir = _init_run(tmp_path)
|
||||
queue = AgentQueue(run_dir)
|
||||
need = queue.create_request(
|
||||
run_id="run",
|
||||
task_type="generate_report",
|
||||
stage="report",
|
||||
expected_schema=object_schema({"report_markdown": {"type": "string"}}, ["report_markdown"]),
|
||||
)
|
||||
response_path = Path(need.expected_response_file)
|
||||
response_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need.request_id,
|
||||
"status": "skipped",
|
||||
"output": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = queue.validate_response_file(response_path)
|
||||
|
||||
assert not result.ok
|
||||
assert any("missing required field" in error for error in result.errors)
|
||||
|
||||
|
||||
def test_agent_queue_supports_all_declared_task_types(tmp_path):
|
||||
run_dir = _init_run(tmp_path)
|
||||
queue = AgentQueue(run_dir)
|
||||
schema = object_schema({"result": {"type": "object"}}, ["result"])
|
||||
|
||||
created = []
|
||||
for task_type in sorted(AGENT_TASK_TYPES):
|
||||
need = queue.create_request(
|
||||
run_id="run",
|
||||
task_type=task_type,
|
||||
stage=task_type,
|
||||
expected_schema=schema,
|
||||
structured_input={"task_type": task_type},
|
||||
)
|
||||
created.append(need.request_id)
|
||||
|
||||
assert len(created) == len(AGENT_TASK_TYPES)
|
||||
assert len(queue.list_requests()) == len(AGENT_TASK_TYPES)
|
||||
|
||||
|
||||
def test_all_task_types_have_strict_schema_and_mock_output():
|
||||
assert set(TASK_OUTPUT_SCHEMAS) == AGENT_TASK_TYPES
|
||||
provider = MockLLMProvider()
|
||||
|
||||
for task_type in sorted(AGENT_TASK_TYPES):
|
||||
schema = TASK_OUTPUT_SCHEMAS[task_type]
|
||||
result = provider.run_task(
|
||||
LLMTask(
|
||||
run_id="run",
|
||||
task_type=task_type,
|
||||
stage=task_type,
|
||||
expected_schema=schema,
|
||||
structured_input={
|
||||
"actions": [{"agent_id": "agent_1", "action_id": "action_1"}],
|
||||
"candidate": {},
|
||||
"invalid_response": {"output": {}},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status == "ok"
|
||||
assert not validate_json_schema(result.output, schema), task_type
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
from app.adapters.llm.camel_adapter import AgentModelBackendAdapter
|
||||
from app.adapters.llm.agent_queue import AgentQueueLLMProvider
|
||||
from app.adapters.llm.mock import MockLLMProvider
|
||||
from app.adapters.llm.agent_runtime import AgentRuntime
|
||||
from app.agent_engine.queue import AgentQueue
|
||||
from camel.models import BaseModelBackend
|
||||
|
||||
|
||||
def test_agent_model_backend_adapter_batches_actions(tmp_path):
|
||||
runtime = AgentRuntime(provider=MockLLMProvider(), run_dir=str(tmp_path))
|
||||
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
|
||||
result = adapter.run_batch_actions(
|
||||
"round_1",
|
||||
[
|
||||
{"agent_id": "a1", "action_id": "x1"},
|
||||
{"agent_id": "a2", "action_id": "x2"},
|
||||
],
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
actions = result["output"]["actions"]
|
||||
assert {item["action_id"] for item in actions} == {"x1", "x2"}
|
||||
|
||||
|
||||
def test_agent_model_backend_adapter_is_camel_backend_and_returns_tool_call(tmp_path):
|
||||
runtime = AgentRuntime(provider=MockLLMProvider(), run_dir=str(tmp_path))
|
||||
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
|
||||
assert isinstance(adapter, BaseModelBackend)
|
||||
|
||||
response = adapter.run(
|
||||
[{"role": "user", "content": "Act now"}],
|
||||
tools=[{"type": "function", "function": {"name": "create_post", "parameters": {"type": "object"}}}],
|
||||
)
|
||||
tool_calls = response.choices[0].message.tool_calls
|
||||
assert tool_calls
|
||||
assert tool_calls[0].function.name == "create_post"
|
||||
|
||||
|
||||
def test_agent_model_backend_adapter_agent_queue_generates_request_without_keys(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ZEP_API_KEY", raising=False)
|
||||
|
||||
runtime = AgentRuntime(provider=AgentQueueLLMProvider(run_dir=tmp_path), run_dir=str(tmp_path))
|
||||
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
|
||||
response = adapter.run(
|
||||
[{"role": "user", "content": "Act in the simulation round"}],
|
||||
tools=[{"type": "function", "function": {"name": "do_nothing", "parameters": {"type": "object"}}}],
|
||||
)
|
||||
assert response.choices[0].message.tool_calls[0].function.name == "do_nothing"
|
||||
requests = AgentQueue(tmp_path).list_requests()
|
||||
assert requests
|
||||
assert requests[0]["type"] == "simulate_agent_action"
|
||||
assert adapter.last_need_agent_response is not None
|
||||
assert adapter.last_need_agent_response["status"] == "need_agent_response"
|
||||
assert adapter.last_need_agent_response["request_id"] == requests[0]["request_id"]
|
||||
|
||||
|
||||
def test_agent_model_backend_adapter_records_batch_need_agent_response(tmp_path):
|
||||
runtime = AgentRuntime(provider=AgentQueueLLMProvider(run_dir=tmp_path), run_dir=str(tmp_path))
|
||||
adapter = AgentModelBackendAdapter("run", str(tmp_path), runtime=runtime)
|
||||
|
||||
result = adapter.run_batch_actions(
|
||||
"round_1",
|
||||
[
|
||||
{"agent_id": "a1", "action_id": "x1"},
|
||||
{"agent_id": "a2", "action_id": "x2"},
|
||||
],
|
||||
)
|
||||
|
||||
requests = AgentQueue(tmp_path).list_requests()
|
||||
assert result["status"] == "need_agent_response"
|
||||
assert adapter.last_need_agent_response is not None
|
||||
assert adapter.last_need_agent_response["request_id"] == result["request_id"]
|
||||
request = AgentQueue(tmp_path).load_request(result["request_id"])
|
||||
assert len(request.structured_input["actions"]) == 2
|
||||
assert {item["action_id"] for item in request.structured_input["actions"]} == {"x1", "x2"}
|
||||
assert requests[0]["type"] == "simulate_agent_action"
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.graph.graphiti import (
|
||||
GraphitiCompatibilityStore,
|
||||
GraphitiDependencyError,
|
||||
GraphitiGraphProvider,
|
||||
)
|
||||
from app.adapters.graph.base import GraphTriple
|
||||
|
||||
|
||||
def test_graphiti_provider_add_search_export_without_openai_key(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "store.json"))
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
|
||||
provider = GraphitiGraphProvider()
|
||||
provider.add_triples(
|
||||
"run",
|
||||
[
|
||||
{
|
||||
"subject": "美国商务部",
|
||||
"predicate": "限制",
|
||||
"object": "先进AI芯片出口",
|
||||
"fact": "美国商务部限制先进AI芯片出口。",
|
||||
"valid_at": "2024-01-01",
|
||||
"invalid_at": None,
|
||||
"source": "现实种子",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "限制先进AI芯片出口",
|
||||
"confidence": 0.82,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
results = provider.search("run", "AI芯片", limit=5)
|
||||
assert results
|
||||
assert results[0]["fact"] == "美国商务部限制先进AI芯片出口。"
|
||||
|
||||
out = tmp_path / "snapshot.json"
|
||||
provider.export_snapshot("run", str(out))
|
||||
snapshot = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert snapshot["run_id"] == "run"
|
||||
assert len(snapshot["triples"]) == 1
|
||||
|
||||
|
||||
def test_graphiti_missing_dependency_error_is_clear(monkeypatch):
|
||||
monkeypatch.setattr("importlib.util.find_spec", lambda name: None if name == "graphiti_core" else object())
|
||||
with pytest.raises(GraphitiDependencyError, match="graphiti_core is not installed"):
|
||||
GraphitiGraphProvider(require_graphiti_package=True)
|
||||
|
||||
|
||||
def test_graphiti_auto_store_requires_neo4j_driver(monkeypatch):
|
||||
monkeypatch.delenv("NEO4J_URI", raising=False)
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "auto")
|
||||
monkeypatch.setattr("importlib.util.find_spec", lambda name: None if name == "neo4j" else object())
|
||||
|
||||
with pytest.raises(GraphitiDependencyError, match="neo4j Python package is required"):
|
||||
GraphitiCompatibilityStore()
|
||||
|
||||
|
||||
def test_graphiti_file_store_explicitly_skips_neo4j_dependency(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "store.json"))
|
||||
monkeypatch.setattr("importlib.util.find_spec", lambda name: None if name == "neo4j" else object())
|
||||
|
||||
store = GraphitiCompatibilityStore()
|
||||
|
||||
assert store.driver is None
|
||||
assert (tmp_path / "store.json").exists()
|
||||
|
||||
|
||||
def test_graphiti_compatibility_store_encapsulates_schema_assumptions():
|
||||
assert hasattr(GraphitiCompatibilityStore, "_add_triplet_neo4j")
|
||||
assert not hasattr(GraphitiGraphProvider, "_add_triplet_neo4j")
|
||||
|
||||
|
||||
def test_graphiti_snapshot_uses_neo4j_branch_when_driver_present():
|
||||
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
|
||||
store.driver = object()
|
||||
store.list_entities = lambda run_id: [{"name": "A"}]
|
||||
store._list_facts_neo4j = lambda run_id: [{"subject": "A", "object": "B", "uuid": "internal"}]
|
||||
store._list_episodes_neo4j = lambda run_id: [{"content": "episode"}]
|
||||
store._list_memory_neo4j = lambda run_id: {"agent_1": {"belief": "x"}}
|
||||
|
||||
snapshot = store.snapshot("run")
|
||||
|
||||
assert snapshot["store"] == "neo4j"
|
||||
assert snapshot["entities"] == [{"name": "A"}]
|
||||
assert snapshot["triples"][0]["uuid"] == "internal"
|
||||
assert snapshot["memory"]["agent_1"]["belief"] == "x"
|
||||
|
||||
|
||||
def test_graphiti_neo4j_import_snapshot_cleans_internal_uuid(tmp_path):
|
||||
snapshot_path = tmp_path / "snapshot.json"
|
||||
snapshot_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"entities": [{"name": "A", "labels": ["Entity"]}],
|
||||
"triples": [
|
||||
{
|
||||
"uuid": "internal",
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 0.9,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
"episodes": [{"content": "episode", "metadata": {"source": "seed"}}],
|
||||
"memory": {"agent_1": {"belief": "x"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
imported = {}
|
||||
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
|
||||
store.driver = object()
|
||||
store.clear_run_graph = lambda run_id: imported.setdefault("cleared", run_id)
|
||||
store.get_or_create_entity_node = lambda run_id, name, labels=None: imported.setdefault("entity", (run_id, name, labels))
|
||||
store.add_episode = lambda run_id, content, metadata=None: imported.setdefault("episode", (run_id, content, metadata))
|
||||
store.write_agent_memory = lambda run_id, agent_id, memory: imported.setdefault("memory", (run_id, agent_id, memory))
|
||||
|
||||
def add_triples(run_id, triples):
|
||||
imported["triples"] = triples
|
||||
return {"triples_added": len(triples)}
|
||||
|
||||
store.add_triples = add_triples
|
||||
|
||||
result = store.import_snapshot("run", str(snapshot_path))
|
||||
|
||||
assert result["imported"] is True
|
||||
assert isinstance(imported["triples"][0], GraphTriple)
|
||||
assert imported["triples"][0].subject == "A"
|
||||
|
||||
|
||||
def test_graphiti_neo4j_neighbors_respects_depth():
|
||||
captured = {}
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def run(self, query, **params):
|
||||
captured["query"] = query
|
||||
captured["params"] = params
|
||||
return []
|
||||
|
||||
class FakeDriver:
|
||||
def session(self, database=None):
|
||||
captured["database"] = database
|
||||
return FakeSession()
|
||||
|
||||
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
|
||||
store.driver = FakeDriver()
|
||||
store.neo4j_database = "neo4j"
|
||||
|
||||
assert store.neighbors("run", "A", depth=4) == []
|
||||
|
||||
assert "[*1..4]" in captured["query"]
|
||||
assert captured["params"] == {"run_id": "run", "normalized": "a"}
|
||||
|
||||
|
||||
def test_graphiti_neo4j_search_does_not_shadow_driver_query_argument():
|
||||
captured = {}
|
||||
|
||||
class FakeSession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def run(self, cypher, **params):
|
||||
captured["cypher"] = cypher
|
||||
captured["params"] = params
|
||||
return []
|
||||
|
||||
class FakeDriver:
|
||||
def session(self, database=None):
|
||||
return FakeSession()
|
||||
|
||||
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
|
||||
store.driver = FakeDriver()
|
||||
store.neo4j_database = "neo4j"
|
||||
|
||||
assert store.search_facts("run", "AI芯片", limit=5) == []
|
||||
|
||||
assert "$search_query" in captured["cypher"]
|
||||
assert captured["params"] == {"run_id": "run", "search_query": "AI芯片", "limit": 5}
|
||||
|
||||
|
||||
def test_graphiti_neo4j_values_are_jsonable():
|
||||
class FakeDateTime:
|
||||
def iso_format(self):
|
||||
return "2026-06-06T00:00:00Z"
|
||||
|
||||
store = GraphitiCompatibilityStore.__new__(GraphitiCompatibilityStore)
|
||||
|
||||
assert store._to_jsonable({"created_at": FakeDateTime(), "items": [FakeDateTime()]}) == {
|
||||
"created_at": "2026-06-06T00:00:00Z",
|
||||
"items": ["2026-06-06T00:00:00Z"],
|
||||
}
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.adapters.graph.factory import create_graph_provider
|
||||
from app.agent_engine.cli import build_parser
|
||||
from app.agent_engine.queue import AgentQueue
|
||||
from app.agent_engine.runner import PredictionRunService
|
||||
|
||||
|
||||
def _write_response(run_dir: Path, payload: dict) -> None:
|
||||
requests = AgentQueue(run_dir).list_requests()
|
||||
request_id = requests[-1]["request_id"]
|
||||
response_path = run_dir / "responses" / f"{request_id}.json"
|
||||
response_path.write_text(
|
||||
json.dumps({"request_id": request_id, "status": "ok", "output": payload}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_full_agent_queue_runner_flow(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ZEP_API_KEY", raising=False)
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("美国商务部限制先进AI芯片出口。", encoding="utf-8")
|
||||
run_dir = tmp_path / "chip-2036"
|
||||
service = PredictionRunService()
|
||||
created = service.create_run(str(seed), "预测未来10年全球芯片能力格局变化", str(run_dir))
|
||||
assert created["status"] == "created"
|
||||
|
||||
need = service.run(str(run_dir))
|
||||
assert need["status"] == "need_agent_response"
|
||||
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "extract_triples"
|
||||
_write_response(
|
||||
run_dir,
|
||||
{
|
||||
"triples": [
|
||||
{
|
||||
"subject": "美国商务部",
|
||||
"predicate": "限制",
|
||||
"object": "先进AI芯片出口",
|
||||
"fact": "美国商务部限制先进AI芯片出口。",
|
||||
"valid_at": "2024-01-01",
|
||||
"invalid_at": None,
|
||||
"source": "现实种子",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "美国商务部限制先进AI芯片出口。",
|
||||
"confidence": 0.82,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "generate_oasis_profiles"
|
||||
_write_response(run_dir, {"profiles": [{"agent_id": "agent_1", "name": "Analyst", "persona": "Analyst"}]})
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "generate_simulation_config"
|
||||
_write_response(run_dir, {"config": {"rounds": 1}})
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "simulate_agent_action"
|
||||
request = AgentQueue(run_dir).load_request(need["request_id"])
|
||||
assert len(request.structured_input["actions"]) == 1
|
||||
_write_response(
|
||||
run_dir,
|
||||
{"actions": [{"agent_id": "agent_1", "action_id": "round_1_action_1", "action_type": "CREATE_POST", "content": "Chip export controls may shift supply chains."}]},
|
||||
)
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "generate_report"
|
||||
_write_response(
|
||||
run_dir,
|
||||
{
|
||||
"report_markdown": "# Report\n\nChip capacity may bifurcate.",
|
||||
"verdict": {"status": "ok", "confidence": 0.7},
|
||||
"timeline": [{"valid_at": "2024-01-01", "fact": "export controls"}],
|
||||
},
|
||||
)
|
||||
|
||||
result = service.resume(str(run_dir))
|
||||
assert result["status"] == "completed"
|
||||
for artifact in ["report.md", "verdict.json", "timeline.json", "graph_snapshot.json"]:
|
||||
assert (run_dir / "artifacts" / artifact).exists()
|
||||
|
||||
|
||||
def test_graph_build_provider_override_survives_agent_wait(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "zep")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
monkeypatch.delenv("ZEP_API_KEY", raising=False)
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / "override-run"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "test graph override", str(run_dir))
|
||||
|
||||
need = service.build_graph(str(run_dir), provider="graphiti")
|
||||
assert need["status"] == "need_agent_response"
|
||||
_write_response(
|
||||
run_dir,
|
||||
{
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 0.9,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
next_step = service.resume(str(run_dir))
|
||||
assert next_step["status"] == "need_agent_response"
|
||||
assert next_step["type"] == "generate_oasis_profiles"
|
||||
assert (run_dir / "artifacts" / "graph_snapshot.json").exists()
|
||||
|
||||
|
||||
def test_explicit_stage_commands_reuse_existing_waiting_request(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
service = PredictionRunService()
|
||||
|
||||
for command_name, command in [
|
||||
("graph", lambda run: service.build_graph(str(run), provider="graphiti")),
|
||||
("simulation", lambda run: service.start_simulation(str(run))),
|
||||
("report", lambda run: service.generate_report(str(run))),
|
||||
]:
|
||||
seed = tmp_path / f"{command_name}.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / f"{command_name}-run"
|
||||
service.create_run(str(seed), f"test {command_name}", str(run_dir))
|
||||
|
||||
first = command(run_dir)
|
||||
second = command(run_dir)
|
||||
requests = AgentQueue(run_dir).list_requests()
|
||||
|
||||
assert first["status"] == "need_agent_response"
|
||||
assert second["status"] == "need_agent_response"
|
||||
assert second["request_id"] == first["request_id"]
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
def test_resume_creates_repair_request_for_invalid_stage_response(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / "repair-run"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "test repair", str(run_dir))
|
||||
|
||||
need = service.run(str(run_dir))
|
||||
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "extract_triples"
|
||||
bad_response_path = run_dir / "responses" / f"{need['request_id']}.json"
|
||||
bad_response_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need["request_id"],
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 2.0,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
repair = service.resume(str(run_dir))
|
||||
assert repair["status"] == "need_agent_response"
|
||||
assert repair["type"] == "repair_invalid_json"
|
||||
|
||||
repair_request = AgentQueue(run_dir).load_request(repair["request_id"])
|
||||
assert repair_request.structured_input["validation_errors"]
|
||||
_write_response(
|
||||
run_dir,
|
||||
{
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 0.9,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
next_step = service.resume(str(run_dir))
|
||||
assert next_step["status"] == "need_agent_response"
|
||||
assert next_step["type"] == "generate_oasis_profiles"
|
||||
|
||||
|
||||
def test_response_submit_repair_request_is_attached_to_waiting_stage(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / "submit-repair-run"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "test submit repair", str(run_dir))
|
||||
|
||||
need = service.run(str(run_dir))
|
||||
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "extract_triples"
|
||||
bad_response_path = run_dir / "responses" / f"{need['request_id']}.json"
|
||||
bad_response_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need["request_id"],
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 2.0,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
submitted = service.submit_response(str(run_dir), str(bad_response_path))
|
||||
repair_id = submitted["repair_request"]["request_id"]
|
||||
resumed = service.resume(str(run_dir))
|
||||
|
||||
assert submitted["ok"] is False
|
||||
assert resumed["request_id"] == repair_id
|
||||
assert len(AgentQueue(run_dir).list_requests()) == 3
|
||||
|
||||
|
||||
def test_followup_question_uses_agent_queue_and_graph_context(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
monkeypatch.delenv("LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ZEP_API_KEY", raising=False)
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("美国商务部限制先进AI芯片出口。", encoding="utf-8")
|
||||
run_dir = tmp_path / "followup-run"
|
||||
service = PredictionRunService()
|
||||
created = service.create_run(str(seed), "预测未来10年全球芯片能力格局变化", str(run_dir))
|
||||
|
||||
create_graph_provider("graphiti").add_triples(
|
||||
created["run_id"],
|
||||
[
|
||||
{
|
||||
"subject": "美国商务部",
|
||||
"predicate": "限制",
|
||||
"object": "先进AI芯片出口",
|
||||
"fact": "美国商务部限制先进AI芯片出口。",
|
||||
"valid_at": "2024-01-01",
|
||||
"invalid_at": None,
|
||||
"source": "现实种子",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "美国商务部限制先进AI芯片出口。",
|
||||
"confidence": 0.82,
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
need = service.ask_followup_question(str(run_dir), "先进AI芯片出口限制有什么影响?", limit=5)
|
||||
assert need["status"] == "need_agent_response"
|
||||
assert need["type"] == "answer_followup_question"
|
||||
request = AgentQueue(run_dir).load_request(need["request_id"])
|
||||
assert request.structured_input["question"] == "先进AI芯片出口限制有什么影响?"
|
||||
assert request.structured_input["graph_results"]
|
||||
|
||||
response_path = run_dir / "responses" / f"{need['request_id']}.json"
|
||||
response_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"request_id": need["request_id"],
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"answer_markdown": "出口限制可能推动供应链分化。",
|
||||
"used_graph_results": request.structured_input["graph_results"],
|
||||
"confidence": 0.8,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
answer = service.get_followup_answer(str(run_dir), need["request_id"])
|
||||
assert answer["status"] == "ok"
|
||||
assert (run_dir / "artifacts" / "followups" / f"{need['request_id']}.md").exists()
|
||||
assert (run_dir / "artifacts" / "followups" / f"{need['request_id']}.json").exists()
|
||||
|
||||
|
||||
def test_create_run_persists_hard_round_settings(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / "settings-run"
|
||||
|
||||
created = PredictionRunService().create_run(
|
||||
str(seed),
|
||||
"predict hard settings",
|
||||
str(run_dir),
|
||||
mode="staged",
|
||||
rounds=12,
|
||||
round_unit="year",
|
||||
pause_each_round=True,
|
||||
agent_count=3,
|
||||
simulation_name="hard-settings",
|
||||
)
|
||||
|
||||
settings = created["state"]["simulation_settings"]
|
||||
assert settings["rounds"] == 12
|
||||
assert settings["max_rounds"] == 12
|
||||
assert settings["simulation_rounds"] == 12
|
||||
assert settings["round_unit"] == "year"
|
||||
assert settings["minutes_per_round"] == 525600
|
||||
assert settings["pause_each_round"] is True
|
||||
assert settings["agent_count"] == 3
|
||||
assert created["state"]["workflow_mode"] == "staged"
|
||||
assert created["state"]["stages"]["seed_input"]["status"] == "awaiting_user_confirmation"
|
||||
|
||||
|
||||
def test_rounds_below_minimum_fails_without_debug_mode(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIROFISH_ALLOW_DEBUG_ROUNDS", raising=False)
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
|
||||
try:
|
||||
PredictionRunService().create_run(str(seed), "too short", str(tmp_path / "bad"), rounds=3)
|
||||
except ValueError as exc:
|
||||
assert "rounds must be at least 10" in str(exc)
|
||||
else:
|
||||
raise AssertionError("rounds below 10 should fail")
|
||||
|
||||
monkeypatch.setenv("MIROFISH_ALLOW_DEBUG_ROUNDS", "true")
|
||||
created = PredictionRunService().create_run(str(seed), "debug short", str(tmp_path / "debug"), rounds=3)
|
||||
assert created["state"]["simulation_settings"]["rounds"] == 3
|
||||
|
||||
|
||||
def test_staged_mode_pauses_and_approve_advances(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / "staged-run"
|
||||
service = PredictionRunService()
|
||||
created = service.create_run(str(seed), "predict staged", str(run_dir), mode="staged", rounds=10)
|
||||
assert created["state"]["current_stage"] == "seed_input"
|
||||
assert created["state"]["stages"]["seed_input"]["status"] == "awaiting_user_confirmation"
|
||||
|
||||
approved = service.approve_stage(str(run_dir))
|
||||
assert approved["next_stage"] == "prediction_requirement"
|
||||
status = service.status(str(run_dir))["state"]
|
||||
assert status["current_stage"] == "prediction_requirement"
|
||||
assert status["stages"]["prediction_requirement"]["status"] == "pending"
|
||||
|
||||
paused = service.resume(str(run_dir))
|
||||
assert paused["status"] == "awaiting_user_confirmation"
|
||||
assert paused["stage"] == "prediction_requirement"
|
||||
|
||||
|
||||
def test_update_settings_stales_downstream_and_config_uses_hard_rounds(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIROFISH_MODE", "agent")
|
||||
monkeypatch.setenv("MIROFISH_LLM_PROVIDER", "agent_queue")
|
||||
monkeypatch.setenv("MIROFISH_GRAPH_PROVIDER", "graphiti")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_STORE", "file")
|
||||
monkeypatch.setenv("MIROFISH_GRAPHITI_COMPAT_PATH", str(tmp_path / "graph_store.json"))
|
||||
|
||||
seed = tmp_path / "seed.md"
|
||||
seed.write_text("A affects B.", encoding="utf-8")
|
||||
run_dir = tmp_path / "staged-config-run"
|
||||
service = PredictionRunService()
|
||||
service.create_run(str(seed), "predict staged config", str(run_dir), mode="staged", rounds=10)
|
||||
service.approve_stage(str(run_dir))
|
||||
service.resume(str(run_dir))
|
||||
service.approve_stage(str(run_dir))
|
||||
service.resume(str(run_dir))
|
||||
updated = service.update_simulation_settings(str(run_dir), rounds=12, round_unit="month")
|
||||
assert updated["simulation_settings"]["rounds"] == 12
|
||||
assert updated["state"]["stages"]["profile_and_config"]["stale"] is True
|
||||
|
||||
service.approve_stage(str(run_dir))
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "generate_ontology"
|
||||
_write_response(run_dir, {"ontology": {"entity_types": [], "edge_types": []}})
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "extract_triples"
|
||||
_write_response(
|
||||
run_dir,
|
||||
{
|
||||
"triples": [
|
||||
{
|
||||
"subject": "A",
|
||||
"predicate": "affects",
|
||||
"object": "B",
|
||||
"fact": "A affects B.",
|
||||
"valid_at": None,
|
||||
"invalid_at": None,
|
||||
"source": "seed",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "A affects B.",
|
||||
"confidence": 0.9,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
paused = service.resume(str(run_dir))
|
||||
assert paused["status"] == "awaiting_user_confirmation"
|
||||
assert paused["stage"] == "graph_build"
|
||||
service.approve_stage(str(run_dir))
|
||||
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "generate_oasis_profiles"
|
||||
_write_response(run_dir, {"profiles": [{"agent_id": "agent_1", "name": "Agent"}]})
|
||||
need = service.resume(str(run_dir))
|
||||
assert need["type"] == "generate_simulation_config"
|
||||
_write_response(run_dir, {"config": {"rounds": 1, "simulation_rounds": 1}})
|
||||
paused = service.resume(str(run_dir))
|
||||
assert paused["status"] == "awaiting_user_confirmation"
|
||||
config = json.loads((run_dir / "artifacts" / "simulation_config.json").read_text(encoding="utf-8"))
|
||||
assert config["rounds"] == 12
|
||||
assert config["simulation_rounds"] == 12
|
||||
assert config["round_unit"] == "month"
|
||||
|
||||
|
||||
def test_cli_parser_exposes_rounds_and_stage_commands():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"create-run",
|
||||
"--seed",
|
||||
"seed.md",
|
||||
"--requirement",
|
||||
"predict",
|
||||
"--output",
|
||||
"runs/demo",
|
||||
"--mode",
|
||||
"staged",
|
||||
"--rounds",
|
||||
"10",
|
||||
"--round-unit",
|
||||
"year",
|
||||
]
|
||||
)
|
||||
assert args.command == "create-run"
|
||||
assert args.rounds == 10
|
||||
assert args.mode == "staged"
|
||||
|
||||
stage_args = parser.parse_args(["stage", "update-settings", "--run", "runs/demo", "--rounds", "12"])
|
||||
assert stage_args.stage_command == "update-settings"
|
||||
assert stage_args.rounds == 12
|
||||
1978
backend/uv.lock
1978
backend/uv.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,42 @@
|
|||
services:
|
||||
neo4j:
|
||||
image: neo4j:5.26-community
|
||||
container_name: mirofish-neo4j
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-password}
|
||||
NEO4J_dbms_default__database: ${NEO4J_DATABASE:-neo4j}
|
||||
NEO4J_server_memory_heap_initial__size: 512m
|
||||
NEO4J_server_memory_heap_max__size: 1G
|
||||
NEO4J_server_memory_pagecache_size: 512m
|
||||
ports:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
- neo4j_logs:/logs
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"cypher-shell -u ${NEO4J_USER:-neo4j} -p ${NEO4J_PASSWORD:-password} 'RETURN 1' >/dev/null 2>&1",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 12
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: mirofish-ollama
|
||||
profiles:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
|
||||
volumes:
|
||||
neo4j_data:
|
||||
neo4j_logs:
|
||||
ollama_data:
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# Claude Code Usage
|
||||
|
||||
Open the repository:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
```
|
||||
|
||||
Run the CLI from the backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
|
||||
uv run mirofish-agent run --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
For each `need_agent_response`, inspect the request:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent requests show --run ../runs/chip-2036 --request-id req_000001 --json
|
||||
```
|
||||
|
||||
Write the response file exactly matching `expected_schema`, validate it, then resume:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
|
||||
uv run mirofish-agent resume --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
Claude Code MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mirofish": {
|
||||
"command": "uv",
|
||||
"args": ["run", "mirofish-mcp"],
|
||||
"cwd": "/Users/leaf/Documents/future/MiroFish/backend"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# Codex Desktop Usage
|
||||
|
||||
Open `/Users/leaf/Documents/future/MiroFish` in Codex Desktop.
|
||||
|
||||
Initialize a run:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
|
||||
uv run mirofish-agent run --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
When the CLI returns `need_agent_response`, read `request_file`, generate the response JSON, and write it to `expected_response_file`.
|
||||
|
||||
Codex triple extraction prompt:
|
||||
|
||||
```text
|
||||
读取 request_file,严格按照 expected_schema 生成 response_file。只输出 JSON,不要添加解释。每个 triple 必须包含 subject、predicate、object、fact、evidence、confidence。不要编造现实种子中没有的事实。无法确认的关系不要写入,或将 confidence 降低。
|
||||
```
|
||||
|
||||
Validate and continue:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
|
||||
uv run mirofish-agent resume --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
Repeat until status is `completed`. Final artifacts are in:
|
||||
|
||||
```text
|
||||
runs/chip-2036/artifacts/report.md
|
||||
runs/chip-2036/artifacts/verdict.json
|
||||
runs/chip-2036/artifacts/timeline.json
|
||||
runs/chip-2036/artifacts/graph_snapshot.json
|
||||
```
|
||||
|
||||
Ask a follow-up question after a completed run:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent followup ask --run ../runs/chip-2036 --question "先进AI芯片出口限制有什么影响?" --json
|
||||
uv run mirofish-agent requests show --run ../runs/chip-2036 --request-id req_000007 --json
|
||||
uv run mirofish-agent followup show --run ../runs/chip-2036 --request-id req_000007 --json
|
||||
```
|
||||
|
||||
Follow-up answers are written under `runs/chip-2036/artifacts/followups/`.
|
||||
|
||||
MCP config example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mirofish": {
|
||||
"command": "uv",
|
||||
"args": ["run", "mirofish-mcp"],
|
||||
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
|
||||
"env": {
|
||||
"MIROFISH_MODE": "agent",
|
||||
"MIROFISH_LLM_PROVIDER": "agent_queue",
|
||||
"MIROFISH_GRAPH_PROVIDER": "graphiti"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Cursor Usage
|
||||
|
||||
Open `/Users/leaf/Documents/future/MiroFish` as the workspace.
|
||||
|
||||
CLI flow:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
|
||||
uv run mirofish-agent run --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
Cursor can handle request files by reading `runs/<run_id>/requests/req_*.json` and writing strict responses to `runs/<run_id>/responses/req_*.json`.
|
||||
|
||||
MCP config example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mirofish": {
|
||||
"command": "uv",
|
||||
"args": ["run", "mirofish-mcp"],
|
||||
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
|
||||
"env": {
|
||||
"MIROFISH_MODE": "agent",
|
||||
"MIROFISH_LLM_PROVIDER": "agent_queue",
|
||||
"MIROFISH_GRAPH_PROVIDER": "graphiti"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# Graphiti + Neo4j Setup
|
||||
|
||||
Agent mode defaults to:
|
||||
|
||||
```bash
|
||||
MIROFISH_MODE=agent
|
||||
MIROFISH_LLM_PROVIDER=agent_queue
|
||||
MIROFISH_GRAPH_PROVIDER=graphiti
|
||||
```
|
||||
|
||||
MiroFish does not vendor Graphiti source code. Install Graphiti through the backend Python dependency group:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv sync --extra agent --group dev
|
||||
```
|
||||
|
||||
You can also run the helper:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
bash scripts/setup_agent_deps.sh --neo4j desktop
|
||||
```
|
||||
|
||||
The helper loads `/Users/leaf/Documents/future/MiroFish/.env` automatically when it exists.
|
||||
|
||||
Docker is optional. `doctor` does not fail just because Docker or Docker Compose is unavailable.
|
||||
|
||||
## Required Environment
|
||||
|
||||
```bash
|
||||
export NEO4J_URI=bolt://localhost:7687
|
||||
export NEO4J_USER=neo4j
|
||||
export NEO4J_PASSWORD=password
|
||||
export NEO4J_DATABASE=neo4j
|
||||
```
|
||||
|
||||
Neo4j must be reachable and must be version `5.26` or newer.
|
||||
|
||||
Graphiti stores and searches graph facts. It does not extract complex triples from raw seed text in MiroFish agent mode. MiroFish writes `extract_triples` requests, a desktop agent writes validated responses, then `GraphitiGraphProvider` stores triples using `run_id` as the namespace.
|
||||
|
||||
## Option 1: Neo4j Desktop
|
||||
|
||||
Recommended when you do not want Docker and prefer a GUI-managed local database:
|
||||
|
||||
1. Install Neo4j Desktop.
|
||||
2. Create a local DBMS using Neo4j `5.26` or newer.
|
||||
3. Set the password to match `NEO4J_PASSWORD`.
|
||||
4. Start the database.
|
||||
5. Export the connection values:
|
||||
|
||||
```bash
|
||||
export NEO4J_URI=bolt://localhost:7687
|
||||
export NEO4J_USER=neo4j
|
||||
export NEO4J_PASSWORD=your-password
|
||||
export NEO4J_DATABASE=neo4j
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run mirofish-agent doctor --json
|
||||
```
|
||||
|
||||
The `docker` and `docker_compose` doctor checks may show warnings, but they are optional and do not fail doctor.
|
||||
|
||||
## Option 2: Homebrew / Native Install
|
||||
|
||||
Recommended when you do not want Docker and prefer a local service managed by macOS. Install and start Neo4j locally:
|
||||
|
||||
```bash
|
||||
brew install neo4j
|
||||
brew services start neo4j
|
||||
```
|
||||
|
||||
Set the same environment variables:
|
||||
|
||||
```bash
|
||||
export NEO4J_URI=bolt://localhost:7687
|
||||
export NEO4J_USER=neo4j
|
||||
export NEO4J_PASSWORD=your-password
|
||||
export NEO4J_DATABASE=neo4j
|
||||
```
|
||||
|
||||
If your native installation uses another port, update `NEO4J_URI`.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
bash scripts/setup_agent_deps.sh --neo4j native
|
||||
cd backend
|
||||
uv run mirofish-agent doctor --json
|
||||
```
|
||||
|
||||
## Option 3: Existing Neo4j Instance
|
||||
|
||||
Point MiroFish at any reachable Neo4j `5.26+` instance:
|
||||
|
||||
```bash
|
||||
export NEO4J_URI=bolt://your-host:7687
|
||||
export NEO4J_USER=neo4j
|
||||
export NEO4J_PASSWORD=your-password
|
||||
export NEO4J_DATABASE=neo4j
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
bash scripts/setup_agent_deps.sh --neo4j existing
|
||||
```
|
||||
|
||||
## Option 4: Docker Compose
|
||||
|
||||
Docker Compose remains available for users who prefer it:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
docker compose -f docker-compose.agent.yml up -d neo4j
|
||||
```
|
||||
|
||||
Open the browser console at `http://localhost:7474` and log in with:
|
||||
|
||||
```text
|
||||
user: neo4j
|
||||
password: password
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
bash scripts/setup_agent_deps.sh --neo4j docker --start-docker
|
||||
```
|
||||
|
||||
If Docker is not installed, the setup script prints `Docker optional, skipped`; it does not fail for that reason. The required check is still whether Neo4j is reachable through `NEO4J_URI`.
|
||||
|
||||
## Ollama Embedding
|
||||
|
||||
The no-LLM triplet write path and `fulltext` graph search do not require Ollama. Doctor only hard-fails Ollama checks when both conditions are true:
|
||||
|
||||
```bash
|
||||
export MIROFISH_GRAPH_SEARCH_MODE=semantic # or hybrid
|
||||
export MIROFISH_EMBEDDING_PROVIDER=ollama
|
||||
```
|
||||
|
||||
If `MIROFISH_GRAPH_SEARCH_MODE=fulltext` or `MIROFISH_EMBEDDING_PROVIDER=none`, missing Ollama is reported as an optional warning only. Semantic retrieval may be unavailable, but the agent engine and Graphiti/Neo4j fulltext path can still run.
|
||||
|
||||
If you opt into Ollama embeddings, install and start Ollama locally, then pull the embedding model:
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
Configure:
|
||||
|
||||
```bash
|
||||
export MIROFISH_EMBEDDING_PROVIDER=ollama
|
||||
export MIROFISH_GRAPH_SEARCH_MODE=semantic
|
||||
export OLLAMA_BASE_URL=http://localhost:11434
|
||||
export OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
```
|
||||
|
||||
Doctor checks `GET $OLLAMA_BASE_URL/api/tags` and verifies that `OLLAMA_EMBEDDING_MODEL` is installed.
|
||||
|
||||
## Offline Compatibility Store
|
||||
|
||||
Offline tests can use a file-backed no-LLM triplet store:
|
||||
|
||||
```bash
|
||||
export MIROFISH_GRAPHITI_STORE=file
|
||||
export MIROFISH_GRAPHITI_COMPAT_PATH=/tmp/mirofish-graphiti-store.json
|
||||
```
|
||||
|
||||
This is for smoke tests and local development without Neo4j. Production agent mode should use Neo4j. The default `MIROFISH_GRAPHITI_STORE=auto` path uses Neo4j; it does not silently downgrade to file storage.
|
||||
|
||||
## Compatibility Layer
|
||||
|
||||
`GraphitiCompatibilityStore` provides the no-LLM triplet write path. If it writes directly to Neo4j, all Cypher and Graphiti schema assumptions stay inside that class. Business code must use `GraphProvider` and must not depend on Graphiti node or edge internals.
|
||||
|
||||
References:
|
||||
|
||||
- Graphiti episodes: https://help.getzep.com/graphiti/core-concepts/adding-episodes
|
||||
- Graphiti fact triples: https://help.getzep.com/graphiti/working-with-data/adding-fact-triples
|
||||
- Graphiti namespacing: https://help.getzep.com/graphiti/core-concepts/graph-namespacing
|
||||
- Graphiti Neo4j config: https://help.getzep.com/graphiti/configuration/neo-4-j-configuration
|
||||
- Graphiti LLM config: https://help.getzep.com/graphiti/configuration/llm-configuration
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
# MiroFish MCP Server
|
||||
|
||||
The MCP server exposes MiroFish lifecycle tools, not a Graphiti proxy.
|
||||
|
||||
Start:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run mirofish-mcp
|
||||
```
|
||||
|
||||
Tools:
|
||||
|
||||
- `mirofish_create_run`
|
||||
- `mirofish_run`
|
||||
- `mirofish_resume_run`
|
||||
- `mirofish_get_status`
|
||||
- `mirofish_get_current_stage`
|
||||
- `mirofish_update_simulation_settings`
|
||||
- `mirofish_approve_stage`
|
||||
- `mirofish_reject_stage`
|
||||
- `mirofish_rerun_stage`
|
||||
- `mirofish_list_requests`
|
||||
- `mirofish_get_request`
|
||||
- `mirofish_submit_response`
|
||||
- `mirofish_validate_response`
|
||||
- `mirofish_build_graph`
|
||||
- `mirofish_search_graph`
|
||||
- `mirofish_export_graph`
|
||||
- `mirofish_start_simulation`
|
||||
- `mirofish_resume_simulation`
|
||||
- `mirofish_generate_report`
|
||||
- `mirofish_get_report`
|
||||
- `mirofish_ask_followup_question`
|
||||
- `mirofish_get_followup_answer`
|
||||
- `mirofish_list_artifacts`
|
||||
- `mirofish_doctor`
|
||||
|
||||
## Interaction Tools
|
||||
|
||||
After a run completes, these tools let you interact with agents through the queue:
|
||||
|
||||
- `mirofish_generate_web_console` — generates an interactive HTML console at `runs/<run_id>/artifacts/web/index.html`.
|
||||
- `mirofish_list_agents` — lists all agent profiles from a completed run.
|
||||
- `mirofish_get_agent` — returns a single agent's profile.
|
||||
- `mirofish_ask_agent` — sends a question to a specific agent via `agent_queue`. Returns `need_agent_response` with a `request_id`.
|
||||
- `mirofish_get_agent_answer` — after the desktop agent writes the response file, call this to validate, persist, and retrieve the answer.
|
||||
- `mirofish_send_questionnaire` — sends a batch questionnaire to all agents. `questions_json` is a JSON string: `'[{"question_id":"q1","question":"Biggest risk?"}, ...]'`.
|
||||
- `mirofish_get_questionnaire_result` — retrieves questionnaire answers and summary.
|
||||
- `mirofish_ask_report_question` — asks a question about the report via `agent_queue`.
|
||||
- `mirofish_get_report_question_answer` — retrieves and persists a report question answer.
|
||||
|
||||
### Web Console
|
||||
|
||||
The Web Console is a static HTML page with embedded run data plus live API interaction when the Flask backend is running.
|
||||
|
||||
1. Generate the console:
|
||||
```bash
|
||||
uv run mirofish-agent web generate --run ../runs/chip-2036 --json
|
||||
```
|
||||
Or via MCP: call `mirofish_generate_web_console`.
|
||||
|
||||
2. Open the generated file: `runs/<run_id>/artifacts/web/index.html`
|
||||
|
||||
3. Start the Flask backend for interactive features:
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish/backend
|
||||
uv run flask --app app run --port 5001
|
||||
```
|
||||
|
||||
4. The console auto-detects the API at `http://localhost:5001`. You can change the base URL in the sidebar.
|
||||
|
||||
When the API is offline, the console falls back to displaying embedded static data from the run artifacts.
|
||||
|
||||
### Agent Q&A Flow
|
||||
|
||||
1. Call `mirofish_ask_agent(run, agent_id, question)` — returns `request_id`.
|
||||
2. A desktop agent reads `runs/<run_id>/requests/<request_id>.json` and writes `runs/<run_id>/responses/<request_id>.json`.
|
||||
3. Call `mirofish_get_agent_answer(run, request_id)` to validate the response and persist it to `artifacts/interactions/agent_questions/`.
|
||||
|
||||
### Questionnaire Flow
|
||||
|
||||
1. Call `mirofish_send_questionnaire(run, questions_json)` with a JSON array of `{question_id, question}` objects.
|
||||
2. Each agent gets a separate `agent_queue` request per question.
|
||||
3. Call `mirofish_get_questionnaire_result(run, questionnaire_id)` to collect answers and summary.
|
||||
|
||||
## Staged Mode
|
||||
|
||||
Use staged mode when a desktop agent should mirror the original MiroFish step-by-step UI flow. The simulation round count is a hard MCP field, not text hidden in the requirement.
|
||||
|
||||
Typical Qoder/Codex/Claude Code sequence:
|
||||
|
||||
1. Call `mirofish_doctor`.
|
||||
2. Call `mirofish_create_run` with `mode="staged"`, `rounds=10`, `round_unit="year"`, and the seed/requirement/output path.
|
||||
3. Call `mirofish_get_current_stage` and show the user the stage summary.
|
||||
4. After user confirmation, call `mirofish_approve_stage`.
|
||||
5. Call `mirofish_resume_run`; staged mode advances only to the next pause point or `need_agent_response`.
|
||||
6. When `need_agent_response` appears, read `request_file`, write the response JSON, call `mirofish_validate_response`, then `mirofish_submit_response`.
|
||||
7. Repeat resume/approve until `report.md`, `verdict.json`, `timeline.json`, and `graph_snapshot.json` exist.
|
||||
8. Use `mirofish_ask_followup_question` for post-report questions.
|
||||
|
||||
Example `mirofish_create_run` arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"seed": "/Users/leaf/Documents/future/MiroFish/seeds/chip.md",
|
||||
"requirement": "预测未来10年全球芯片能力格局变化",
|
||||
"output": "/Users/leaf/Documents/future/MiroFish/runs/chip-2036",
|
||||
"mode": "staged",
|
||||
"rounds": 10,
|
||||
"round_unit": "year",
|
||||
"minutes_per_round": 525600,
|
||||
"pause_each_round": false,
|
||||
"agent_count": 5,
|
||||
"simulation_name": "chip-2036"
|
||||
}
|
||||
```
|
||||
|
||||
If the user changes hard parameters before approval, call `mirofish_update_simulation_settings`. The engine marks dependent stages stale/pending so old profile/config/simulation/report outputs are not silently reused.
|
||||
|
||||
Example MCP server config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mirofish": {
|
||||
"command": "uv",
|
||||
"args": ["run", "mirofish-mcp"],
|
||||
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
|
||||
"env": {
|
||||
"MIROFISH_MODE": "agent",
|
||||
"MIROFISH_LLM_PROVIDER": "agent_queue",
|
||||
"MIROFISH_GRAPH_PROVIDER": "graphiti",
|
||||
"MIROFISH_RUNS_DIR": "./runs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: https://modelcontextprotocol.github.io/python-sdk/server/
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# opencode Usage
|
||||
|
||||
Open the repository:
|
||||
|
||||
```bash
|
||||
cd /Users/leaf/Documents/future/MiroFish
|
||||
```
|
||||
|
||||
Run MiroFish through the CLI from the backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run mirofish-agent init --seed /path/to/seed.md --requirement "预测未来10年全球芯片能力格局变化" --output ../runs/chip-2036 --json
|
||||
uv run mirofish-agent run --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
When a command returns `need_agent_response`, read `request_file`, produce JSON that exactly matches `expected_schema`, write it to `expected_response_file`, validate it, and resume:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent responses validate --run ../runs/chip-2036 --response ../runs/chip-2036/responses/req_000001.json --json
|
||||
uv run mirofish-agent resume --run ../runs/chip-2036 --json
|
||||
```
|
||||
|
||||
MCP server config shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mirofish": {
|
||||
"command": "uv",
|
||||
"args": ["run", "mirofish-mcp"],
|
||||
"cwd": "/Users/leaf/Documents/future/MiroFish/backend",
|
||||
"env": {
|
||||
"MIROFISH_MODE": "agent",
|
||||
"MIROFISH_LLM_PROVIDER": "agent_queue",
|
||||
"MIROFISH_GRAPH_PROVIDER": "graphiti"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Follow-up questions use the same queue:
|
||||
|
||||
```bash
|
||||
uv run mirofish-agent followup ask --run ../runs/chip-2036 --question "这个预测里最大的风险是什么?" --json
|
||||
uv run mirofish-agent followup show --run ../runs/chip-2036 --request-id req_000007 --json
|
||||
```
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# QoderWork Staged Usage
|
||||
|
||||
QoderWork should use the MiroFish MCP server as a staged business workflow, not as a one-shot prompt wrapper.
|
||||
|
||||
## Run Sequence
|
||||
|
||||
1. Call `mirofish_doctor`.
|
||||
2. Call `mirofish_create_run` with hard simulation settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"seed": "/absolute/path/seed.md",
|
||||
"requirement": "预测未来10年全球芯片能力格局变化",
|
||||
"output": "/Users/leaf/Documents/future/MiroFish/runs/chip-2036",
|
||||
"mode": "staged",
|
||||
"rounds": 10,
|
||||
"round_unit": "year",
|
||||
"minutes_per_round": 525600,
|
||||
"pause_each_round": false,
|
||||
"agent_count": 5,
|
||||
"simulation_name": "chip-2036"
|
||||
}
|
||||
```
|
||||
|
||||
3. Call `mirofish_get_current_stage` and present the summary to the user.
|
||||
4. After the user confirms, call `mirofish_approve_stage`.
|
||||
5. Call `mirofish_resume_run`.
|
||||
6. If the result is `need_agent_response`, read `request_file`, generate the response JSON exactly against `expected_schema`, then call `mirofish_validate_response` and `mirofish_submit_response`.
|
||||
7. If the result is `awaiting_user_confirmation`, show the stage summary and ask whether to approve, reject, update settings, or rerun.
|
||||
8. Continue until the run returns `completed`.
|
||||
9. Read artifacts with `mirofish_get_report` and `mirofish_list_artifacts`.
|
||||
10. Ask follow-up questions with `mirofish_ask_followup_question`.
|
||||
|
||||
## Important Rule
|
||||
|
||||
Do not put the round count only in the natural-language requirement. Always pass `rounds` and `round_unit` through MCP fields so the simulation config, timeline, verdict, and report record the actual hard settings.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
# Triple Extraction Response Format
|
||||
|
||||
Responses must be strict JSON with no extra top-level fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "req_000001",
|
||||
"status": "ok",
|
||||
"output": {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "美国商务部",
|
||||
"predicate": "限制",
|
||||
"object": "先进AI芯片出口",
|
||||
"fact": "美国商务部限制先进AI芯片出口。",
|
||||
"valid_at": "2024-01-01",
|
||||
"invalid_at": null,
|
||||
"source": "现实种子",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "原文证据片段",
|
||||
"confidence": 0.82,
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `request_id` must exactly match the request file.
|
||||
- `status` must be `ok`, `error`, or `skipped`.
|
||||
- `output` must match `expected_schema`.
|
||||
- `confidence` must be between `0.0` and `1.0`.
|
||||
- Do not add extra fields.
|
||||
- Do not invent facts not present in the seed or referenced context.
|
||||
|
|
@ -1,6 +1,22 @@
|
|||
import axios from 'axios'
|
||||
import i18n from '../i18n'
|
||||
|
||||
const getResponseErrorMessage = (error) => {
|
||||
const data = error?.response?.data
|
||||
if (data?.error) return data.error
|
||||
if (data?.message) return data.message
|
||||
if (typeof data === 'string') return data
|
||||
return error?.message || 'Error'
|
||||
}
|
||||
|
||||
const normalizeApiError = (error) => {
|
||||
const normalized = new Error(getResponseErrorMessage(error))
|
||||
normalized.status = error?.response?.status
|
||||
normalized.data = error?.response?.data
|
||||
normalized.originalError = error
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
|
||||
|
|
@ -48,7 +64,7 @@ service.interceptors.response.use(
|
|||
console.error('Network error - please check your connection')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(normalizeApiError(error))
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -58,6 +74,10 @@ export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000)
|
|||
try {
|
||||
return await requestFn()
|
||||
} catch (error) {
|
||||
if (error.status >= 400 && error.status < 500) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (i === maxRetries - 1) throw error
|
||||
|
||||
console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail if business code imports direct model or Zep SDKs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCAN_ROOTS = [ROOT / "backend" / "app", ROOT / "backend" / "scripts"]
|
||||
ALLOWED = {
|
||||
ROOT / "backend" / "app" / "adapters" / "llm" / "openai_compatible.py",
|
||||
ROOT / "backend" / "app" / "adapters" / "llm" / "camel_adapter.py",
|
||||
ROOT / "backend" / "app" / "adapters" / "graph" / "zep.py",
|
||||
}
|
||||
ALLOWED_LEGACY_ADAPTER_IMPORTS = {
|
||||
ROOT / "backend" / "app" / "adapters" / "llm" / "factory.py",
|
||||
ROOT / "backend" / "app" / "adapters" / "graph" / "factory.py",
|
||||
}
|
||||
ALLOWED_GRAPHITI_SCHEMA = {
|
||||
ROOT / "backend" / "app" / "adapters" / "graph" / "graphiti.py",
|
||||
}
|
||||
FORBIDDEN = {
|
||||
"openai",
|
||||
"anthropic",
|
||||
"dashscope",
|
||||
"qwen",
|
||||
"zep_cloud",
|
||||
"camel.messages",
|
||||
"camel.models",
|
||||
}
|
||||
FORBIDDEN_STRING_PATTERNS = tuple(
|
||||
pattern
|
||||
for module in FORBIDDEN
|
||||
for pattern in (f"import {module}", f"from {module}")
|
||||
)
|
||||
FORBIDDEN_LEGACY_ADAPTER_IMPORTS = {
|
||||
"app.adapters.llm.openai_compatible",
|
||||
"app.adapters.graph.zep",
|
||||
"backend.app.adapters.llm.openai_compatible",
|
||||
"backend.app.adapters.graph.zep",
|
||||
}
|
||||
FORBIDDEN_GRAPHITI_SCHEMA_PATTERNS = {
|
||||
"MiroFishEntity",
|
||||
"MiroFishEpisode",
|
||||
"MiroFishAgentMemory",
|
||||
"MIROFISH_FACT",
|
||||
"CREATE CONSTRAINT",
|
||||
"MERGE (",
|
||||
"MATCH (",
|
||||
}
|
||||
|
||||
|
||||
def module_name(node: ast.AST) -> str | None:
|
||||
if isinstance(node, ast.Import):
|
||||
return None
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
return node.module
|
||||
return None
|
||||
|
||||
|
||||
def imported_names(node: ast.AST) -> list[str]:
|
||||
if isinstance(node, ast.Import):
|
||||
return [alias.name for alias in node.names]
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
return [node.module or ""]
|
||||
return []
|
||||
|
||||
|
||||
def is_forbidden(name: str) -> bool:
|
||||
return any(name == forbidden or name.startswith(f"{forbidden}.") for forbidden in FORBIDDEN)
|
||||
|
||||
|
||||
def is_forbidden_legacy_adapter_import(name: str) -> bool:
|
||||
return any(
|
||||
name == forbidden or name.startswith(f"{forbidden}.")
|
||||
for forbidden in FORBIDDEN_LEGACY_ADAPTER_IMPORTS
|
||||
)
|
||||
|
||||
|
||||
def display_path(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def collect_violations(
|
||||
scan_roots: list[Path],
|
||||
*,
|
||||
root: Path = ROOT,
|
||||
allowed: set[Path] = ALLOWED,
|
||||
allowed_legacy_adapter_imports: set[Path] = ALLOWED_LEGACY_ADAPTER_IMPORTS,
|
||||
allowed_graphiti_schema: set[Path] = ALLOWED_GRAPHITI_SCHEMA,
|
||||
) -> list[str]:
|
||||
violations: list[str] = []
|
||||
for scan_root in scan_roots:
|
||||
for path in scan_root.rglob("*.py"):
|
||||
if "__pycache__" in path.parts or path in allowed:
|
||||
continue
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError as exc:
|
||||
violations.append(f"{path}: syntax error: {exc}")
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for name in imported_names(node):
|
||||
if is_forbidden(name):
|
||||
violations.append(f"{display_path(path, root)} imports forbidden SDK module {name}")
|
||||
if path not in allowed_legacy_adapter_imports and is_forbidden_legacy_adapter_import(name):
|
||||
violations.append(
|
||||
f"{display_path(path, root)} imports legacy provider adapter directly: {name}"
|
||||
)
|
||||
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
for pattern in FORBIDDEN_STRING_PATTERNS:
|
||||
if pattern in node.value:
|
||||
violations.append(
|
||||
f"{display_path(path, root)} contains forbidden SDK import string {pattern!r}"
|
||||
)
|
||||
if path not in allowed_graphiti_schema:
|
||||
for pattern in FORBIDDEN_GRAPHITI_SCHEMA_PATTERNS:
|
||||
if pattern in node.value:
|
||||
violations.append(
|
||||
f"{display_path(path, root)} contains Graphiti/Neo4j schema assumption {pattern!r}"
|
||||
)
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
violations = collect_violations(SCAN_ROOTS)
|
||||
if violations:
|
||||
print("Provider boundary violations found:")
|
||||
for violation in violations:
|
||||
print(f"- {violation}")
|
||||
return 1
|
||||
print("Provider boundary check passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BACKEND_DIR="$ROOT_DIR/backend"
|
||||
NEO4J_MODE="existing"
|
||||
START_DOCKER="prompt"
|
||||
FAILURES=0
|
||||
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/setup_agent_deps.sh [options]
|
||||
|
||||
Options:
|
||||
--neo4j desktop|native|docker|existing
|
||||
Choose the Neo4j setup path to explain/check. Default: existing.
|
||||
desktop = Neo4j Desktop-managed local database.
|
||||
native = Homebrew or other local host installation.
|
||||
docker = optional Docker Compose path.
|
||||
existing = already-running local or remote Neo4j instance.
|
||||
|
||||
--start-docker
|
||||
Start the optional Docker Compose Neo4j service if Docker is available.
|
||||
|
||||
--skip-services
|
||||
Do not try to start Docker services. Still checks Neo4j connectivity.
|
||||
|
||||
--start-services
|
||||
Backward-compatible alias for --start-docker.
|
||||
|
||||
Installs MiroFish agent-mode Python dependencies and checks required local
|
||||
services. Graphiti is installed as a Python dependency; its source is not
|
||||
vendored into this repository. Docker is optional.
|
||||
EOF
|
||||
}
|
||||
|
||||
ok() {
|
||||
printf '[ok] %s\n' "$1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[warn] %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[fail] %s\n' "$1"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--neo4j)
|
||||
shift
|
||||
if [ "$#" -eq 0 ]; then
|
||||
fail "--neo4j requires one of: desktop, native, docker, existing"
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
case "$1" in
|
||||
desktop|native|docker|existing)
|
||||
NEO4J_MODE="$1"
|
||||
;;
|
||||
*)
|
||||
fail "unsupported --neo4j mode: $1"
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
--start-docker|--start-services)
|
||||
START_DOCKER="yes"
|
||||
;;
|
||||
--skip-services)
|
||||
START_DOCKER="no"
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail "unknown argument: $1"
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
install_agent_deps() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
ok "installing backend agent extras with uv"
|
||||
if (cd "$BACKEND_DIR" && uv sync --extra agent --group dev); then
|
||||
ok "Python agent dependencies installed"
|
||||
else
|
||||
fail "uv sync --extra agent --group dev failed"
|
||||
fi
|
||||
else
|
||||
warn "uv is not installed; install dependencies manually from backend:"
|
||||
warn "python -m pip install -e '.[agent]'"
|
||||
fi
|
||||
}
|
||||
|
||||
explain_neo4j_mode() {
|
||||
case "$NEO4J_MODE" in
|
||||
desktop)
|
||||
warn "Neo4j Desktop path selected"
|
||||
warn "Create/start a Neo4j 5.26+ DBMS in Neo4j Desktop, then export NEO4J_URI/NEO4J_USER/NEO4J_PASSWORD/NEO4J_DATABASE."
|
||||
;;
|
||||
native)
|
||||
warn "Native Neo4j path selected"
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
warn "Homebrew detected. Typical install/start: brew install neo4j && brew services start neo4j"
|
||||
else
|
||||
warn "Homebrew not detected. Install Neo4j 5.26+ with your local package manager and start it on NEO4J_URI."
|
||||
fi
|
||||
;;
|
||||
docker)
|
||||
warn "Optional Docker Compose Neo4j path selected"
|
||||
warn "Compose file: docker-compose.agent.yml"
|
||||
;;
|
||||
existing)
|
||||
warn "Existing Neo4j path selected"
|
||||
warn "Set NEO4J_URI/NEO4J_USER/NEO4J_PASSWORD/NEO4J_DATABASE for a reachable Neo4j 5.26+ instance."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_docker_optional() {
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ok "$(docker --version)"
|
||||
else
|
||||
warn "Docker optional, skipped"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
ok "$(docker compose version)"
|
||||
else
|
||||
warn "Docker Compose optional, skipped"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
maybe_start_docker_neo4j() {
|
||||
if [ "$NEO4J_MODE" != "docker" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if ! check_docker_optional; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$START_DOCKER" = "prompt" ]; then
|
||||
if [ -t 0 ]; then
|
||||
printf 'Start optional Neo4j Docker Compose service now? [y/N] '
|
||||
read -r answer
|
||||
case "$answer" in
|
||||
y|Y|yes|YES)
|
||||
START_DOCKER="yes"
|
||||
;;
|
||||
*)
|
||||
START_DOCKER="no"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
START_DOCKER="no"
|
||||
warn "non-interactive shell; skipping optional Docker startup prompt"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$START_DOCKER" = "yes" ]; then
|
||||
if docker compose -f "$ROOT_DIR/docker-compose.agent.yml" up -d neo4j; then
|
||||
ok "optional Neo4j compose service requested"
|
||||
else
|
||||
warn "optional Docker Compose startup failed; Neo4j connectivity check will report actual readiness"
|
||||
fi
|
||||
else
|
||||
warn "optional Docker startup skipped"
|
||||
warn "manual Docker command: docker compose -f docker-compose.agent.yml up -d neo4j"
|
||||
fi
|
||||
}
|
||||
|
||||
run_backend_python() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
(cd "$BACKEND_DIR" && uv run python "$@")
|
||||
else
|
||||
python3 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
check_neo4j() {
|
||||
run_backend_python - <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
uri = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
||||
user = os.environ.get("NEO4J_USER", "neo4j")
|
||||
password = os.environ.get("NEO4J_PASSWORD", "password")
|
||||
database = os.environ.get("NEO4J_DATABASE", "neo4j")
|
||||
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
except Exception as exc:
|
||||
print(f"[fail] neo4j Python driver import failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_version(value):
|
||||
cleaned = value.split("-", 1)[0]
|
||||
parts = cleaned.split(".")
|
||||
major = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else 0
|
||||
minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
||||
return major, minor
|
||||
|
||||
|
||||
try:
|
||||
driver = GraphDatabase.driver(uri, auth=(user, password))
|
||||
with driver.session(database=database) as session:
|
||||
record = session.run(
|
||||
"CALL dbms.components() YIELD name, versions RETURN name, versions LIMIT 1"
|
||||
).single()
|
||||
driver.close()
|
||||
except Exception as exc:
|
||||
print(f"[fail] Neo4j connection/version check failed for {uri}: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
versions = record["versions"] if record else []
|
||||
version = versions[0] if versions else "unknown"
|
||||
major, minor = parse_version(version)
|
||||
if not (major > 5 or (major == 5 and minor >= 26)):
|
||||
print(f"[fail] Neo4j version {version} is unsupported; use Neo4j 5.26+")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[ok] Neo4j version {version}")
|
||||
PY
|
||||
status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_ollama_if_configured() {
|
||||
provider="${MIROFISH_EMBEDDING_PROVIDER:-none}"
|
||||
search_mode="${MIROFISH_GRAPH_SEARCH_MODE:-fulltext}"
|
||||
if [ "$search_mode" != "semantic" ] && [ "$search_mode" != "hybrid" ]; then
|
||||
warn "Ollama optional, skipped because MIROFISH_GRAPH_SEARCH_MODE=$search_mode uses no semantic embedding"
|
||||
return
|
||||
fi
|
||||
if [ "$provider" != "ollama" ]; then
|
||||
warn "Ollama optional, skipped because MIROFISH_EMBEDDING_PROVIDER=$provider; semantic retrieval may be degraded"
|
||||
return
|
||||
fi
|
||||
|
||||
run_backend_python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
|
||||
model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
url = f"{base_url}/api/tags"
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
||||
print(f"[fail] Ollama tags check failed for {url}: {exc}")
|
||||
print(f"[hint] start Ollama and run: ollama pull {model}")
|
||||
sys.exit(1)
|
||||
|
||||
names = [item.get("name", "") for item in payload.get("models", [])]
|
||||
found = any(name == model or name.startswith(model + ":") for name in names)
|
||||
if not found:
|
||||
print(f"[fail] Ollama embedding model '{model}' not found")
|
||||
print(f"[hint] run: ollama pull {model}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[ok] Ollama embedding model available: {model}")
|
||||
PY
|
||||
status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
FAILURES=$((FAILURES + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
install_agent_deps
|
||||
explain_neo4j_mode
|
||||
if [ "$NEO4J_MODE" != "docker" ]; then
|
||||
check_docker_optional || true
|
||||
fi
|
||||
maybe_start_docker_neo4j
|
||||
check_neo4j
|
||||
check_ollama_if_configured
|
||||
|
||||
if [ "$FAILURES" -gt 0 ]; then
|
||||
printf '[fail] setup completed with %s required failure(s)\n' "$FAILURES"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok "agent dependency and required service checks completed"
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BACKEND="$ROOT/backend"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
RUN_DIR="$TMPDIR/chip-2036"
|
||||
SEED="$TMPDIR/seed.md"
|
||||
OBSERVED_TYPES="$TMPDIR/observed_request_types.txt"
|
||||
|
||||
export MIROFISH_MODE=agent
|
||||
export MIROFISH_LLM_PROVIDER=agent_queue
|
||||
export MIROFISH_GRAPH_PROVIDER=graphiti
|
||||
export MIROFISH_GRAPHITI_STORE=file
|
||||
export MIROFISH_GRAPHITI_COMPAT_PATH="$TMPDIR/graphiti-store.json"
|
||||
export MIROFISH_RUNS_DIR="$TMPDIR/runs"
|
||||
unset LLM_API_KEY
|
||||
unset OPENAI_API_KEY
|
||||
unset ZEP_API_KEY
|
||||
|
||||
printf '%s\n' '美国商务部限制先进AI芯片出口。' > "$SEED"
|
||||
|
||||
cd "$BACKEND"
|
||||
uv run mirofish-agent init --seed "$SEED" --requirement "预测未来10年全球芯片能力格局变化" --output "$RUN_DIR" --json >/tmp/mirofish_smoke_init.json
|
||||
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
uv run mirofish-agent resume --run "$RUN_DIR" --json > /tmp/mirofish_smoke_resume.json
|
||||
STATUS="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_resume.json"))["status"])')"
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
test -f "$RUN_DIR/artifacts/report.md"
|
||||
test -f "$RUN_DIR/artifacts/verdict.json"
|
||||
test -f "$RUN_DIR/artifacts/timeline.json"
|
||||
test -f "$RUN_DIR/artifacts/graph_snapshot.json"
|
||||
uv run mirofish-agent followup ask --run "$RUN_DIR" --question "先进AI芯片出口限制有什么影响?" --json > /tmp/mirofish_smoke_followup.json
|
||||
FOLLOWUP_STATUS="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_followup.json"))["status"])')"
|
||||
test "$FOLLOWUP_STATUS" = "need_agent_response"
|
||||
FOLLOWUP_REQUEST_ID="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_followup.json"))["request_id"])')"
|
||||
FOLLOWUP_RESPONSE="$(python "$ROOT/scripts/write_mock_agent_response.py" --run "$RUN_DIR" --request-id "$FOLLOWUP_REQUEST_ID")"
|
||||
uv run mirofish-agent responses validate --run "$RUN_DIR" --response "$FOLLOWUP_RESPONSE" --json >/tmp/mirofish_smoke_followup_validate.json
|
||||
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_smoke_followup_validate.json")); sys.exit(0 if data["ok"] else 1)'
|
||||
uv run mirofish-agent followup show --run "$RUN_DIR" --request-id "$FOLLOWUP_REQUEST_ID" --json >/tmp/mirofish_smoke_followup_show.json
|
||||
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_smoke_followup_show.json")); sys.exit(0 if data["status"] == "ok" else 1)'
|
||||
test -f "$RUN_DIR/artifacts/followups/$FOLLOWUP_REQUEST_ID.md"
|
||||
for expected_type in generate_ontology extract_triples generate_oasis_profiles generate_simulation_config simulate_agent_action generate_report; do
|
||||
if ! grep -qx "$expected_type" "$OBSERVED_TYPES"; then
|
||||
echo "missing expected agent_queue request type: $expected_type"
|
||||
cat "$OBSERVED_TYPES" || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "CLI full agent_queue smoke passed: $RUN_DIR"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$STATUS" != "need_agent_response" ]; then
|
||||
cat /tmp/mirofish_smoke_resume.json
|
||||
exit 1
|
||||
fi
|
||||
REQUEST_ID="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_resume.json"))["request_id"])')"
|
||||
REQUEST_FILE="$(python -c 'import json; print(json.load(open("/tmp/mirofish_smoke_resume.json"))["request_file"])')"
|
||||
python -c 'import json,sys; print(json.load(open(sys.argv[1]))["type"])' "$REQUEST_FILE" >> "$OBSERVED_TYPES"
|
||||
RESPONSE="$(python "$ROOT/scripts/write_mock_agent_response.py" --run "$RUN_DIR" --request-id "$REQUEST_ID")"
|
||||
uv run mirofish-agent responses validate --run "$RUN_DIR" --response "$RESPONSE" --json >/tmp/mirofish_smoke_validate.json
|
||||
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_smoke_validate.json")); sys.exit(0 if data["ok"] else 1)'
|
||||
done
|
||||
|
||||
echo "CLI smoke did not complete within expected steps"
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BACKEND="$ROOT/backend"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
RUN_DIR="$TMPDIR/staged-chip-2036"
|
||||
SEED="$TMPDIR/seed.md"
|
||||
OBSERVED_TYPES="$TMPDIR/observed_request_types.txt"
|
||||
|
||||
export MIROFISH_MODE=agent
|
||||
export MIROFISH_LLM_PROVIDER=agent_queue
|
||||
export MIROFISH_GRAPH_PROVIDER=graphiti
|
||||
export MIROFISH_GRAPHITI_STORE=file
|
||||
export MIROFISH_GRAPHITI_COMPAT_PATH="$TMPDIR/graphiti-store.json"
|
||||
export MIROFISH_RUNS_DIR="$TMPDIR/runs"
|
||||
unset LLM_API_KEY
|
||||
unset OPENAI_API_KEY
|
||||
unset ZEP_API_KEY
|
||||
|
||||
printf '%s\n' '美国商务部限制先进AI芯片出口。' > "$SEED"
|
||||
|
||||
cd "$BACKEND"
|
||||
uv run mirofish-agent create-run \
|
||||
--seed "$SEED" \
|
||||
--requirement "预测未来10年全球芯片能力格局变化" \
|
||||
--output "$RUN_DIR" \
|
||||
--mode staged \
|
||||
--rounds 10 \
|
||||
--round-unit year \
|
||||
--json >/tmp/mirofish_staged_init.json
|
||||
|
||||
for _ in $(seq 1 80); do
|
||||
uv run mirofish-agent resume --run "$RUN_DIR" --json > /tmp/mirofish_staged_resume.json
|
||||
STATUS="$(python -c 'import json; print(json.load(open("/tmp/mirofish_staged_resume.json"))["status"])')"
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
test -f "$RUN_DIR/artifacts/report.md"
|
||||
test -f "$RUN_DIR/artifacts/verdict.json"
|
||||
test -f "$RUN_DIR/artifacts/timeline.json"
|
||||
test -f "$RUN_DIR/artifacts/graph_snapshot.json"
|
||||
python - "$RUN_DIR" <<'PY'
|
||||
import json, sys
|
||||
run = sys.argv[1]
|
||||
verdict = json.load(open(f"{run}/artifacts/verdict.json"))
|
||||
timeline = json.load(open(f"{run}/artifacts/timeline.json"))
|
||||
assert verdict["rounds"] == 10, verdict
|
||||
assert verdict["simulation_settings"]["round_unit"] == "year", verdict
|
||||
assert len(timeline) == 10, timeline
|
||||
PY
|
||||
for expected_type in generate_ontology extract_triples generate_oasis_profiles generate_simulation_config simulate_agent_action generate_report; do
|
||||
if ! grep -qx "$expected_type" "$OBSERVED_TYPES"; then
|
||||
echo "missing expected staged request type: $expected_type"
|
||||
cat "$OBSERVED_TYPES" || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "CLI staged agent_queue smoke passed: $RUN_DIR"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$STATUS" = "awaiting_user_confirmation" ]; then
|
||||
uv run mirofish-agent stage approve --run "$RUN_DIR" --json >/tmp/mirofish_staged_approve.json
|
||||
continue
|
||||
fi
|
||||
if [ "$STATUS" != "need_agent_response" ]; then
|
||||
cat /tmp/mirofish_staged_resume.json
|
||||
exit 1
|
||||
fi
|
||||
REQUEST_ID="$(python -c 'import json; print(json.load(open("/tmp/mirofish_staged_resume.json"))["request_id"])')"
|
||||
REQUEST_FILE="$(python -c 'import json; print(json.load(open("/tmp/mirofish_staged_resume.json"))["request_file"])')"
|
||||
python -c 'import json,sys; print(json.load(open(sys.argv[1]))["type"])' "$REQUEST_FILE" >> "$OBSERVED_TYPES"
|
||||
RESPONSE="$(python "$ROOT/scripts/write_mock_agent_response.py" --run "$RUN_DIR" --request-id "$REQUEST_ID")"
|
||||
uv run mirofish-agent responses validate --run "$RUN_DIR" --response "$RESPONSE" --json >/tmp/mirofish_staged_validate.json
|
||||
python -c 'import json,sys; data=json.load(open("/tmp/mirofish_staged_validate.json")); sys.exit(0 if data["ok"] else 1)'
|
||||
done
|
||||
|
||||
echo "CLI staged smoke did not complete within expected steps"
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env python3
|
||||
"""MCP lifecycle smoke.
|
||||
|
||||
This verifies the FastMCP server can be constructed and then exercises the
|
||||
same lifecycle service used by MCP tools. If the MCP SDK is missing, the script
|
||||
fails with a clear dependency blocker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from write_mock_agent_response import output_for
|
||||
|
||||
|
||||
async def call_tool(server, name: str, arguments: dict) -> dict:
|
||||
result = await server.call_tool(name, arguments)
|
||||
if isinstance(result, tuple) and len(result) > 1 and isinstance(result[1], dict):
|
||||
return result[1]["result"]
|
||||
if isinstance(result, dict):
|
||||
return result.get("result", result)
|
||||
raise RuntimeError(f"Unexpected MCP tool result for {name}: {result!r}")
|
||||
|
||||
|
||||
async def async_main() -> int:
|
||||
try:
|
||||
import mcp # noqa: F401
|
||||
except ImportError as exc:
|
||||
print("BLOCKER: MCP Python SDK package 'mcp' is not installed.")
|
||||
print(f"Import error: {exc}")
|
||||
return 2
|
||||
|
||||
from app.mcp_server.server import create_server
|
||||
|
||||
server = create_server()
|
||||
if server is None:
|
||||
print("BLOCKER: create_server returned None")
|
||||
return 2
|
||||
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
os.environ["MIROFISH_MODE"] = "agent"
|
||||
os.environ["MIROFISH_LLM_PROVIDER"] = "agent_queue"
|
||||
os.environ["MIROFISH_GRAPH_PROVIDER"] = "graphiti"
|
||||
os.environ["MIROFISH_GRAPHITI_STORE"] = "file"
|
||||
os.environ["MIROFISH_GRAPHITI_COMPAT_PATH"] = str(tmp / "graphiti-store.json")
|
||||
os.environ.pop("LLM_API_KEY", None)
|
||||
os.environ.pop("OPENAI_API_KEY", None)
|
||||
os.environ.pop("ZEP_API_KEY", None)
|
||||
|
||||
seed = tmp / "seed.md"
|
||||
seed.write_text("美国商务部限制先进AI芯片出口。", encoding="utf-8")
|
||||
run_dir = tmp / "mcp-run"
|
||||
tools = await server.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
required = {
|
||||
"mirofish_create_run",
|
||||
"mirofish_run",
|
||||
"mirofish_resume_run",
|
||||
"mirofish_get_status",
|
||||
"mirofish_get_current_stage",
|
||||
"mirofish_update_simulation_settings",
|
||||
"mirofish_approve_stage",
|
||||
"mirofish_reject_stage",
|
||||
"mirofish_rerun_stage",
|
||||
"mirofish_list_requests",
|
||||
"mirofish_get_request",
|
||||
"mirofish_submit_response",
|
||||
"mirofish_validate_response",
|
||||
"mirofish_build_graph",
|
||||
"mirofish_search_graph",
|
||||
"mirofish_export_graph",
|
||||
"mirofish_start_simulation",
|
||||
"mirofish_resume_simulation",
|
||||
"mirofish_generate_report",
|
||||
"mirofish_get_report",
|
||||
"mirofish_ask_followup_question",
|
||||
"mirofish_get_followup_answer",
|
||||
"mirofish_list_artifacts",
|
||||
"mirofish_doctor",
|
||||
}
|
||||
missing = sorted(required - tool_names)
|
||||
if missing:
|
||||
print(f"BLOCKER: MCP tools missing: {missing}")
|
||||
return 2
|
||||
create_tool = next(tool for tool in tools if tool.name == "mirofish_create_run")
|
||||
create_schema = getattr(create_tool, "inputSchema", {}) or {}
|
||||
create_props = create_schema.get("properties", {})
|
||||
if "rounds" not in create_props or "mode" not in create_props:
|
||||
print(f"BLOCKER: mirofish_create_run schema does not expose rounds/mode: {create_schema}")
|
||||
return 2
|
||||
|
||||
staged_dir = tmp / "mcp-staged-run"
|
||||
staged = await call_tool(
|
||||
server,
|
||||
"mirofish_create_run",
|
||||
{
|
||||
"seed": str(seed),
|
||||
"requirement": "预测未来10年全球芯片能力格局变化",
|
||||
"output": str(staged_dir),
|
||||
"mode": "staged",
|
||||
"rounds": 10,
|
||||
"round_unit": "year",
|
||||
},
|
||||
)
|
||||
assert staged["status"] == "created", staged
|
||||
assert staged["state"]["workflow_mode"] == "staged", staged
|
||||
assert staged["state"]["simulation_settings"]["rounds"] == 10, staged
|
||||
current_stage = await call_tool(server, "mirofish_get_current_stage", {"run": str(staged_dir)})
|
||||
assert current_stage["stage"]["current_stage"] == "seed_input", current_stage
|
||||
approved_stage = await call_tool(server, "mirofish_approve_stage", {"run": str(staged_dir)})
|
||||
assert approved_stage["next_stage"] == "prediction_requirement", approved_stage
|
||||
|
||||
created = await call_tool(
|
||||
server,
|
||||
"mirofish_create_run",
|
||||
{"seed": str(seed), "requirement": "预测未来10年全球芯片能力格局变化", "output": str(run_dir), "rounds": 10},
|
||||
)
|
||||
assert created["status"] == "created"
|
||||
|
||||
result = await call_tool(server, "mirofish_run", {"run": str(run_dir)})
|
||||
for _ in range(10):
|
||||
if result["status"] == "completed":
|
||||
status = await call_tool(server, "mirofish_get_status", {"run": str(run_dir)})
|
||||
assert status["status"] == "ok", status
|
||||
report = await call_tool(server, "mirofish_get_report", {"run": str(run_dir)})
|
||||
assert report["status"] == "ok"
|
||||
search = await call_tool(
|
||||
server,
|
||||
"mirofish_search_graph",
|
||||
{"run": str(run_dir), "query": "先进AI芯片出口", "limit": 5},
|
||||
)
|
||||
assert search["status"] == "ok", search
|
||||
exported = await call_tool(server, "mirofish_export_graph", {"run": str(run_dir), "output": None})
|
||||
assert exported["status"] == "ok", exported
|
||||
artifacts = await call_tool(server, "mirofish_list_artifacts", {"run": str(run_dir)})
|
||||
artifact_names = {artifact["name"] for artifact in artifacts["artifacts"]}
|
||||
assert {"report.md", "verdict.json", "timeline.json", "graph_snapshot.json"}.issubset(artifact_names)
|
||||
doctor = await call_tool(server, "mirofish_doctor", {"runs_dir": str(tmp / "doctor-runs")})
|
||||
assert doctor["status"] == "ok", doctor
|
||||
followup = await call_tool(
|
||||
server,
|
||||
"mirofish_ask_followup_question",
|
||||
{"run": str(run_dir), "question": "先进AI芯片出口限制有什么影响?", "limit": 5},
|
||||
)
|
||||
assert followup["status"] == "need_agent_response", followup
|
||||
followup_request = await call_tool(
|
||||
server,
|
||||
"mirofish_get_request",
|
||||
{"run": str(run_dir), "request_id": followup["request_id"]},
|
||||
)
|
||||
request = followup_request["request"]
|
||||
response_path = run_dir / "responses" / f"{request['request_id']}.json"
|
||||
response_path.write_text(
|
||||
json.dumps(
|
||||
{"request_id": request["request_id"], "status": "ok", "output": output_for(request)},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
submitted = await call_tool(
|
||||
server,
|
||||
"mirofish_submit_response",
|
||||
{"run": str(run_dir), "response": str(response_path)},
|
||||
)
|
||||
assert submitted["ok"], submitted
|
||||
answer = await call_tool(
|
||||
server,
|
||||
"mirofish_get_followup_answer",
|
||||
{"run": str(run_dir), "request_id": request["request_id"]},
|
||||
)
|
||||
assert answer["status"] == "ok", answer
|
||||
print(f"MCP lifecycle smoke passed: {run_dir}")
|
||||
return 0
|
||||
assert result["status"] == "need_agent_response", result
|
||||
listed = await call_tool(server, "mirofish_list_requests", {"run": str(run_dir)})
|
||||
assert any(item["request_id"] == result["request_id"] for item in listed["requests"])
|
||||
request_result = await call_tool(
|
||||
server,
|
||||
"mirofish_get_request",
|
||||
{"run": str(run_dir), "request_id": result["request_id"]},
|
||||
)
|
||||
request = request_result["request"]
|
||||
request_id = request["request_id"]
|
||||
response_path = run_dir / "responses" / f"{request_id}.json"
|
||||
response_path.write_text(
|
||||
json.dumps({"request_id": request_id, "status": "ok", "output": output_for(request)}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
validation = await call_tool(
|
||||
server,
|
||||
"mirofish_validate_response",
|
||||
{"run": str(run_dir), "response": str(response_path)},
|
||||
)
|
||||
assert validation["ok"], validation
|
||||
submitted = await call_tool(
|
||||
server,
|
||||
"mirofish_submit_response",
|
||||
{"run": str(run_dir), "response": str(response_path)},
|
||||
)
|
||||
assert submitted["ok"], submitted
|
||||
result = await call_tool(server, "mirofish_resume_run", {"run": str(run_dir)})
|
||||
|
||||
print("MCP lifecycle smoke did not complete within expected steps")
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return asyncio.run(async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BACKEND="$ROOT/backend"
|
||||
|
||||
cd "$BACKEND"
|
||||
uv run python "$ROOT/scripts/smoke_mcp_full.py"
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Write a deterministic agent response for the latest or selected request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_request(run_dir: Path, request_id: str | None) -> dict:
|
||||
requests = sorted((run_dir / "requests").glob("req_*.json"))
|
||||
if request_id:
|
||||
path = run_dir / "requests" / f"{request_id}.json"
|
||||
else:
|
||||
unanswered = [path for path in requests if not (run_dir / "responses" / path.name).exists()]
|
||||
path = unanswered[-1] if unanswered else requests[-1]
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def output_for(request: dict) -> dict:
|
||||
task_type = request["type"]
|
||||
if task_type == "generate_ontology":
|
||||
return {"ontology": {"entity_types": [{"name": "Organization"}], "edge_types": [{"name": "AFFECTS"}]}}
|
||||
if task_type == "extract_triples":
|
||||
return {
|
||||
"triples": [
|
||||
{
|
||||
"subject": "美国商务部",
|
||||
"predicate": "限制",
|
||||
"object": "先进AI芯片出口",
|
||||
"fact": "美国商务部限制先进AI芯片出口。",
|
||||
"valid_at": "2024-01-01",
|
||||
"invalid_at": None,
|
||||
"source": "smoke",
|
||||
"source_file": "seed.md",
|
||||
"evidence": "美国商务部限制先进AI芯片出口。",
|
||||
"confidence": 0.82,
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
if task_type == "generate_oasis_profiles":
|
||||
return {"profiles": [{"agent_id": "agent_1", "name": "芯片分析员", "persona": "关注芯片供应链变化。"}]}
|
||||
if task_type == "generate_simulation_config":
|
||||
return {"config": {"rounds": 1, "platforms": ["agent_queue"], "agents": ["agent_1"]}}
|
||||
if task_type == "simulate_agent_action":
|
||||
actions = []
|
||||
for item in request.get("structured_input", {}).get("actions", []):
|
||||
actions.append(
|
||||
{
|
||||
"agent_id": str(item.get("agent_id")),
|
||||
"action_id": str(item.get("action_id")),
|
||||
"action_type": "CREATE_POST",
|
||||
"content": "先进AI芯片出口限制会推动供应链分化。",
|
||||
}
|
||||
)
|
||||
return {"actions": actions}
|
||||
if task_type == "summarize_round":
|
||||
return {
|
||||
"summary_markdown": "Mock round summary generated without model APIs.",
|
||||
"key_events": [],
|
||||
"memory_updates": [],
|
||||
}
|
||||
if task_type == "update_memory":
|
||||
return {
|
||||
"memory": request.get("structured_input", {}).get("memory", {}),
|
||||
"events": request.get("structured_input", {}).get("events", []),
|
||||
}
|
||||
if task_type == "generate_report":
|
||||
return {
|
||||
"report_markdown": "# MiroFish Agent Smoke Report\n\n先进AI芯片出口限制可能推动供应链分化。",
|
||||
"verdict": {"status": "ok", "confidence": 0.7},
|
||||
"timeline": [{"valid_at": "2024-01-01", "fact": "美国商务部限制先进AI芯片出口。"}],
|
||||
}
|
||||
if task_type == "answer_followup_question":
|
||||
question = request.get("structured_input", {}).get("question", "")
|
||||
graph_results = request.get("structured_input", {}).get("graph_results", [])
|
||||
return {
|
||||
"answer_markdown": f"Mock follow-up answer for: {question}",
|
||||
"used_graph_results": graph_results[:3],
|
||||
"confidence": 0.6,
|
||||
}
|
||||
if task_type == "validate_json_output":
|
||||
return {
|
||||
"valid": True,
|
||||
"errors": [],
|
||||
"output": request.get("structured_input", {}).get("candidate", {}),
|
||||
}
|
||||
if task_type == "repair_invalid_json":
|
||||
return request.get("structured_input", {}).get("invalid_response", {}).get("output", {})
|
||||
return {"result": {}}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--run", required=True)
|
||||
parser.add_argument("--request-id", default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run)
|
||||
request = load_request(run_dir, args.request_id)
|
||||
response = {"request_id": request["request_id"], "status": "ok", "output": output_for(request)}
|
||||
response_path = run_dir / "responses" / f"{request['request_id']}.json"
|
||||
response_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
response_path.write_text(json.dumps(response, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(response_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue