Merge main: consolidate JSON reads into enhanced read_json, keep error-handling improvements
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
9e6ec12acd
|
|
@ -1,31 +1,20 @@
|
|||
const api = {
|
||||
async get(path) {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
async post(path, body = {}, controller) {
|
||||
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
||||
async request(path, { method = 'GET', body, controller } = {}) {
|
||||
const opts = { method };
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
if (controller) opts.signal = controller.signal;
|
||||
const r = await fetch(path, opts);
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
async put(path, body = {}) {
|
||||
const r = await fetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
async patch(path, body = {}) {
|
||||
const r = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
async del(path) {
|
||||
const r = await fetch(path, { method: 'DELETE' });
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
|
||||
return r.json();
|
||||
},
|
||||
get(path) { return api.request(path); },
|
||||
post(path, body = {}, controller) { return api.request(path, { method: 'POST', body, controller }); },
|
||||
put(path, body = {}) { return api.request(path, { method: 'PUT', body }); },
|
||||
patch(path, body = {}) { return api.request(path, { method: 'PATCH', body }); },
|
||||
del(path) { return api.request(path, { method: 'DELETE' }); },
|
||||
getStatus: () => api.get('/api/status'),
|
||||
getBrain: () => api.get('/api/brain'),
|
||||
getBrainFile: (name) => api.get(`/api/brain/${encodeURIComponent(name)}`),
|
||||
|
|
|
|||
210
server.py
210
server.py
|
|
@ -28,6 +28,9 @@ from pydantic import BaseModel
|
|||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
# Agents supported across chat, routing, health, and kanban dispatch.
|
||||
AGENTS = ["opencode", "hermes", "gemini"]
|
||||
|
||||
app = FastAPI(title="Agentic OS", version="1.1.0")
|
||||
|
||||
# Load OpenRouter API key from Hermes .env
|
||||
|
|
@ -125,43 +128,53 @@ def write_file(path: Path, content: str):
|
|||
path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
def load_json_file(path: Path, default=_MISSING, best_effort=False):
|
||||
"""Read and parse a JSON file.
|
||||
|
||||
Raises a descriptive HTTPException instead of leaking an opaque 500 when the
|
||||
file is missing or corrupt, so callers propagate a clear error to the client.
|
||||
If ``default`` is provided it is returned when the file does not exist.
|
||||
|
||||
Set ``best_effort=True`` (with a ``default``) for aggregate/listing callers
|
||||
that should tolerate one corrupt file rather than aborting the whole view:
|
||||
the corruption is logged and ``default`` is returned instead of raising.
|
||||
"""
|
||||
if not path.exists():
|
||||
if default is not _MISSING:
|
||||
return default
|
||||
raise HTTPException(404, f"{path.name} not found")
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||||
if best_effort and default is not _MISSING:
|
||||
print(f"[load] skipping corrupt {path.name}: {e}")
|
||||
return default
|
||||
raise HTTPException(500, f"Failed to read {path.name}: {e}")
|
||||
|
||||
def list_dir(path: Path):
|
||||
if not path.exists():
|
||||
return []
|
||||
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".")])
|
||||
|
||||
def read_json(path: Path, default=None, best_effort=False):
|
||||
"""Load JSON from path, returning ``default`` when the file is missing.
|
||||
|
||||
On corrupt or unreadable content a descriptive ``HTTPException(500)`` is
|
||||
raised so the error is propagated to the client instead of surfacing as an
|
||||
opaque 500. Aggregate/listing callers can set ``best_effort=True`` to
|
||||
tolerate one bad file: the corruption is logged and ``default`` is returned
|
||||
instead of aborting the whole view.
|
||||
"""
|
||||
if not path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
|
||||
if best_effort:
|
||||
print(f"[load] skipping corrupt {path.name}: {e}")
|
||||
return default
|
||||
raise HTTPException(500, f"Failed to read {path.name}: {e}")
|
||||
|
||||
def write_json(path: Path, data, indent: int = 2):
|
||||
"""Serialize data as pretty JSON to path."""
|
||||
path.write_text(json.dumps(data, indent=indent), encoding="utf-8")
|
||||
|
||||
def iter_skill_dirs():
|
||||
"""Yield skill directories, skipping hidden and underscore-prefixed ones."""
|
||||
skills_dir = BASE_DIR / "skills"
|
||||
if not skills_dir.exists():
|
||||
return
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
yield d
|
||||
|
||||
def new_id():
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
def get_timestamp():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def append_audit(entry: dict):
|
||||
audit_file = BASE_DIR / "audit" / "audit.log"
|
||||
entry["timestamp"] = get_timestamp()
|
||||
entry["id"] = str(uuid.uuid4())[:8]
|
||||
entry["id"] = new_id()
|
||||
try:
|
||||
audit_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(audit_file, "a", encoding="utf-8") as f:
|
||||
|
|
@ -231,7 +244,7 @@ def check_agent(name: str) -> dict:
|
|||
|
||||
@app.get("/api/status")
|
||||
def get_status():
|
||||
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
|
||||
agents = [check_agent(a) for a in AGENTS]
|
||||
skills = list_dir(BASE_DIR / "skills")
|
||||
return {
|
||||
"status": "healthy",
|
||||
|
|
@ -307,19 +320,18 @@ def skill_context_file_path(name: str, filename: str) -> Path:
|
|||
@app.get("/api/skills")
|
||||
def list_skills():
|
||||
skills = []
|
||||
for d in sorted((BASE_DIR / "skills").iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
skill_md = read_file(d / "SKILL.md")
|
||||
learnings = read_file(d / "learnings.md")
|
||||
eval_data = load_json_file(d / "eval.json", default={}, best_effort=True)
|
||||
score_history = load_json_file(d / "score-history.json", default=[], best_effort=True)
|
||||
skills.append({
|
||||
"name": d.name,
|
||||
"description": skill_md[:200] if skill_md else "",
|
||||
"has_learnings": bool(learnings),
|
||||
"eval_criteria": eval_data.get("criteria", []),
|
||||
"scores": score_history,
|
||||
})
|
||||
for d in iter_skill_dirs():
|
||||
skill_md = read_file(d / "SKILL.md")
|
||||
learnings = read_file(d / "learnings.md")
|
||||
eval_data = read_json(d / "eval.json", {}, best_effort=True)
|
||||
score_history = read_json(d / "score-history.json", [], best_effort=True)
|
||||
skills.append({
|
||||
"name": d.name,
|
||||
"description": skill_md[:200] if skill_md else "",
|
||||
"has_learnings": bool(learnings),
|
||||
"eval_criteria": eval_data.get("criteria", []),
|
||||
"scores": score_history,
|
||||
})
|
||||
return skills
|
||||
|
||||
@app.get("/api/skills/{name}")
|
||||
|
|
@ -329,8 +341,8 @@ def get_skill(name: str):
|
|||
"name": name,
|
||||
"skill": read_file(path / "SKILL.md"),
|
||||
"learnings": read_file(path / "learnings.md"),
|
||||
"eval": load_json_file(path / "eval.json", default={}),
|
||||
"score_history": load_json_file(path / "score-history.json", default=[]),
|
||||
"eval": read_json(path / "eval.json", default={}),
|
||||
"score_history": read_json(path / "score-history.json", default=[]),
|
||||
"context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [],
|
||||
}
|
||||
|
||||
|
|
@ -420,7 +432,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
|||
if skill_input:
|
||||
prompt += f"## User Input\n{skill_input}"
|
||||
|
||||
run_id = str(uuid.uuid4())[:8]
|
||||
run_id = new_id()
|
||||
|
||||
# Execute via agent
|
||||
try:
|
||||
|
|
@ -464,7 +476,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
|
|||
@app.get("/api/skills/{name}/eval")
|
||||
def get_skill_eval(name: str):
|
||||
path = resolve_skill_dir(name) / "score-history.json"
|
||||
return {"scores": load_json_file(path, default=[])}
|
||||
return {"scores": read_json(path, default=[])}
|
||||
|
||||
# ─── Routes: Scheduler ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -473,7 +485,7 @@ def list_jobs():
|
|||
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
||||
jobs = []
|
||||
for f in sorted(jobs_dir.glob("*.json")):
|
||||
job = load_json_file(f, default=None, best_effort=True)
|
||||
job = read_json(f, default=None, best_effort=True)
|
||||
if job is not None:
|
||||
jobs.append(job)
|
||||
return jobs
|
||||
|
|
@ -483,7 +495,7 @@ def create_job(job: ScheduleJobRequest):
|
|||
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
||||
jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
job_data = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"name": job.name,
|
||||
"skill": job.skill,
|
||||
"cron": job.cron,
|
||||
|
|
@ -502,7 +514,7 @@ def create_job(job: ScheduleJobRequest):
|
|||
def delete_job(job_id: str):
|
||||
jobs_dir = BASE_DIR / "scheduler" / "jobs"
|
||||
for f in jobs_dir.glob("*.json"):
|
||||
data = load_json_file(f, default=None, best_effort=True)
|
||||
data = read_json(f, default=None, best_effort=True)
|
||||
if data and data.get("id") == job_id:
|
||||
f.unlink()
|
||||
append_audit({"action": "job_deleted", "job_id": job_id})
|
||||
|
|
@ -533,18 +545,12 @@ def get_audit(limit: int = Query(100, le=500)):
|
|||
@app.get("/api/cost")
|
||||
def get_cost():
|
||||
cost_file = BASE_DIR / "data" / "cost-history.json"
|
||||
return load_json_file(
|
||||
cost_file,
|
||||
default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []},
|
||||
)
|
||||
return read_json(cost_file, {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []})
|
||||
|
||||
@app.post("/api/cost/record")
|
||||
def record_cost(data: dict):
|
||||
cost_file = BASE_DIR / "data" / "cost-history.json"
|
||||
cost_data = load_json_file(
|
||||
cost_file,
|
||||
default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []},
|
||||
)
|
||||
cost_data = read_json(cost_file, {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []})
|
||||
cost_data["entries"].append({
|
||||
"timestamp": get_timestamp(),
|
||||
"agent": data.get("agent", "unknown"),
|
||||
|
|
@ -552,7 +558,7 @@ def record_cost(data: dict):
|
|||
"cost": data.get("cost", 0.0),
|
||||
"model": data.get("model", "unknown"),
|
||||
})
|
||||
cost_file.write_text(json.dumps(cost_data, indent=2))
|
||||
write_json(cost_file, cost_data)
|
||||
return {"status": "recorded"}
|
||||
|
||||
# ─── Routes: Registry/Plugins ─────────────────────────────────────
|
||||
|
|
@ -560,7 +566,7 @@ def record_cost(data: dict):
|
|||
@app.get("/api/plugins")
|
||||
def list_plugins():
|
||||
reg_file = BASE_DIR / "registry" / "plugins.json"
|
||||
return load_json_file(reg_file, default={"plugins": []})
|
||||
return read_json(reg_file, {"plugins": []})
|
||||
|
||||
@app.post("/api/plugins/install")
|
||||
def install_plugin(data: dict):
|
||||
|
|
@ -568,7 +574,7 @@ def install_plugin(data: dict):
|
|||
if not name:
|
||||
raise HTTPException(400, "Plugin name required")
|
||||
reg_file = BASE_DIR / "registry" / "plugins.json"
|
||||
reg = load_json_file(reg_file, default={"plugins": []})
|
||||
reg = read_json(reg_file, {"plugins": []})
|
||||
if any(p["name"] == name for p in reg["plugins"]):
|
||||
return {"status": "already_installed"}
|
||||
reg["plugins"].append({
|
||||
|
|
@ -576,7 +582,7 @@ def install_plugin(data: dict):
|
|||
"installed": get_timestamp(),
|
||||
"version": "1.0.0",
|
||||
})
|
||||
reg_file.write_text(json.dumps(reg, indent=2))
|
||||
write_json(reg_file, reg)
|
||||
append_audit({"action": "plugin_installed", "plugin": name})
|
||||
return {"status": "installed", "plugin": name}
|
||||
|
||||
|
|
@ -633,15 +639,15 @@ def list_prompts():
|
|||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
sf = BASE_DIR / "data" / "settings.json"
|
||||
return load_json_file(sf, default={})
|
||||
return read_json(sf, {})
|
||||
|
||||
@app.put("/api/settings")
|
||||
def update_settings(data: SettingsUpdate):
|
||||
sf = BASE_DIR / "data" / "settings.json"
|
||||
# Merge with existing
|
||||
existing = load_json_file(sf, default={})
|
||||
existing = read_json(sf, {})
|
||||
existing.update(data.settings)
|
||||
sf.write_text(json.dumps(existing, indent=2))
|
||||
write_json(sf, existing)
|
||||
append_audit({"action": "settings_updated"})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
|
@ -673,14 +679,14 @@ def discover_standards():
|
|||
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
|
||||
|
||||
def load_chat_history():
|
||||
return load_json_file(CHAT_HISTORY_FILE, default={"messages": []})
|
||||
return read_json(CHAT_HISTORY_FILE, {"messages": []})
|
||||
|
||||
def save_chat_message(msg: dict):
|
||||
history = load_chat_history()
|
||||
history["messages"].append(msg)
|
||||
if len(history["messages"]) > 200:
|
||||
history["messages"] = history["messages"][-200:]
|
||||
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
|
||||
write_json(CHAT_HISTORY_FILE, history)
|
||||
|
||||
def run_cli(args: list, timeout: int = 30) -> tuple:
|
||||
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
|
|
@ -787,11 +793,11 @@ def execute_agent(agent: str, message: str) -> str:
|
|||
@app.post("/api/chat")
|
||||
def chat(req: ChatRequest):
|
||||
agent = req.agent.lower().strip()
|
||||
if agent not in ["opencode", "hermes", "gemini"]:
|
||||
if agent not in AGENTS:
|
||||
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
|
||||
|
||||
user_msg = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"role": "user",
|
||||
"agent": agent,
|
||||
"content": req.message,
|
||||
|
|
@ -802,7 +808,7 @@ def chat(req: ChatRequest):
|
|||
response_text = execute_agent(agent, req.message)
|
||||
|
||||
agent_msg = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"role": "assistant",
|
||||
"agent": agent,
|
||||
"content": response_text,
|
||||
|
|
@ -1001,7 +1007,7 @@ def load_kanban_tasks():
|
|||
ensure_dir(KANBAN_DIR)
|
||||
tasks = []
|
||||
for f in sorted(KANBAN_DIR.glob("*.json")):
|
||||
task = load_json_file(f, default=None, best_effort=True)
|
||||
task = read_json(f, default=None, best_effort=True)
|
||||
if task is not None:
|
||||
tasks.append(task)
|
||||
return tasks
|
||||
|
|
@ -1022,7 +1028,7 @@ def save_kanban_task(task: dict):
|
|||
ensure_dir(KANBAN_DIR)
|
||||
kanban_task_path(task["id"]).write_text(json.dumps(task, indent=2))
|
||||
|
||||
KANBAN_AGENTS = {"opencode", "hermes", "gemini"}
|
||||
KANBAN_AGENTS = set(AGENTS)
|
||||
|
||||
def dispatch_kanban_task(task_id: str):
|
||||
"""Move a task to in_progress and hand it to its assignee agent in the background."""
|
||||
|
|
@ -1057,7 +1063,7 @@ def _run_kanban_agent(task_id: str):
|
|||
|
||||
task = json.loads(path.read_text()) # reload in case it changed while the agent ran
|
||||
task.setdefault("comments", []).append({
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"message": f"🤖 **{agent}**\n\n{response}",
|
||||
"timestamp": get_timestamp(),
|
||||
})
|
||||
|
|
@ -1085,10 +1091,10 @@ def _run_kanban_agent(task_id: str):
|
|||
print(f"[kanban] could not mark task {task_id} as blocked: {inner}")
|
||||
|
||||
def load_goals():
|
||||
return load_json_file(GOALS_FILE, default=[])
|
||||
return read_json(GOALS_FILE, [])
|
||||
|
||||
def save_goals(goals: list):
|
||||
GOALS_FILE.write_text(json.dumps(goals, indent=2))
|
||||
write_json(GOALS_FILE, goals)
|
||||
|
||||
# ─── Routes: Kanban Board (13 endpoints) ────────────────────────
|
||||
|
||||
|
|
@ -1127,7 +1133,7 @@ def kanban_delete_task(task_id: str):
|
|||
def kanban_create_task(data: KanbanTaskCreate):
|
||||
try:
|
||||
task = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"title": data.title,
|
||||
"body": data.body,
|
||||
"status": data.status,
|
||||
|
|
@ -1224,7 +1230,7 @@ def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
|
|||
raise HTTPException(404, "Task not found")
|
||||
task = json.loads(path.read_text())
|
||||
comment = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"message": data.message,
|
||||
"timestamp": get_timestamp(),
|
||||
}
|
||||
|
|
@ -1294,7 +1300,7 @@ def kanban_decompose_task(task_id: str):
|
|||
subtask = subtask.strip().lstrip("-* ")
|
||||
if subtask:
|
||||
child = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"title": subtask[:80],
|
||||
"body": subtask,
|
||||
"status": "todo",
|
||||
|
|
@ -1324,7 +1330,7 @@ def create_goal(data: GoalCreate):
|
|||
try:
|
||||
goals = load_goals()
|
||||
goal = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"id": new_id(),
|
||||
"title": data.title,
|
||||
"description": data.description,
|
||||
"category": data.category,
|
||||
|
|
@ -1436,7 +1442,7 @@ def search_journal(q: str = Query("")):
|
|||
def get_agent_health():
|
||||
try:
|
||||
agents = []
|
||||
for name in ["opencode", "hermes", "gemini"]:
|
||||
for name in AGENTS:
|
||||
info = check_agent(name)
|
||||
info["uptime"] = 0
|
||||
info["success_rate"] = 100
|
||||
|
|
@ -1449,7 +1455,7 @@ def get_agent_health():
|
|||
@app.get("/api/agents/{name}/stats")
|
||||
def get_agent_stats(name: str):
|
||||
try:
|
||||
if name not in ["opencode", "hermes", "gemini"]:
|
||||
if name not in AGENTS:
|
||||
raise HTTPException(400, "Invalid agent")
|
||||
info = check_agent(name)
|
||||
return {
|
||||
|
|
@ -1470,7 +1476,7 @@ def get_agent_stats(name: str):
|
|||
def refresh_agent_health():
|
||||
try:
|
||||
agents = []
|
||||
for name in ["opencode", "hermes", "gemini"]:
|
||||
for name in AGENTS:
|
||||
info = check_agent(name)
|
||||
agents.append(info)
|
||||
append_audit({"action": "agent_health_refreshed"})
|
||||
|
|
@ -1508,7 +1514,7 @@ def router_suggest(data: RouterSuggest):
|
|||
def router_route(data: RouterRoute):
|
||||
try:
|
||||
agent = data.agent.lower()
|
||||
if agent not in ["opencode", "hermes", "gemini"]:
|
||||
if agent not in AGENTS:
|
||||
return {"status": "error", "message": f"Invalid agent: {agent}"}
|
||||
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
|
||||
return {
|
||||
|
|
@ -1525,22 +1531,17 @@ def router_route(data: RouterRoute):
|
|||
@app.get("/api/analytics/skills")
|
||||
def get_skill_analytics():
|
||||
try:
|
||||
skills_dir = BASE_DIR / "skills"
|
||||
analytics = []
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
eval_path = d / "eval.json"
|
||||
score_path = d / "score-history.json"
|
||||
scores = json.loads(score_path.read_text()) if score_path.exists() else []
|
||||
eval_data = json.loads(eval_path.read_text()) if eval_path.exists() else {}
|
||||
avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
|
||||
analytics.append({
|
||||
"name": d.name,
|
||||
"total_runs": len(scores),
|
||||
"avg_score": round(avg_score, 1),
|
||||
"last_score": scores[-1].get("score", 0) if scores else 0,
|
||||
"trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
|
||||
})
|
||||
for d in iter_skill_dirs():
|
||||
scores = read_json(d / "score-history.json", [])
|
||||
avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
|
||||
analytics.append({
|
||||
"name": d.name,
|
||||
"total_runs": len(scores),
|
||||
"avg_score": round(avg_score, 1),
|
||||
"last_score": scores[-1].get("score", 0) if scores else 0,
|
||||
"trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
|
||||
})
|
||||
return {"skills": sorted(analytics, key=lambda x: x["total_runs"], reverse=True)}
|
||||
except Exception as e:
|
||||
return {"skills": [], "error": str(e)}
|
||||
|
|
@ -1548,18 +1549,15 @@ def get_skill_analytics():
|
|||
@app.get("/api/analytics/trends")
|
||||
def get_trend_analytics():
|
||||
try:
|
||||
skills_dir = BASE_DIR / "skills"
|
||||
trends = []
|
||||
for d in sorted(skills_dir.iterdir()):
|
||||
if d.is_dir() and not d.name.startswith("_"):
|
||||
score_path = d / "score-history.json"
|
||||
scores = json.loads(score_path.read_text()) if score_path.exists() else []
|
||||
if scores:
|
||||
trends.append({
|
||||
"name": d.name,
|
||||
"scores": [s.get("score", 0) for s in scores[-10:]],
|
||||
"labels": [s.get("date", "") for s in scores[-10:]],
|
||||
})
|
||||
for d in iter_skill_dirs():
|
||||
scores = read_json(d / "score-history.json", [])
|
||||
if scores:
|
||||
trends.append({
|
||||
"name": d.name,
|
||||
"scores": [s.get("score", 0) for s in scores[-10:]],
|
||||
"labels": [s.get("date", "") for s in scores[-10:]],
|
||||
})
|
||||
return {"trends": trends}
|
||||
except Exception as e:
|
||||
return {"trends": [], "error": str(e)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue