2799 lines
136 KiB
Python
2799 lines
136 KiB
Python
"""Shared run lifecycle service used by CLI and MCP."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import html
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from ..adapters.graph.factory import create_graph_provider
|
|
from ..adapters.llm.agent_runtime import AgentRuntime
|
|
from ..adapters.llm.factory import create_llm_provider
|
|
from .contracts import (
|
|
AGENT_QUESTION_OUTPUT_SCHEMA,
|
|
AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA,
|
|
FOLLOWUP_OUTPUT_SCHEMA,
|
|
QUESTIONNAIRE_SUMMARY_OUTPUT_SCHEMA,
|
|
REPORT_QUESTION_OUTPUT_SCHEMA,
|
|
STAGE_CONTRACTS,
|
|
)
|
|
from .queue import AgentQueue
|
|
from .schemas import AgentResponse, StageStatus
|
|
from .state import RUN_STAGES, STAGED_RUN_STAGES, RunStore
|
|
|
|
|
|
ROUND_UNIT_MINUTES = {
|
|
"year": 525_600,
|
|
"month": 43_200,
|
|
"day": 1_440,
|
|
"step": 60,
|
|
}
|
|
|
|
STAGED_INTERNAL_START = {
|
|
"graph_build": "ontology",
|
|
"profile_and_config": "profiles",
|
|
"simulation_run": "simulation",
|
|
"report_generation": "report",
|
|
}
|
|
|
|
STAGED_INTERNAL_NEXT = {
|
|
("graph_build", "ontology"): "graph",
|
|
("profile_and_config", "profiles"): "config",
|
|
}
|
|
|
|
STAGED_DOWNSTREAM = {
|
|
"seed_input": ["prediction_requirement", "simulation_settings", "graph_build", "profile_and_config", "simulation_run", "report_generation"],
|
|
"prediction_requirement": ["simulation_settings", "graph_build", "profile_and_config", "simulation_run", "report_generation"],
|
|
"simulation_settings": ["profile_and_config", "simulation_run", "report_generation"],
|
|
"graph_build": ["profile_and_config", "simulation_run", "report_generation"],
|
|
"profile_and_config": ["simulation_run", "report_generation"],
|
|
"simulation_run": ["report_generation"],
|
|
"report_generation": [],
|
|
"followup_question": [],
|
|
}
|
|
|
|
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
|
|
|
|
def _resolve_agent_id(profile: Dict[str, Any], index: int = 0) -> str:
|
|
"""Derive a stable, non-empty agent_id from a profile dict.
|
|
|
|
Priority:
|
|
1. profile["agent_id"] (if truthy)
|
|
2. profile["user_id"] (if truthy)
|
|
3. slug of profile["name"] (e.g. "Google China" -> "google_china")
|
|
4. positional fallback "agent_{index+1}"
|
|
"""
|
|
raw = profile.get("agent_id") or profile.get("user_id")
|
|
if raw:
|
|
return str(raw).strip() or f"agent_{index + 1}"
|
|
name = profile.get("name")
|
|
if name and isinstance(name, str) and name.strip():
|
|
slug = _SLUG_RE.sub("_", name.strip().lower()).strip("_")
|
|
return slug or f"agent_{index + 1}"
|
|
return f"agent_{index + 1}"
|
|
|
|
|
|
class PredictionRunService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
llm_provider: Optional[str] = None,
|
|
graph_provider: Optional[str] = None,
|
|
):
|
|
self.llm_provider_name = llm_provider
|
|
self.graph_provider_name = graph_provider
|
|
|
|
def create_run(
|
|
self,
|
|
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]:
|
|
if mode not in {"auto", "staged"}:
|
|
raise ValueError("mode must be auto or staged")
|
|
run_dir = Path(output).resolve()
|
|
run_id = run_dir.name or f"run-{uuid.uuid4().hex[:8]}"
|
|
store = RunStore(run_dir)
|
|
store.ensure_layout()
|
|
simulation_settings = self._canonical_simulation_settings(
|
|
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,
|
|
output_directory=str(run_dir),
|
|
)
|
|
|
|
seed_path = Path(seed).resolve()
|
|
if not seed_path.exists():
|
|
raise FileNotFoundError(f"seed file not found: {seed}")
|
|
target_seed = run_dir / seed_path.name
|
|
if seed_path != target_seed:
|
|
shutil.copyfile(seed_path, target_seed)
|
|
|
|
state = store.init_state(
|
|
run_id=run_id,
|
|
requirement=requirement,
|
|
seed_files=[target_seed.name],
|
|
mode=os.environ.get("MIROFISH_MODE", "agent"),
|
|
workflow_mode=mode,
|
|
simulation_settings=simulation_settings,
|
|
seed_path=str(target_seed),
|
|
metadata={
|
|
"llm_provider": self.llm_provider_name or os.environ.get("MIROFISH_LLM_PROVIDER", "agent_queue"),
|
|
"graph_provider": self.graph_provider_name or os.environ.get("MIROFISH_GRAPH_PROVIDER", "graphiti"),
|
|
},
|
|
)
|
|
if mode == "staged":
|
|
self._complete_static_staged_stage(store, state, "seed_input")
|
|
return {"status": "created", "run_id": run_id, "run_dir": str(run_dir), "state": state.model_dump()}
|
|
|
|
def run(self, run_dir: str) -> Dict[str, Any]:
|
|
return self.resume(run_dir)
|
|
|
|
def resume(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
if state.workflow_mode == "staged":
|
|
return self._resume_staged(store, state)
|
|
return self._resume_auto(store, state)
|
|
|
|
def _resume_auto(self, store: RunStore, state) -> Dict[str, Any]:
|
|
run_dir = str(store.run_dir)
|
|
stage = state.current_stage
|
|
checkpoint = state.stages[stage]
|
|
|
|
if checkpoint.status == StageStatus.COMPLETED:
|
|
next_stage = self._next_stage(stage)
|
|
if not next_stage:
|
|
return {"status": "completed", "run_id": state.run_id, "artifacts": self.list_artifacts(run_dir)["artifacts"]}
|
|
stage = next_stage
|
|
checkpoint = state.stages[stage]
|
|
state.current_stage = stage
|
|
store.save(state)
|
|
|
|
if checkpoint.status == StageStatus.WAITING_AGENT:
|
|
request_id = checkpoint.request_ids[-1]
|
|
response_path = store.responses_dir / f"{request_id}.json"
|
|
if not response_path.exists():
|
|
request_path = store.requests_dir / f"{request_id}.json"
|
|
request = AgentQueue(run_dir).load_request(request_id)
|
|
return {
|
|
"status": "need_agent_response",
|
|
"request_id": request_id,
|
|
"request_file": str(request_path),
|
|
"expected_response_file": str(response_path),
|
|
"stage": stage,
|
|
"type": request.type,
|
|
}
|
|
queue = AgentQueue(run_dir)
|
|
validation = queue.submit_response(response_path)
|
|
if not validation.ok:
|
|
if validation.repair_request:
|
|
store.add_stage_request(state, stage, validation.repair_request.request_id)
|
|
return validation.repair_request.model_dump()
|
|
store.set_stage(state, stage, StageStatus.FAILED, "; ".join(validation.errors))
|
|
return {"status": "failed", "stage": stage, "errors": validation.errors}
|
|
response = queue.load_response(request_id)
|
|
if response.status == "error":
|
|
store.set_stage(state, stage, StageStatus.FAILED, response.error or "agent returned error")
|
|
return {"status": "failed", "stage": stage, "error": response.error or "agent returned error"}
|
|
self._process_stage_output(store, state, stage, response.output)
|
|
store.set_stage(state, stage, StageStatus.COMPLETED)
|
|
next_stage = self._next_stage(stage)
|
|
if not next_stage:
|
|
return {"status": "completed", "run_id": state.run_id, "artifacts": self.list_artifacts(run_dir)["artifacts"]}
|
|
state.current_stage = next_stage
|
|
store.save(state)
|
|
return self._start_stage(store, state, next_stage)
|
|
|
|
if checkpoint.status in {StageStatus.PENDING, StageStatus.RUNNING}:
|
|
return self._start_stage(store, state, stage)
|
|
|
|
if checkpoint.status == StageStatus.FAILED:
|
|
return {"status": "failed", "stage": stage, "error": checkpoint.error}
|
|
|
|
return self.status(run_dir)
|
|
|
|
def _resume_staged(self, store: RunStore, state) -> Dict[str, Any]:
|
|
stage = state.current_stage
|
|
checkpoint = state.stages[stage]
|
|
|
|
if checkpoint.status == StageStatus.AWAITING_USER_CONFIRMATION:
|
|
return self._awaiting_confirmation_payload(store, state, stage)
|
|
|
|
if checkpoint.status == StageStatus.COMPLETED:
|
|
next_stage = self._next_staged_stage(stage)
|
|
if not next_stage:
|
|
return {"status": "completed", "run_id": state.run_id, "artifacts": self.list_artifacts(str(store.run_dir))["artifacts"]}
|
|
state.current_stage = next_stage
|
|
store.save(state)
|
|
return self._resume_staged(store, state)
|
|
|
|
if checkpoint.status == StageStatus.WAITING_AGENT:
|
|
request_id = checkpoint.request_ids[-1]
|
|
response_path = store.responses_dir / f"{request_id}.json"
|
|
if not response_path.exists():
|
|
request_path = store.requests_dir / f"{request_id}.json"
|
|
request = AgentQueue(store.run_dir).load_request(request_id)
|
|
return {
|
|
"status": "need_agent_response",
|
|
"request_id": request_id,
|
|
"request_file": str(request_path),
|
|
"expected_response_file": str(response_path),
|
|
"stage": stage,
|
|
"type": request.type,
|
|
}
|
|
queue = AgentQueue(store.run_dir)
|
|
validation = queue.submit_response(response_path)
|
|
if not validation.ok:
|
|
if validation.repair_request:
|
|
store.add_stage_request(state, stage, validation.repair_request.request_id)
|
|
return validation.repair_request.model_dump()
|
|
store.set_stage(state, stage, StageStatus.FAILED, "; ".join(validation.errors))
|
|
return {"status": "failed", "stage": stage, "errors": validation.errors}
|
|
response = queue.load_response(request_id)
|
|
if response.status == "error":
|
|
store.set_stage(state, stage, StageStatus.FAILED, response.error or "agent returned error")
|
|
return {"status": "failed", "stage": stage, "error": response.error or "agent returned error"}
|
|
return self._handle_staged_response(store, state, stage, response)
|
|
|
|
if checkpoint.status in {StageStatus.PENDING, StageStatus.RUNNING}:
|
|
return self._start_staged_stage(store, state, stage)
|
|
|
|
if checkpoint.status == StageStatus.FAILED:
|
|
return {"status": "failed", "stage": stage, "error": checkpoint.error}
|
|
|
|
return self.status(str(store.run_dir))
|
|
|
|
def status(self, run_dir: str) -> Dict[str, Any]:
|
|
return {"status": "ok", "state": RunStore(run_dir).as_status()}
|
|
|
|
def get_current_stage(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
return {"status": "ok", "stage": self._stage_detail(store, state, state.current_stage)}
|
|
|
|
def approve_stage(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
stage = state.current_stage
|
|
checkpoint = state.stages[stage]
|
|
if checkpoint.status != StageStatus.AWAITING_USER_CONFIRMATION:
|
|
return {"status": "error", "error": f"stage {stage} is not awaiting user confirmation", "stage": stage}
|
|
checkpoint.status = StageStatus.COMPLETED
|
|
checkpoint.error = None
|
|
checkpoint.stale = False
|
|
checkpoint.stale_reason = None
|
|
next_stage = self._next_staged_stage(stage) if state.workflow_mode == "staged" else self._next_stage(stage)
|
|
if next_stage:
|
|
state.current_stage = next_stage
|
|
store.save(state)
|
|
return {"status": "ok", "approved_stage": stage, "next_stage": next_stage, "state": state.model_dump()}
|
|
|
|
def reject_stage(self, run_dir: str, reason: str = "") -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
stage = state.current_stage
|
|
checkpoint = state.stages[stage]
|
|
checkpoint.status = StageStatus.FAILED
|
|
checkpoint.error = reason or "stage rejected by user"
|
|
checkpoint.metadata["rejection_reason"] = checkpoint.error
|
|
store.save(state)
|
|
return {"status": "failed", "stage": stage, "reason": checkpoint.error}
|
|
|
|
def update_simulation_settings(
|
|
self,
|
|
run_dir: 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]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
current = state.simulation_settings or {}
|
|
settings = self._canonical_simulation_settings(
|
|
rounds=rounds if rounds is not None else int(current.get("rounds", 10)),
|
|
round_unit=round_unit or str(current.get("round_unit", "year")),
|
|
minutes_per_round=minutes_per_round if minutes_per_round is not None else current.get("minutes_per_round"),
|
|
pause_each_round=pause_each_round if pause_each_round is not None else bool(current.get("pause_each_round", False)),
|
|
agent_count=agent_count if agent_count is not None else current.get("agent_count"),
|
|
simulation_name=simulation_name if simulation_name is not None else current.get("simulation_name"),
|
|
output_directory=current.get("output_directory") or str(store.run_dir),
|
|
)
|
|
state.simulation_settings = settings
|
|
self._write_json(store.artifacts_dir / "simulation_settings.json", settings)
|
|
self._mark_downstream_pending(state, "simulation_settings", "simulation settings changed")
|
|
if "simulation_settings" in state.stages:
|
|
checkpoint = state.stages["simulation_settings"]
|
|
checkpoint.status = StageStatus.AWAITING_USER_CONFIRMATION
|
|
checkpoint.stale = False
|
|
checkpoint.stale_reason = None
|
|
state.current_stage = "simulation_settings"
|
|
store.save(state)
|
|
return {"status": "ok", "simulation_settings": settings, "state": state.model_dump()}
|
|
|
|
def rerun_stage(self, run_dir: str, stage: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
stage = self._normalize_stage_name(state, stage)
|
|
if stage not in state.stages:
|
|
return {"status": "error", "error": f"unknown stage: {stage}"}
|
|
checkpoint = state.stages[stage]
|
|
checkpoint.status = StageStatus.PENDING
|
|
checkpoint.request_ids = []
|
|
checkpoint.error = None
|
|
checkpoint.stale = False
|
|
checkpoint.stale_reason = None
|
|
checkpoint.metadata = {}
|
|
self._mark_downstream_pending(state, stage, f"upstream stage {stage} rerun")
|
|
state.current_stage = stage
|
|
store.save(state)
|
|
return {"status": "ok", "stage": stage, "state": state.model_dump()}
|
|
|
|
def list_requests(self, run_dir: str) -> Dict[str, Any]:
|
|
return {"status": "ok", "requests": AgentQueue(run_dir).list_requests()}
|
|
|
|
def get_request(self, run_dir: str, request_id: str) -> Dict[str, Any]:
|
|
request = AgentQueue(run_dir).load_request(request_id)
|
|
return {"status": "ok", "request": request.model_dump()}
|
|
|
|
def validate_response(self, run_dir: str, response: str) -> Dict[str, Any]:
|
|
result = AgentQueue(run_dir).validate_response_file(response)
|
|
return result.model_dump()
|
|
|
|
def submit_response(self, run_dir: str, response: str) -> Dict[str, Any]:
|
|
queue = AgentQueue(run_dir)
|
|
result = queue.submit_response(response)
|
|
if result.repair_request:
|
|
self._attach_repair_request_to_waiting_stage(run_dir, queue, result.repair_request.request_id)
|
|
return result.model_dump()
|
|
|
|
def build_graph(self, run_dir: str, provider: Optional[str] = None, mode: str = "agent-triples") -> Dict[str, Any]:
|
|
if mode != "agent-triples":
|
|
raise ValueError("only agent-triples graph build mode is supported in agent engine")
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
if provider:
|
|
state.metadata["graph_provider"] = provider
|
|
if state.workflow_mode == "staged":
|
|
state.current_stage = "graph_build"
|
|
if state.stages["graph_build"].status == StageStatus.COMPLETED:
|
|
state.stages["graph_build"].status = StageStatus.PENDING
|
|
store.save(state)
|
|
waiting = self._existing_agent_wait(store, state, "graph_build")
|
|
if waiting:
|
|
return waiting
|
|
return self._start_staged_stage(store, state, "graph_build")
|
|
state.current_stage = "graph"
|
|
if state.stages["graph"].status == StageStatus.COMPLETED:
|
|
state.stages["graph"].status = StageStatus.PENDING
|
|
store.save(state)
|
|
waiting = self._existing_agent_wait(store, state, "graph")
|
|
if waiting:
|
|
return waiting
|
|
return self._start_stage(store, state, "graph", graph_provider_override=provider)
|
|
|
|
def search_graph(self, run_dir: str, query: str, limit: int = 20) -> Dict[str, Any]:
|
|
state = RunStore(run_dir).load()
|
|
provider = create_graph_provider(self._graph_provider_name(state))
|
|
return {"status": "ok", "results": provider.search(state.run_id, query, limit)}
|
|
|
|
def export_graph(self, run_dir: str, output: Optional[str] = None) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
path = output or str(store.artifacts_dir / "graph_snapshot.json")
|
|
provider = create_graph_provider(self._graph_provider_name(state))
|
|
result = provider.export_snapshot(state.run_id, path)
|
|
return {"status": "ok", "result": result}
|
|
|
|
def start_simulation(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
if state.workflow_mode == "staged":
|
|
state.current_stage = "simulation_run"
|
|
store.save(state)
|
|
waiting = self._existing_agent_wait(store, state, "simulation_run")
|
|
if waiting:
|
|
return waiting
|
|
return self._start_staged_stage(store, state, "simulation_run")
|
|
state.current_stage = "simulation"
|
|
store.save(state)
|
|
waiting = self._existing_agent_wait(store, state, "simulation")
|
|
if waiting:
|
|
return waiting
|
|
return self._start_stage(store, state, "simulation")
|
|
|
|
def simulation_status(self, run_dir: str) -> Dict[str, Any]:
|
|
state = RunStore(run_dir).load()
|
|
if state.workflow_mode == "staged":
|
|
return {"status": "ok", "simulation": state.stages["simulation_run"].model_dump(), "progress": state.simulation_progress}
|
|
return {"status": "ok", "simulation": state.stages["simulation"].model_dump()}
|
|
|
|
def generate_report(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
if state.workflow_mode == "staged":
|
|
state.current_stage = "report_generation"
|
|
store.save(state)
|
|
waiting = self._existing_agent_wait(store, state, "report_generation")
|
|
if waiting:
|
|
return waiting
|
|
return self._start_staged_stage(store, state, "report_generation")
|
|
state.current_stage = "report"
|
|
store.save(state)
|
|
waiting = self._existing_agent_wait(store, state, "report")
|
|
if waiting:
|
|
return waiting
|
|
return self._start_stage(store, state, "report")
|
|
|
|
def get_report(self, run_dir: str) -> Dict[str, Any]:
|
|
path = RunStore(run_dir).artifacts_dir / "report.md"
|
|
if not path.exists():
|
|
return {"status": "missing", "report": None, "path": str(path)}
|
|
return {"status": "ok", "path": str(path), "report": path.read_text(encoding="utf-8")}
|
|
|
|
def ask_followup_question(self, run_dir: str, question: str, limit: int = 20) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
provider = create_graph_provider(self._graph_provider_name(state))
|
|
graph_results = provider.search(state.run_id, question, limit=limit)
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type="answer_followup_question",
|
|
stage="followup",
|
|
expected_schema=FOLLOWUP_OUTPUT_SCHEMA,
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input={
|
|
"question": question,
|
|
"requirement": state.requirement,
|
|
"graph_results": graph_results,
|
|
"artifacts": self._artifact_context(store),
|
|
},
|
|
system_prompt="Answer a follow-up question using only run artifacts and GraphProvider retrieval context.",
|
|
user_prompt=question,
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": FOLLOWUP_OUTPUT_SCHEMA},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
return result.to_dict() | {"stage": "followup", "type": "answer_followup_question"}
|
|
if result.status != "ok":
|
|
return {"status": "failed", "stage": "followup", "error": result.error}
|
|
return self._persist_followup_answer(store, state.run_id, f"mock_{uuid.uuid4().hex[:8]}", question, result.output or {})
|
|
|
|
def get_followup_answer(self, run_dir: str, request_id: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
queue = AgentQueue(run_dir)
|
|
request = queue.load_request(request_id)
|
|
if request.type not in {"answer_followup_question", "repair_invalid_json"} or request.stage != "followup":
|
|
return {"status": "error", "error": f"request {request_id} is not a follow-up answer request"}
|
|
response_path = store.responses_dir / f"{request_id}.json"
|
|
if not response_path.exists():
|
|
return {"status": "missing", "request_id": request_id, "expected_response_file": str(response_path)}
|
|
validation = queue.submit_response(response_path)
|
|
if not validation.ok:
|
|
if validation.repair_request:
|
|
return validation.repair_request.model_dump()
|
|
return {"status": "failed", "request_id": request_id, "errors": validation.errors}
|
|
response = queue.load_response(request_id)
|
|
if response.status == "error":
|
|
return {"status": "failed", "request_id": request_id, "error": response.error or "agent returned error"}
|
|
return self._persist_followup_answer(
|
|
store,
|
|
state.run_id,
|
|
request_id,
|
|
self._followup_question_from_request(request),
|
|
response.output,
|
|
)
|
|
|
|
def list_artifacts(self, run_dir: str) -> Dict[str, Any]:
|
|
artifacts_dir = RunStore(run_dir).artifacts_dir
|
|
artifacts = []
|
|
if artifacts_dir.exists():
|
|
for path in sorted(artifacts_dir.rglob("*")):
|
|
if path.is_file():
|
|
artifacts.append(
|
|
{
|
|
"name": str(path.relative_to(artifacts_dir)),
|
|
"path": str(path),
|
|
"bytes": path.stat().st_size,
|
|
}
|
|
)
|
|
return {"status": "ok", "artifacts": artifacts}
|
|
|
|
# ── Agent interaction methods ──────────────────────────────────────────
|
|
|
|
def list_agents(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
profiles_path = store.artifacts_dir / "profiles.json"
|
|
if not profiles_path.exists():
|
|
return {"status": "ok", "agents": [], "count": 0}
|
|
profiles = json.loads(profiles_path.read_text(encoding="utf-8"))
|
|
agents = []
|
|
for idx, profile in enumerate(profiles):
|
|
agent_id = _resolve_agent_id(profile, idx)
|
|
agents.append({
|
|
"agent_id": agent_id,
|
|
"name": profile.get("name", ""),
|
|
"persona": profile.get("persona", ""),
|
|
"profile": profile,
|
|
})
|
|
return {"status": "ok", "agents": agents, "count": len(agents)}
|
|
|
|
def get_agent(self, run_dir: str, agent_id: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
profiles_path = store.artifacts_dir / "profiles.json"
|
|
if not profiles_path.exists():
|
|
return {"status": "error", "error": "profiles.json not found"}
|
|
profiles = json.loads(profiles_path.read_text(encoding="utf-8"))
|
|
for idx, profile in enumerate(profiles):
|
|
pid = _resolve_agent_id(profile, idx)
|
|
if pid == agent_id:
|
|
return {"status": "ok", "agent": {
|
|
"agent_id": pid,
|
|
"name": profile.get("name", ""),
|
|
"persona": profile.get("persona", ""),
|
|
"profile": profile,
|
|
}}
|
|
return {"status": "error", "error": f"agent not found: {agent_id}"}
|
|
|
|
def ask_agent(self, run_dir: str, agent_id: str, question: str, limit: int = 20) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
# Verify agent exists
|
|
agent_result = self.get_agent(run_dir, agent_id)
|
|
if agent_result["status"] == "error":
|
|
return agent_result
|
|
provider = create_graph_provider(self._graph_provider_name(state))
|
|
graph_results = provider.search(state.run_id, question, limit=limit)
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type="answer_agent_question",
|
|
stage="interaction",
|
|
expected_schema=AGENT_QUESTION_OUTPUT_SCHEMA,
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input={
|
|
"agent_id": agent_id,
|
|
"question": question,
|
|
"requirement": state.requirement,
|
|
"graph_results": graph_results,
|
|
"agent_profile": agent_result["agent"]["profile"],
|
|
"artifacts": self._artifact_context(store),
|
|
},
|
|
system_prompt=(
|
|
f"You are agent '{agent_id}'. Answer the question from your persona's perspective "
|
|
"using only run artifacts and GraphProvider retrieval context."
|
|
),
|
|
user_prompt=question,
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": AGENT_QUESTION_OUTPUT_SCHEMA},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
return result.to_dict() | {"stage": "interaction", "type": "answer_agent_question", "agent_id": agent_id}
|
|
if result.status != "ok":
|
|
return {"status": "failed", "stage": "interaction", "error": result.error}
|
|
return self._persist_agent_question_answer(store, state.run_id, agent_id, f"mock_{uuid.uuid4().hex[:8]}", question, result.output or {})
|
|
|
|
def get_agent_answer(self, run_dir: str, request_id: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
queue = AgentQueue(run_dir)
|
|
request = queue.load_request(request_id)
|
|
if request.type != "answer_agent_question" or request.stage != "interaction":
|
|
return {"status": "error", "error": f"request {request_id} is not an agent question request"}
|
|
response_path = store.responses_dir / f"{request_id}.json"
|
|
if not response_path.exists():
|
|
return {"status": "missing", "request_id": request_id, "expected_response_file": str(response_path)}
|
|
validation = queue.submit_response(response_path)
|
|
if not validation.ok:
|
|
if validation.repair_request:
|
|
return validation.repair_request.model_dump()
|
|
return {"status": "failed", "request_id": request_id, "errors": validation.errors}
|
|
response = queue.load_response(request_id)
|
|
if response.status == "error":
|
|
return {"status": "failed", "request_id": request_id, "error": response.error or "agent returned error"}
|
|
agent_id = str(request.structured_input.get("agent_id", ""))
|
|
question = str(request.structured_input.get("question", ""))
|
|
return self._persist_agent_question_answer(store, state.run_id, agent_id, request_id, question, response.output)
|
|
|
|
def send_questionnaire(self, run_dir: str, questions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
profiles_path = store.artifacts_dir / "profiles.json"
|
|
if not profiles_path.exists():
|
|
return {"status": "error", "error": "profiles.json not found; cannot send questionnaire"}
|
|
profiles = json.loads(profiles_path.read_text(encoding="utf-8"))
|
|
agents = [
|
|
{"agent_id": _resolve_agent_id(p, i), "profile": p}
|
|
for i, p in enumerate(profiles)
|
|
]
|
|
questionnaire_id = f"questionnaire_{uuid.uuid4().hex[:8]}"
|
|
provider = create_graph_provider(self._graph_provider_name(state))
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
request_ids = []
|
|
for question_item in questions:
|
|
question_text = question_item.get("question", "")
|
|
question_id = question_item.get("question_id", f"q_{uuid.uuid4().hex[:6]}")
|
|
graph_results = provider.search(state.run_id, question_text, limit=10)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type="answer_agent_questionnaire",
|
|
stage="interaction",
|
|
expected_schema=AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA,
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input={
|
|
"questionnaire_id": questionnaire_id,
|
|
"question_id": question_id,
|
|
"questions": [{"question_id": question_id, "question": question_text}],
|
|
"agents": agents,
|
|
"requirement": state.requirement,
|
|
"graph_results": graph_results,
|
|
"artifacts": self._artifact_context(store),
|
|
},
|
|
system_prompt=(
|
|
"Answer the questionnaire on behalf of all agents. "
|
|
"Each agent should answer from their own persona's perspective."
|
|
),
|
|
user_prompt=question_text,
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": AGENT_QUESTIONNAIRE_OUTPUT_SCHEMA},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
request_ids.append(result.request_id)
|
|
elif result.status == "ok":
|
|
self._persist_questionnaire_answers(store, state.run_id, questionnaire_id, result.output or {})
|
|
# Save questionnaire metadata
|
|
self._persist_questionnaire_metadata(store, questionnaire_id, questions, request_ids)
|
|
return {
|
|
"status": "ok" if not request_ids else "need_agent_response",
|
|
"questionnaire_id": questionnaire_id,
|
|
"request_ids": request_ids,
|
|
"question_count": len(questions),
|
|
"agent_count": len(agents),
|
|
}
|
|
|
|
def get_questionnaire_result(self, run_dir: str, questionnaire_id: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
questionnaires_dir = store.artifacts_dir / "interactions" / "questionnaires"
|
|
meta_path = questionnaires_dir / f"{questionnaire_id}_meta.json"
|
|
if not meta_path.exists():
|
|
return {"status": "error", "error": f"questionnaire not found: {questionnaire_id}"}
|
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
# Collect all answers
|
|
answers = []
|
|
answers_path = questionnaires_dir / f"{questionnaire_id}_answers.json"
|
|
if answers_path.exists():
|
|
answers = json.loads(answers_path.read_text(encoding="utf-8"))
|
|
# Check pending requests
|
|
pending_request_ids = meta.get("request_ids", [])
|
|
queue = AgentQueue(run_dir)
|
|
for req_id in list(pending_request_ids):
|
|
response_path = store.responses_dir / f"{req_id}.json"
|
|
if response_path.exists():
|
|
try:
|
|
validation = queue.submit_response(response_path)
|
|
if validation.ok:
|
|
response = queue.load_response(req_id)
|
|
if response.status == "ok":
|
|
new_answers = self._extract_questionnaire_answers(response.output)
|
|
answers.extend(new_answers)
|
|
# Persist summary_markdown from response if present
|
|
resp_summary = response.output.get("summary_markdown", "")
|
|
if resp_summary:
|
|
meta["summary_markdown"] = resp_summary
|
|
pending_request_ids.remove(req_id)
|
|
except Exception:
|
|
pass
|
|
# Re-save answers if we collected new ones
|
|
if answers:
|
|
self._write_json(answers_path, answers)
|
|
# Update metadata
|
|
meta["request_ids"] = pending_request_ids
|
|
meta["answer_count"] = len(answers)
|
|
self._write_json(meta_path, meta)
|
|
summary = meta.get("summary_markdown", "")
|
|
return {
|
|
"status": "ok" if not pending_request_ids else "partial",
|
|
"questionnaire_id": questionnaire_id,
|
|
"answers": answers,
|
|
"answer_count": len(answers),
|
|
"pending_request_ids": pending_request_ids,
|
|
"summary_markdown": summary,
|
|
}
|
|
|
|
def ask_report_question(self, run_dir: str, question: str, limit: int = 20) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
provider = create_graph_provider(self._graph_provider_name(state))
|
|
graph_results = provider.search(state.run_id, question, limit=limit)
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type="ask_report_question",
|
|
stage="interaction",
|
|
expected_schema=REPORT_QUESTION_OUTPUT_SCHEMA,
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input={
|
|
"question": question,
|
|
"requirement": state.requirement,
|
|
"graph_results": graph_results,
|
|
"artifacts": self._artifact_context(store),
|
|
},
|
|
system_prompt="Answer a question about the prediction report using only run artifacts and GraphProvider retrieval context.",
|
|
user_prompt=question,
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": REPORT_QUESTION_OUTPUT_SCHEMA},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
return result.to_dict() | {"stage": "interaction", "type": "ask_report_question"}
|
|
if result.status != "ok":
|
|
return {"status": "failed", "stage": "interaction", "error": result.error}
|
|
return self._persist_report_question_answer(store, state.run_id, f"mock_{uuid.uuid4().hex[:8]}", question, result.output or {})
|
|
|
|
def generate_web_console(self, run_dir: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
web_dir = store.artifacts_dir / "web"
|
|
web_dir.mkdir(parents=True, exist_ok=True)
|
|
html_path = web_dir / "index.html"
|
|
# Read all relevant artifacts for embedding
|
|
artifact_data = self._collect_web_console_data(store)
|
|
artifact_data["run_dir"] = str(store.run_dir)
|
|
html_content = self._render_web_console_html(artifact_data)
|
|
html_path.write_text(html_content, encoding="utf-8")
|
|
return {
|
|
"status": "ok",
|
|
"path": str(html_path),
|
|
"artifact_count": len(artifact_data),
|
|
}
|
|
|
|
def doctor(self, runs_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
checks = []
|
|
mode = os.environ.get("MIROFISH_MODE", "agent")
|
|
llm_provider = os.environ.get("MIROFISH_LLM_PROVIDER", "agent_queue" if mode == "agent" else "openai_compatible")
|
|
graph_provider = os.environ.get("MIROFISH_GRAPH_PROVIDER", "graphiti" if mode == "agent" else "zep")
|
|
graphiti_store = os.environ.get("MIROFISH_GRAPHITI_STORE", "auto").lower()
|
|
graph_search_mode = os.environ.get("MIROFISH_GRAPH_SEARCH_MODE", "fulltext").lower()
|
|
embedding_provider = os.environ.get("MIROFISH_EMBEDDING_PROVIDER", "none").lower()
|
|
agent_graphiti = mode == "agent" and graph_provider == "graphiti"
|
|
production_graphiti = agent_graphiti and graphiti_store != "file"
|
|
require_ollama = production_graphiti and graph_search_mode in {"semantic", "hybrid"} and embedding_provider == "ollama"
|
|
|
|
def add_check(name: str, ok: bool, value: Any, *, required: bool = True) -> None:
|
|
checks.append({"name": name, "ok": ok, "value": value, "required": required})
|
|
|
|
def parse_neo4j_version(value: str) -> tuple[int, int]:
|
|
base = value.split("-", 1)[0]
|
|
parts = base.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
|
|
|
|
add_check("mode", mode in {"agent", "legacy"}, mode, required=False)
|
|
add_check("llm_provider", llm_provider in {"agent_queue", "mock", "openai_compatible"}, llm_provider, required=False)
|
|
add_check("graph_provider", graph_provider in {"graphiti", "zep"}, graph_provider, required=False)
|
|
add_check("llm_key_not_required_in_agent", mode != "agent" or True, "agent mode skips LLM_API_KEY validation", required=False)
|
|
add_check("zep_key_not_required_in_agent", mode != "agent" or True, "agent mode skips ZEP_API_KEY validation", required=False)
|
|
docker_path = shutil.which("docker")
|
|
add_check("docker", docker_path is not None, docker_path or "Docker optional, skipped", required=False)
|
|
if docker_path:
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "compose", "version"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
compose_value = (result.stdout or result.stderr).strip() or "docker compose"
|
|
add_check("docker_compose", result.returncode == 0, compose_value, required=False)
|
|
except Exception as exc:
|
|
add_check("docker_compose", False, str(exc), required=False)
|
|
else:
|
|
add_check("docker_compose", False, "Docker optional, skipped", required=False)
|
|
add_check("mcp_package", importlib.util.find_spec("mcp") is not None, "mcp", required=False)
|
|
add_check(
|
|
"graphiti_package",
|
|
importlib.util.find_spec("graphiti_core") is not None,
|
|
"graphiti_core",
|
|
required=production_graphiti,
|
|
)
|
|
neo4j_package_ok = importlib.util.find_spec("neo4j") is not None
|
|
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")
|
|
add_check("neo4j_package", neo4j_package_ok, "neo4j", required=production_graphiti)
|
|
add_check(
|
|
"neo4j_uri",
|
|
bool(neo4j_uri),
|
|
neo4j_uri,
|
|
required=False,
|
|
)
|
|
if production_graphiti:
|
|
if neo4j_package_ok:
|
|
try:
|
|
from neo4j import GraphDatabase
|
|
|
|
driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_user, neo4j_password))
|
|
with driver.session(database=neo4j_database) as session:
|
|
record = session.run(
|
|
"CALL dbms.components() YIELD name, versions RETURN name, versions LIMIT 1"
|
|
).single()
|
|
driver.close()
|
|
versions = record["versions"] if record else []
|
|
neo4j_version = versions[0] if versions else "unknown"
|
|
major, minor = parse_neo4j_version(neo4j_version)
|
|
version_supported = major > 5 or (major == 5 and minor >= 26)
|
|
add_check("neo4j_connectable", True, neo4j_uri, required=True)
|
|
add_check("neo4j_version_supported", version_supported, neo4j_version, required=True)
|
|
except Exception as exc:
|
|
add_check("neo4j_connectable", False, f"{neo4j_uri}: {exc}", required=True)
|
|
add_check("neo4j_version_supported", False, "not checked", required=True)
|
|
else:
|
|
add_check("neo4j_connectable", False, "neo4j package is not installed", required=True)
|
|
add_check("neo4j_version_supported", False, "not checked", required=True)
|
|
else:
|
|
add_check(
|
|
"neo4j_connectable",
|
|
False,
|
|
"not required unless agent graphiti mode uses Neo4j store",
|
|
required=False,
|
|
)
|
|
add_check(
|
|
"neo4j_version_supported",
|
|
False,
|
|
"not checked",
|
|
required=False,
|
|
)
|
|
|
|
add_check(
|
|
"openai_key_not_required_by_graphiti_provider",
|
|
graph_provider != "graphiti" or True,
|
|
"GraphitiGraphProvider add_triples/search/export_snapshot do not require OPENAI_API_KEY",
|
|
required=False,
|
|
)
|
|
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
|
|
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
|
add_check("graph_search_mode", graph_search_mode in {"fulltext", "semantic", "hybrid"}, graph_search_mode, required=False)
|
|
add_check("embedding_provider", embedding_provider in {"none", "ollama", "openai_compatible", "local"}, embedding_provider, required=False)
|
|
add_check(
|
|
"ollama_base_url",
|
|
bool(ollama_base_url),
|
|
ollama_base_url,
|
|
required=False,
|
|
)
|
|
if require_ollama:
|
|
try:
|
|
with urllib.request.urlopen(f"{ollama_base_url}/api/tags", timeout=5) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
model_names = [item.get("name", "") for item in payload.get("models", [])]
|
|
model_found = any(name == ollama_model or name.startswith(f"{ollama_model}:") for name in model_names)
|
|
add_check("ollama_connectable", True, ollama_base_url, required=True)
|
|
add_check("ollama_embedding_model", model_found, ollama_model, required=True)
|
|
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
|
|
add_check("ollama_connectable", False, f"{ollama_base_url}: {exc}", required=True)
|
|
add_check("ollama_embedding_model", False, ollama_model, required=True)
|
|
else:
|
|
ollama_reason = (
|
|
"semantic graph search disabled; fulltext search does not require Ollama"
|
|
if graph_search_mode == "fulltext" or embedding_provider == "none"
|
|
else "not required unless MIROFISH_GRAPH_SEARCH_MODE=semantic/hybrid and MIROFISH_EMBEDDING_PROVIDER=ollama"
|
|
)
|
|
add_check(
|
|
"ollama_connectable",
|
|
False,
|
|
ollama_reason,
|
|
required=False,
|
|
)
|
|
add_check("ollama_embedding_model", False, ollama_model, required=False)
|
|
|
|
writable_dir = Path(runs_dir or os.environ.get("MIROFISH_RUNS_DIR", "./runs"))
|
|
try:
|
|
writable_dir.mkdir(parents=True, exist_ok=True)
|
|
probe = writable_dir / ".mirofish_write_probe"
|
|
probe.write_text("ok", encoding="utf-8")
|
|
probe.unlink()
|
|
writable = True
|
|
except Exception as exc:
|
|
writable = False
|
|
add_check("runs_dir_error", False, str(exc))
|
|
add_check("runs_dir_writable", writable, str(writable_dir), required=False)
|
|
|
|
hard_failures = [check for check in checks if check["required"] and not check["ok"]]
|
|
warnings = [check for check in checks if not check["required"] and not check["ok"]]
|
|
return {
|
|
"status": "ok" if not hard_failures else "failed",
|
|
"checks": checks,
|
|
"warnings": warnings,
|
|
"hard_failures": hard_failures,
|
|
}
|
|
|
|
def _start_staged_stage(self, store: RunStore, state, stage: str) -> Dict[str, Any]:
|
|
if stage in {"seed_input", "prediction_requirement", "simulation_settings"}:
|
|
self._complete_static_staged_stage(store, state, stage)
|
|
return self._awaiting_confirmation_payload(store, state, stage)
|
|
if stage == "graph_build":
|
|
internal_stage = state.stages[stage].metadata.get("internal_stage") or STAGED_INTERNAL_START[stage]
|
|
return self._start_staged_contract_stage(store, state, stage, internal_stage)
|
|
if stage == "profile_and_config":
|
|
internal_stage = state.stages[stage].metadata.get("internal_stage") or STAGED_INTERNAL_START[stage]
|
|
return self._start_staged_contract_stage(store, state, stage, internal_stage)
|
|
if stage == "simulation_run":
|
|
return self._start_staged_simulation_round(store, state)
|
|
if stage == "report_generation":
|
|
return self._start_staged_contract_stage(store, state, stage, "report")
|
|
if stage == "followup_question":
|
|
return {"status": "ok", "stage": stage, "message": "Use followup ask or mirofish_ask_followup_question."}
|
|
return {"status": "error", "error": f"unsupported staged stage: {stage}"}
|
|
|
|
def _start_staged_contract_stage(self, store: RunStore, state, stage: str, internal_stage: str) -> Dict[str, Any]:
|
|
contract = STAGE_CONTRACTS[internal_stage]
|
|
checkpoint = state.stages[stage]
|
|
checkpoint.metadata["internal_stage"] = internal_stage
|
|
checkpoint.status = StageStatus.RUNNING
|
|
state.current_stage = stage
|
|
store.save(state)
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type=contract["task_type"],
|
|
stage=stage,
|
|
expected_schema=contract["schema"],
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input=self._stage_structured_input(store, state, internal_stage) | {
|
|
"workflow_stage": stage,
|
|
"internal_stage": internal_stage,
|
|
},
|
|
system_prompt=contract["system_prompt"],
|
|
user_prompt=contract["user_prompt"],
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": contract["schema"]},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
store.add_stage_request(state, stage, result.request_id)
|
|
return result.to_dict() | {"stage": stage, "internal_stage": internal_stage, "type": contract["task_type"]}
|
|
if result.status != "ok":
|
|
store.set_stage(state, stage, StageStatus.FAILED, result.error)
|
|
return {"status": "failed", "stage": stage, "error": result.error}
|
|
return self._handle_staged_response(
|
|
store,
|
|
state,
|
|
stage,
|
|
AgentResponse(request_id=f"req_{uuid.uuid4().hex[:6]}", status="ok", output=result.output or {}),
|
|
)
|
|
|
|
def _handle_staged_response(self, store: RunStore, state, stage: str, response: AgentResponse) -> Dict[str, Any]:
|
|
checkpoint = state.stages[stage]
|
|
internal_stage = checkpoint.metadata.get("internal_stage")
|
|
if not internal_stage:
|
|
internal_stage = self._internal_stage_from_response_type(response, stage)
|
|
if not internal_stage:
|
|
store.set_stage(state, stage, StageStatus.FAILED, "cannot determine staged internal stage")
|
|
return {"status": "failed", "stage": stage, "error": "cannot determine staged internal stage"}
|
|
|
|
if stage == "simulation_run":
|
|
self._process_staged_simulation_output(store, state, response.output)
|
|
return self._after_staged_simulation_round(store, state)
|
|
|
|
self._process_stage_output(store, state, internal_stage, response.output)
|
|
next_internal = STAGED_INTERNAL_NEXT.get((stage, internal_stage))
|
|
if next_internal:
|
|
checkpoint.metadata["internal_stage"] = next_internal
|
|
checkpoint.status = StageStatus.PENDING
|
|
store.save(state)
|
|
return self._start_staged_contract_stage(store, state, stage, next_internal)
|
|
|
|
if stage == "graph_build":
|
|
state.graph_summary = self._graph_summary(store)
|
|
elif stage == "profile_and_config":
|
|
state.profiles_summary = self._profiles_summary(store)
|
|
state.config_summary = self._config_summary(store, state)
|
|
elif stage == "report_generation":
|
|
self._ensure_final_artifacts(store, state)
|
|
state.report_artifacts = self._report_artifacts(store)
|
|
checkpoint.status = StageStatus.COMPLETED
|
|
checkpoint.metadata["summary"] = state.report_artifacts
|
|
state.current_stage = "followup_question" if "followup_question" in state.stages else stage
|
|
store.save(state)
|
|
return {"status": "completed", "run_id": state.run_id, "artifacts": self.list_artifacts(str(store.run_dir))["artifacts"]}
|
|
|
|
checkpoint.status = StageStatus.AWAITING_USER_CONFIRMATION
|
|
checkpoint.error = None
|
|
checkpoint.stale = False
|
|
checkpoint.stale_reason = None
|
|
checkpoint.metadata["summary"] = self._stage_summary(store, state, stage)
|
|
state.current_stage = stage
|
|
store.save(state)
|
|
return self._awaiting_confirmation_payload(store, state, stage)
|
|
|
|
def _start_staged_simulation_round(self, store: RunStore, state) -> Dict[str, Any]:
|
|
progress = state.simulation_progress or {}
|
|
settings = self._ensure_state_simulation_settings(state, store)
|
|
total_rounds = int(settings["rounds"])
|
|
completed_rounds = int(progress.get("completed_rounds", 0))
|
|
if completed_rounds >= total_rounds:
|
|
state.stages["simulation_run"].status = StageStatus.AWAITING_USER_CONFIRMATION
|
|
state.simulation_progress = self._simulation_progress_payload(state, completed_rounds)
|
|
store.save(state)
|
|
return self._awaiting_confirmation_payload(store, state, "simulation_run")
|
|
|
|
round_index = completed_rounds + 1
|
|
checkpoint = state.stages["simulation_run"]
|
|
checkpoint.metadata["internal_stage"] = "simulation"
|
|
checkpoint.metadata["round_index"] = round_index
|
|
checkpoint.status = StageStatus.RUNNING
|
|
state.current_stage = "simulation_run"
|
|
state.simulation_progress = self._simulation_progress_payload(state, completed_rounds)
|
|
store.save(state)
|
|
|
|
contract = STAGE_CONTRACTS["simulation"]
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type=contract["task_type"],
|
|
stage="simulation_run",
|
|
expected_schema=contract["schema"],
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input=self._simulation_round_structured_input(store, state, round_index),
|
|
system_prompt=contract["system_prompt"],
|
|
user_prompt=contract["user_prompt"],
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": contract["schema"]},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
store.add_stage_request(state, "simulation_run", result.request_id)
|
|
return result.to_dict() | {"stage": "simulation_run", "round_index": round_index, "type": contract["task_type"]}
|
|
if result.status != "ok":
|
|
store.set_stage(state, "simulation_run", StageStatus.FAILED, result.error)
|
|
return {"status": "failed", "stage": "simulation_run", "error": result.error}
|
|
self._process_staged_simulation_output(store, state, result.output or {})
|
|
return self._after_staged_simulation_round(store, state)
|
|
|
|
def _after_staged_simulation_round(self, store: RunStore, state) -> Dict[str, Any]:
|
|
settings = self._ensure_state_simulation_settings(state, store)
|
|
progress = state.simulation_progress
|
|
completed_rounds = int(progress.get("completed_rounds", 0))
|
|
total_rounds = int(settings["rounds"])
|
|
checkpoint = state.stages["simulation_run"]
|
|
if completed_rounds >= total_rounds:
|
|
checkpoint.status = StageStatus.AWAITING_USER_CONFIRMATION
|
|
checkpoint.metadata["summary"] = state.simulation_progress
|
|
store.save(state)
|
|
return self._awaiting_confirmation_payload(store, state, "simulation_run")
|
|
if settings.get("pause_each_round"):
|
|
checkpoint.status = StageStatus.AWAITING_USER_CONFIRMATION
|
|
checkpoint.metadata["summary"] = state.simulation_progress
|
|
checkpoint.metadata["awaiting_round_approval"] = completed_rounds
|
|
store.save(state)
|
|
return self._awaiting_confirmation_payload(store, state, "simulation_run")
|
|
checkpoint.status = StageStatus.PENDING
|
|
store.save(state)
|
|
return self._start_staged_simulation_round(store, state)
|
|
|
|
def _complete_static_staged_stage(self, store: RunStore, state, stage: str) -> None:
|
|
artifacts = store.artifacts_dir
|
|
artifacts.mkdir(parents=True, exist_ok=True)
|
|
checkpoint = state.stages[stage]
|
|
if stage == "seed_input":
|
|
seed_text = store.read_seed_text()
|
|
summary = {
|
|
"seed_path": state.seed_path or (state.seed_files[0] if state.seed_files else None),
|
|
"seed_files": state.seed_files,
|
|
"character_count": len(seed_text),
|
|
"line_count": len(seed_text.splitlines()),
|
|
"valid": bool(seed_text.strip()),
|
|
"preview": seed_text[:500],
|
|
}
|
|
self._write_json(artifacts / "seed_summary.json", summary)
|
|
state.metadata["seed_summary"] = summary
|
|
store.add_stage_artifact(state, stage, str(artifacts / "seed_summary.json"))
|
|
elif stage == "prediction_requirement":
|
|
summary = {
|
|
"requirement": state.requirement,
|
|
"character_count": len(state.requirement),
|
|
"valid": bool(state.requirement.strip()),
|
|
}
|
|
self._write_json(artifacts / "requirement_summary.json", summary)
|
|
state.metadata["requirement_summary"] = summary
|
|
store.add_stage_artifact(state, stage, str(artifacts / "requirement_summary.json"))
|
|
elif stage == "simulation_settings":
|
|
state.simulation_settings = self._canonical_simulation_settings(**self._settings_kwargs(state, store))
|
|
self._write_json(artifacts / "simulation_settings.json", state.simulation_settings)
|
|
checkpoint.metadata["summary"] = state.simulation_settings
|
|
store.add_stage_artifact(state, stage, str(artifacts / "simulation_settings.json"))
|
|
checkpoint.status = StageStatus.AWAITING_USER_CONFIRMATION
|
|
checkpoint.error = None
|
|
checkpoint.stale = False
|
|
checkpoint.stale_reason = None
|
|
checkpoint.metadata["summary"] = self._stage_summary(store, state, stage)
|
|
state.current_stage = stage
|
|
store.save(state)
|
|
|
|
def _start_stage(
|
|
self,
|
|
store: RunStore,
|
|
state,
|
|
stage: str,
|
|
*,
|
|
graph_provider_override: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
if stage == "artifacts":
|
|
self._ensure_final_artifacts(store, state)
|
|
store.set_stage(state, stage, StageStatus.COMPLETED)
|
|
return {"status": "completed", "run_id": state.run_id, "artifacts": self.list_artifacts(str(store.run_dir))["artifacts"]}
|
|
|
|
contract = STAGE_CONTRACTS[stage]
|
|
store.set_stage(state, stage, StageStatus.RUNNING)
|
|
runtime = AgentRuntime(
|
|
provider=create_llm_provider(self.llm_provider_name, run_dir=store.run_dir),
|
|
run_dir=str(store.run_dir),
|
|
)
|
|
result = runtime.run_task(
|
|
run_id=state.run_id,
|
|
task_type=contract["task_type"],
|
|
stage=stage,
|
|
expected_schema=contract["schema"],
|
|
input_text=store.read_seed_text(),
|
|
input_files=state.seed_files,
|
|
structured_input=self._stage_structured_input(store, state, stage),
|
|
system_prompt=contract["system_prompt"],
|
|
user_prompt=contract["user_prompt"],
|
|
validation_rules={"strict": True},
|
|
retry_policy={"max_repair_attempts": 1},
|
|
context_refs=self._context_refs(store),
|
|
output_contract={"schema": contract["schema"]},
|
|
)
|
|
if result.status == "need_agent_response":
|
|
store.add_stage_request(state, stage, result.request_id)
|
|
return result.to_dict() | {"stage": stage, "type": contract["task_type"]}
|
|
if result.status != "ok":
|
|
store.set_stage(state, stage, StageStatus.FAILED, result.error)
|
|
return {"status": "failed", "stage": stage, "error": result.error}
|
|
self._process_stage_output(store, state, stage, result.output or {}, graph_provider_override=graph_provider_override)
|
|
store.set_stage(state, stage, StageStatus.COMPLETED)
|
|
return self.resume(str(store.run_dir))
|
|
|
|
def _process_stage_output(
|
|
self,
|
|
store: RunStore,
|
|
state,
|
|
stage: str,
|
|
output: Dict[str, Any],
|
|
*,
|
|
graph_provider_override: Optional[str] = None,
|
|
) -> None:
|
|
artifacts = store.artifacts_dir
|
|
artifacts.mkdir(parents=True, exist_ok=True)
|
|
if stage == "ontology":
|
|
self._write_json(artifacts / "ontology.json", output["ontology"])
|
|
store.add_stage_artifact(state, stage, str(artifacts / "ontology.json"))
|
|
elif stage == "graph":
|
|
triples = output["triples"]
|
|
self._write_json(artifacts / "triples.json", triples)
|
|
provider = create_graph_provider(graph_provider_override or self._graph_provider_name(state))
|
|
provider.add_triples(state.run_id, triples)
|
|
provider.export_snapshot(state.run_id, str(artifacts / "graph_snapshot.json"))
|
|
if hasattr(provider, "export_timeline"):
|
|
provider.export_timeline(state.run_id, str(artifacts / "timeline.json"))
|
|
else:
|
|
self._write_json(artifacts / "timeline.json", [])
|
|
store.add_stage_artifact(state, stage, str(artifacts / "graph_snapshot.json"))
|
|
store.add_stage_artifact(state, stage, str(artifacts / "timeline.json"))
|
|
elif stage == "profiles":
|
|
self._write_json(artifacts / "profiles.json", output["profiles"])
|
|
store.add_stage_artifact(state, stage, str(artifacts / "profiles.json"))
|
|
elif stage == "config":
|
|
config = self._config_with_settings(output["config"], state)
|
|
self._write_json(artifacts / "simulation_config.json", config)
|
|
store.add_stage_artifact(state, stage, str(artifacts / "simulation_config.json"))
|
|
elif stage == "simulation":
|
|
self._write_json(artifacts / "simulation_actions.json", output["actions"])
|
|
store.add_stage_artifact(state, stage, str(artifacts / "simulation_actions.json"))
|
|
elif stage == "report":
|
|
settings = self._settings_for_output(state)
|
|
(artifacts / "report.md").write_text(output["report_markdown"], encoding="utf-8")
|
|
verdict = output["verdict"] | {"simulation_settings": settings, "rounds": settings.get("rounds")}
|
|
self._write_json(artifacts / "verdict.json", verdict)
|
|
self._write_json(artifacts / "timeline.json", self._timeline_with_rounds(output.get("timeline", []), state))
|
|
store.add_stage_artifact(state, stage, str(artifacts / "report.md"))
|
|
store.add_stage_artifact(state, stage, str(artifacts / "verdict.json"))
|
|
store.add_stage_artifact(state, stage, str(artifacts / "timeline.json"))
|
|
|
|
def _stage_structured_input(self, store: RunStore, state, stage: str) -> Dict[str, Any]:
|
|
artifacts = store.artifacts_dir
|
|
data: Dict[str, Any] = {"requirement": state.requirement, "simulation_settings": self._ensure_state_simulation_settings(state, store)}
|
|
for name in ["ontology", "triples", "profiles", "simulation_config", "simulation_actions"]:
|
|
path = artifacts / f"{name}.json"
|
|
if path.exists():
|
|
data[name] = json.loads(path.read_text(encoding="utf-8"))
|
|
if stage == "simulation":
|
|
data |= self._simulation_round_structured_input(store, state, 1)
|
|
return data
|
|
|
|
def _context_refs(self, store: RunStore) -> List[str]:
|
|
if not store.artifacts_dir.exists():
|
|
return []
|
|
return [str(path) for path in sorted(store.artifacts_dir.glob("*.json"))]
|
|
|
|
def _artifact_context(self, store: RunStore) -> Dict[str, Any]:
|
|
context: Dict[str, Any] = {}
|
|
if not store.artifacts_dir.exists():
|
|
return context
|
|
for path in sorted(store.artifacts_dir.glob("*.json")):
|
|
try:
|
|
context[path.stem] = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
context[path.stem] = {"path": str(path), "error": "invalid json"}
|
|
report_path = store.artifacts_dir / "report.md"
|
|
if report_path.exists():
|
|
context["report_markdown"] = report_path.read_text(encoding="utf-8")[:50_000]
|
|
return context
|
|
|
|
def _persist_followup_answer(
|
|
self,
|
|
store: RunStore,
|
|
run_id: str,
|
|
request_id: str,
|
|
question: str,
|
|
output: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
followups_dir = store.artifacts_dir / "followups"
|
|
followups_dir.mkdir(parents=True, exist_ok=True)
|
|
markdown_path = followups_dir / f"{request_id}.md"
|
|
json_path = followups_dir / f"{request_id}.json"
|
|
markdown_path.write_text(output["answer_markdown"], encoding="utf-8")
|
|
payload = {
|
|
"run_id": run_id,
|
|
"request_id": request_id,
|
|
"question": question,
|
|
"answer": output,
|
|
}
|
|
self._write_json(json_path, payload)
|
|
return {
|
|
"status": "ok",
|
|
"request_id": request_id,
|
|
"question": question,
|
|
"answer": output,
|
|
"artifacts": {
|
|
"markdown": str(markdown_path),
|
|
"json": str(json_path),
|
|
},
|
|
}
|
|
|
|
def _followup_question_from_request(self, request) -> str:
|
|
if request.type == "answer_followup_question":
|
|
return str(request.structured_input.get("question", ""))
|
|
original = request.structured_input.get("original_request", {})
|
|
if isinstance(original, dict):
|
|
structured_input = original.get("structured_input", {})
|
|
if isinstance(structured_input, dict):
|
|
return str(structured_input.get("question", ""))
|
|
return ""
|
|
|
|
def _graph_provider_name(self, state) -> Optional[str]:
|
|
return self.graph_provider_name or state.metadata.get("graph_provider")
|
|
|
|
def _ensure_final_artifacts(self, store: RunStore, state) -> None:
|
|
artifacts = store.artifacts_dir
|
|
artifacts.mkdir(parents=True, exist_ok=True)
|
|
if not (artifacts / "report.md").exists():
|
|
(artifacts / "report.md").write_text("# MiroFish Report\n\nReport generation has not completed.", encoding="utf-8")
|
|
if not (artifacts / "verdict.json").exists():
|
|
self._write_json(artifacts / "verdict.json", {"status": "incomplete"})
|
|
if not (artifacts / "timeline.json").exists():
|
|
self._write_json(artifacts / "timeline.json", [])
|
|
if not (artifacts / "graph_snapshot.json").exists():
|
|
create_graph_provider(self.graph_provider_name).export_snapshot(state.run_id, str(artifacts / "graph_snapshot.json"))
|
|
|
|
def _next_stage(self, stage: str) -> Optional[str]:
|
|
try:
|
|
index = RUN_STAGES.index(stage)
|
|
except ValueError:
|
|
return None
|
|
if index + 1 >= len(RUN_STAGES):
|
|
return None
|
|
return RUN_STAGES[index + 1]
|
|
|
|
def _existing_agent_wait(self, store: RunStore, state, stage: str) -> Optional[Dict[str, Any]]:
|
|
checkpoint = state.stages[stage]
|
|
if checkpoint.status != StageStatus.WAITING_AGENT or not checkpoint.request_ids:
|
|
return None
|
|
request_id = checkpoint.request_ids[-1]
|
|
request = AgentQueue(store.run_dir).load_request(request_id)
|
|
return {
|
|
"status": "need_agent_response",
|
|
"request_id": request_id,
|
|
"request_file": str(store.requests_dir / f"{request_id}.json"),
|
|
"expected_response_file": str(store.responses_dir / f"{request_id}.json"),
|
|
"stage": stage,
|
|
"type": request.type,
|
|
}
|
|
|
|
def _attach_repair_request_to_waiting_stage(self, run_dir: str, queue: AgentQueue, repair_request_id: str) -> None:
|
|
try:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
repair_request = queue.load_request(repair_request_id)
|
|
original = repair_request.structured_input.get("original_request", {})
|
|
original_request_id = original.get("request_id") if isinstance(original, dict) else None
|
|
if not original_request_id:
|
|
return
|
|
for stage, checkpoint in state.stages.items():
|
|
if checkpoint.status == StageStatus.WAITING_AGENT and checkpoint.request_ids[-1:] == [original_request_id]:
|
|
store.add_stage_request(state, stage, repair_request_id)
|
|
return
|
|
except Exception:
|
|
return
|
|
|
|
def _canonical_simulation_settings(
|
|
self,
|
|
*,
|
|
rounds: int,
|
|
round_unit: str,
|
|
minutes_per_round: Optional[int],
|
|
pause_each_round: bool,
|
|
agent_count: Optional[int] = None,
|
|
simulation_name: Optional[str] = None,
|
|
output_directory: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
if round_unit not in ROUND_UNIT_MINUTES:
|
|
raise ValueError(f"round_unit must be one of {sorted(ROUND_UNIT_MINUTES)}")
|
|
rounds = int(rounds)
|
|
allow_debug_rounds = os.environ.get("MIROFISH_ALLOW_DEBUG_ROUNDS", "").lower() in {"1", "true", "yes"}
|
|
if rounds < 10 and not allow_debug_rounds:
|
|
raise ValueError("rounds must be at least 10 unless MIROFISH_ALLOW_DEBUG_ROUNDS=true")
|
|
canonical_minutes = int(minutes_per_round) if minutes_per_round is not None else ROUND_UNIT_MINUTES[round_unit]
|
|
settings: Dict[str, Any] = {
|
|
"rounds": rounds,
|
|
"max_rounds": rounds,
|
|
"simulation_rounds": rounds,
|
|
"round_unit": round_unit,
|
|
"minutes_per_round": canonical_minutes,
|
|
"pause_each_round": bool(pause_each_round),
|
|
}
|
|
if agent_count is not None:
|
|
settings["agent_count"] = int(agent_count)
|
|
if simulation_name:
|
|
settings["simulation_name"] = simulation_name
|
|
if output_directory:
|
|
settings["output_directory"] = output_directory
|
|
return settings
|
|
|
|
def _settings_kwargs(self, state, store: RunStore) -> Dict[str, Any]:
|
|
current = state.simulation_settings or {}
|
|
return {
|
|
"rounds": int(current.get("rounds", 10)),
|
|
"round_unit": str(current.get("round_unit", "year")),
|
|
"minutes_per_round": current.get("minutes_per_round"),
|
|
"pause_each_round": bool(current.get("pause_each_round", False)),
|
|
"agent_count": current.get("agent_count"),
|
|
"simulation_name": current.get("simulation_name"),
|
|
"output_directory": current.get("output_directory") or str(store.run_dir),
|
|
}
|
|
|
|
def _ensure_state_simulation_settings(self, state, store: RunStore) -> Dict[str, Any]:
|
|
if not state.simulation_settings:
|
|
state.simulation_settings = self._canonical_simulation_settings(
|
|
rounds=10,
|
|
round_unit="year",
|
|
minutes_per_round=None,
|
|
pause_each_round=False,
|
|
output_directory=str(store.run_dir),
|
|
)
|
|
return state.simulation_settings
|
|
|
|
def _settings_for_output(self, state) -> Dict[str, Any]:
|
|
settings = dict(state.simulation_settings or {})
|
|
rounds = int(settings.get("rounds", 10))
|
|
settings["rounds"] = rounds
|
|
settings["max_rounds"] = rounds
|
|
settings["simulation_rounds"] = rounds
|
|
return settings
|
|
|
|
def _config_with_settings(self, config: Dict[str, Any], state) -> Dict[str, Any]:
|
|
settings = self._settings_for_output(state)
|
|
merged = dict(config or {})
|
|
merged["rounds"] = settings["rounds"]
|
|
merged["max_rounds"] = settings["rounds"]
|
|
merged["simulation_rounds"] = settings["rounds"]
|
|
merged["round_unit"] = settings.get("round_unit")
|
|
merged["minutes_per_round"] = settings.get("minutes_per_round")
|
|
merged["pause_each_round"] = settings.get("pause_each_round", False)
|
|
if "agent_count" in settings:
|
|
merged["agent_count"] = settings["agent_count"]
|
|
if "simulation_name" in settings:
|
|
merged["simulation_name"] = settings["simulation_name"]
|
|
merged["simulation_settings"] = settings
|
|
return merged
|
|
|
|
def _simulation_round_structured_input(self, store: RunStore, state, round_index: int) -> Dict[str, Any]:
|
|
artifacts = store.artifacts_dir
|
|
profiles_path = artifacts / "profiles.json"
|
|
profiles = json.loads(profiles_path.read_text(encoding="utf-8")) if profiles_path.exists() else [{"agent_id": "agent_1"}]
|
|
settings = self._ensure_state_simulation_settings(state, store)
|
|
agent_limit = int(settings.get("agent_count") or min(len(profiles), 5) or 1)
|
|
selected_profiles = profiles[:agent_limit]
|
|
round_id = f"round_{round_index}"
|
|
return {
|
|
"round_id": round_id,
|
|
"round_index": round_index,
|
|
"simulation_settings": settings,
|
|
"actions": [
|
|
{
|
|
"agent_id": _resolve_agent_id(profile, index),
|
|
"action_id": f"{round_id}_action_{index + 1}",
|
|
"round_id": round_id,
|
|
}
|
|
for index, profile in enumerate(selected_profiles)
|
|
],
|
|
"simulation_progress": state.simulation_progress or {},
|
|
}
|
|
|
|
def _process_staged_simulation_output(self, store: RunStore, state, output: Dict[str, Any]) -> None:
|
|
artifacts = store.artifacts_dir
|
|
rounds_dir = artifacts / "simulation_rounds"
|
|
rounds_dir.mkdir(parents=True, exist_ok=True)
|
|
checkpoint = state.stages["simulation_run"]
|
|
round_index = int(checkpoint.metadata.get("round_index", 1))
|
|
actions = output.get("actions", [])
|
|
round_payload = {
|
|
"round_index": round_index,
|
|
"round_id": f"round_{round_index}",
|
|
"round_unit": state.simulation_settings.get("round_unit", "year"),
|
|
"actions": actions,
|
|
}
|
|
round_path = rounds_dir / f"round_{round_index:03d}.json"
|
|
self._write_json(round_path, round_payload)
|
|
all_actions_path = artifacts / "simulation_actions.json"
|
|
all_actions: List[Dict[str, Any]] = []
|
|
if all_actions_path.exists():
|
|
all_actions = json.loads(all_actions_path.read_text(encoding="utf-8"))
|
|
all_actions.extend(actions)
|
|
self._write_json(all_actions_path, all_actions)
|
|
store.add_stage_artifact(state, "simulation_run", str(round_path))
|
|
store.add_stage_artifact(state, "simulation_run", str(all_actions_path))
|
|
state.simulation_progress = self._simulation_progress_payload(state, round_index)
|
|
|
|
def _simulation_progress_payload(self, state, completed_rounds: int) -> Dict[str, Any]:
|
|
settings = self._settings_for_output(state)
|
|
total_rounds = int(settings["rounds"])
|
|
return {
|
|
"completed_rounds": completed_rounds,
|
|
"total_rounds": total_rounds,
|
|
"current_round": min(completed_rounds + 1, total_rounds),
|
|
"round_unit": settings.get("round_unit", "year"),
|
|
"minutes_per_round": settings.get("minutes_per_round"),
|
|
"pause_each_round": settings.get("pause_each_round", False),
|
|
}
|
|
|
|
def _timeline_with_rounds(self, timeline: List[Dict[str, Any]], state) -> List[Dict[str, Any]]:
|
|
settings = self._settings_for_output(state)
|
|
rounds = int(settings["rounds"])
|
|
existing = list(timeline or [])
|
|
normalized = []
|
|
for index in range(1, rounds + 1):
|
|
item = existing[index - 1] if index - 1 < len(existing) and isinstance(existing[index - 1], dict) else {}
|
|
normalized.append(
|
|
item
|
|
| {
|
|
"round": index,
|
|
"round_unit": settings.get("round_unit", "year"),
|
|
"minutes_per_round": settings.get("minutes_per_round"),
|
|
}
|
|
)
|
|
return normalized
|
|
|
|
def _stage_summary(self, store: RunStore, state, stage: str) -> Dict[str, Any]:
|
|
if stage == "seed_input":
|
|
return state.metadata.get("seed_summary", {})
|
|
if stage == "prediction_requirement":
|
|
return state.metadata.get("requirement_summary", {})
|
|
if stage == "simulation_settings":
|
|
return state.simulation_settings
|
|
if stage == "graph_build":
|
|
return state.graph_summary
|
|
if stage == "profile_and_config":
|
|
return {"profiles_summary": state.profiles_summary, "config_summary": state.config_summary}
|
|
if stage == "simulation_run":
|
|
return state.simulation_progress
|
|
if stage == "report_generation":
|
|
return state.report_artifacts
|
|
return {}
|
|
|
|
def _stage_detail(self, store: RunStore, state, stage: str) -> Dict[str, Any]:
|
|
checkpoint = state.stages[stage]
|
|
return {
|
|
"run_id": state.run_id,
|
|
"workflow_mode": state.workflow_mode,
|
|
"current_stage": stage,
|
|
"checkpoint": checkpoint.model_dump(),
|
|
"summary": self._stage_summary(store, state, stage),
|
|
"simulation_settings": state.simulation_settings,
|
|
"artifacts": checkpoint.artifact_paths,
|
|
}
|
|
|
|
def _awaiting_confirmation_payload(self, store: RunStore, state, stage: str) -> Dict[str, Any]:
|
|
return {
|
|
"status": "awaiting_user_confirmation",
|
|
"run_id": state.run_id,
|
|
"stage": stage,
|
|
"current_stage": stage,
|
|
"stage_detail": self._stage_detail(store, state, stage),
|
|
}
|
|
|
|
def _graph_summary(self, store: RunStore) -> Dict[str, Any]:
|
|
triples_path = store.artifacts_dir / "triples.json"
|
|
triples = json.loads(triples_path.read_text(encoding="utf-8")) if triples_path.exists() else []
|
|
entities: Dict[str, int] = {}
|
|
for triple in triples:
|
|
for key in ("subject", "object"):
|
|
value = str(triple.get(key, "")).strip()
|
|
if value:
|
|
entities[value] = entities.get(value, 0) + 1
|
|
return {
|
|
"entity_count": len(entities),
|
|
"triple_count": len(triples),
|
|
"top_entities": sorted(entities, key=entities.get, reverse=True)[:10],
|
|
"warnings": [],
|
|
}
|
|
|
|
def _profiles_summary(self, store: RunStore) -> Dict[str, Any]:
|
|
path = store.artifacts_dir / "profiles.json"
|
|
profiles = json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
|
|
return {
|
|
"profile_count": len(profiles),
|
|
"agent_ids": [_resolve_agent_id(profile, i) for i, profile in enumerate(profiles)],
|
|
}
|
|
|
|
def _config_summary(self, store: RunStore, state) -> Dict[str, Any]:
|
|
path = store.artifacts_dir / "simulation_config.json"
|
|
config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
return {
|
|
"rounds": config.get("rounds"),
|
|
"round_unit": config.get("round_unit"),
|
|
"minutes_per_round": config.get("minutes_per_round"),
|
|
"agent_count": config.get("agent_count", state.simulation_settings.get("agent_count")),
|
|
}
|
|
|
|
def _report_artifacts(self, store: RunStore) -> Dict[str, Any]:
|
|
names = ["report.md", "verdict.json", "timeline.json", "graph_snapshot.json"]
|
|
return {name: str(store.artifacts_dir / name) for name in names if (store.artifacts_dir / name).exists()}
|
|
|
|
def _mark_downstream_pending(self, state, stage: str, reason: str) -> None:
|
|
for downstream in STAGED_DOWNSTREAM.get(stage, []):
|
|
if downstream not in state.stages:
|
|
continue
|
|
checkpoint = state.stages[downstream]
|
|
checkpoint.status = StageStatus.PENDING
|
|
checkpoint.request_ids = []
|
|
checkpoint.error = None
|
|
checkpoint.stale = True
|
|
checkpoint.stale_reason = reason
|
|
checkpoint.metadata = {}
|
|
state.graph_summary = state.graph_summary if stage not in {"seed_input", "prediction_requirement"} else {}
|
|
if stage in {"seed_input", "prediction_requirement", "simulation_settings", "graph_build"}:
|
|
state.profiles_summary = {}
|
|
state.config_summary = {}
|
|
state.simulation_progress = {}
|
|
state.report_artifacts = {}
|
|
|
|
def _normalize_stage_name(self, state, stage: str) -> str:
|
|
if stage in state.stages:
|
|
return stage
|
|
aliases = {
|
|
"graph": "graph_build",
|
|
"profiles": "profile_and_config",
|
|
"config": "profile_and_config",
|
|
"simulation": "simulation_run",
|
|
"report": "report_generation",
|
|
}
|
|
return aliases.get(stage, stage)
|
|
|
|
def _next_staged_stage(self, stage: str) -> Optional[str]:
|
|
try:
|
|
index = STAGED_RUN_STAGES.index(stage)
|
|
except ValueError:
|
|
return None
|
|
if index + 1 >= len(STAGED_RUN_STAGES):
|
|
return None
|
|
return STAGED_RUN_STAGES[index + 1]
|
|
|
|
def _internal_stage_from_response_type(self, response: AgentResponse, stage: str) -> Optional[str]:
|
|
task_map = {
|
|
"generate_ontology": "ontology",
|
|
"extract_triples": "graph",
|
|
"generate_oasis_profiles": "profiles",
|
|
"generate_simulation_config": "config",
|
|
"simulate_agent_action": "simulation",
|
|
"generate_report": "report",
|
|
}
|
|
return task_map.get(getattr(response, "type", ""), STAGED_INTERNAL_START.get(stage))
|
|
|
|
def _write_json(self, path: Path, data: Any) -> None:
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
# ── Interaction helper methods ─────────────────────────────────────────
|
|
|
|
def _persist_agent_question_answer(
|
|
self,
|
|
store: RunStore,
|
|
run_id: str,
|
|
agent_id: str,
|
|
request_id: str,
|
|
question: str,
|
|
output: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
questions_dir = store.artifacts_dir / "interactions" / "agent_questions"
|
|
questions_dir.mkdir(parents=True, exist_ok=True)
|
|
markdown_path = questions_dir / f"{request_id}.md"
|
|
json_path = questions_dir / f"{request_id}.json"
|
|
answer_md = output.get("answer_markdown", "")
|
|
markdown_path.write_text(answer_md, encoding="utf-8")
|
|
payload = {
|
|
"run_id": run_id,
|
|
"request_id": request_id,
|
|
"agent_id": agent_id,
|
|
"question": question,
|
|
"answer": output,
|
|
}
|
|
self._write_json(json_path, payload)
|
|
return {
|
|
"status": "ok",
|
|
"request_id": request_id,
|
|
"agent_id": agent_id,
|
|
"question": question,
|
|
"answer": output,
|
|
"artifacts": {
|
|
"markdown": str(markdown_path),
|
|
"json": str(json_path),
|
|
},
|
|
}
|
|
|
|
def _persist_questionnaire_answers(
|
|
self,
|
|
store: RunStore,
|
|
run_id: str,
|
|
questionnaire_id: str,
|
|
output: Dict[str, Any],
|
|
) -> None:
|
|
questionnaires_dir = store.artifacts_dir / "interactions" / "questionnaires"
|
|
questionnaires_dir.mkdir(parents=True, exist_ok=True)
|
|
answers_path = questionnaires_dir / f"{questionnaire_id}_answers.json"
|
|
existing = []
|
|
if answers_path.exists():
|
|
existing = json.loads(answers_path.read_text(encoding="utf-8"))
|
|
new_answers = self._extract_questionnaire_answers(output)
|
|
existing.extend(new_answers)
|
|
self._write_json(answers_path, existing)
|
|
|
|
def _extract_questionnaire_answers(self, output: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
answers = output.get("answers", [])
|
|
if isinstance(answers, list):
|
|
return answers
|
|
return []
|
|
|
|
def _persist_questionnaire_metadata(
|
|
self,
|
|
store: RunStore,
|
|
questionnaire_id: str,
|
|
questions: List[Dict[str, Any]],
|
|
request_ids: List[str],
|
|
) -> None:
|
|
questionnaires_dir = store.artifacts_dir / "interactions" / "questionnaires"
|
|
questionnaires_dir.mkdir(parents=True, exist_ok=True)
|
|
meta_path = questionnaires_dir / f"{questionnaire_id}_meta.json"
|
|
meta = {
|
|
"questionnaire_id": questionnaire_id,
|
|
"questions": questions,
|
|
"request_ids": request_ids,
|
|
"answer_count": 0,
|
|
"summary_markdown": "",
|
|
}
|
|
self._write_json(meta_path, meta)
|
|
|
|
def _persist_report_question_answer(
|
|
self,
|
|
store: RunStore,
|
|
run_id: str,
|
|
request_id: str,
|
|
question: str,
|
|
output: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
rq_dir = store.artifacts_dir / "interactions" / "report_questions"
|
|
rq_dir.mkdir(parents=True, exist_ok=True)
|
|
markdown_path = rq_dir / f"{request_id}.md"
|
|
json_path = rq_dir / f"{request_id}.json"
|
|
answer_md = output.get("answer_markdown", "")
|
|
markdown_path.write_text(answer_md, encoding="utf-8")
|
|
payload = {
|
|
"run_id": run_id,
|
|
"request_id": request_id,
|
|
"question": question,
|
|
"answer": output,
|
|
}
|
|
self._write_json(json_path, payload)
|
|
return {
|
|
"status": "ok",
|
|
"request_id": request_id,
|
|
"question": question,
|
|
"answer": output,
|
|
"artifacts": {
|
|
"markdown": str(markdown_path),
|
|
"json": str(json_path),
|
|
},
|
|
}
|
|
|
|
def get_report_question_answer(self, run_dir: str, request_id: str) -> Dict[str, Any]:
|
|
store = RunStore(run_dir)
|
|
state = store.load()
|
|
queue = AgentQueue(run_dir)
|
|
request = queue.load_request(request_id)
|
|
if request.type != "ask_report_question" or request.stage != "interaction":
|
|
return {"status": "error", "error": f"request {request_id} is not a report question request"}
|
|
response_path = store.responses_dir / f"{request_id}.json"
|
|
if not response_path.exists():
|
|
return {"status": "missing", "request_id": request_id, "expected_response_file": str(response_path)}
|
|
validation = queue.submit_response(response_path)
|
|
if not validation.ok:
|
|
if validation.repair_request:
|
|
return validation.repair_request.model_dump()
|
|
return {"status": "failed", "request_id": request_id, "errors": validation.errors}
|
|
response = queue.load_response(request_id)
|
|
if response.status == "error":
|
|
return {"status": "failed", "request_id": request_id, "error": response.error or "agent returned error"}
|
|
question = str(request.structured_input.get("question", ""))
|
|
return self._persist_report_question_answer(store, state.run_id, request_id, question, response.output)
|
|
|
|
def _collect_web_console_data(self, store: RunStore) -> Dict[str, Any]:
|
|
artifacts = store.artifacts_dir
|
|
data: Dict[str, Any] = {"run_id": "", "artifacts": {}}
|
|
# Try to load state for run_id
|
|
state_path = store.run_dir / "state.json"
|
|
if state_path.exists():
|
|
state_data = json.loads(state_path.read_text(encoding="utf-8"))
|
|
data["run_id"] = state_data.get("run_id", "")
|
|
data["requirement"] = state_data.get("requirement", "")
|
|
data["simulation_settings"] = state_data.get("simulation_settings", {})
|
|
# Read artifact files
|
|
artifact_names = [
|
|
"report.md",
|
|
"verdict.json",
|
|
"timeline.json",
|
|
"graph_snapshot.json",
|
|
"profiles.json",
|
|
"simulation_config.json",
|
|
"simulation_actions.json",
|
|
]
|
|
for name in artifact_names:
|
|
path = artifacts / name
|
|
if path.exists():
|
|
if name.endswith(".json"):
|
|
try:
|
|
data["artifacts"][name] = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
data["artifacts"][name] = None
|
|
else:
|
|
data["artifacts"][name] = path.read_text(encoding="utf-8")
|
|
# Read interaction data
|
|
interactions: Dict[str, Any] = {"agent_questions": [], "questionnaires": []}
|
|
aq_dir = artifacts / "interactions" / "agent_questions"
|
|
if aq_dir.exists():
|
|
for p in sorted(aq_dir.glob("*.json")):
|
|
try:
|
|
interactions["agent_questions"].append(json.loads(p.read_text(encoding="utf-8")))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
q_dir = artifacts / "interactions" / "questionnaires"
|
|
if q_dir.exists():
|
|
for p in sorted(q_dir.glob("*_meta.json")):
|
|
try:
|
|
meta = json.loads(p.read_text(encoding="utf-8"))
|
|
answers_path = p.parent / f"{meta['questionnaire_id']}_answers.json"
|
|
answers = []
|
|
if answers_path.exists():
|
|
answers = json.loads(answers_path.read_text(encoding="utf-8"))
|
|
meta["answers"] = answers
|
|
interactions["questionnaires"].append(meta)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
data["interactions"] = interactions
|
|
return data
|
|
|
|
def _render_web_console_html(self, data: Dict[str, Any]) -> str:
|
|
run_id = data.get("run_id", "unknown")
|
|
requirement = data.get("requirement", "")
|
|
artifacts = data.get("artifacts", {})
|
|
interactions = data.get("interactions", {})
|
|
report_md = artifacts.get("report.md", "# Report not yet generated")
|
|
verdict = artifacts.get("verdict.json", {})
|
|
timeline = artifacts.get("timeline.json", [])
|
|
graph_snapshot = artifacts.get("graph_snapshot.json", {})
|
|
profiles = artifacts.get("profiles.json", [])
|
|
# Normalize profile IDs before embedding (mirrors JS _resolveId logic)
|
|
if isinstance(profiles, list):
|
|
for _pi, _pp in enumerate(profiles):
|
|
if isinstance(_pp, dict) and not _pp.get("agent_id"):
|
|
_pp["agent_id"] = _resolve_agent_id(_pp, _pi)
|
|
sim_config = artifacts.get("simulation_config.json", {})
|
|
sim_actions = artifacts.get("simulation_actions.json", [])
|
|
agent_questions = interactions.get("agent_questions", [])
|
|
questionnaires = interactions.get("questionnaires", [])
|
|
# JSON-escape for embedding
|
|
def json_embed(obj):
|
|
return json.dumps(obj, ensure_ascii=False)
|
|
return _WEB_CONSOLE_TEMPLATE.replace(
|
|
"{{RUN_ID}}", html.escape(str(run_id), quote=True)
|
|
).replace(
|
|
"{{RUN_ID_JSON}}", json_embed(run_id)
|
|
).replace(
|
|
"{{REQUIREMENT_JSON}}", json_embed(requirement)
|
|
).replace(
|
|
"{{REPORT_MD_JSON}}", json_embed(report_md)
|
|
).replace(
|
|
"{{RUN_DIR_JSON}}", json_embed(data.get("run_dir", ""))
|
|
).replace(
|
|
"{{VERDICT_JSON}}", json_embed(verdict)
|
|
).replace(
|
|
"{{TIMELINE_JSON}}", json_embed(timeline)
|
|
).replace(
|
|
"{{GRAPH_SNAPSHOT_JSON}}", json_embed(graph_snapshot)
|
|
).replace(
|
|
"{{PROFILES_JSON}}", json_embed(profiles)
|
|
).replace(
|
|
"{{SIM_CONFIG_JSON}}", json_embed(sim_config)
|
|
).replace(
|
|
"{{SIM_ACTIONS_JSON}}", json_embed(sim_actions)
|
|
).replace(
|
|
"{{AGENT_QUESTIONS_JSON}}", json_embed(agent_questions)
|
|
).replace(
|
|
"{{QUESTIONNAIRES_JSON}}", json_embed(questionnaires)
|
|
)
|
|
|
|
|
|
# ── Web Console HTML template ────────────────────────────────────────────
|
|
|
|
_WEB_CONSOLE_TEMPLATE = r"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>MiroFish Web Console — {{RUN_ID}}</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117;
|
|
--surface: #1a1d27;
|
|
--surface2: #232733;
|
|
--border: #2d3140;
|
|
--text: #e2e4e9;
|
|
--text2: #9ca0ad;
|
|
--accent: #6c8cff;
|
|
--accent2: #4a6adf;
|
|
--success: #4caf50;
|
|
--warn: #ff9800;
|
|
--error: #f44336;
|
|
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
}
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { background:var(--bg); color:var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height:1.6; }
|
|
.app { display:flex; height:100vh; }
|
|
.sidebar { width:240px; background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
|
|
.sidebar-header { padding:16px 20px; border-bottom:1px solid var(--border); }
|
|
.sidebar-header h1 { font-size:16px; font-weight:600; color:var(--accent); }
|
|
.sidebar-header .run-id { font-size:11px; color:var(--text2); font-family:var(--mono); margin-top:4px; }
|
|
.nav { flex:1; padding:8px 0; overflow-y:auto; }
|
|
.nav-item { padding:10px 20px; cursor:pointer; color:var(--text2); font-size:13px; transition:all .15s; display:flex; align-items:center; gap:8px; }
|
|
.nav-item:hover { background:var(--surface2); color:var(--text); }
|
|
.nav-item.active { background:var(--surface2); color:var(--accent); border-right:2px solid var(--accent); }
|
|
.nav-icon { width:16px; text-align:center; font-size:14px; }
|
|
.api-status { padding:12px 20px; border-top:1px solid var(--border); font-size:11px; display:flex; align-items:center; gap:6px; }
|
|
.api-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
|
.api-dot.online { background:var(--success); }
|
|
.api-dot.offline { background:var(--error); }
|
|
.api-dot.checking { background:var(--warn); animation: pulse 1s infinite; }
|
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
|
.main { flex:1; overflow-y:auto; }
|
|
.panel { display:none; padding:24px 32px; max-width:1000px; }
|
|
.panel.active { display:block; }
|
|
.panel h2 { font-size:20px; font-weight:600; margin-bottom:16px; color:var(--text); }
|
|
.panel h3 { font-size:15px; font-weight:600; margin:20px 0 8px; color:var(--text); }
|
|
.card { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px 20px; margin-bottom:16px; }
|
|
.card-header { font-size:13px; font-weight:600; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:8px; }
|
|
.stat-row { display:flex; gap:16px; margin-bottom:16px; }
|
|
.stat { flex:1; background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:14px 16px; }
|
|
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
|
|
.stat-label { font-size:11px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-top:2px; }
|
|
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
|
|
.badge-ok { background:rgba(76,175,80,.15); color:var(--success); }
|
|
.badge-warn { background:rgba(255,152,0,.15); color:var(--warn); }
|
|
.badge-error { background:rgba(244,67,54,.15); color:var(--error); }
|
|
.badge-pending { background:rgba(108,140,255,.15); color:var(--accent); }
|
|
pre, code { font-family:var(--mono); font-size:12px; }
|
|
pre { background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:12px 16px; overflow-x:auto; white-space:pre-wrap; word-break:break-word; color:var(--text2); max-height:400px; overflow-y:auto; }
|
|
.report-content { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:24px; }
|
|
.report-content h1,.report-content h2,.report-content h3 { color:var(--accent); margin:16px 0 8px; }
|
|
.report-content p { margin-bottom:8px; color:var(--text); }
|
|
.agent-card { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:12px; }
|
|
.agent-card .agent-name { font-weight:600; font-size:15px; color:var(--accent); }
|
|
.agent-card .agent-id { font-size:11px; color:var(--text2); font-family:var(--mono); }
|
|
.agent-card .agent-persona { font-size:13px; color:var(--text2); margin-top:4px; }
|
|
.timeline-item { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid var(--border); }
|
|
.timeline-item:last-child { border-bottom:none; }
|
|
.timeline-round { font-weight:700; color:var(--accent); min-width:60px; font-size:13px; }
|
|
.timeline-detail { flex:1; font-size:13px; color:var(--text2); }
|
|
.graph-entity { display:inline-block; background:var(--surface2); border:1px solid var(--border); border-radius:4px; padding:4px 10px; margin:3px; font-size:12px; color:var(--text); font-family:var(--mono); }
|
|
.qa-item { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:12px; }
|
|
.qa-question { font-weight:600; color:var(--text); margin-bottom:8px; font-size:14px; }
|
|
.qa-answer { color:var(--text2); font-size:13px; white-space:pre-wrap; }
|
|
.qa-meta { font-size:11px; color:var(--text2); margin-top:8px; font-family:var(--mono); }
|
|
.empty-state { text-align:center; padding:48px; color:var(--text2); font-size:14px; }
|
|
.requirement-box { background:var(--surface2); border-left:3px solid var(--accent); padding:12px 16px; border-radius:0 6px 6px 0; margin-bottom:16px; font-size:14px; color:var(--text); }
|
|
.tab-bar { display:flex; gap:4px; margin-bottom:16px; }
|
|
.tab-btn { padding:6px 14px; border-radius:6px; border:1px solid var(--border); background:transparent; color:var(--text2); cursor:pointer; font-size:12px; }
|
|
.tab-btn.active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
.action-item { background:var(--surface2); border-radius:6px; padding:10px 14px; margin-bottom:8px; font-size:13px; }
|
|
.action-item .action-agent { color:var(--accent); font-weight:600; }
|
|
.action-item .action-type { color:var(--text2); font-size:11px; font-family:var(--mono); }
|
|
/* Interactive form styles */
|
|
.form-group { margin-bottom:14px; }
|
|
.form-group label { display:block; font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:6px; }
|
|
.form-input, .form-select, .form-textarea { width:100%; background:var(--surface2); border:1px solid var(--border); border-radius:6px; padding:10px 14px; color:var(--text); font-size:13px; font-family:inherit; outline:none; transition:border-color .15s; }
|
|
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color:var(--accent); }
|
|
.form-textarea { min-height:80px; resize:vertical; }
|
|
.form-select { appearance:none; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%239ca0ad' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 12px center; padding-right:32px; }
|
|
.form-select option { background:var(--surface); color:var(--text); }
|
|
.btn { display:inline-flex; align-items:center; gap:6px; padding:8px 18px; border-radius:6px; border:none; font-size:13px; font-weight:600; cursor:pointer; transition:all .15s; }
|
|
.btn-primary { background:var(--accent); color:#fff; }
|
|
.btn-primary:hover { background:var(--accent2); }
|
|
.btn-primary:disabled { opacity:.5; cursor:not-allowed; }
|
|
.btn-secondary { background:var(--surface2); color:var(--text); border:1px solid var(--border); }
|
|
.btn-secondary:hover { background:var(--border); }
|
|
.btn-sm { padding:5px 12px; font-size:12px; }
|
|
.btn-row { display:flex; gap:8px; align-items:center; margin-top:12px; }
|
|
.interaction-result { margin-top:16px; }
|
|
.spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin .6s linear infinite; }
|
|
@keyframes spin { to{transform:rotate(360deg)} }
|
|
.toast { position:fixed; bottom:24px; right:24px; padding:12px 20px; border-radius:8px; font-size:13px; font-weight:500; z-index:9999; animation:slideUp .3s ease; }
|
|
.toast-success { background:rgba(76,175,80,.9); color:#fff; }
|
|
.toast-error { background:rgba(244,67,54,.9); color:#fff; }
|
|
@keyframes slideUp { from{transform:translateY(20px);opacity:0} to{transform:translateY(0);opacity:1} }
|
|
.question-row { display:flex; gap:8px; align-items:center; margin-bottom:8px; }
|
|
.question-row .form-input { flex:1; }
|
|
.api-base-row { display:flex; gap:8px; align-items:center; margin-top:8px; }
|
|
.api-base-row .form-input { flex:1; font-size:11px; padding:6px 10px; }
|
|
.section-divider { border:none; border-top:1px solid var(--border); margin:20px 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">
|
|
<h1>MiroFish Console</h1>
|
|
<div class="run-id">{{RUN_ID}}</div>
|
|
</div>
|
|
<div class="nav" id="nav">
|
|
<div class="nav-item active" data-panel="overview"><span class="nav-icon">◈</span> Overview</div>
|
|
<div class="nav-item" data-panel="report"><span class="nav-icon">◉</span> Report</div>
|
|
<div class="nav-item" data-panel="agents"><span class="nav-icon">●</span> Agents</div>
|
|
<div class="nav-item" data-panel="timeline"><span class="nav-icon">▸</span> Timeline</div>
|
|
<div class="nav-item" data-panel="graph"><span class="nav-icon">◊</span> Graph</div>
|
|
<div class="nav-item" data-panel="simulation"><span class="nav-icon">▷</span> Simulation</div>
|
|
<div class="nav-item" data-panel="ask"><span class="nav-icon">◑</span> Ask Agent</div>
|
|
<div class="nav-item" data-panel="questionnaires"><span class="nav-icon">◓</span> Questionnaires</div>
|
|
<div class="nav-item" data-panel="report-q"><span class="nav-icon">?</span> Report Q&A</div>
|
|
<div class="nav-item" data-panel="history"><span class="nav-icon">☰</span> History</div>
|
|
<div class="nav-item" data-panel="raw"><span class="nav-icon"></></span> Raw Artifacts</div>
|
|
</div>
|
|
<div class="api-status" id="api-status">
|
|
<span class="api-dot checking" id="api-dot"></span>
|
|
<span id="api-status-text">Checking API...</span>
|
|
</div>
|
|
<div style="padding:0 20px 12px;">
|
|
<div class="api-base-row">
|
|
<input class="form-input" id="api-base-input" value="http://localhost:5001" placeholder="API Base URL" style="font-size:11px;">
|
|
<button class="btn btn-sm btn-secondary" id="api-reconnect-btn">Connect</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="main">
|
|
<!-- Overview Panel -->
|
|
<div class="panel active" id="panel-overview">
|
|
<h2>Overview</h2>
|
|
<div class="requirement-box" id="requirement-text"></div>
|
|
<div class="stat-row" id="overview-stats"></div>
|
|
<div class="card">
|
|
<div class="card-header">Verdict</div>
|
|
<div id="verdict-display"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Report Panel -->
|
|
<div class="panel" id="panel-report">
|
|
<h2>Report</h2>
|
|
<div class="report-content" id="report-content"></div>
|
|
</div>
|
|
<!-- Agents Panel -->
|
|
<div class="panel" id="panel-agents">
|
|
<h2>Agents</h2>
|
|
<div id="agents-list"></div>
|
|
</div>
|
|
<!-- Timeline Panel -->
|
|
<div class="panel" id="panel-timeline">
|
|
<h2>Timeline</h2>
|
|
<div class="card" id="timeline-list"></div>
|
|
</div>
|
|
<!-- Graph Panel -->
|
|
<div class="panel" id="panel-graph">
|
|
<h2>Knowledge Graph</h2>
|
|
<div class="stat-row" id="graph-stats"></div>
|
|
<div class="card">
|
|
<div class="card-header">Entities</div>
|
|
<div id="graph-entities"></div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header">Triples</div>
|
|
<div id="graph-triples" style="max-height:400px;overflow-y:auto;"></div>
|
|
</div>
|
|
</div>
|
|
<!-- Simulation Panel -->
|
|
<div class="panel" id="panel-simulation">
|
|
<h2>Simulation</h2>
|
|
<div class="stat-row" id="sim-stats"></div>
|
|
<div id="sim-actions"></div>
|
|
</div>
|
|
<!-- Ask Agent Panel (Interactive) -->
|
|
<div class="panel" id="panel-ask">
|
|
<h2>Ask an Agent</h2>
|
|
<div class="card">
|
|
<div class="card-header">New Question</div>
|
|
<div class="form-group">
|
|
<label>Select Agent</label>
|
|
<select class="form-select" id="ask-agent-select">
|
|
<option value="">-- Select an agent --</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Question</label>
|
|
<textarea class="form-textarea" id="ask-question-input" placeholder="Type your question for this agent..."></textarea>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button class="btn btn-primary" id="ask-submit-btn" disabled>Submit Question</button>
|
|
<span id="ask-status" style="font-size:12px;color:var(--text2);"></span>
|
|
</div>
|
|
</div>
|
|
<div class="interaction-result" id="ask-result"></div>
|
|
<hr class="section-divider">
|
|
<h3>Previous Questions</h3>
|
|
<div id="ask-history"></div>
|
|
</div>
|
|
<!-- Questionnaires Panel (Interactive) -->
|
|
<div class="panel" id="panel-questionnaires">
|
|
<h2>Questionnaires</h2>
|
|
<div class="card">
|
|
<div class="card-header">New Questionnaire</div>
|
|
<div id="questionnaire-questions">
|
|
<div class="question-row" data-qidx="0">
|
|
<input class="form-input" placeholder="Question ID (e.g. q1)" style="max-width:140px;" value="q1">
|
|
<input class="form-input" placeholder="Question text..." style="flex:1;">
|
|
<button class="btn btn-sm btn-secondary" onclick="removeQuestionRow(this)">Remove</button>
|
|
</div>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button class="btn btn-secondary btn-sm" id="add-question-btn">+ Add Question</button>
|
|
<button class="btn btn-primary" id="questionnaire-submit-btn">Send Questionnaire</button>
|
|
<span id="questionnaire-status" style="font-size:12px;color:var(--text2);"></span>
|
|
</div>
|
|
</div>
|
|
<div class="interaction-result" id="questionnaire-result"></div>
|
|
<hr class="section-divider">
|
|
<h3>Previous Questionnaires</h3>
|
|
<div id="questionnaire-history"></div>
|
|
</div>
|
|
<!-- Report Q&A Panel (Interactive) -->
|
|
<div class="panel" id="panel-report-q">
|
|
<h2>Report Q&A</h2>
|
|
<div class="card">
|
|
<div class="card-header">Ask a question about the report</div>
|
|
<div class="form-group">
|
|
<label>Question</label>
|
|
<textarea class="form-textarea" id="report-q-input" placeholder="Ask a follow-up question about the report findings..."></textarea>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button class="btn btn-primary" id="report-q-submit-btn">Submit Question</button>
|
|
<span id="report-q-status" style="font-size:12px;color:var(--text2);"></span>
|
|
</div>
|
|
</div>
|
|
<div class="interaction-result" id="report-q-result"></div>
|
|
<hr class="section-divider">
|
|
<h3>Previous Report Questions</h3>
|
|
<div id="report-q-history"></div>
|
|
</div>
|
|
<!-- History Panel -->
|
|
<div class="panel" id="panel-history">
|
|
<h2>Interaction History</h2>
|
|
<div id="all-interactions-list"></div>
|
|
</div>
|
|
<!-- Raw Artifacts Panel -->
|
|
<div class="panel" id="panel-raw">
|
|
<h2>Raw Artifacts</h2>
|
|
<div class="tab-bar" id="raw-tabs"></div>
|
|
<div id="raw-content"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
// ── Embedded data ──────────────────────────────────────────────────────
|
|
const DATA = {
|
|
runId: {{RUN_ID_JSON}},
|
|
requirement: {{REQUIREMENT_JSON}},
|
|
reportMd: {{REPORT_MD_JSON}},
|
|
verdict: {{VERDICT_JSON}},
|
|
timeline: {{TIMELINE_JSON}},
|
|
graphSnapshot: {{GRAPH_SNAPSHOT_JSON}},
|
|
profiles: {{PROFILES_JSON}},
|
|
simConfig: {{SIM_CONFIG_JSON}},
|
|
simActions: {{SIM_ACTIONS_JSON}},
|
|
agentQuestions: {{AGENT_QUESTIONS_JSON}},
|
|
questionnaires: {{QUESTIONNAIRES_JSON}},
|
|
runDir: {{RUN_DIR_JSON}}
|
|
};
|
|
|
|
// ── Agent ID resolver (mirrors Python _resolve_agent_id) ──────────────
|
|
function _resolveId(p, idx) {
|
|
if (p.agent_id) return String(p.agent_id).trim() || _posFallback(idx);
|
|
if (p.user_id) return String(p.user_id).trim() || _posFallback(idx);
|
|
if (p.name && typeof p.name === "string" && p.name.trim()) {
|
|
var slug = p.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
return slug || _posFallback(idx);
|
|
}
|
|
return _posFallback(idx);
|
|
}
|
|
function _posFallback(idx) { return "agent_" + ((idx || 0) + 1); }
|
|
|
|
// Normalize embedded profiles so each has a guaranteed agent_id
|
|
var _profiles = Array.isArray(DATA.profiles) ? DATA.profiles : [];
|
|
_profiles.forEach(function(p, i) { if (!p.agent_id) p.agent_id = _resolveId(p, i); });
|
|
|
|
// ── API Client ─────────────────────────────────────────────────────────
|
|
let API_BASE = "http://localhost:5001";
|
|
let API_ONLINE = false;
|
|
let pollTimers = {};
|
|
const POLL_INTERVAL = 3000;
|
|
const POLL_MAX = 120000;
|
|
|
|
function apiUrl(path) {
|
|
const base = API_BASE.replace(/\/+$/, "");
|
|
const sep = path.indexOf("?") >= 0 ? "&" : "?";
|
|
return base + "/api/interaction" + path + sep + "run=" + encodeURIComponent(DATA.runDir);
|
|
}
|
|
|
|
async function apiGet(path) {
|
|
const resp = await fetch(apiUrl(path), { method: "GET" });
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
return resp.json();
|
|
}
|
|
|
|
async function apiPost(path, body) {
|
|
const resp = await fetch(apiUrl(path), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
return resp.json();
|
|
}
|
|
|
|
async function checkApiStatus() {
|
|
const dot = document.getElementById("api-dot");
|
|
const txt = document.getElementById("api-status-text");
|
|
dot.className = "api-dot checking";
|
|
txt.textContent = "Checking API...";
|
|
try {
|
|
const resp = await fetch(apiUrl("/agents"), { method: "GET", signal: AbortSignal.timeout(5000) });
|
|
if (resp.ok) {
|
|
API_ONLINE = true;
|
|
dot.className = "api-dot online";
|
|
txt.textContent = "API Connected";
|
|
// Prefer API agent list (backend-normalized IDs) over embedded fallback
|
|
_refreshDropdownFromApi(resp);
|
|
} else {
|
|
API_ONLINE = false;
|
|
dot.className = "api-dot offline";
|
|
txt.textContent = "API Error";
|
|
}
|
|
} catch (e) {
|
|
API_ONLINE = false;
|
|
dot.className = "api-dot offline";
|
|
txt.textContent = "API Offline";
|
|
}
|
|
}
|
|
|
|
async function _refreshDropdownFromApi(initialResp) {
|
|
try {
|
|
var body = initialResp ? await initialResp.json() : null;
|
|
if (!body || !body.success) {
|
|
var r = await apiGet("/agents");
|
|
body = r;
|
|
}
|
|
if (!body || !body.success || !body.data) return;
|
|
var apiAgents = body.data.agents || [];
|
|
if (apiAgents.length === 0) return;
|
|
// Rebuild dropdown from API data (backend IDs are authoritative)
|
|
var sel = document.getElementById("ask-agent-select");
|
|
sel.innerHTML = '<option value="">-- Select an agent --</option>';
|
|
apiAgents.forEach(function(a) {
|
|
var opt = document.createElement("option");
|
|
opt.value = a.agent_id;
|
|
opt.textContent = (a.name || a.agent_id) + " (" + a.agent_id + ")";
|
|
sel.appendChild(opt);
|
|
});
|
|
// Also update the profiles array for agent cards display
|
|
_profiles.length = 0;
|
|
apiAgents.forEach(function(a, i) {
|
|
var p = a.profile || { agent_id: a.agent_id, name: a.name, persona: a.persona };
|
|
if (!p.agent_id) p.agent_id = a.agent_id;
|
|
_profiles.push(p);
|
|
});
|
|
// Re-render agent cards
|
|
_renderAgentCards();
|
|
} catch (e) { /* API dropdown refresh is best-effort */ }
|
|
}
|
|
|
|
function _renderAgentCards() {
|
|
var list = document.getElementById("agents-list");
|
|
if (_profiles.length === 0) {
|
|
list.innerHTML = '<div class="empty-state">No agent profiles generated yet.</div>';
|
|
} else {
|
|
list.innerHTML = _profiles.map(function(p, i) {
|
|
var aid = p.agent_id;
|
|
return '<div class="agent-card"><div class="agent-name">' + (p.name || aid) + '</div>' +
|
|
'<div class="agent-id">' + aid + '</div>' +
|
|
(p.persona ? '<div class="agent-persona">' + p.persona + '</div>' : '') +
|
|
'<pre style="margin-top:8px;">' + JSON.stringify(p, null, 2) + '</pre></div>';
|
|
}).join("");
|
|
}
|
|
}
|
|
|
|
function pollForAnswer(requestId, callback) {
|
|
const startTime = Date.now();
|
|
const timerId = setInterval(async function() {
|
|
if (Date.now() - startTime > POLL_MAX) {
|
|
clearInterval(timerId);
|
|
callback(null, "Timed out waiting for agent response");
|
|
return;
|
|
}
|
|
try {
|
|
const res = await apiGet("/requests/" + requestId + "/status");
|
|
if (res.success && res.data.has_response) {
|
|
clearInterval(timerId);
|
|
callback(true, null);
|
|
}
|
|
} catch (e) {
|
|
// keep polling
|
|
}
|
|
}, POLL_INTERVAL);
|
|
pollTimers[requestId] = timerId;
|
|
}
|
|
|
|
function showToast(msg, type) {
|
|
const el = document.createElement("div");
|
|
el.className = "toast toast-" + (type || "success");
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
setTimeout(function() { el.remove(); }, 3500);
|
|
}
|
|
|
|
function renderAnswerCard(container, data, label) {
|
|
if (!data || data.status === "error" || data.status === "missing" || data.status === "failed") {
|
|
container.innerHTML = '<div class="card"><span class="badge badge-error">' + (data ? data.status : "error") + '</span> ' +
|
|
(data && data.error ? data.error : "No response received") + '</div>';
|
|
return;
|
|
}
|
|
const output = data.output || data;
|
|
const ansMd = output.answer_markdown || output.summary_markdown || JSON.stringify(output, null, 2);
|
|
const confidence = output.confidence != null ? (output.confidence * 100).toFixed(0) + "%" : "-";
|
|
const agentId = output.agent_id || data.agent_id || label || "";
|
|
container.innerHTML = '<div class="card">' +
|
|
'<div class="card-header">Answer</div>' +
|
|
'<div class="qa-answer">' + ansMd + '</div>' +
|
|
'<div class="qa-meta">Agent: ' + agentId + ' | Confidence: ' + confidence + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
// ── Navigation ─────────────────────────────────────────────────────────
|
|
document.querySelectorAll(".nav-item").forEach(function(item) {
|
|
item.addEventListener("click", function() {
|
|
document.querySelectorAll(".nav-item").forEach(function(n) { n.classList.remove("active"); });
|
|
document.querySelectorAll(".panel").forEach(function(p) { p.classList.remove("active"); });
|
|
this.classList.add("active");
|
|
document.getElementById("panel-" + this.dataset.panel).classList.add("active");
|
|
});
|
|
});
|
|
|
|
// ── API Base URL config ────────────────────────────────────────────────
|
|
document.getElementById("api-reconnect-btn").addEventListener("click", function() {
|
|
API_BASE = document.getElementById("api-base-input").value || "http://localhost:5001";
|
|
checkApiStatus();
|
|
});
|
|
document.getElementById("api-base-input").addEventListener("keydown", function(e) {
|
|
if (e.key === "Enter") { API_BASE = this.value || "http://localhost:5001"; checkApiStatus(); }
|
|
});
|
|
|
|
// ── Overview ───────────────────────────────────────────────────────────
|
|
document.getElementById("requirement-text").textContent = DATA.requirement || "No requirement specified";
|
|
var profiles = _profiles;
|
|
var timeline = Array.isArray(DATA.timeline) ? DATA.timeline : [];
|
|
var simActions = Array.isArray(DATA.simActions) ? DATA.simActions : [];
|
|
var triples = Array.isArray(DATA.graphSnapshot) ? DATA.graphSnapshot : (DATA.graphSnapshot && DATA.graphSnapshot.triples ? DATA.graphSnapshot.triples : []);
|
|
|
|
var statsHtml = [
|
|
{ value: profiles.length, label: "Agents" },
|
|
{ value: timeline.length, label: "Timeline Events" },
|
|
{ value: triples.length, label: "Graph Triples" },
|
|
{ value: simActions.length, label: "Sim Actions" }
|
|
].map(function(s) { return '<div class="stat"><div class="stat-value">' + s.value + '</div><div class="stat-label">' + s.label + '</div></div>'; }).join("");
|
|
document.getElementById("overview-stats").innerHTML = statsHtml;
|
|
|
|
var v = DATA.verdict || {};
|
|
var verdictBadge = v.status === "ok" ? "badge-ok" : (v.status === "mock" ? "badge-warn" : "badge-error");
|
|
document.getElementById("verdict-display").innerHTML =
|
|
'<span class="badge ' + verdictBadge + '">' + (v.status || "unknown") + '</span>' +
|
|
(v.confidence ? ' <span style="color:var(--text2);font-size:13px;">Confidence: ' + (v.confidence * 100).toFixed(0) + '%</span>' : '') +
|
|
'<pre style="margin-top:12px;">' + JSON.stringify(v, null, 2) + '</pre>';
|
|
|
|
// ── Report ─────────────────────────────────────────────────────────────
|
|
function mdToHtml(md) {
|
|
return md
|
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
.replace(/\n\n/g, '</p><p>')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
document.getElementById("report-content").innerHTML = '<p>' + mdToHtml(DATA.reportMd) + '</p>';
|
|
|
|
// ── Agents ─────────────────────────────────────────────────────────────
|
|
if (profiles.length === 0) {
|
|
document.getElementById("agents-list").innerHTML = '<div class="empty-state">No agent profiles generated yet.</div>';
|
|
} else {
|
|
document.getElementById("agents-list").innerHTML = profiles.map(function(p, i) {
|
|
var aid = p.agent_id;
|
|
return '<div class="agent-card"><div class="agent-name">' + (p.name || aid) + '</div>' +
|
|
'<div class="agent-id">' + aid + '</div>' +
|
|
(p.persona ? '<div class="agent-persona">' + p.persona + '</div>' : '') +
|
|
'<pre style="margin-top:8px;">' + JSON.stringify(p, null, 2) + '</pre></div>';
|
|
}).join("");
|
|
}
|
|
|
|
// ── Timeline ───────────────────────────────────────────────────────────
|
|
if (timeline.length === 0) {
|
|
document.getElementById("timeline-list").innerHTML = '<div class="empty-state">No timeline events.</div>';
|
|
} else {
|
|
document.getElementById("timeline-list").innerHTML = timeline.map(function(t, i) {
|
|
return '<div class="timeline-item"><div class="timeline-round">R' + (t.round || i + 1) + '</div>' +
|
|
'<div class="timeline-detail">' + (t.summary || t.fact || JSON.stringify(t)) + '</div></div>';
|
|
}).join("");
|
|
}
|
|
|
|
// ── Graph ──────────────────────────────────────────────────────────────
|
|
var entities = new Set();
|
|
triples.forEach(function(t) { if(t.subject) entities.add(t.subject); if(t.object) entities.add(t.object); });
|
|
document.getElementById("graph-stats").innerHTML =
|
|
'<div class="stat"><div class="stat-value">' + entities.size + '</div><div class="stat-label">Entities</div></div>' +
|
|
'<div class="stat"><div class="stat-value">' + triples.length + '</div><div class="stat-label">Triples</div></div>';
|
|
document.getElementById("graph-entities").innerHTML = Array.from(entities).map(function(e) {
|
|
return '<span class="graph-entity">' + e + '</span>';
|
|
}).join("") || '<div class="empty-state">No entities.</div>';
|
|
if (triples.length === 0) {
|
|
document.getElementById("graph-triples").innerHTML = '<div class="empty-state">No triples.</div>';
|
|
} else {
|
|
document.getElementById("graph-triples").innerHTML = triples.slice(0, 50).map(function(t) {
|
|
return '<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px;">' +
|
|
'<span style="color:var(--accent);">' + (t.subject||'') + '</span> ' +
|
|
'<span style="color:var(--text2);">[' + (t.predicate||'') + ']</span> ' +
|
|
'<span style="color:var(--success);">' + (t.object||'') + '</span>' +
|
|
(t.confidence ? ' <span class="badge badge-ok">' + (t.confidence*100).toFixed(0) + '%</span>' : '') +
|
|
'</div>';
|
|
}).join("");
|
|
}
|
|
|
|
// ── Simulation ─────────────────────────────────────────────────────────
|
|
var cfg = DATA.simConfig || {};
|
|
document.getElementById("sim-stats").innerHTML =
|
|
'<div class="stat"><div class="stat-value">' + (cfg.rounds || cfg.simulation_rounds || 0) + '</div><div class="stat-label">Rounds</div></div>' +
|
|
'<div class="stat"><div class="stat-value">' + (cfg.round_unit || '-') + '</div><div class="stat-label">Round Unit</div></div>' +
|
|
'<div class="stat"><div class="stat-value">' + simActions.length + '</div><div class="stat-label">Actions</div></div>';
|
|
if (simActions.length === 0) {
|
|
document.getElementById("sim-actions").innerHTML = '<div class="empty-state">No simulation actions.</div>';
|
|
} else {
|
|
document.getElementById("sim-actions").innerHTML = simActions.slice(0, 100).map(function(a) {
|
|
return '<div class="action-item"><span class="action-agent">' + (a.agent_id||'') + '</span> ' +
|
|
'<span class="action-type">' + (a.action_type||'') + '</span><br>' +
|
|
'<span style="color:var(--text);">' + (a.content||'') + '</span></div>';
|
|
}).join("");
|
|
}
|
|
|
|
// ── Ask Agent (Interactive) ────────────────────────────────────────────
|
|
var agentSelect = document.getElementById("ask-agent-select");
|
|
var askSubmitBtn = document.getElementById("ask-submit-btn");
|
|
var askQuestionInput = document.getElementById("ask-question-input");
|
|
var askResultDiv = document.getElementById("ask-result");
|
|
var askStatusSpan = document.getElementById("ask-status");
|
|
|
|
profiles.forEach(function(p, i) {
|
|
var aid = p.agent_id;
|
|
var opt = document.createElement("option");
|
|
opt.value = aid;
|
|
opt.textContent = (p.name || aid) + " (" + aid + ")";
|
|
agentSelect.appendChild(opt);
|
|
});
|
|
|
|
agentSelect.addEventListener("change", function() {
|
|
askSubmitBtn.disabled = !this.value || !askQuestionInput.value.trim();
|
|
});
|
|
askQuestionInput.addEventListener("input", function() {
|
|
askSubmitBtn.disabled = !agentSelect.value || !this.value.trim();
|
|
});
|
|
|
|
askSubmitBtn.addEventListener("click", async function() {
|
|
var agentId = agentSelect.value;
|
|
var question = askQuestionInput.value.trim();
|
|
if (!agentId || !question) return;
|
|
if (!API_ONLINE) { showToast("API is offline. Start the backend server first.", "error"); return; }
|
|
|
|
askSubmitBtn.disabled = true;
|
|
askStatusSpan.innerHTML = '<span class="spinner"></span> Submitting...';
|
|
askResultDiv.innerHTML = "";
|
|
|
|
try {
|
|
var res = await apiPost("/agents/" + encodeURIComponent(agentId) + "/ask", { question: question, limit: 20 });
|
|
var data = res.data || {};
|
|
var requestId = data.request_id;
|
|
if (!requestId) {
|
|
askStatusSpan.textContent = "";
|
|
renderAnswerCard(askResultDiv, data, agentId);
|
|
askSubmitBtn.disabled = false;
|
|
return;
|
|
}
|
|
askStatusSpan.innerHTML = '<span class="spinner"></span> Waiting for agent response...';
|
|
askResultDiv.innerHTML = '<div class="card"><span class="badge badge-pending">pending</span> Request ' + requestId + ' submitted. Polling for response...</div>';
|
|
|
|
pollForAnswer(requestId, async function(ok, err) {
|
|
if (!ok) {
|
|
askStatusSpan.textContent = "";
|
|
askResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">timeout</span> ' + (err || "No response received") + '</div>';
|
|
askSubmitBtn.disabled = false;
|
|
return;
|
|
}
|
|
try {
|
|
var ansRes = await apiPost("/agents/answer/" + encodeURIComponent(requestId), {});
|
|
askStatusSpan.textContent = "";
|
|
renderAnswerCard(askResultDiv, ansRes.data, agentId);
|
|
showToast("Agent answered your question!");
|
|
} catch (e2) {
|
|
askStatusSpan.textContent = "";
|
|
askResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">error</span> Failed to retrieve answer: ' + e2.message + '</div>';
|
|
}
|
|
askSubmitBtn.disabled = false;
|
|
});
|
|
} catch (e) {
|
|
askStatusSpan.textContent = "";
|
|
askResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">error</span> ' + e.message + '</div>';
|
|
askSubmitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// ── Questionnaires (Interactive) ───────────────────────────────────────
|
|
var questionnaireQuestionsDiv = document.getElementById("questionnaire-questions");
|
|
var addQuestionBtn = document.getElementById("add-question-btn");
|
|
var questionnaireSubmitBtn = document.getElementById("questionnaire-submit-btn");
|
|
var questionnaireResultDiv = document.getElementById("questionnaire-result");
|
|
var questionnaireStatusSpan = document.getElementById("questionnaire-status");
|
|
var questionCounter = 1;
|
|
|
|
addQuestionBtn.addEventListener("click", function() {
|
|
questionCounter++;
|
|
var row = document.createElement("div");
|
|
row.className = "question-row";
|
|
row.dataset.qidx = questionCounter - 1;
|
|
row.innerHTML = '<input class="form-input" placeholder="Question ID (e.g. q' + questionCounter + ')" style="max-width:140px;" value="q' + questionCounter + '">' +
|
|
'<input class="form-input" placeholder="Question text..." style="flex:1;">' +
|
|
'<button class="btn btn-sm btn-secondary" onclick="removeQuestionRow(this)">Remove</button>';
|
|
questionnaireQuestionsDiv.appendChild(row);
|
|
});
|
|
|
|
window.removeQuestionRow = function(btn) {
|
|
var rows = questionnaireQuestionsDiv.querySelectorAll(".question-row");
|
|
if (rows.length <= 1) { showToast("Need at least one question", "error"); return; }
|
|
btn.closest(".question-row").remove();
|
|
};
|
|
|
|
questionnaireSubmitBtn.addEventListener("click", async function() {
|
|
if (!API_ONLINE) { showToast("API is offline. Start the backend server first.", "error"); return; }
|
|
|
|
var rows = questionnaireQuestionsDiv.querySelectorAll(".question-row");
|
|
var questions = [];
|
|
rows.forEach(function(row) {
|
|
var inputs = row.querySelectorAll("input");
|
|
var qid = inputs[0].value.trim();
|
|
var qtxt = inputs[1].value.trim();
|
|
if (qid && qtxt) questions.push({ question_id: qid, question: qtxt });
|
|
});
|
|
if (questions.length === 0) { showToast("Add at least one question with ID and text", "error"); return; }
|
|
|
|
questionnaireSubmitBtn.disabled = true;
|
|
questionnaireStatusSpan.innerHTML = '<span class="spinner"></span> Sending...';
|
|
questionnaireResultDiv.innerHTML = "";
|
|
|
|
try {
|
|
var res = await apiPost("/questionnaires", { questions: questions });
|
|
var data = res.data || {};
|
|
var questionnaireId = data.questionnaire_id;
|
|
if (!questionnaireId) {
|
|
questionnaireStatusSpan.textContent = "";
|
|
questionnaireResultDiv.innerHTML = '<div class="card">' + JSON.stringify(data, null, 2) + '</div>';
|
|
questionnaireSubmitBtn.disabled = false;
|
|
return;
|
|
}
|
|
questionnaireStatusSpan.innerHTML = '<span class="spinner"></span> Questionnaire ' + questionnaireId + ' sent. Waiting for agent responses...';
|
|
questionnaireResultDiv.innerHTML = '<div class="card"><span class="badge badge-pending">pending</span> Polling for responses...</div>';
|
|
|
|
// Poll for questionnaire completion
|
|
var startTime = Date.now();
|
|
var pollTimer = setInterval(async function() {
|
|
if (Date.now() - startTime > POLL_MAX) {
|
|
clearInterval(pollTimer);
|
|
questionnaireStatusSpan.textContent = "";
|
|
questionnaireResultDiv.innerHTML = '<div class="card"><span class="badge badge-warn">timeout</span> Questionnaire timed out. Some agents may not have responded.</div>';
|
|
questionnaireSubmitBtn.disabled = false;
|
|
return;
|
|
}
|
|
try {
|
|
var qRes = await apiGet("/questionnaires/" + encodeURIComponent(questionnaireId));
|
|
var qData = qRes.data || {};
|
|
var answers = qData.answers || [];
|
|
var totalExpected = profiles.length * questions.length;
|
|
if (answers.length >= totalExpected || (qData.status === "completed")) {
|
|
clearInterval(pollTimer);
|
|
questionnaireStatusSpan.textContent = "";
|
|
var summaryMd = qData.summary_markdown || "";
|
|
var answersHtml = answers.map(function(a) {
|
|
return '<div class="qa-item"><div class="qa-question">' + (a.question_id||"") + " — " + (a.agent_id||"") + '</div>' +
|
|
'<div class="qa-answer">' + (a.answer_markdown||"") + '</div>' +
|
|
'<div class="qa-meta">Confidence: ' + ((a.confidence||0)*100).toFixed(0) + '%</div></div>';
|
|
}).join("");
|
|
questionnaireResultDiv.innerHTML = '<div class="card"><div class="card-header">Results</div>' +
|
|
(summaryMd ? '<div class="qa-answer" style="margin-bottom:12px;">' + summaryMd + '</div>' : '') +
|
|
answersHtml + '</div>';
|
|
showToast("Questionnaire completed with " + answers.length + " answers!");
|
|
} else {
|
|
questionnaireStatusSpan.innerHTML = '<span class="spinner"></span> ' + answers.length + '/' + totalExpected + ' responses received...';
|
|
}
|
|
} catch (e) { /* keep polling */ }
|
|
}, POLL_INTERVAL);
|
|
} catch (e) {
|
|
questionnaireStatusSpan.textContent = "";
|
|
questionnaireResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">error</span> ' + e.message + '</div>';
|
|
}
|
|
questionnaireSubmitBtn.disabled = false;
|
|
});
|
|
|
|
// ── Report Q&A (Interactive) ──────────────────────────────────────────
|
|
var reportQInput = document.getElementById("report-q-input");
|
|
var reportQSubmitBtn = document.getElementById("report-q-submit-btn");
|
|
var reportQResultDiv = document.getElementById("report-q-result");
|
|
var reportQStatusSpan = document.getElementById("report-q-status");
|
|
|
|
reportQSubmitBtn.addEventListener("click", async function() {
|
|
var question = reportQInput.value.trim();
|
|
if (!question) return;
|
|
if (!API_ONLINE) { showToast("API is offline. Start the backend server first.", "error"); return; }
|
|
|
|
reportQSubmitBtn.disabled = true;
|
|
reportQStatusSpan.innerHTML = '<span class="spinner"></span> Submitting...';
|
|
reportQResultDiv.innerHTML = "";
|
|
|
|
try {
|
|
var res = await apiPost("/report-questions", { question: question, limit: 20 });
|
|
var data = res.data || {};
|
|
var requestId = data.request_id;
|
|
if (!requestId) {
|
|
reportQStatusSpan.textContent = "";
|
|
renderAnswerCard(reportQResultDiv, data, "report");
|
|
reportQSubmitBtn.disabled = false;
|
|
return;
|
|
}
|
|
reportQStatusSpan.innerHTML = '<span class="spinner"></span> Waiting for response...';
|
|
reportQResultDiv.innerHTML = '<div class="card"><span class="badge badge-pending">pending</span> Request ' + requestId + ' submitted. Polling...</div>';
|
|
|
|
pollForAnswer(requestId, async function(ok, err) {
|
|
if (!ok) {
|
|
reportQStatusSpan.textContent = "";
|
|
reportQResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">timeout</span> ' + (err || "No response received") + '</div>';
|
|
reportQSubmitBtn.disabled = false;
|
|
return;
|
|
}
|
|
try {
|
|
var ansRes = await apiPost("/report-questions/answer/" + encodeURIComponent(requestId), {});
|
|
reportQStatusSpan.textContent = "";
|
|
renderAnswerCard(reportQResultDiv, ansRes.data, "report");
|
|
showToast("Report question answered!");
|
|
} catch (e2) {
|
|
reportQStatusSpan.textContent = "";
|
|
reportQResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">error</span> Failed to retrieve answer: ' + e2.message + '</div>';
|
|
}
|
|
reportQSubmitBtn.disabled = false;
|
|
});
|
|
} catch (e) {
|
|
reportQStatusSpan.textContent = "";
|
|
reportQResultDiv.innerHTML = '<div class="card"><span class="badge badge-error">error</span> ' + e.message + '</div>';
|
|
reportQSubmitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// ── History: render embedded Q&A and questionnaire data ────────────────
|
|
var aqs = DATA.agentQuestions || [];
|
|
var qs = DATA.questionnaires || [];
|
|
|
|
function renderStaticQA() {
|
|
var container = document.getElementById("ask-history");
|
|
if (aqs.length === 0) {
|
|
container.innerHTML = '<div class="empty-state" style="padding:24px;">No previous agent questions.</div>';
|
|
} else {
|
|
container.innerHTML = aqs.map(function(q) {
|
|
var ans = q.answer || {};
|
|
return '<div class="qa-item"><div class="qa-question">Q: ' + (q.question||'') + '</div>' +
|
|
'<div class="qa-answer">' + (ans.answer_markdown || '') + '</div>' +
|
|
'<div class="qa-meta">Agent: ' + (q.agent_id||'') + ' | Confidence: ' + ((ans.confidence||0)*100).toFixed(0) + '%</div></div>';
|
|
}).join("");
|
|
}
|
|
}
|
|
|
|
function renderStaticQuestionnaires() {
|
|
var container = document.getElementById("questionnaire-history");
|
|
if (qs.length === 0) {
|
|
container.innerHTML = '<div class="empty-state" style="padding:24px;">No previous questionnaires.</div>';
|
|
} else {
|
|
container.innerHTML = qs.map(function(q) {
|
|
var answers = q.answers || [];
|
|
var qhtml = answers.map(function(a) {
|
|
return '<div class="qa-item" style="margin-bottom:8px;"><div class="qa-question">' + (a.question_id||'') + ' — ' + (a.agent_id||'') + '</div>' +
|
|
'<div class="qa-answer">' + (a.answer_markdown||'') + '</div>' +
|
|
'<div class="qa-meta">Confidence: ' + ((a.confidence||0)*100).toFixed(0) + '%</div></div>';
|
|
}).join("");
|
|
return '<div class="card"><div class="card-header">Questionnaire: ' + (q.questionnaire_id||'') + '</div>' +
|
|
'<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">Questions: ' + (q.questions||[]).length + ' | Answers: ' + answers.length + '</div>' +
|
|
(q.summary_markdown ? '<div class="qa-answer" style="margin-bottom:12px;">' + q.summary_markdown + '</div>' : '') +
|
|
(qhtml || '<div class="empty-state">No answers yet.</div>') + '</div>';
|
|
}).join("");
|
|
}
|
|
}
|
|
|
|
function renderAllInteractions() {
|
|
var container = document.getElementById("all-interactions-list");
|
|
var html = "";
|
|
if (aqs.length > 0) {
|
|
html += "<h3>Agent Questions</h3>";
|
|
html += aqs.map(function(q) {
|
|
var ans = q.answer || {};
|
|
return '<div class="qa-item"><div class="qa-question">Q: ' + (q.question||'') + '</div>' +
|
|
'<div class="qa-answer">' + (ans.answer_markdown || '') + '</div>' +
|
|
'<div class="qa-meta">Agent: ' + (q.agent_id||'') + ' | Confidence: ' + ((ans.confidence||0)*100).toFixed(0) + '%</div></div>';
|
|
}).join("");
|
|
}
|
|
if (qs.length > 0) {
|
|
html += "<h3>Questionnaires</h3>";
|
|
html += qs.map(function(q) {
|
|
var answers = q.answers || [];
|
|
return '<div class="card"><div class="card-header">Questionnaire: ' + (q.questionnaire_id||'') + '</div>' +
|
|
'<div style="font-size:13px;color:var(--text2);margin-bottom:8px;">Questions: ' + (q.questions||[]).length + ' | Answers: ' + answers.length + '</div></div>';
|
|
}).join("");
|
|
}
|
|
if (!html) html = '<div class="empty-state">No interactions recorded yet.</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
renderStaticQA();
|
|
renderStaticQuestionnaires();
|
|
renderAllInteractions();
|
|
|
|
// ── Raw Artifacts ──────────────────────────────────────────────────────
|
|
var rawArtifacts = {
|
|
"verdict.json": DATA.verdict,
|
|
"timeline.json": DATA.timeline,
|
|
"graph_snapshot.json": DATA.graphSnapshot,
|
|
"profiles.json": DATA.profiles,
|
|
"simulation_config.json": DATA.simConfig,
|
|
"simulation_actions.json": DATA.simActions,
|
|
"report.md": DATA.reportMd
|
|
};
|
|
var tabNames = Object.keys(rawArtifacts);
|
|
document.getElementById("raw-tabs").innerHTML = tabNames.map(function(name, i) {
|
|
return '<button class="tab-btn' + (i === 0 ? ' active' : '') + '" data-tab="' + name + '">' + name + '</button>';
|
|
}).join("");
|
|
function showRawTab(name) {
|
|
var val = rawArtifacts[name];
|
|
var content = typeof val === "string" ? val : JSON.stringify(val, null, 2);
|
|
document.getElementById("raw-content").innerHTML = '<pre>' + (content || '(empty)') + '</pre>';
|
|
}
|
|
showRawTab(tabNames[0]);
|
|
document.querySelectorAll("#raw-tabs .tab-btn").forEach(function(btn) {
|
|
btn.addEventListener("click", function() {
|
|
document.querySelectorAll("#raw-tabs .tab-btn").forEach(function(b) { b.classList.remove("active"); });
|
|
btn.classList.add("active");
|
|
showRawTab(btn.dataset.tab);
|
|
});
|
|
});
|
|
|
|
// ── Init ───────────────────────────────────────────────────────────────
|
|
checkApiStatus();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>"""
|